commit 3e34f391b3e2206784083e36c4fdfc6a98a83341 Author: vl.arkhangelskii Date: Mon Sep 21 03:53:15 2026 +0300 feat(proj): init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..baf35d4 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..02991c1 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c6ac2b --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.tmp-tiles/index.html b/.tmp-tiles/index.html new file mode 100644 index 0000000..26ca850 --- /dev/null +++ b/.tmp-tiles/index.html @@ -0,0 +1,78 @@ + + + + + + Tile Server + + + + +
+ +
+
+ Tile Server + MapLibre · OSM · Geofabrik +
+ + + + + OpenAPI +
+ +
+
+

Камера

+

+
+
+ + + +
+
+ + + +
+ + +
+ + + + + + + + + diff --git a/.tmp-tiles/package-lock.json b/.tmp-tiles/package-lock.json new file mode 100644 index 0000000..c5469d4 --- /dev/null +++ b/.tmp-tiles/package-lock.json @@ -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" + } + } + } +} diff --git a/.tmp-tiles/package.json b/.tmp-tiles/package.json new file mode 100644 index 0000000..cbda459 --- /dev/null +++ b/.tmp-tiles/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "puppeteer-core": "^23.11.1" + } +} diff --git a/.tmp-tiles/probe-map.html b/.tmp-tiles/probe-map.html new file mode 100644 index 0000000..6a18127 --- /dev/null +++ b/.tmp-tiles/probe-map.html @@ -0,0 +1,56 @@ + + + + + + + + +
+

+
+
+
+
diff --git a/.tmp-tiles/probe.mjs b/.tmp-tiles/probe.mjs
new file mode 100644
index 0000000..e1f52f9
--- /dev/null
+++ b/.tmp-tiles/probe.mjs
@@ -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();
diff --git a/.tmp-tiles/shots/mode.png b/.tmp-tiles/shots/mode.png
new file mode 100644
index 0000000..5366162
Binary files /dev/null and b/.tmp-tiles/shots/mode.png differ
diff --git a/.tmp-tiles/shots/plain-z14.png b/.tmp-tiles/shots/plain-z14.png
new file mode 100644
index 0000000..dec7a19
Binary files /dev/null and b/.tmp-tiles/shots/plain-z14.png differ
diff --git a/.tmp-tiles/shots/probe-cdp.png b/.tmp-tiles/shots/probe-cdp.png
new file mode 100644
index 0000000..fac5624
Binary files /dev/null and b/.tmp-tiles/shots/probe-cdp.png differ
diff --git a/.tmp-tiles/shots/probe.png b/.tmp-tiles/shots/probe.png
new file mode 100644
index 0000000..fac5624
Binary files /dev/null and b/.tmp-tiles/shots/probe.png differ
diff --git a/.tmp-tiles/shots/prod-hook.png b/.tmp-tiles/shots/prod-hook.png
new file mode 100644
index 0000000..fac5624
Binary files /dev/null and b/.tmp-tiles/shots/prod-hook.png differ
diff --git a/.tmp-tiles/shots/transform-z14.png b/.tmp-tiles/shots/transform-z14.png
new file mode 100644
index 0000000..5366162
Binary files /dev/null and b/.tmp-tiles/shots/transform-z14.png differ
diff --git a/.tmp-tiles/shots/ui-desktop.png b/.tmp-tiles/shots/ui-desktop.png
new file mode 100644
index 0000000..efbbe9c
Binary files /dev/null and b/.tmp-tiles/shots/ui-desktop.png differ
diff --git a/.tmp-tiles/shots/ui-mobile.png b/.tmp-tiles/shots/ui-mobile.png
new file mode 100644
index 0000000..eb6e75d
Binary files /dev/null and b/.tmp-tiles/shots/ui-mobile.png differ
diff --git a/.tmp-tiles/shots/water-z1.png b/.tmp-tiles/shots/water-z1.png
new file mode 100644
index 0000000..5ab278c
Binary files /dev/null and b/.tmp-tiles/shots/water-z1.png differ
diff --git a/.tmp-tiles/shots/water-z14.png b/.tmp-tiles/shots/water-z14.png
new file mode 100644
index 0000000..45f01b7
Binary files /dev/null and b/.tmp-tiles/shots/water-z14.png differ
diff --git a/.tmp-tiles/shots/z1.png b/.tmp-tiles/shots/z1.png
new file mode 100644
index 0000000..76675d5
Binary files /dev/null and b/.tmp-tiles/shots/z1.png differ
diff --git a/.tmp-tiles/shots/z14.png b/.tmp-tiles/shots/z14.png
new file mode 100644
index 0000000..a8610d8
Binary files /dev/null and b/.tmp-tiles/shots/z14.png differ
diff --git a/.tmp-tiles/shots/z6.png b/.tmp-tiles/shots/z6.png
new file mode 100644
index 0000000..76cb3fb
Binary files /dev/null and b/.tmp-tiles/shots/z6.png differ
diff --git a/.tmp-tiles/ui-preview.html b/.tmp-tiles/ui-preview.html
new file mode 100644
index 0000000..85296d3
--- /dev/null
+++ b/.tmp-tiles/ui-preview.html
@@ -0,0 +1,80 @@
+
+
+
+  
+  
+  Tile Server UI preview
+  
+  
+  
+
+
+  
+
+
+ + + Tile Server + OSM · MapLibre · Geofabrik + +
+
+ + + готово + + OpenAPI +
+
+
+
+
+

Камера

+

55.75580°·37.61730°z14.00

+
+
+
+ + + +
+
+ + + +
+ +
+ +
+
+ + +
+
+ + diff --git a/.tmp-tiles/ui-shot.mjs b/.tmp-tiles/ui-shot.mjs new file mode 100644 index 0000000..36e23fd --- /dev/null +++ b/.tmp-tiles/ui-shot.mjs @@ -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); diff --git a/.tmp-tiles/z0.h b/.tmp-tiles/z0.h new file mode 100644 index 0000000..222e2d0 --- /dev/null +++ b/.tmp-tiles/z0.h @@ -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 + diff --git a/.tmp-tiles/z0.pbf b/.tmp-tiles/z0.pbf new file mode 100644 index 0000000..ee8b2a0 Binary files /dev/null and b/.tmp-tiles/z0.pbf differ diff --git a/.tmp-tiles/z1.h b/.tmp-tiles/z1.h new file mode 100644 index 0000000..cf7568e --- /dev/null +++ b/.tmp-tiles/z1.h @@ -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 + diff --git a/.tmp-tiles/z1.pbf b/.tmp-tiles/z1.pbf new file mode 100644 index 0000000..775acb0 Binary files /dev/null and b/.tmp-tiles/z1.pbf differ diff --git a/.tmp-tiles/z14.h b/.tmp-tiles/z14.h new file mode 100644 index 0000000..0415c6c --- /dev/null +++ b/.tmp-tiles/z14.h @@ -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 + diff --git a/.tmp-tiles/z14.pbf b/.tmp-tiles/z14.pbf new file mode 100644 index 0000000..f313f62 Binary files /dev/null and b/.tmp-tiles/z14.pbf differ diff --git a/.tmp-tiles/z15.h b/.tmp-tiles/z15.h new file mode 100644 index 0000000..970539d --- /dev/null +++ b/.tmp-tiles/z15.h @@ -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 + diff --git a/.tmp-tiles/z15.pbf b/.tmp-tiles/z15.pbf new file mode 100644 index 0000000..23c7708 Binary files /dev/null and b/.tmp-tiles/z15.pbf differ diff --git a/.tmp-tiles/z2.h b/.tmp-tiles/z2.h new file mode 100644 index 0000000..4e91ae7 --- /dev/null +++ b/.tmp-tiles/z2.h @@ -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 + diff --git a/.tmp-tiles/z2.pbf b/.tmp-tiles/z2.pbf new file mode 100644 index 0000000..68710ff Binary files /dev/null and b/.tmp-tiles/z2.pbf differ diff --git a/.tmp-tiles/z6.h b/.tmp-tiles/z6.h new file mode 100644 index 0000000..e70b9d0 --- /dev/null +++ b/.tmp-tiles/z6.h @@ -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 + diff --git a/.tmp-tiles/z6.pbf b/.tmp-tiles/z6.pbf new file mode 100644 index 0000000..704b910 Binary files /dev/null and b/.tmp-tiles/z6.pbf differ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..6073af5 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,10 @@ + + + net8.0 + enable + enable + latest + true + false + + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..caa211b --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc4ac77 --- /dev/null +++ b/README.md @@ -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`. diff --git a/TileServer.sln b/TileServer.sln new file mode 100644 index 0000000..8c16e85 --- /dev/null +++ b/TileServer.sln @@ -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 diff --git a/context/2026-09-20_20-30-00_tile-server.md b/context/2026-09-20_20-30-00_tile-server.md new file mode 100644 index 0000000..08a8fc6 --- /dev/null +++ b/context/2026-09-20_20-30-00_tile-server.md @@ -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`. diff --git a/context/2026-09-20_20-41-00_docker-compose-51300.md b/context/2026-09-20_20-41-00_docker-compose-51300.md new file mode 100644 index 0000000..5a42a43 --- /dev/null +++ b/context/2026-09-20_20-41-00_docker-compose-51300.md @@ -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. diff --git a/context/2026-09-20_21-05-00_planetiler-staging-extension.md b/context/2026-09-20_21-05-00_planetiler-staging-extension.md new file mode 100644 index 0000000..f15dbef --- /dev/null +++ b/context/2026-09-20_21-05-00_planetiler-staging-extension.md @@ -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`. diff --git a/context/2026-09-20_21-14-00_planetiler-maptiler-timeout.md b/context/2026-09-20_21-14-00_planetiler-maptiler-timeout.md new file mode 100644 index 0000000..e77d42b --- /dev/null +++ b/context/2026-09-20_21-14-00_planetiler-maptiler-timeout.md @@ -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 удаляются. diff --git a/context/2026-09-20_22-31-00_natural-earth-stall.md b/context/2026-09-20_22-31-00_natural-earth-stall.md new file mode 100644 index 0000000..96514ac --- /dev/null +++ b/context/2026-09-20_22-31-00_natural-earth-stall.md @@ -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 сразу при старте. diff --git a/context/2026-09-20_22-47-00_mixed-content-https.md b/context/2026-09-20_22-47-00_mixed-content-https.md new file mode 100644 index 0000000..d168878 --- /dev/null +++ b/context/2026-09-20_22-47-00_mixed-content-https.md @@ -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 на демо как страховка. diff --git a/context/2026-09-20_22-58-00_named-volume.md b/context/2026-09-20_22-58-00_named-volume.md new file mode 100644 index 0000000..2822db5 --- /dev/null +++ b/context/2026-09-20_22-58-00_named-volume.md @@ -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` и начнёт сборку заново. diff --git a/context/2026-09-20_23-03-00_absolute-tile-urls.md b/context/2026-09-20_23-03-00_absolute-tile-urls.md new file mode 100644 index 0000000..b1ba3ab --- /dev/null +++ b/context/2026-09-20_23-03-00_absolute-tile-urls.md @@ -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)`. diff --git a/context/2026-09-20_23-14-00_overzoom-maxzoom.md b/context/2026-09-20_23-14-00_overzoom-maxzoom.md new file mode 100644 index 0000000..aac0edf --- /dev/null +++ b/context/2026-09-20_23-14-00_overzoom-maxzoom.md @@ -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. diff --git a/context/2026-09-20_23-20-00_maxzoom-15.md b/context/2026-09-20_23-20-00_maxzoom-15.md new file mode 100644 index 0000000..c59cb97 --- /dev/null +++ b/context/2026-09-20_23-20-00_maxzoom-15.md @@ -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 этим стеком нельзя. diff --git a/context/2026-09-20_23-25-00_server-overzoom.md b/context/2026-09-20_23-25-00_server-overzoom.md new file mode 100644 index 0000000..0d3877b --- /dev/null +++ b/context/2026-09-20_23-25-00_server-overzoom.md @@ -0,0 +1,5 @@ +# 2026-09-20 — серверный overzoom MVT + +Клиентский overzoom нельзя требовать от всех. Отдать parent.pbf как child нельзя: MVT привязан к экстенту тайла. + +Сервер для z > dataMaxzoom (15) клипает геометрию в координаты ребёнка, gzip, кэш. TileJSON/style maxzoom=18. diff --git a/context/2026-09-20_23-29-00_goto-coordinates.md b/context/2026-09-20_23-29-00_goto-coordinates.md new file mode 100644 index 0000000..4bfc021 --- /dev/null +++ b/context/2026-09-20_23-29-00_goto-coordinates.md @@ -0,0 +1,3 @@ +# 2026-09-20 — переход к lat/lon/zoom + +В демо-панели форма «Перейти к точке»: широта, долгота, зум, flyTo. Запятая как десятичный разделитель ок. diff --git a/context/2026-09-20_23-36-00_empty-tiles-maxzoom.md b/context/2026-09-20_23-36-00_empty-tiles-maxzoom.md new file mode 100644 index 0000000..8f435d1 --- /dev/null +++ b/context/2026-09-20_23-36-00_empty-tiles-maxzoom.md @@ -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. diff --git a/context/2026-09-20_23-43-00_rebuild-demo.md b/context/2026-09-20_23-43-00_rebuild-demo.md new file mode 100644 index 0000000..da3ac4b --- /dev/null +++ b/context/2026-09-20_23-43-00_rebuild-demo.md @@ -0,0 +1,3 @@ +# 2026-09-20 — пересборка demo + +Карта на весь экран, HUD поверх: источник/стиль, камера (пресеты Москва/ЦФО, lat lon zoom), live z/lat/lon, hash `#z/lat/lon`. Воркер свёрнут. Поллинг больше не сбрасывает камеру при смене стиля. diff --git a/context/2026-09-20_23-47-00_blank-map-gzip-bounds.md b/context/2026-09-20_23-47-00_blank-map-gzip-bounds.md new file mode 100644 index 0000000..a448a21 --- /dev/null +++ b/context/2026-09-20_23-47-00_blank-map-gzip-bounds.md @@ -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-ответов. diff --git a/context/2026-09-21_00-05-00_sqlite-concurrent-empty-tiles.md b/context/2026-09-21_00-05-00_sqlite-concurrent-empty-tiles.md new file mode 100644 index 0000000..eacce9e --- /dev/null +++ b/context/2026-09-21_00-05-00_sqlite-concurrent-empty-tiles.md @@ -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). diff --git a/context/2026-09-21_00-25-00_demo-ui.md b/context/2026-09-21_00-25-00_demo-ui.md new file mode 100644 index 0000000..aa65f23 --- /dev/null +++ b/context/2026-09-21_00-25-00_demo-ui.md @@ -0,0 +1,3 @@ +# 2026-09-21 — UI демо + +HUD больше не тянется на всю ширину. Стекло-острова: тулбар слева сверху, камера слева снизу, воркер справа снизу. Те же id для demo.js. Статус — пилюля с точкой, шрифты tabular, MapLibre-контролы в том же тёмном стекле. diff --git a/context/2026-09-21_00-40-00_nginx-tile-cache.md b/context/2026-09-21_00-40-00_nginx-tile-cache.md new file mode 100644 index 0000000..664dd25 --- /dev/null +++ b/context/2026-09-21_00-40-00_nginx-tile-cache.md @@ -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 тайлов. diff --git a/context/2026-09-21_01-20-00_accounts-oauth-frontend.md b/context/2026-09-21_01-20-00_accounts-oauth-frontend.md new file mode 100644 index 0000000..54566dd --- /dev/null +++ b/context/2026-09-21_01-20-00_accounts-oauth-frontend.md @@ -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. diff --git a/context/2026-09-21_01-25-00_frontend-port-5250.md b/context/2026-09-21_01-25-00_frontend-port-5250.md new file mode 100644 index 0000000..ff43a54 --- /dev/null +++ b/context/2026-09-21_01-25-00_frontend-port-5250.md @@ -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`. diff --git a/context/2026-09-21_01-35-00_yandex-oauth-env.md b/context/2026-09-21_01-35-00_yandex-oauth-env.md new file mode 100644 index 0000000..97e5071 --- /dev/null +++ b/context/2026-09-21_01-35-00_yandex-oauth-env.md @@ -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`. diff --git a/context/2026-09-21_01-45-00_uikit-legal-login.md b/context/2026-09-21_01-45-00_uikit-legal-login.md new file mode 100644 index 0000000..f970e56 --- /dev/null +++ b/context/2026-09-21_01-45-00_uikit-legal-login.md @@ -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. diff --git a/context/2026-09-21_01-50-00_local-env.md b/context/2026-09-21_01-50-00_local-env.md new file mode 100644 index 0000000..bbea0aa --- /dev/null +++ b/context/2026-09-21_01-50-00_local-env.md @@ -0,0 +1,3 @@ +# Локальный .env для проверки Яндекс OAuth + +Рядом с compose лежит `.env` (в gitignore) с текущими YANDEX_* — чтобы проверить вход. После проверки сменить секрет в кабинете OAuth. diff --git a/context/2026-09-21_01-55-00_accounts-postgres.md b/context/2026-09-21_01-55-00_accounts-postgres.md new file mode 100644 index 0000000..2715241 --- /dev/null +++ b/context/2026-09-21_01-55-00_accounts-postgres.md @@ -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` — это не трогали. diff --git a/context/2026-09-21_02-00-00_vite-demo.md b/context/2026-09-21_02-00-00_vite-demo.md new file mode 100644 index 0000000..6aa309e --- /dev/null +++ b/context/2026-09-21_02-00-00_vite-demo.md @@ -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` оставлен как есть. diff --git a/context/2026-09-21_02-10-00_vite-demo-personal.md b/context/2026-09-21_02-10-00_vite-demo-personal.md new file mode 100644 index 0000000..0ad11d5 --- /dev/null +++ b/context/2026-09-21_02-10-00_vite-demo-personal.md @@ -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`. Токен и стиль создаются в кабинете. diff --git a/context/2026-09-21_02-15-00_vite-demo-prod-api.md b/context/2026-09-21_02-15-00_vite-demo-prod-api.md new file mode 100644 index 0000000..a9ad55d --- /dev/null +++ b/context/2026-09-21_02-15-00_vite-demo-prod-api.md @@ -0,0 +1,5 @@ +# Vite demo → боевой API + +`web-demo` ходит напрямую на `https://tile-server.ru` (CORS `*`). Локальный proxy на 5088 убран. + +Переопределение: `VITE_API_ORIGIN`. Личный режим: slug + `ts_…` → `/u/{slug}/…` на том же хосте. diff --git a/context/2026-09-21_02-20-00_close-public-map.md b/context/2026-09-21_02-20-00_close-public-map.md new file mode 100644 index 0000000..be55ded --- /dev/null +++ b/context/2026-09-21_02-20-00_close-public-map.md @@ -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 больше не отдаются. diff --git a/context/2026-09-21_02-25-00_nginx-u-location.md b/context/2026-09-21_02-25-00_nginx-u-location.md new file mode 100644 index 0000000..3159252 --- /dev/null +++ b/context/2026-09-21_02-25-00_nginx-u-location.md @@ -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`. diff --git a/context/2026-09-21_02-30-00_public-styles-500.md b/context/2026-09-21_02-30-00_public-styles-500.md new file mode 100644 index 0000000..6847907 --- /dev/null +++ b/context/2026-09-21_02-30-00_public-styles-500.md @@ -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. diff --git a/context/2026-09-21_02-45-00_cabinet-guides-editor.md b/context/2026-09-21_02-45-00_cabinet-guides-editor.md new file mode 100644 index 0000000..562eb11 --- /dev/null +++ b/context/2026-09-21_02-45-00_cabinet-guides-editor.md @@ -0,0 +1,5 @@ +# Кабинет: инструкции и редактор стилей + +- Убран пункт «Демо карты». +- Раздел `/guides`: шаги, URL стиля/тайлов, HTML и React, живая карта. +- Редактор `/styles/:name`: слева свойства слоя (цвет, толщина, видимость), по центру MapLibre, справа список слоёв. Пресеты read-only. diff --git a/context/2026-09-21_02-55-00_usage-groupby-500.md b/context/2026-09-21_02-55-00_usage-groupby-500.md new file mode 100644 index 0000000..ee0b5ce --- /dev/null +++ b/context/2026-09-21_02-55-00_usage-groupby-500.md @@ -0,0 +1,5 @@ +# 500 на /api/v1/me/usage + +Npgsql/EF не транслирует `GroupBy(Date).Select(new UsageDayDto(Sum…))`. Запрос падал `InvalidOperationException` → 500. + +Чтение строк по диапазону, агрегация по дню уже в памяти (неделя × токены — мало строк). diff --git a/data/styles/dark.json b/data/styles/dark.json new file mode 100644 index 0000000..441a19d --- /dev/null +++ b/data/styles/dark.json @@ -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 } + } + ] +} diff --git a/data/styles/osm-bright.json b/data/styles/osm-bright.json new file mode 100644 index 0000000..3def162 --- /dev/null +++ b/data/styles/osm-bright.json @@ -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 + } + } + ] +} diff --git a/data/styles/positron.json b/data/styles/positron.json new file mode 100644 index 0000000..d7fde87 --- /dev/null +++ b/data/styles/positron.json @@ -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 } + } + ] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..31a18d0 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/src/TileServer.Api/Controllers/AuthController.cs b/src/TileServer.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..87ab8ca --- /dev/null +++ b/src/TileServer.Api/Controllers/AuthController.cs @@ -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 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 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 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); diff --git a/src/TileServer.Api/Controllers/DemoController.cs b/src/TileServer.Api/Controllers/DemoController.cs new file mode 100644 index 0000000..967afef --- /dev/null +++ b/src/TileServer.Api/Controllers/DemoController.cs @@ -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(); +} diff --git a/src/TileServer.Api/Controllers/MeController.cs b/src/TileServer.Api/Controllers/MeController.cs new file mode 100644 index 0000000..f097444 --- /dev/null +++ b/src/TileServer.Api/Controllers/MeController.cs @@ -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 Get(CancellationToken ct) + => Ok(await accounts.GetMeAsync(SessionUser.GetId(User), ct).ConfigureAwait(false)); + + [HttpGet("tokens")] + public async Task ListTokens(CancellationToken ct) + => Ok(await tokens.ListAsync(SessionUser.GetId(User), ct).ConfigureAwait(false)); + + [HttpPost("tokens")] + public async Task 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 RevokeToken(Guid id, CancellationToken ct) + { + await tokens.RevokeAsync(SessionUser.GetId(User), id, ct).ConfigureAwait(false); + return NoContent(); + } + + [HttpGet("styles")] + public async Task ListStyles(CancellationToken ct) + => Ok(await styles.ListMineAsync(SessionUser.GetId(User), ct).ConfigureAwait(false)); + + [HttpGet("styles/{name}")] + public async Task GetStyle(string name, CancellationToken ct) + => new JsonResult(await styles.GetMineAsync(SessionUser.GetId(User), name, ct).ConfigureAwait(false)); + + [HttpPost("styles")] + public async Task 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 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 DeleteStyle(string name, CancellationToken ct) + { + await styles.DeleteMineAsync(SessionUser.GetId(User), name, ct).ConfigureAwait(false); + return NoContent(); + } + + [HttpGet("usage")] + public async Task 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); diff --git a/src/TileServer.Api/Controllers/PersonalDeliveryController.cs b/src/TileServer.Api/Controllers/PersonalDeliveryController.cs new file mode 100644 index 0000000..0fe556d --- /dev/null +++ b/src/TileServer.Api/Controllers/PersonalDeliveryController.cs @@ -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 options) : ControllerBase +{ + [HttpGet("tiles/{source}/{z:int}/{x:int}/{y:int}.pbf")] + public async Task 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 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 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 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 ResolveAsync(string slug, CancellationToken ct) + => tokens.ResolveAsync(slug, AccessToken.Read(Request), bearer: null, ct); +} diff --git a/src/TileServer.Api/Controllers/SourcesController.cs b/src/TileServer.Api/Controllers/SourcesController.cs new file mode 100644 index 0000000..872fb4f --- /dev/null +++ b/src/TileServer.Api/Controllers/SourcesController.cs @@ -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 options) : ControllerBase +{ + [HttpGet] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task List(CancellationToken ct) + => Ok(await extracts.ListAsync(PublicUrl.GetBase(Request, options), ct).ConfigureAwait(false)); + + [HttpGet("{id}")] + [ProducesResponseType(typeof(ExtractResponse), StatusCodes.Status200OK)] + public async Task 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 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 Delete(string id, CancellationToken ct) + { + await extracts.RemoveAsync(id, ct).ConfigureAwait(false); + return NoContent(); + } +} diff --git a/src/TileServer.Api/Controllers/StylesController.cs b/src/TileServer.Api/Controllers/StylesController.cs new file mode 100644 index 0000000..7cb6ade --- /dev/null +++ b/src/TileServer.Api/Controllers/StylesController.cs @@ -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."); +} diff --git a/src/TileServer.Api/Controllers/SyncController.cs b/src/TileServer.Api/Controllers/SyncController.cs new file mode 100644 index 0000000..17e3d1b --- /dev/null +++ b/src/TileServer.Api/Controllers/SyncController.cs @@ -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 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 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 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."); + } + } +} diff --git a/src/TileServer.Api/Controllers/TilesController.cs b/src/TileServer.Api/Controllers/TilesController.cs new file mode 100644 index 0000000..41790cd --- /dev/null +++ b/src/TileServer.Api/Controllers/TilesController.cs @@ -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="); +} diff --git a/src/TileServer.Api/Http/AccessToken.cs b/src/TileServer.Api/Http/AccessToken.cs new file mode 100644 index 0000000..bdd9d95 --- /dev/null +++ b/src/TileServer.Api/Http/AccessToken.cs @@ -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; + } +} diff --git a/src/TileServer.Api/Http/PublicDelivery.cs b/src/TileServer.Api/Http/PublicDelivery.cs new file mode 100644 index 0000000..ba6ed3e --- /dev/null +++ b/src/TileServer.Api/Http/PublicDelivery.cs @@ -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 + }; +} diff --git a/src/TileServer.Api/Http/PublicUrl.cs b/src/TileServer.Api/Http/PublicUrl.cs new file mode 100644 index 0000000..41aa340 --- /dev/null +++ b/src/TileServer.Api/Http/PublicUrl.cs @@ -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 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; + } +} diff --git a/src/TileServer.Api/Http/SessionUser.cs b/src/TileServer.Api/Http/SessionUser.cs new file mode 100644 index 0000000..cf6ee4d --- /dev/null +++ b/src/TileServer.Api/Http/SessionUser.cs @@ -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; + } +} diff --git a/src/TileServer.Api/Middleware/DomainExceptionHandler.cs b/src/TileServer.Api/Middleware/DomainExceptionHandler.cs new file mode 100644 index 0000000..f99fd07 --- /dev/null +++ b/src/TileServer.Api/Middleware/DomainExceptionHandler.cs @@ -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 logger) : IExceptionHandler +{ + public async ValueTask 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; + } +} diff --git a/src/TileServer.Api/Program.cs b/src/TileServer.Api/Program.cs new file mode 100644 index 0000000..80b2ff1 --- /dev/null +++ b/src/TileServer.Api/Program.cs @@ -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(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(); +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(); + 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(); diff --git a/src/TileServer.Api/Properties/launchSettings.json b/src/TileServer.Api/Properties/launchSettings.json new file mode 100644 index 0000000..64f0543 --- /dev/null +++ b/src/TileServer.Api/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/src/TileServer.Api/TileServer.Api.csproj b/src/TileServer.Api/TileServer.Api.csproj new file mode 100644 index 0000000..cbf9981 --- /dev/null +++ b/src/TileServer.Api/TileServer.Api.csproj @@ -0,0 +1,16 @@ + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/src/TileServer.Api/TileServer.Api.http b/src/TileServer.Api/TileServer.Api.http new file mode 100644 index 0000000..0bfe12e --- /dev/null +++ b/src/TileServer.Api/TileServer.Api.http @@ -0,0 +1,6 @@ +@TileServer.Api_HostAddress = http://localhost:5005 + +GET {{TileServer.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/src/TileServer.Api/Views/Demo/Index.cshtml b/src/TileServer.Api/Views/Demo/Index.cshtml new file mode 100644 index 0000000..d98a0b7 --- /dev/null +++ b/src/TileServer.Api/Views/Demo/Index.cshtml @@ -0,0 +1,96 @@ + + + + + + Tile Server + + + + +
+ +
+
+ + + Tile Server + OSM · MapLibre · Geofabrik + +
+ +
+ + + + + OpenAPI +
+
+ +
+
+
+

Камера

+

+
+
+
+ + + +
+
+ + + +
+ + +
+ + + + + + + + + diff --git a/src/TileServer.Api/Views/_ViewImports.cshtml b/src/TileServer.Api/Views/_ViewImports.cshtml new file mode 100644 index 0000000..a757b41 --- /dev/null +++ b/src/TileServer.Api/Views/_ViewImports.cshtml @@ -0,0 +1 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/TileServer.Api/appsettings.Development.json b/src/TileServer.Api/appsettings.Development.json new file mode 100644 index 0000000..34f00ef --- /dev/null +++ b/src/TileServer.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information" + } + } +} diff --git a/src/TileServer.Api/appsettings.json b/src/TileServer.Api/appsettings.json new file mode 100644 index 0000000..fc4b904 --- /dev/null +++ b/src/TileServer.Api/appsettings.json @@ -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 + } + ] + } +} diff --git a/src/TileServer.Api/wwwroot/css/demo.css b/src/TileServer.Api/wwwroot/css/demo.css new file mode 100644 index 0000000..d47bf4b --- /dev/null +++ b/src/TileServer.Api/wwwroot/css/demo.css @@ -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; + } +} diff --git a/src/TileServer.Api/wwwroot/js/demo.js b/src/TileServer.Api/wwwroot/js/demo.js new file mode 100644 index 0000000..d172baf --- /dev/null +++ b/src/TileServer.Api/wwwroot/js/demo.js @@ -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)}°·${center.lng.toFixed(5)}°z${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) => ``).join(""); + styleSelect.innerHTML = styles.map((item) => ``).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); diff --git a/src/TileServer.Application/Accounts/AccountContracts.cs b/src/TileServer.Application/Accounts/AccountContracts.cs new file mode 100644 index 0000000..b93031b --- /dev/null +++ b/src/TileServer.Application/Accounts/AccountContracts.cs @@ -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); diff --git a/src/TileServer.Application/Accounts/AccountService.cs b/src/TileServer.Application/Accounts/AccountService.cs new file mode 100644 index 0000000..acbabda --- /dev/null +++ b/src/TileServer.Application/Accounts/AccountService.cs @@ -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 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 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 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); +} diff --git a/src/TileServer.Application/Accounts/ApiTokenService.cs b/src/TileServer.Application/Accounts/ApiTokenService.cs new file mode 100644 index 0000000..5938d22 --- /dev/null +++ b/src/TileServer.Application/Accounts/ApiTokenService.cs @@ -0,0 +1,89 @@ +using TileServer.Domain.Accounts; +using TileServer.Domain.Exceptions; + +namespace TileServer.Application.Accounts; + +public sealed class ApiTokenService( + IUserRepository users, + IApiTokenRepository tokens) : IApiTokenService +{ + public async Task> ListAsync(Guid userId, CancellationToken ct) + { + var items = await tokens.ListByUserAsync(userId, ct).ConfigureAwait(false); + return items + .Select(t => new TokenListItem(t.Id, t.Name, t.Prefix, t.CreatedAt, t.RevokedAt)) + .ToArray(); + } + + public async Task CreateAsync(Guid userId, string name, CancellationToken ct) + { + if (await users.FindByIdAsync(userId, ct).ConfigureAwait(false) is null) + { + throw new ResourceNotFoundException("User", userId.ToString()); + } + + var trimmed = string.IsNullOrWhiteSpace(name) ? "Token" : name.Trim(); + if (trimmed.Length > 64) + { + throw new DomainValidationException("Token name must be at most 64 characters."); + } + + var plaintext = ApiTokenHash.CreatePlaintext(); + var token = new ApiToken + { + Id = Guid.NewGuid(), + UserId = userId, + Name = trimmed, + 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 new CreatedToken(token.Id, token.Name, token.Prefix, plaintext, token.CreatedAt); + } + + public async Task RevokeAsync(Guid userId, Guid tokenId, CancellationToken ct) + { + var token = await tokens.FindByIdAsync(userId, tokenId, ct).ConfigureAwait(false) + ?? throw new ResourceNotFoundException("Token", tokenId.ToString()); + if (token.IsRevoked) + { + return; + } + + token.RevokedAt = DateTimeOffset.UtcNow; + await tokens.SaveChangesAsync(ct).ConfigureAwait(false); + } + + public async Task ResolveAsync(string slug, string? queryToken, string? bearer, CancellationToken ct) + { + var plaintext = !string.IsNullOrWhiteSpace(queryToken) + ? queryToken.Trim() + : bearer?.Trim(); + if (string.IsNullOrWhiteSpace(plaintext)) + { + throw new UnauthenticatedException("API token is required."); + } + + if (!plaintext.StartsWith(ApiTokenHash.Prefix, StringComparison.Ordinal)) + { + throw new UnauthenticatedException("API token is invalid."); + } + + var user = await users.FindBySlugAsync(slug, ct).ConfigureAwait(false) + ?? throw new UnauthenticatedException("API token is invalid."); + var token = await tokens.FindByHashAsync(ApiTokenHash.Compute(plaintext), ct).ConfigureAwait(false); + if (token is null || token.UserId != user.Id || !ApiTokenHash.Verify(plaintext, token.Hash)) + { + throw new UnauthenticatedException("API token is invalid."); + } + + if (token.IsRevoked) + { + throw new UnauthenticatedException("API token has been revoked."); + } + + return new ResolvedAccess(user, token); + } +} diff --git a/src/TileServer.Application/Accounts/IAccountService.cs b/src/TileServer.Application/Accounts/IAccountService.cs new file mode 100644 index 0000000..9efb517 --- /dev/null +++ b/src/TileServer.Application/Accounts/IAccountService.cs @@ -0,0 +1,11 @@ +namespace TileServer.Application.Accounts; + +public interface IAccountService +{ + Task<(MeResponse Me, string? CabinetToken)> SignInFromYandexAsync( + string code, + string redirectUri, + CancellationToken ct); + + Task GetMeAsync(Guid userId, CancellationToken ct); +} diff --git a/src/TileServer.Application/Accounts/IApiTokenRepository.cs b/src/TileServer.Application/Accounts/IApiTokenRepository.cs new file mode 100644 index 0000000..65c7e9f --- /dev/null +++ b/src/TileServer.Application/Accounts/IApiTokenRepository.cs @@ -0,0 +1,18 @@ +using TileServer.Domain.Accounts; + +namespace TileServer.Application.Accounts; + +public interface IApiTokenRepository +{ + Task> ListByUserAsync(Guid userId, CancellationToken ct); + + Task FindByIdAsync(Guid userId, Guid tokenId, CancellationToken ct); + + Task FindByHashAsync(string hash, CancellationToken ct); + + Task CountActiveAsync(Guid userId, CancellationToken ct); + + Task AddAsync(ApiToken token, CancellationToken ct); + + Task SaveChangesAsync(CancellationToken ct); +} diff --git a/src/TileServer.Application/Accounts/IApiTokenService.cs b/src/TileServer.Application/Accounts/IApiTokenService.cs new file mode 100644 index 0000000..6319d3e --- /dev/null +++ b/src/TileServer.Application/Accounts/IApiTokenService.cs @@ -0,0 +1,12 @@ +namespace TileServer.Application.Accounts; + +public interface IApiTokenService +{ + Task> ListAsync(Guid userId, CancellationToken ct); + + Task CreateAsync(Guid userId, string name, CancellationToken ct); + + Task RevokeAsync(Guid userId, Guid tokenId, CancellationToken ct); + + Task ResolveAsync(string slug, string? queryToken, string? bearer, CancellationToken ct); +} diff --git a/src/TileServer.Application/Accounts/IUsageRecorder.cs b/src/TileServer.Application/Accounts/IUsageRecorder.cs new file mode 100644 index 0000000..26c2321 --- /dev/null +++ b/src/TileServer.Application/Accounts/IUsageRecorder.cs @@ -0,0 +1,15 @@ +namespace TileServer.Application.Accounts; + +public interface IUsageRecorder +{ + void RecordTile(Guid userId, Guid tokenId, long bytes); + + void RecordStyle(Guid userId, Guid tokenId, long bytes); + + IReadOnlyList Drain(); +} + +public interface IUsageQuery +{ + Task> GetAsync(Guid userId, DateOnly from, DateOnly to, CancellationToken ct); +} diff --git a/src/TileServer.Application/Accounts/IUsageRepository.cs b/src/TileServer.Application/Accounts/IUsageRepository.cs new file mode 100644 index 0000000..6b6a0aa --- /dev/null +++ b/src/TileServer.Application/Accounts/IUsageRepository.cs @@ -0,0 +1,8 @@ +namespace TileServer.Application.Accounts; + +public interface IUsageRepository +{ + Task ApplyAsync(IReadOnlyList deltas, CancellationToken ct); + + Task> GetAsync(Guid userId, DateOnly from, DateOnly to, CancellationToken ct); +} diff --git a/src/TileServer.Application/Accounts/IUserRepository.cs b/src/TileServer.Application/Accounts/IUserRepository.cs new file mode 100644 index 0000000..175a3e5 --- /dev/null +++ b/src/TileServer.Application/Accounts/IUserRepository.cs @@ -0,0 +1,18 @@ +using TileServer.Domain.Accounts; + +namespace TileServer.Application.Accounts; + +public interface IUserRepository +{ + Task FindByIdAsync(Guid id, CancellationToken ct); + + Task FindByYandexIdAsync(string yandexId, CancellationToken ct); + + Task FindBySlugAsync(string slug, CancellationToken ct); + + Task SlugExistsAsync(string slug, Guid? exceptUserId, CancellationToken ct); + + Task AddAsync(User user, CancellationToken ct); + + Task SaveChangesAsync(CancellationToken ct); +} diff --git a/src/TileServer.Application/Accounts/IUserStyleRepository.cs b/src/TileServer.Application/Accounts/IUserStyleRepository.cs new file mode 100644 index 0000000..f41db3d --- /dev/null +++ b/src/TileServer.Application/Accounts/IUserStyleRepository.cs @@ -0,0 +1,16 @@ +using TileServer.Domain.Accounts; + +namespace TileServer.Application.Accounts; + +public interface IUserStyleRepository +{ + Task> ListByUserAsync(Guid userId, CancellationToken ct); + + Task FindAsync(Guid userId, string name, CancellationToken ct); + + Task AddAsync(UserStyle style, CancellationToken ct); + + Task RemoveAsync(UserStyle style, CancellationToken ct); + + Task SaveChangesAsync(CancellationToken ct); +} diff --git a/src/TileServer.Application/Accounts/IUserStyleService.cs b/src/TileServer.Application/Accounts/IUserStyleService.cs new file mode 100644 index 0000000..e05c2b3 --- /dev/null +++ b/src/TileServer.Application/Accounts/IUserStyleService.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Nodes; +using TileServer.Application.Styles; + +namespace TileServer.Application.Accounts; + +public interface IUserStyleService +{ + Task> ListMineAsync(Guid userId, CancellationToken ct); + + Task GetMineAsync(Guid userId, string name, CancellationToken ct); + + Task CloneAsync(Guid userId, string name, string from, CancellationToken ct); + + Task SaveMineAsync(Guid userId, string name, JsonObject style, CancellationToken ct); + + Task DeleteMineAsync(Guid userId, string name, CancellationToken ct); + + Task> ListForDeliveryAsync(string slug, string baseUrl, string token, CancellationToken ct); + + Task GetForDeliveryAsync(string slug, string name, string extractId, string tilesUrl, CancellationToken ct); +} diff --git a/src/TileServer.Application/Accounts/IYandexOAuthClient.cs b/src/TileServer.Application/Accounts/IYandexOAuthClient.cs new file mode 100644 index 0000000..f00d2f3 --- /dev/null +++ b/src/TileServer.Application/Accounts/IYandexOAuthClient.cs @@ -0,0 +1,6 @@ +namespace TileServer.Application.Accounts; + +public interface IYandexOAuthClient +{ + Task ExchangeAsync(string code, string redirectUri, CancellationToken ct); +} diff --git a/src/TileServer.Application/Accounts/UsageAccumulator.cs b/src/TileServer.Application/Accounts/UsageAccumulator.cs new file mode 100644 index 0000000..459dd68 --- /dev/null +++ b/src/TileServer.Application/Accounts/UsageAccumulator.cs @@ -0,0 +1,66 @@ +using System.Collections.Concurrent; + +namespace TileServer.Application.Accounts; + +public sealed class UsageAccumulator : IUsageRecorder +{ + private readonly ConcurrentDictionary<(Guid UserId, Guid TokenId, DateOnly Day), Counters> _map = new(); + + public void RecordTile(Guid userId, Guid tokenId, long bytes) + => Record(userId, tokenId, tileRequests: 1, styleRequests: 0, bytes); + + public void RecordStyle(Guid userId, Guid tokenId, long bytes) + => Record(userId, tokenId, tileRequests: 0, styleRequests: 1, bytes); + + public void Restore(UsageDelta delta) + => Record(delta.UserId, delta.TokenId, delta.TileRequests, delta.StyleRequests, delta.Bytes); + + public IReadOnlyList Drain() + { + var drained = new List(); + foreach (var key in _map.Keys) + { + if (!_map.TryRemove(key, out var counters)) + { + continue; + } + + drained.Add(new UsageDelta( + key.UserId, + key.TokenId, + key.Day, + counters.TileRequests, + counters.StyleRequests, + counters.Bytes)); + } + + return drained; + } + + private void Record(Guid userId, Guid tokenId, long tileRequests, long styleRequests, long bytes) + { + var key = (userId, tokenId, DateOnly.FromDateTime(DateTime.UtcNow)); + var counters = _map.GetOrAdd(key, static _ => new Counters()); + if (tileRequests != 0) + { + Interlocked.Add(ref counters.TileRequests, tileRequests); + } + + if (styleRequests != 0) + { + Interlocked.Add(ref counters.StyleRequests, styleRequests); + } + + if (bytes > 0) + { + Interlocked.Add(ref counters.Bytes, bytes); + } + } + + private sealed class Counters + { + public long TileRequests; + public long StyleRequests; + public long Bytes; + } +} diff --git a/src/TileServer.Application/Accounts/UserStyleService.cs b/src/TileServer.Application/Accounts/UserStyleService.cs new file mode 100644 index 0000000..226f987 --- /dev/null +++ b/src/TileServer.Application/Accounts/UserStyleService.cs @@ -0,0 +1,154 @@ +using System.Text.Json.Nodes; +using TileServer.Application.Styles; +using TileServer.Domain; +using TileServer.Domain.Accounts; +using TileServer.Domain.Exceptions; + +namespace TileServer.Application.Accounts; + +public sealed class UserStyleService( + IUserRepository users, + IUserStyleRepository styles, + IStyleCatalog catalog, + IStyleService styleService) : IUserStyleService +{ + public async Task> ListMineAsync(Guid userId, CancellationToken ct) + { + var own = await styles.ListByUserAsync(userId, ct).ConfigureAwait(false); + var ownNames = own.Select(s => s.Name).ToHashSet(StringComparer.Ordinal); + var items = own + .Select(s => new UserStyleListItem(s.Name, s.CreatedFrom, s.UpdatedAt, IsPreset: false)) + .ToList(); + items.AddRange(catalog.List() + .Where(p => !ownNames.Contains(p.Name)) + .Select(p => new UserStyleListItem(p.Name, null, p.UpdatedAt, IsPreset: true))); + return items + .OrderBy(s => s.IsPreset) + .ThenBy(s => s.Name, StringComparer.Ordinal) + .ToArray(); + } + + public async Task GetMineAsync(Guid userId, string name, CancellationToken ct) + { + var styleName = ResourceName.Require(name, nameof(name)); + var own = await styles.FindAsync(userId, styleName, ct).ConfigureAwait(false); + if (own is not null) + { + return Parse(own.Json, styleName); + } + + return catalog.GetRequired(styleName); + } + + public async Task CloneAsync(Guid userId, string name, string from, CancellationToken ct) + { + var styleName = ResourceName.Require(name, nameof(name)); + var sourceName = ResourceName.Require(from, nameof(from)); + if (await styles.FindAsync(userId, styleName, ct).ConfigureAwait(false) is not null) + { + throw new ResourceConflictException($"Style '{styleName}' already exists."); + } + + var source = catalog.GetRequired(sourceName); + var now = DateTimeOffset.UtcNow; + var row = new UserStyle + { + Id = Guid.NewGuid(), + UserId = userId, + Name = styleName, + Json = source.ToJsonString(), + CreatedFrom = sourceName, + CreatedAt = now, + UpdatedAt = now + }; + await styles.AddAsync(row, ct).ConfigureAwait(false); + await styles.SaveChangesAsync(ct).ConfigureAwait(false); + return new UserStyleListItem(row.Name, row.CreatedFrom, row.UpdatedAt, IsPreset: false); + } + + public async Task SaveMineAsync(Guid userId, string name, JsonObject style, CancellationToken ct) + { + var styleName = ResourceName.Require(name, nameof(name)); + if (style["version"] is null) + { + style["version"] = 8; + } + + var now = DateTimeOffset.UtcNow; + var existing = await styles.FindAsync(userId, styleName, ct).ConfigureAwait(false); + if (existing is null) + { + await styles.AddAsync(new UserStyle + { + Id = Guid.NewGuid(), + UserId = userId, + Name = styleName, + Json = style.ToJsonString(), + CreatedAt = now, + UpdatedAt = now + }, ct).ConfigureAwait(false); + } + else + { + existing.Json = style.ToJsonString(); + existing.UpdatedAt = now; + } + + await styles.SaveChangesAsync(ct).ConfigureAwait(false); + } + + public async Task DeleteMineAsync(Guid userId, string name, CancellationToken ct) + { + var styleName = ResourceName.Require(name, nameof(name)); + var existing = await styles.FindAsync(userId, styleName, ct).ConfigureAwait(false) + ?? throw new ResourceNotFoundException("Style", styleName); + await styles.RemoveAsync(existing, ct).ConfigureAwait(false); + await styles.SaveChangesAsync(ct).ConfigureAwait(false); + } + + public async Task> ListForDeliveryAsync( + string slug, + string baseUrl, + string token, + CancellationToken ct) + { + var user = await users.FindBySlugAsync(slug, ct).ConfigureAwait(false) + ?? throw new ResourceNotFoundException("User", slug); + var own = await styles.ListByUserAsync(user.Id, ct).ConfigureAwait(false); + var ownNames = own.Select(s => s.Name).ToHashSet(StringComparer.Ordinal); + var items = own + .Select(s => new StyleListItem(s.Name, DeliveryUrl(baseUrl, slug, s.Name, token))) + .ToList(); + items.AddRange(catalog.List() + .Where(p => !ownNames.Contains(p.Name)) + .Select(p => new StyleListItem(p.Name, DeliveryUrl(baseUrl, slug, p.Name, token)))); + return items + .OrderBy(s => s.Name, StringComparer.Ordinal) + .ToArray(); + } + + public async Task GetForDeliveryAsync( + string slug, + string name, + string extractId, + string tilesUrl, + CancellationToken ct) + { + var styleName = ResourceName.Require(name, nameof(name)); + var user = await users.FindBySlugAsync(slug, ct).ConfigureAwait(false) + ?? throw new ResourceNotFoundException("User", slug); + var own = await styles.FindAsync(user.Id, styleName, ct).ConfigureAwait(false); + var raw = own is null ? catalog.GetRequired(styleName) : Parse(own.Json, styleName); + return styleService.Bind(raw, extractId, tilesUrl); + } + + private static JsonObject Parse(string json, string name) + => JsonNode.Parse(json)?.AsObject() + ?? throw new DomainValidationException($"Style '{name}' is not a JSON object."); + + private static string DeliveryUrl(string baseUrl, string slug, string name, string token) + { + var root = string.IsNullOrEmpty(baseUrl) ? string.Empty : baseUrl.TrimEnd('/'); + return $"{root}/u/{slug}/styles/{name}?token={Uri.EscapeDataString(token)}"; + } +} diff --git a/src/TileServer.Application/Configuration/TileServerOptions.cs b/src/TileServer.Application/Configuration/TileServerOptions.cs new file mode 100644 index 0000000..b8d720e --- /dev/null +++ b/src/TileServer.Application/Configuration/TileServerOptions.cs @@ -0,0 +1,155 @@ +namespace TileServer.Application.Configuration; + +public sealed class TileServerOptions +{ + public const string SectionName = "TileServer"; + + public string DataDirectory { get; set; } = "data"; + + public string JavaPath { get; set; } = "java"; + + public string JvmMaxHeap { get; set; } = "8g"; + + public string PlanetilerJarUrl { get; set; } = + "https://github.com/openmaptiles/planetiler-openmaptiles/releases/download/v3.16/planetiler-openmaptiles.jar"; + + public string PlanetilerJarFileName { get; set; } = "planetiler-openmaptiles.jar"; + + public int DefaultMinZoom { get; set; } = 0; + + public int DefaultMaxZoom { get; set; } = 15; + + public string GlyphsUrl { get; set; } = "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf"; + + /// + /// Public origin for absolute URLs in styles/TileJSON, e.g. https://tile-server.ru. + /// Empty = root-relative paths (safe behind HTTPS reverse proxy). + /// + public string? PublicBaseUrl { get; set; } + + public int TileCacheSize { get; set; } = 512; + + /// + /// Highest zoom generated on the fly from stored tiles when a client asks past + /// the MBTiles pyramid. Not advertised in TileJSON/style: MapLibre must see the + /// stored maxzoom so it overzooms z14/z15 instead of requesting sparse children. + /// + public int OverzoomMaxZoom { get; set; } = 18; + + public string PlanetilerHttpTimeout { get; set; } = "15m"; + + public int DownloadStallTimeoutSeconds { get; set; } = 45; + + public List NaturalEarthUrls { get; set; } = + [ + "https://naciscdn.org/naturalearth/packages/natural_earth_vector.sqlite.zip", + "https://naturalearth.s3.amazonaws.com/packages/natural_earth_vector.sqlite.zip" + ]; + + public List LakeCenterlinesUrls { get; set; } = + [ + "https://github.com/acalcutt/osm-lakelines/releases/download/latest/lake_centerline.shp.zip", + "https://acalcutt.github.io/osm-lakelines/lake_centerline.shp.zip" + ]; + + public List WaterPolygonsUrls { get; set; } = + [ + "https://osmdata.openstreetmap.de/download/water-polygons-split-3857.zip" + ]; + + public string NaturalEarthUrl + { + get => NaturalEarthUrls.Count > 0 ? NaturalEarthUrls[0] : string.Empty; + set + { + if (!string.IsNullOrWhiteSpace(value)) + { + NaturalEarthUrls = PrependUnique(NaturalEarthUrls, value); + } + } + } + + public string LakeCenterlinesUrl + { + get => LakeCenterlinesUrls.Count > 0 ? LakeCenterlinesUrls[0] : string.Empty; + set + { + if (!string.IsNullOrWhiteSpace(value)) + { + LakeCenterlinesUrls = PrependUnique(LakeCenterlinesUrls, value); + } + } + } + + public string WaterPolygonsUrl + { + get => WaterPolygonsUrls.Count > 0 ? WaterPolygonsUrls[0] : string.Empty; + set + { + if (!string.IsNullOrWhiteSpace(value)) + { + WaterPolygonsUrls = PrependUnique(WaterPolygonsUrls, value); + } + } + } + + public SyncOptions Sync { get; set; } = new(); + + public YandexOAuthOptions Yandex { get; set; } = new(); + + public List Extracts { get; set; } = []; + + private static List PrependUnique(List existing, string value) + { + var result = new List { value }; + foreach (var item in existing) + { + if (!string.Equals(item, value, StringComparison.OrdinalIgnoreCase)) + { + result.Add(item); + } + } + + return result; + } +} + +public sealed class SyncOptions +{ + public string Cron { get; set; } = "0 3 * * *"; + + public string TimeZone { get; set; } = "UTC"; + + public bool RunOnStartup { get; set; } = true; + + public bool RebuildIfTilesMissing { get; set; } = true; +} + +public sealed class YandexOAuthOptions +{ + public string ClientId { get; set; } = string.Empty; + + public string ClientSecret { get; set; } = string.Empty; + + public string RedirectUri { get; set; } = "https://tile-server.ru"; + + public bool IsConfigured => + !string.IsNullOrWhiteSpace(ClientId) && !string.IsNullOrWhiteSpace(ClientSecret); +} + +public sealed class ExtractOptions +{ + public string Id { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public string Url { get; set; } = string.Empty; + + public double[] Center { get; set; } = [37.6173, 55.7558]; + + public int MinZoom { get; set; } = 0; + + public int MaxZoom { get; set; } = 15; + + public bool Enabled { get; set; } = true; +} diff --git a/src/TileServer.Application/DependencyInjection.cs b/src/TileServer.Application/DependencyInjection.cs new file mode 100644 index 0000000..0b1712a --- /dev/null +++ b/src/TileServer.Application/DependencyInjection.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.DependencyInjection; +using TileServer.Application.Accounts; +using TileServer.Application.Extracts; +using TileServer.Application.Styles; +using TileServer.Application.Sync; +using TileServer.Application.Tiles; + +namespace TileServer.Application; + +public static class DependencyInjection +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/src/TileServer.Application/Extracts/ExtractService.cs b/src/TileServer.Application/Extracts/ExtractService.cs new file mode 100644 index 0000000..fa5a4c9 --- /dev/null +++ b/src/TileServer.Application/Extracts/ExtractService.cs @@ -0,0 +1,93 @@ +using Microsoft.Extensions.Options; +using TileServer.Application.Configuration; +using TileServer.Application.Tiles; +using TileServer.Domain; +using TileServer.Domain.Exceptions; +using TileServer.Domain.Extracts; +using TileServer.Domain.Tiles; + +namespace TileServer.Application.Extracts; + +public sealed class ExtractService( + IExtractCatalog catalog, + IExtractStateStore stateStore, + ITileStore tileStore, + IOptions options) : IExtractService +{ + public async Task> ListAsync(string baseUrl, CancellationToken ct) + { + var result = new List(); + foreach (var extract in catalog.GetAll()) + { + result.Add(await MapAsync(extract, baseUrl, ct).ConfigureAwait(false)); + } + + return result; + } + + public async Task GetAsync(string id, string baseUrl, CancellationToken ct) + => await MapAsync(catalog.GetRequired(id), baseUrl, ct).ConfigureAwait(false); + + public async Task AddOrUpdateAsync(CreateExtractRequest request, string baseUrl, CancellationToken ct) + { + var id = ResourceName.Require(request.Id, nameof(request.Id)); + if (string.IsNullOrWhiteSpace(request.Name)) + { + throw new DomainValidationException("Extract name is required."); + } + + if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var url) || + (url.Scheme != Uri.UriSchemeHttp && url.Scheme != Uri.UriSchemeHttps)) + { + throw new DomainValidationException("Extract URL must be an absolute http(s) URI."); + } + + var defaults = options.Value; + var center = request.Center is { Length: >= 2 } ? request.Center : [37.6173, 55.7558]; + var extract = new Extract + { + Id = id, + Name = request.Name.Trim(), + Url = url, + CenterLon = center[0], + CenterLat = center[1], + MinZoom = request.MinZoom ?? defaults.DefaultMinZoom, + MaxZoom = PlanetilerLimits.ClampMaxZoom(request.MaxZoom ?? defaults.DefaultMaxZoom), + Enabled = request.Enabled + }; + + await catalog.AddOrUpdateAsync(extract, ct).ConfigureAwait(false); + return await MapAsync(extract, baseUrl, ct).ConfigureAwait(false); + } + + public Task RemoveAsync(string id, CancellationToken ct) + => catalog.RemoveAsync(ResourceName.Require(id, nameof(id)), ct); + + private async Task MapAsync(Extract extract, string baseUrl, CancellationToken ct) + { + var state = await stateStore.GetAsync(extract.Id, ct).ConfigureAwait(false); + var metadata = tileStore.GetMetadata(extract.Id); + var bounds = metadata is null + ? null + : new TilesetBounds(metadata.MinLon, metadata.MinLat, metadata.MaxLon, metadata.MaxLat); + + return new ExtractResponse( + extract.Id, + extract.Name, + extract.Url.ToString(), + extract.Enabled, + [extract.CenterLon, extract.CenterLat], + extract.MinZoom, + extract.MaxZoom, + string.Empty, + string.Empty, + tileStore.Exists(extract.Id) && state.Status == ExtractSyncState.Pending + ? ExtractSyncState.Ready + : state.Status, + state.DownloadedAt, + state.BuiltAt, + state.PbfBytes, + state.LastError, + bounds); + } +} diff --git a/src/TileServer.Application/Extracts/IExtractCatalog.cs b/src/TileServer.Application/Extracts/IExtractCatalog.cs new file mode 100644 index 0000000..e18bf2b --- /dev/null +++ b/src/TileServer.Application/Extracts/IExtractCatalog.cs @@ -0,0 +1,16 @@ +using TileServer.Domain.Extracts; + +namespace TileServer.Application.Extracts; + +public interface IExtractCatalog +{ + IReadOnlyList GetAll(); + + Extract? Find(string id); + + Extract GetRequired(string id); + + Task AddOrUpdateAsync(Extract extract, CancellationToken ct); + + Task RemoveAsync(string id, CancellationToken ct); +} diff --git a/src/TileServer.Application/Extracts/IExtractDownloader.cs b/src/TileServer.Application/Extracts/IExtractDownloader.cs new file mode 100644 index 0000000..aae7ead --- /dev/null +++ b/src/TileServer.Application/Extracts/IExtractDownloader.cs @@ -0,0 +1,14 @@ +using TileServer.Domain.Extracts; + +namespace TileServer.Application.Extracts; + +public sealed record DownloadResult( + bool Changed, + long Bytes, + string? ETag, + DateTimeOffset? LastModified); + +public interface IExtractDownloader +{ + Task DownloadAsync(Extract extract, string destinationPath, ExtractRuntimeState state, CancellationToken ct); +} diff --git a/src/TileServer.Application/Extracts/IExtractService.cs b/src/TileServer.Application/Extracts/IExtractService.cs new file mode 100644 index 0000000..3fb9976 --- /dev/null +++ b/src/TileServer.Application/Extracts/IExtractService.cs @@ -0,0 +1,42 @@ +using TileServer.Domain.Extracts; + +namespace TileServer.Application.Extracts; + +public sealed record ExtractResponse( + string Id, + string Name, + string Url, + bool Enabled, + double[] Center, + int MinZoom, + int MaxZoom, + string TileUrlTemplate, + string TileJsonUrl, + ExtractSyncState Status, + DateTimeOffset? DownloadedAt, + DateTimeOffset? BuiltAt, + long? PbfBytes, + string? LastError, + TilesetBounds? Bounds); + +public sealed record TilesetBounds(double MinLon, double MinLat, double MaxLon, double MaxLat); + +public sealed record CreateExtractRequest( + string Id, + string Name, + string Url, + double[]? Center, + int? MinZoom, + int? MaxZoom, + bool Enabled = true); + +public interface IExtractService +{ + Task> ListAsync(string baseUrl, CancellationToken ct); + + Task GetAsync(string id, string baseUrl, CancellationToken ct); + + Task AddOrUpdateAsync(CreateExtractRequest request, string baseUrl, CancellationToken ct); + + Task RemoveAsync(string id, CancellationToken ct); +} diff --git a/src/TileServer.Application/Extracts/IExtractStateStore.cs b/src/TileServer.Application/Extracts/IExtractStateStore.cs new file mode 100644 index 0000000..f18d0de --- /dev/null +++ b/src/TileServer.Application/Extracts/IExtractStateStore.cs @@ -0,0 +1,10 @@ +using TileServer.Domain.Extracts; + +namespace TileServer.Application.Extracts; + +public interface IExtractStateStore +{ + Task GetAsync(string extractId, CancellationToken ct); + + Task SaveAsync(ExtractRuntimeState state, CancellationToken ct); +} diff --git a/src/TileServer.Application/IDataPaths.cs b/src/TileServer.Application/IDataPaths.cs new file mode 100644 index 0000000..d0d0d8c --- /dev/null +++ b/src/TileServer.Application/IDataPaths.cs @@ -0,0 +1,32 @@ +namespace TileServer.Application; + +public interface IDataPaths +{ + string Root { get; } + + string Extracts { get; } + + string Tiles { get; } + + string Styles { get; } + + string Tools { get; } + + string State { get; } + + string PlanetilerSources { get; } + + string Tmp { get; } + + string OverlayExtractsFile { get; } + + string AppDatabase { get; } + + string OsmPbf(string extractId); + + string Mbtiles(string extractId); + + string StagingMbtiles(string extractId); + + void EnsureCreated(); +} diff --git a/src/TileServer.Application/Styles/IStyleCatalog.cs b/src/TileServer.Application/Styles/IStyleCatalog.cs new file mode 100644 index 0000000..6e88bbb --- /dev/null +++ b/src/TileServer.Application/Styles/IStyleCatalog.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Nodes; + +namespace TileServer.Application.Styles; + +public sealed record MapStyleInfo(string Name, DateTimeOffset UpdatedAt); + +public interface IStyleCatalog +{ + IReadOnlyList List(); + + JsonObject GetRequired(string name); + + Task SaveAsync(string name, JsonObject style, CancellationToken ct); + + Task SeedDefaultsAsync(CancellationToken ct); +} diff --git a/src/TileServer.Application/Styles/IStyleService.cs b/src/TileServer.Application/Styles/IStyleService.cs new file mode 100644 index 0000000..3463752 --- /dev/null +++ b/src/TileServer.Application/Styles/IStyleService.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Nodes; + +namespace TileServer.Application.Styles; + +public sealed record StyleListItem(string Name, string Url); + +public interface IStyleService +{ + IReadOnlyList List(string baseUrl); + + JsonObject Get(string name, string extractId, string baseUrl); + + JsonObject Bind(JsonObject style, string extractId, string tilesUrl); + + Task SaveAsync(string name, JsonObject style, CancellationToken ct); +} diff --git a/src/TileServer.Application/Styles/StyleService.cs b/src/TileServer.Application/Styles/StyleService.cs new file mode 100644 index 0000000..ba41a4e --- /dev/null +++ b/src/TileServer.Application/Styles/StyleService.cs @@ -0,0 +1,147 @@ +using System.Text.Json.Nodes; +using Microsoft.Extensions.Options; +using TileServer.Application.Configuration; +using TileServer.Application.Extracts; +using TileServer.Application.Tiles; +using TileServer.Domain; +using TileServer.Domain.Exceptions; +using TileServer.Domain.Extracts; + +namespace TileServer.Application.Styles; + +public sealed class StyleService( + IStyleCatalog catalog, + IExtractCatalog extracts, + ITileStore tileStore, + IOptions options) : IStyleService +{ + public IReadOnlyList List(string baseUrl) + => catalog.List() + .Select(s => new StyleListItem(s.Name, Combine(baseUrl, $"/api/v1/styles/{s.Name}"))) + .ToArray(); + + public JsonObject Get(string name, string extractId, string baseUrl) + { + var styleName = ResourceName.Require(name, nameof(name)); + var extract = ResolveExtract(extractId); + var style = catalog.GetRequired(styleName); + var tilesUrl = Combine(baseUrl, $"/api/v1/tiles/{extract.Id}/{{z}}/{{x}}/{{y}}.pbf?v=3"); + BindRuntimeUrls(style, extract, tilesUrl); + return style; + } + + public JsonObject Bind(JsonObject style, string extractId, string tilesUrl) + { + var extract = ResolveExtract(extractId); + var clone = JsonNode.Parse(style.ToJsonString())?.AsObject() + ?? throw new DomainValidationException("Style must be a JSON object."); + BindRuntimeUrls(clone, extract, tilesUrl); + return clone; + } + + private Extract ResolveExtract(string extractId) + => string.IsNullOrWhiteSpace(extractId) + ? extracts.GetAll().FirstOrDefault(e => e.Enabled) + ?? throw new ResourceNotFoundException("Extract", "(none configured)") + : extracts.GetRequired(extractId); + + public Task SaveAsync(string name, JsonObject style, CancellationToken ct) + { + var styleName = ResourceName.Require(name, nameof(name)); + if (style["version"] is null) + { + style["version"] = 8; + } + + return catalog.SaveAsync(styleName, style, ct); + } + + private void BindRuntimeUrls(JsonObject style, Extract extract, string tilesUrl) + { + var glyphs = options.Value.GlyphsUrl; + var metadata = tileStore.GetMetadata(extract.Id); + var minZoom = metadata?.MinZoom ?? extract.MinZoom; + var maxZoom = metadata?.MaxZoom ?? extract.MaxZoom; + + style["glyphs"] = glyphs; + ReplaceOpenSansFonts(style); + style["sources"] ??= new JsonObject(); + if (style["sources"] is not JsonObject sources) + { + throw new DomainValidationException("Style 'sources' must be a JSON object."); + } + + if (sources.Count == 0) + { + sources["openmaptiles"] = CreateVectorSource(tilesUrl, minZoom, maxZoom); + return; + } + + foreach (var property in sources.ToList()) + { + if (property.Value is JsonObject source && + string.Equals(source["type"]?.GetValue(), "vector", StringComparison.OrdinalIgnoreCase)) + { + ApplyVectorSource(source, tilesUrl, minZoom, maxZoom); + } + } + } + + private static JsonObject CreateVectorSource(string tilesUrl, int minZoom, int maxZoom) + { + var source = new JsonObject + { + ["type"] = "vector" + }; + ApplyVectorSource(source, tilesUrl, minZoom, maxZoom); + return source; + } + + private static void ApplyVectorSource(JsonObject source, string tilesUrl, int minZoom, int maxZoom) + { + source["type"] = "vector"; + source["tiles"] = new JsonArray(tilesUrl); + source["minzoom"] = minZoom; + source["maxzoom"] = maxZoom; + source["scheme"] = "xyz"; + source["attribution"] = "© OpenStreetMap contributors"; + source.Remove("url"); + source.Remove("bounds"); + } + + private static void ReplaceOpenSansFonts(JsonObject style) + { + if (style["layers"] is not JsonArray layers) + { + return; + } + + foreach (var node in layers) + { + if (node is not JsonObject layer || layer["layout"] is not JsonObject layout || layout["text-font"] is not JsonArray fonts) + { + continue; + } + + for (var i = 0; i < fonts.Count; i++) + { + var name = fonts[i]?.GetValue(); + if (name == "Open Sans Regular") + { + fonts[i] = "Noto Sans Regular"; + } + else if (name == "Open Sans Bold") + { + fonts[i] = "Noto Sans Bold"; + } + else if (name == "Open Sans Italic") + { + fonts[i] = "Noto Sans Italic"; + } + } + } + } + + private static string Combine(string baseUrl, string path) + => string.IsNullOrEmpty(baseUrl) ? path : $"{baseUrl.TrimEnd('/')}{path}"; +} diff --git a/src/TileServer.Application/Sync/IMapSyncService.cs b/src/TileServer.Application/Sync/IMapSyncService.cs new file mode 100644 index 0000000..d6da38c --- /dev/null +++ b/src/TileServer.Application/Sync/IMapSyncService.cs @@ -0,0 +1,24 @@ +namespace TileServer.Application.Sync; + +public sealed record SyncJobStatus( + bool IsRunning, + DateTimeOffset? LastStartedAt, + DateTimeOffset? LastFinishedAt, + DateTimeOffset? NextScheduledAt, + string? LastError); + +public interface IMapSyncService +{ + Task RunAllAsync(CancellationToken ct); + + Task RunOneAsync(string extractId, CancellationToken ct); + + SyncJobStatus GetStatus(); + + void SetNextScheduledAt(DateTimeOffset? next); +} + +public interface ISyncSchedulerClock +{ + DateTimeOffset? GetNextOccurrence(DateTimeOffset fromUtc); +} diff --git a/src/TileServer.Application/Sync/MapSyncService.cs b/src/TileServer.Application/Sync/MapSyncService.cs new file mode 100644 index 0000000..f989e08 --- /dev/null +++ b/src/TileServer.Application/Sync/MapSyncService.cs @@ -0,0 +1,231 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TileServer.Application.Configuration; +using TileServer.Application; +using TileServer.Application.Extracts; +using TileServer.Application.Tiles; +using TileServer.Domain.Exceptions; +using TileServer.Domain.Extracts; + +namespace TileServer.Application.Sync; + +public sealed class MapSyncService( + IExtractCatalog catalog, + IExtractDownloader downloader, + IExtractStateStore stateStore, + ITileBuilder tileBuilder, + ITileStore tileStore, + IPlanetilerBootstrapper planetiler, + IPlanetilerSourceBootstrapper planetilerSources, + IDataPaths dataPaths, + IOptions options, + ILogger logger) : IMapSyncService +{ + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly object _statusLock = new(); + private DateTimeOffset? _lastStartedAt; + private DateTimeOffset? _lastFinishedAt; + private DateTimeOffset? _nextScheduledAt; + private string? _lastError; + private int _running; + + public SyncJobStatus GetStatus() + { + lock (_statusLock) + { + return new SyncJobStatus( + Volatile.Read(ref _running) == 1, + _lastStartedAt, + _lastFinishedAt, + _nextScheduledAt, + _lastError); + } + } + + public void SetNextScheduledAt(DateTimeOffset? next) + { + lock (_statusLock) + { + _nextScheduledAt = next; + } + } + + public async Task RunAllAsync(CancellationToken ct) + { + if (!await _gate.WaitAsync(0, ct).ConfigureAwait(false)) + { + throw new ResourceConflictException("A synchronization job is already running."); + } + + try + { + Begin(); + var extracts = catalog.GetAll().Where(e => e.Enabled).ToArray(); + if (extracts.Length == 0) + { + logger.LogWarning("No enabled OSM extracts configured."); + Finish(null); + return; + } + + logger.LogInformation("Starting sync for {Count} OSM extract(s).", extracts.Length); + foreach (var extract in extracts) + { + ct.ThrowIfCancellationRequested(); + await SyncExtractAsync(extract, ct).ConfigureAwait(false); + } + + Finish(null); + } + catch (OperationCanceledException) + { + Finish("Cancelled"); + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Map sync failed."); + Finish(ex.Message); + throw; + } + finally + { + _gate.Release(); + } + } + + public async Task RunOneAsync(string extractId, CancellationToken ct) + { + var extract = catalog.GetRequired(extractId); + if (!await _gate.WaitAsync(0, ct).ConfigureAwait(false)) + { + throw new ResourceConflictException("A synchronization job is already running."); + } + + try + { + Begin(); + await SyncExtractAsync(extract, ct).ConfigureAwait(false); + Finish(null); + } + catch (OperationCanceledException) + { + Finish("Cancelled"); + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Map sync failed for {ExtractId}.", extractId); + Finish(ex.Message); + throw; + } + finally + { + _gate.Release(); + } + } + + private async Task SyncExtractAsync(Extract extract, CancellationToken ct) + { + var state = await stateStore.GetAsync(extract.Id, ct).ConfigureAwait(false); + var pbfPath = dataPaths.OsmPbf(extract.Id); + + try + { + state.Status = ExtractSyncState.Downloading; + state.LastError = null; + await stateStore.SaveAsync(state, ct).ConfigureAwait(false); + + logger.LogInformation("Downloading {ExtractId} from {Url}", extract.Id, extract.Url); + var download = await downloader.DownloadAsync(extract, pbfPath, state, ct).ConfigureAwait(false); + state.ETag = download.ETag; + state.RemoteLastModified = download.LastModified; + state.PbfBytes = download.Bytes; + if (download.Changed) + { + state.DownloadedAt = DateTimeOffset.UtcNow; + } + + var tilesMissing = !tileStore.Exists(extract.Id); + var metadata = tileStore.GetMetadata(extract.Id); + var zoomRaised = metadata is not null && extract.MaxZoom > metadata.MaxZoom; + var shouldBuild = download.Changed + || (tilesMissing && options.Value.Sync.RebuildIfTilesMissing) + || zoomRaised; + if (!shouldBuild) + { + state.Status = ExtractSyncState.Ready; + await stateStore.SaveAsync(state, ct).ConfigureAwait(false); + logger.LogInformation("Extract {ExtractId} is up to date, skip rebuild.", extract.Id); + return; + } + + if (zoomRaised) + { + logger.LogInformation( + "Rebuilding {ExtractId}: configured maxzoom {Wanted} > tileset maxzoom {Have}.", + extract.Id, + extract.MaxZoom, + metadata!.MaxZoom); + } + + if (!planetiler.IsJavaAvailable()) + { + throw new InvalidOperationException( + "Java 21+ is required to build tiles with planetiler-openmaptiles. Install a JDK and set TileServer:JavaPath."); + } + + await planetiler.EnsureJarAsync(ct).ConfigureAwait(false); + + state.Status = ExtractSyncState.PreparingSources; + await stateStore.SaveAsync(state, ct).ConfigureAwait(false); + logger.LogInformation("Preparing Planetiler sidecar sources for {ExtractId}.", extract.Id); + await planetilerSources.EnsureAsync(ct).ConfigureAwait(false); + + state.Status = ExtractSyncState.Building; + await stateStore.SaveAsync(state, ct).ConfigureAwait(false); + + var staging = tileStore.GetStagingPath(extract.Id); + logger.LogInformation("Building vector tiles for {ExtractId} -> {Path}", extract.Id, staging); + await tileBuilder.BuildAsync(extract, pbfPath, staging, ct).ConfigureAwait(false); + tileStore.Activate(extract.Id, staging); + + state.BuiltAt = DateTimeOffset.UtcNow; + state.Status = ExtractSyncState.Ready; + state.LastError = null; + await stateStore.SaveAsync(state, ct).ConfigureAwait(false); + logger.LogInformation("Extract {ExtractId} is ready.", extract.Id); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to sync extract {ExtractId}", extract.Id); + state.Status = ExtractSyncState.Failed; + state.LastError = ex.Message; + await stateStore.SaveAsync(state, CancellationToken.None).ConfigureAwait(false); + } + } + + private void Begin() + { + Volatile.Write(ref _running, 1); + lock (_statusLock) + { + _lastStartedAt = DateTimeOffset.UtcNow; + _lastError = null; + } + } + + private void Finish(string? error) + { + Volatile.Write(ref _running, 0); + lock (_statusLock) + { + _lastFinishedAt = DateTimeOffset.UtcNow; + _lastError = error; + } + } +} diff --git a/src/TileServer.Application/TileServer.Application.csproj b/src/TileServer.Application/TileServer.Application.csproj new file mode 100644 index 0000000..53322e2 --- /dev/null +++ b/src/TileServer.Application/TileServer.Application.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/TileServer.Application/Tiles/ITileBuilder.cs b/src/TileServer.Application/Tiles/ITileBuilder.cs new file mode 100644 index 0000000..8dd2585 --- /dev/null +++ b/src/TileServer.Application/Tiles/ITileBuilder.cs @@ -0,0 +1,20 @@ +using TileServer.Domain.Extracts; + +namespace TileServer.Application.Tiles; + +public interface ITileBuilder +{ + Task BuildAsync(Extract extract, string osmPbfPath, string outputMbtilesPath, CancellationToken ct); +} + +public interface IPlanetilerBootstrapper +{ + Task EnsureJarAsync(CancellationToken ct); + + bool IsJavaAvailable(); +} + +public interface IPlanetilerSourceBootstrapper +{ + Task EnsureAsync(CancellationToken ct); +} diff --git a/src/TileServer.Application/Tiles/ITileQueryService.cs b/src/TileServer.Application/Tiles/ITileQueryService.cs new file mode 100644 index 0000000..5ec5fc7 --- /dev/null +++ b/src/TileServer.Application/Tiles/ITileQueryService.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; +using TileServer.Domain.Tiles; + +namespace TileServer.Application.Tiles; + +public sealed record TileQueryResult(TileData Tile, string ExtractId, DateTimeOffset? BuiltAt); + +public interface ITileQueryService +{ + Task GetTileAsync(string extractId, TileCoordinate tile, CancellationToken ct); + + TileJsonResponse GetTileJson(string extractId, string tilesUrl); +} + +public sealed record TileJsonResponse( + string Tilejson, + string Name, + string[] Tiles, + double[] Bounds, + double[] Center, + int Minzoom, + int Maxzoom, + string Format, + string Attribution, + [property: JsonPropertyName("vector_layers")] IReadOnlyList VectorLayers); + +public sealed record VectorLayerJson( + string Id, + string Description, + int Minzoom, + int Maxzoom, + IReadOnlyDictionary Fields); diff --git a/src/TileServer.Application/Tiles/ITileStore.cs b/src/TileServer.Application/Tiles/ITileStore.cs new file mode 100644 index 0000000..1ded7b8 --- /dev/null +++ b/src/TileServer.Application/Tiles/ITileStore.cs @@ -0,0 +1,19 @@ +using TileServer.Domain.Extracts; +using TileServer.Domain.Tiles; + +namespace TileServer.Application.Tiles; + +public interface ITileStore +{ + bool Exists(string extractId); + + Task GetTileAsync(string extractId, TileCoordinate tile, CancellationToken ct); + + TilesetMetadata? GetMetadata(string extractId); + + string GetActivePath(string extractId); + + string GetStagingPath(string extractId); + + void Activate(string extractId, string stagingPath); +} diff --git a/src/TileServer.Application/Tiles/IVectorTileOverzoomer.cs b/src/TileServer.Application/Tiles/IVectorTileOverzoomer.cs new file mode 100644 index 0000000..7080ed7 --- /dev/null +++ b/src/TileServer.Application/Tiles/IVectorTileOverzoomer.cs @@ -0,0 +1,8 @@ +using TileServer.Domain.Tiles; + +namespace TileServer.Application.Tiles; + +public interface IVectorTileOverzoomer +{ + TileData Overzoom(TileData parent, TileCoordinate parentTile, TileCoordinate childTile); +} diff --git a/src/TileServer.Application/Tiles/TileQueryService.cs b/src/TileServer.Application/Tiles/TileQueryService.cs new file mode 100644 index 0000000..7c2002b --- /dev/null +++ b/src/TileServer.Application/Tiles/TileQueryService.cs @@ -0,0 +1,131 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TileServer.Application.Configuration; +using TileServer.Application.Extracts; +using TileServer.Domain.Exceptions; +using TileServer.Domain.Tiles; + +namespace TileServer.Application.Tiles; + +public sealed class TileQueryService( + ITileStore tileStore, + IExtractCatalog extracts, + IVectorTileOverzoomer overzoomer, + IMemoryCache cache, + IOptions options, + ILogger logger) : ITileQueryService +{ + private const string Attribution = "© OpenStreetMap contributors"; + + public async Task GetTileAsync(string extractId, TileCoordinate tile, CancellationToken ct) + { + var extract = extracts.GetRequired(extractId); + if (!tile.IsValid) + { + throw new DomainValidationException($"Invalid XYZ tile {tile.Z}/{tile.X}/{tile.Y}."); + } + + var metadata = tileStore.GetMetadata(extractId); + var dataMaxZoom = metadata?.MaxZoom ?? extract.MaxZoom; + var servedMaxZoom = TileZoomPolicy.ServedMaxZoom(dataMaxZoom, options.Value.OverzoomMaxZoom); + if (tile.Z > servedMaxZoom) + { + return null; + } + + var stored = await tileStore.GetTileAsync(extractId, tile, ct).ConfigureAwait(false); + if (stored is not null) + { + return new TileQueryResult(stored, extractId, GuessBuiltAt(extractId, metadata)); + } + + if (tile.Z <= dataMaxZoom) + { + return null; + } + + var cacheKey = $"overzoom:{extractId}/{tile.Z}/{tile.X}/{tile.Y}"; + if (cache.TryGetValue(cacheKey, out TileData? cached) && cached is not null) + { + return new TileQueryResult(cached, extractId, GuessBuiltAt(extractId, metadata)); + } + + var parent = tile.Ancestor(dataMaxZoom); + var parentTile = await tileStore.GetTileAsync(extractId, parent, ct).ConfigureAwait(false); + if (parentTile is null) + { + return null; + } + + var overzoomed = overzoomer.Overzoom(parentTile, parent, tile); + cache.Set(cacheKey, overzoomed, new MemoryCacheEntryOptions + { + Size = 1, + SlidingExpiration = TimeSpan.FromMinutes(15) + }); + return new TileQueryResult(overzoomed, extractId, GuessBuiltAt(extractId, metadata)); + } + + public TileJsonResponse GetTileJson(string extractId, string tilesUrl) + { + var extract = extracts.GetRequired(extractId); + var metadata = tileStore.GetMetadata(extractId); + + var minZoom = metadata?.MinZoom ?? extract.MinZoom; + var maxZoom = metadata?.MaxZoom ?? extract.MaxZoom; + var bounds = metadata?.Bounds ?? [-180, -85.0511, 180, 85.0511]; + var center = metadata is null + ? new[] { extract.CenterLon, extract.CenterLat, Math.Clamp(extract.MinZoom + 6, extract.MinZoom, extract.MaxZoom) } + : metadata.Center; + var layers = metadata?.VectorLayers + .Select(l => new VectorLayerJson(l.Id, l.Id, l.MinZoom, l.MaxZoom, l.Fields)) + .ToArray() + ?? DefaultOpenMapTilesLayers(minZoom, maxZoom); + + return new TileJsonResponse( + Tilejson: "3.0.0", + Name: extract.Name, + Tiles: [tilesUrl], + Bounds: bounds, + Center: center, + Minzoom: minZoom, + Maxzoom: maxZoom, + Format: "pbf", + Attribution: Attribution, + VectorLayers: layers); + } + + private DateTimeOffset? GuessBuiltAt(string extractId, TilesetMetadata? metadata) + { + if (metadata is null) + { + return null; + } + + try + { + var path = tileStore.GetActivePath(extractId); + return File.Exists(path) ? new DateTimeOffset(File.GetLastWriteTimeUtc(path), TimeSpan.Zero) : null; + } + catch (Exception ex) + { + logger.LogDebug(ex, "Could not read mtime for extract {ExtractId}", extractId); + return null; + } + } + + private static IReadOnlyList DefaultOpenMapTilesLayers(int minZoom, int maxZoom) + { + string[] ids = + [ + "water", "waterway", "landcover", "landuse", "park", "boundary", "aeroway", + "transportation", "building", "water_name", "transportation_name", "place", + "housenumber", "poi", "mountain_peak", "aerodrome_label" + ]; + + return ids + .Select(id => new VectorLayerJson(id, id, minZoom, maxZoom, new Dictionary())) + .ToArray(); + } +} diff --git a/src/TileServer.Application/Tiles/TileZoomPolicy.cs b/src/TileServer.Application/Tiles/TileZoomPolicy.cs new file mode 100644 index 0000000..0b3c182 --- /dev/null +++ b/src/TileServer.Application/Tiles/TileZoomPolicy.cs @@ -0,0 +1,9 @@ +namespace TileServer.Application.Tiles; + +public static class TileZoomPolicy +{ + public const int ClientMaxZoom = 22; + + public static int ServedMaxZoom(int dataMaxZoom, int overzoomMaxZoom) + => Math.Clamp(Math.Max(dataMaxZoom, overzoomMaxZoom), 0, ClientMaxZoom); +} diff --git a/src/TileServer.Domain/Accounts/AccountSlug.cs b/src/TileServer.Domain/Accounts/AccountSlug.cs new file mode 100644 index 0000000..8074940 --- /dev/null +++ b/src/TileServer.Domain/Accounts/AccountSlug.cs @@ -0,0 +1,60 @@ +using System.Globalization; +using System.Text; + +namespace TileServer.Domain.Accounts; + +public static class AccountSlug +{ + public static string FromLogin(string login) + { + ArgumentException.ThrowIfNullOrWhiteSpace(login); + + var normalized = login.Trim().ToLowerInvariant(); + var buffer = new StringBuilder(normalized.Length); + foreach (var ch in normalized) + { + if (ch is >= 'a' and <= 'z' || ch is >= '0' and <= '9') + { + buffer.Append(ch); + } + else if (ch is '-' or '_' or '.') + { + if (buffer.Length > 0 && buffer[^1] != '-') + { + buffer.Append('-'); + } + } + } + + var slug = buffer.ToString().Trim('-'); + if (slug.Length == 0) + { + slug = "user"; + } + + if (char.IsDigit(slug[0])) + { + slug = "u-" + slug; + } + + if (slug.Length > ResourceName.MaxLength) + { + slug = slug[..ResourceName.MaxLength].TrimEnd('-'); + } + + return ResourceName.Require(slug, nameof(login)); + } + + public static string WithSuffix(string slug, int suffix) + { + var tail = suffix.ToString(CultureInfo.InvariantCulture); + var maxBase = ResourceName.MaxLength - tail.Length - 1; + var head = slug.Length > maxBase ? slug[..maxBase].TrimEnd('-') : slug; + if (head.Length == 0 || !char.IsLetter(head[0])) + { + head = "user"; + } + + return ResourceName.Require($"{head}-{tail}", nameof(slug)); + } +} diff --git a/src/TileServer.Domain/Accounts/ApiToken.cs b/src/TileServer.Domain/Accounts/ApiToken.cs new file mode 100644 index 0000000..bc673e1 --- /dev/null +++ b/src/TileServer.Domain/Accounts/ApiToken.cs @@ -0,0 +1,22 @@ +namespace TileServer.Domain.Accounts; + +public sealed class ApiToken +{ + public Guid Id { get; set; } + + public Guid UserId { get; set; } + + public User? User { get; set; } + + public required string Name { get; set; } + + public required string Hash { get; set; } + + public required string Prefix { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset? RevokedAt { get; set; } + + public bool IsRevoked => RevokedAt is not null; +} diff --git a/src/TileServer.Domain/Accounts/ApiTokenHash.cs b/src/TileServer.Domain/Accounts/ApiTokenHash.cs new file mode 100644 index 0000000..61ddc69 --- /dev/null +++ b/src/TileServer.Domain/Accounts/ApiTokenHash.cs @@ -0,0 +1,47 @@ +using System.Security.Cryptography; +using System.Text; + +namespace TileServer.Domain.Accounts; + +public static class ApiTokenHash +{ + public const string Prefix = "ts_"; + + public static string CreatePlaintext() + { + Span bytes = stackalloc byte[24]; + RandomNumberGenerator.Fill(bytes); + return Prefix + Convert.ToHexString(bytes).ToLowerInvariant(); + } + + public static string Compute(string plaintext) + { + ArgumentException.ThrowIfNullOrWhiteSpace(plaintext); + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(plaintext)); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + public static bool Verify(string plaintext, string expectedHash) + { + if (string.IsNullOrWhiteSpace(plaintext) || string.IsNullOrWhiteSpace(expectedHash)) + { + return false; + } + + var actual = Compute(plaintext); + var actualBytes = Encoding.UTF8.GetBytes(actual); + var expectedBytes = Encoding.UTF8.GetBytes(expectedHash.Trim().ToLowerInvariant()); + return actualBytes.Length == expectedBytes.Length + && CryptographicOperations.FixedTimeEquals(actualBytes, expectedBytes); + } + + public static string DisplayPrefix(string plaintext) + { + if (string.IsNullOrEmpty(plaintext) || plaintext.Length < 11) + { + return Prefix; + } + + return plaintext[..11]; + } +} diff --git a/src/TileServer.Domain/Accounts/UsageDaily.cs b/src/TileServer.Domain/Accounts/UsageDaily.cs new file mode 100644 index 0000000..55b737d --- /dev/null +++ b/src/TileServer.Domain/Accounts/UsageDaily.cs @@ -0,0 +1,18 @@ +namespace TileServer.Domain.Accounts; + +public sealed class UsageDaily +{ + public Guid Id { get; set; } + + public Guid UserId { get; set; } + + public Guid TokenId { get; set; } + + public DateOnly Date { get; set; } + + public long TileRequests { get; set; } + + public long StyleRequests { get; set; } + + public long Bytes { get; set; } +} diff --git a/src/TileServer.Domain/Accounts/User.cs b/src/TileServer.Domain/Accounts/User.cs new file mode 100644 index 0000000..f644f25 --- /dev/null +++ b/src/TileServer.Domain/Accounts/User.cs @@ -0,0 +1,22 @@ +namespace TileServer.Domain.Accounts; + +public sealed class User +{ + public Guid Id { get; set; } + + public required string YandexId { get; set; } + + public required string Login { get; set; } + + public required string DisplayName { get; set; } + + public required string Slug { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset LastLoginAt { get; set; } + + public List Tokens { get; set; } = []; + + public List Styles { get; set; } = []; +} diff --git a/src/TileServer.Domain/Accounts/UserStyle.cs b/src/TileServer.Domain/Accounts/UserStyle.cs new file mode 100644 index 0000000..9477dc8 --- /dev/null +++ b/src/TileServer.Domain/Accounts/UserStyle.cs @@ -0,0 +1,20 @@ +namespace TileServer.Domain.Accounts; + +public sealed class UserStyle +{ + public Guid Id { get; set; } + + public Guid UserId { get; set; } + + public User? User { get; set; } + + public required string Name { get; set; } + + public required string Json { get; set; } + + public string? CreatedFrom { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset UpdatedAt { get; set; } +} diff --git a/src/TileServer.Domain/Exceptions/DomainException.cs b/src/TileServer.Domain/Exceptions/DomainException.cs new file mode 100644 index 0000000..5f102f3 --- /dev/null +++ b/src/TileServer.Domain/Exceptions/DomainException.cs @@ -0,0 +1,43 @@ +namespace TileServer.Domain.Exceptions; + +public abstract class DomainException : Exception +{ + protected DomainException(string message) : base(message) + { + } +} + +public sealed class ResourceNotFoundException : DomainException +{ + public ResourceNotFoundException(string resource, string key) + : base($"{resource} '{key}' was not found.") + { + Resource = resource; + Key = key; + } + + public string Resource { get; } + + public string Key { get; } +} + +public sealed class ResourceConflictException : DomainException +{ + public ResourceConflictException(string message) : base(message) + { + } +} + +public sealed class DomainValidationException : DomainException +{ + public DomainValidationException(string message) : base(message) + { + } +} + +public sealed class UnauthenticatedException : DomainException +{ + public UnauthenticatedException(string message) : base(message) + { + } +} diff --git a/src/TileServer.Domain/Extracts/Extract.cs b/src/TileServer.Domain/Extracts/Extract.cs new file mode 100644 index 0000000..33e46ad --- /dev/null +++ b/src/TileServer.Domain/Extracts/Extract.cs @@ -0,0 +1,22 @@ +using TileServer.Domain.Tiles; + +namespace TileServer.Domain.Extracts; + +public sealed class Extract +{ + public required string Id { get; init; } + + public required string Name { get; init; } + + public required Uri Url { get; init; } + + public double CenterLon { get; init; } + + public double CenterLat { get; init; } + + public int MinZoom { get; init; } = 0; + + public int MaxZoom { get; init; } = PlanetilerLimits.MaxZoom; + + public bool Enabled { get; init; } = true; +} diff --git a/src/TileServer.Domain/Extracts/ExtractSyncState.cs b/src/TileServer.Domain/Extracts/ExtractSyncState.cs new file mode 100644 index 0000000..0be0529 --- /dev/null +++ b/src/TileServer.Domain/Extracts/ExtractSyncState.cs @@ -0,0 +1,32 @@ +namespace TileServer.Domain.Extracts; + +public enum ExtractSyncState +{ + Pending = 0, + Downloading = 1, + Building = 2, + Ready = 3, + Failed = 4, + PreparingSources = 5 +} + +public sealed class ExtractRuntimeState +{ + public required string ExtractId { get; init; } + + public ExtractSyncState Status { get; set; } = ExtractSyncState.Pending; + + public string? ETag { get; set; } + + public DateTimeOffset? RemoteLastModified { get; set; } + + public long? PbfBytes { get; set; } + + public DateTimeOffset? DownloadedAt { get; set; } + + public DateTimeOffset? BuiltAt { get; set; } + + public string? LastError { get; set; } + + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/TileServer.Domain/ResourceName.cs b/src/TileServer.Domain/ResourceName.cs new file mode 100644 index 0000000..dc438a2 --- /dev/null +++ b/src/TileServer.Domain/ResourceName.cs @@ -0,0 +1,26 @@ +using System.Text.RegularExpressions; + +namespace TileServer.Domain; + +public static partial class ResourceName +{ + public const int MaxLength = 64; + + public static bool IsValid(string? value) + => !string.IsNullOrWhiteSpace(value) && value.Length <= MaxLength && Pattern().IsMatch(value); + + public static string Require(string? value, string paramName) + { + if (!IsValid(value)) + { + throw new ArgumentException( + "Name must be lowercase latin, digits and hyphens, start with a letter, max 64 chars.", + paramName); + } + + return value!; + } + + [GeneratedRegex("^[a-z][a-z0-9-]{0,63}$", RegexOptions.CultureInvariant)] + private static partial Regex Pattern(); +} diff --git a/src/TileServer.Domain/TileServer.Domain.csproj b/src/TileServer.Domain/TileServer.Domain.csproj new file mode 100644 index 0000000..e68c7cb --- /dev/null +++ b/src/TileServer.Domain/TileServer.Domain.csproj @@ -0,0 +1,3 @@ + + + diff --git a/src/TileServer.Domain/Tiles/PlanetilerLimits.cs b/src/TileServer.Domain/Tiles/PlanetilerLimits.cs new file mode 100644 index 0000000..b1cddcd --- /dev/null +++ b/src/TileServer.Domain/Tiles/PlanetilerLimits.cs @@ -0,0 +1,15 @@ +namespace TileServer.Domain.Tiles; + +/// +/// OpenMapTiles schema targets z0–z14. Planetiler 0.9.x (planetiler-openmaptiles 3.16) +/// can emit one extra zoom; z16+ is a hard addressing limit in that release. +/// +public static class PlanetilerLimits +{ + public const int DefaultMaxZoom = 14; + + public const int MaxZoom = 15; + + public static int ClampMaxZoom(int maxZoom) + => Math.Clamp(maxZoom, 0, MaxZoom); +} diff --git a/src/TileServer.Domain/Tiles/TileCoordinate.cs b/src/TileServer.Domain/Tiles/TileCoordinate.cs new file mode 100644 index 0000000..1b5b69d --- /dev/null +++ b/src/TileServer.Domain/Tiles/TileCoordinate.cs @@ -0,0 +1,39 @@ +namespace TileServer.Domain.Tiles; + +public readonly record struct TileCoordinate(int Z, int X, int Y) +{ + public bool IsValid + { + get + { + if (Z is < 0 or > 30) + { + return false; + } + + var size = 1 << Z; + return X >= 0 && Y >= 0 && X < size && Y < size; + } + } + + /// + /// MBTiles stores TMS (origin south-west). MapLibre/XYZ origin is north-west. + /// + public int ToTmsY() => (1 << Z) - 1 - Y; + + public TileCoordinate Ancestor(int zoom) + { + if (zoom == Z) + { + return this; + } + + if (zoom < 0 || zoom > Z) + { + throw new ArgumentOutOfRangeException(nameof(zoom), zoom, "Ancestor zoom must be between 0 and Z."); + } + + var shift = Z - zoom; + return new TileCoordinate(zoom, X >> shift, Y >> shift); + } +} diff --git a/src/TileServer.Domain/Tiles/TileData.cs b/src/TileServer.Domain/Tiles/TileData.cs new file mode 100644 index 0000000..3887642 --- /dev/null +++ b/src/TileServer.Domain/Tiles/TileData.cs @@ -0,0 +1,23 @@ +using System.IO.Compression; + +namespace TileServer.Domain.Tiles; + +public sealed record TileData(byte[] Bytes, bool IsGzipped) +{ + public static bool IsGzip(ReadOnlySpan payload) + => payload.Length >= 2 && payload[0] == 0x1F && payload[1] == 0x8B; + + public byte[] Uncompressed() + { + if (!IsGzipped) + { + return Bytes; + } + + using var input = new MemoryStream(Bytes, writable: false); + using var gzip = new GZipStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + gzip.CopyTo(output); + return output.ToArray(); + } +} diff --git a/src/TileServer.Domain/Tiles/TilesetMetadata.cs b/src/TileServer.Domain/Tiles/TilesetMetadata.cs new file mode 100644 index 0000000..711bded --- /dev/null +++ b/src/TileServer.Domain/Tiles/TilesetMetadata.cs @@ -0,0 +1,23 @@ +namespace TileServer.Domain.Tiles; + +public sealed record VectorLayerInfo(string Id, int MinZoom, int MaxZoom, IReadOnlyDictionary Fields); + +public sealed record TilesetMetadata( + string ExtractId, + string Name, + string Format, + double MinLon, + double MinLat, + double MaxLon, + double MaxLat, + double CenterLon, + double CenterLat, + int CenterZoom, + int MinZoom, + int MaxZoom, + IReadOnlyList VectorLayers) +{ + public double[] Bounds => [MinLon, MinLat, MaxLon, MaxLat]; + + public double[] Center => [CenterLon, CenterLat, CenterZoom]; +} diff --git a/src/TileServer.Infrastructure/Accounts/ApiTokenRepository.cs b/src/TileServer.Infrastructure/Accounts/ApiTokenRepository.cs new file mode 100644 index 0000000..b18d0e6 --- /dev/null +++ b/src/TileServer.Infrastructure/Accounts/ApiTokenRepository.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using TileServer.Application.Accounts; +using TileServer.Domain.Accounts; +using TileServer.Infrastructure.Data; + +namespace TileServer.Infrastructure.Accounts; + +public sealed class ApiTokenRepository(AppDbContext db) : IApiTokenRepository +{ + public async Task> ListByUserAsync(Guid userId, CancellationToken ct) + => await db.ApiTokens + .Where(x => x.UserId == userId) + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(ct) + .ConfigureAwait(false); + + public Task FindByIdAsync(Guid userId, Guid tokenId, CancellationToken ct) + => db.ApiTokens.FirstOrDefaultAsync(x => x.UserId == userId && x.Id == tokenId, ct); + + public Task FindByHashAsync(string hash, CancellationToken ct) + => db.ApiTokens.FirstOrDefaultAsync(x => x.Hash == hash, ct); + + public Task CountActiveAsync(Guid userId, CancellationToken ct) + => db.ApiTokens.CountAsync(x => x.UserId == userId && x.RevokedAt == null, ct); + + public async Task AddAsync(ApiToken token, CancellationToken ct) + => await db.ApiTokens.AddAsync(token, ct).ConfigureAwait(false); + + public Task SaveChangesAsync(CancellationToken ct) + => db.SaveChangesAsync(ct); +} diff --git a/src/TileServer.Infrastructure/Accounts/UsageFlushHostedService.cs b/src/TileServer.Infrastructure/Accounts/UsageFlushHostedService.cs new file mode 100644 index 0000000..0df5d90 --- /dev/null +++ b/src/TileServer.Infrastructure/Accounts/UsageFlushHostedService.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using TileServer.Application.Accounts; + +namespace TileServer.Infrastructure.Accounts; + +public sealed class UsageFlushHostedService( + UsageAccumulator accumulator, + IServiceScopeFactory scopes, + ILogger logger) : BackgroundService +{ + private static readonly TimeSpan Interval = TimeSpan.FromSeconds(5); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(Interval); + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) + { + await FlushAsync(stoppingToken).ConfigureAwait(false); + } + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + await FlushAsync(cancellationToken).ConfigureAwait(false); + await base.StopAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task FlushAsync(CancellationToken ct) + { + var deltas = accumulator.Drain(); + if (deltas.Count == 0) + { + return; + } + + try + { + await using var scope = scopes.CreateAsyncScope(); + var usage = scope.ServiceProvider.GetRequiredService(); + await usage.ApplyAsync(deltas, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to flush {Count} usage aggregates", deltas.Count); + foreach (var delta in deltas) + { + accumulator.Restore(delta); + } + } + } +} diff --git a/src/TileServer.Infrastructure/Accounts/UsageRepository.cs b/src/TileServer.Infrastructure/Accounts/UsageRepository.cs new file mode 100644 index 0000000..7117dc1 --- /dev/null +++ b/src/TileServer.Infrastructure/Accounts/UsageRepository.cs @@ -0,0 +1,66 @@ +using Microsoft.EntityFrameworkCore; +using TileServer.Application.Accounts; +using TileServer.Domain.Accounts; +using TileServer.Infrastructure.Data; + +namespace TileServer.Infrastructure.Accounts; + +public sealed class UsageRepository(AppDbContext db) : IUsageRepository, IUsageQuery +{ + public async Task ApplyAsync(IReadOnlyList deltas, CancellationToken ct) + { + if (deltas.Count == 0) + { + return; + } + + foreach (var delta in deltas) + { + var row = await db.UsageDaily.FirstOrDefaultAsync( + x => x.UserId == delta.UserId && x.TokenId == delta.TokenId && x.Date == delta.Day, + ct) + .ConfigureAwait(false); + if (row is null) + { + db.UsageDaily.Add(new UsageDaily + { + Id = Guid.NewGuid(), + UserId = delta.UserId, + TokenId = delta.TokenId, + Date = delta.Day, + TileRequests = delta.TileRequests, + StyleRequests = delta.StyleRequests, + Bytes = delta.Bytes + }); + } + else + { + row.TileRequests += delta.TileRequests; + row.StyleRequests += delta.StyleRequests; + row.Bytes += delta.Bytes; + } + } + + await db.SaveChangesAsync(ct).ConfigureAwait(false); + } + + public async Task> GetAsync(Guid userId, DateOnly from, DateOnly to, CancellationToken ct) + { + var rows = await db.UsageDaily + .AsNoTracking() + .Where(x => x.UserId == userId && x.Date >= from && x.Date <= to) + .Select(x => new { x.Date, x.TileRequests, x.StyleRequests, x.Bytes }) + .ToListAsync(ct) + .ConfigureAwait(false); + + return rows + .GroupBy(x => x.Date) + .Select(g => new UsageDayDto( + g.Key, + g.Sum(x => x.TileRequests), + g.Sum(x => x.StyleRequests), + g.Sum(x => x.Bytes))) + .OrderBy(x => x.Date) + .ToArray(); + } +} diff --git a/src/TileServer.Infrastructure/Accounts/UserRepository.cs b/src/TileServer.Infrastructure/Accounts/UserRepository.cs new file mode 100644 index 0000000..688f02d --- /dev/null +++ b/src/TileServer.Infrastructure/Accounts/UserRepository.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using TileServer.Application.Accounts; +using TileServer.Domain.Accounts; +using TileServer.Infrastructure.Data; + +namespace TileServer.Infrastructure.Accounts; + +public sealed class UserRepository(AppDbContext db) : IUserRepository +{ + public Task FindByIdAsync(Guid id, CancellationToken ct) + => db.Users.FirstOrDefaultAsync(x => x.Id == id, ct); + + public Task FindByYandexIdAsync(string yandexId, CancellationToken ct) + => db.Users.FirstOrDefaultAsync(x => x.YandexId == yandexId, ct); + + public Task FindBySlugAsync(string slug, CancellationToken ct) + => db.Users.FirstOrDefaultAsync(x => x.Slug == slug, ct); + + public Task SlugExistsAsync(string slug, Guid? exceptUserId, CancellationToken ct) + => exceptUserId is null + ? db.Users.AnyAsync(x => x.Slug == slug, ct) + : db.Users.AnyAsync(x => x.Slug == slug && x.Id != exceptUserId, ct); + + public async Task AddAsync(User user, CancellationToken ct) + => await db.Users.AddAsync(user, ct).ConfigureAwait(false); + + public Task SaveChangesAsync(CancellationToken ct) + => db.SaveChangesAsync(ct); +} diff --git a/src/TileServer.Infrastructure/Accounts/UserStyleRepository.cs b/src/TileServer.Infrastructure/Accounts/UserStyleRepository.cs new file mode 100644 index 0000000..d3d4c93 --- /dev/null +++ b/src/TileServer.Infrastructure/Accounts/UserStyleRepository.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using TileServer.Application.Accounts; +using TileServer.Domain.Accounts; +using TileServer.Infrastructure.Data; + +namespace TileServer.Infrastructure.Accounts; + +public sealed class UserStyleRepository(AppDbContext db) : IUserStyleRepository +{ + public async Task> ListByUserAsync(Guid userId, CancellationToken ct) + => await db.UserStyles + .Where(x => x.UserId == userId) + .OrderBy(x => x.Name) + .ToListAsync(ct) + .ConfigureAwait(false); + + public Task FindAsync(Guid userId, string name, CancellationToken ct) + => db.UserStyles.FirstOrDefaultAsync(x => x.UserId == userId && x.Name == name, ct); + + public async Task AddAsync(UserStyle style, CancellationToken ct) + => await db.UserStyles.AddAsync(style, ct).ConfigureAwait(false); + + public Task RemoveAsync(UserStyle style, CancellationToken ct) + { + db.UserStyles.Remove(style); + return Task.CompletedTask; + } + + public Task SaveChangesAsync(CancellationToken ct) + => db.SaveChangesAsync(ct); +} diff --git a/src/TileServer.Infrastructure/Auth/YandexOAuthClient.cs b/src/TileServer.Infrastructure/Auth/YandexOAuthClient.cs new file mode 100644 index 0000000..fb99c24 --- /dev/null +++ b/src/TileServer.Infrastructure/Auth/YandexOAuthClient.cs @@ -0,0 +1,86 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Options; +using TileServer.Application.Accounts; +using TileServer.Application.Configuration; +using TileServer.Domain.Exceptions; + +namespace TileServer.Infrastructure.Auth; + +public sealed class YandexOAuthClient( + IHttpClientFactory httpClientFactory, + IOptions options) : IYandexOAuthClient +{ + public async Task ExchangeAsync(string code, string redirectUri, CancellationToken ct) + { + var yandex = options.Value.Yandex; + if (!yandex.IsConfigured) + { + throw new DomainValidationException("Yandex OAuth is not configured."); + } + + var callback = string.IsNullOrWhiteSpace(redirectUri) ? yandex.RedirectUri : redirectUri.Trim(); + var client = httpClientFactory.CreateClient("Yandex"); + using var tokenRequest = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = code, + ["client_id"] = yandex.ClientId, + ["client_secret"] = yandex.ClientSecret, + ["redirect_uri"] = callback + }); + using var tokenResponse = await client.PostAsync("https://oauth.yandex.ru/token", tokenRequest, ct) + .ConfigureAwait(false); + if (!tokenResponse.IsSuccessStatusCode) + { + throw new UnauthenticatedException("Yandex rejected the authorization code."); + } + + var token = await tokenResponse.Content.ReadFromJsonAsync(ct).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(token?.AccessToken)) + { + throw new UnauthenticatedException("Yandex did not return an access token."); + } + + using var infoRequest = new HttpRequestMessage(HttpMethod.Get, "https://login.yandex.ru/info?format=json"); + infoRequest.Headers.Authorization = new AuthenticationHeaderValue("OAuth", token.AccessToken); + using var infoResponse = await client.SendAsync(infoRequest, ct).ConfigureAwait(false); + if (!infoResponse.IsSuccessStatusCode) + { + throw new UnauthenticatedException("Could not load the Yandex profile."); + } + + var profile = await infoResponse.Content.ReadFromJsonAsync(ct).ConfigureAwait(false); + if (profile is null || string.IsNullOrWhiteSpace(profile.Id) || string.IsNullOrWhiteSpace(profile.Login)) + { + throw new UnauthenticatedException("Yandex profile is incomplete."); + } + + var display = string.IsNullOrWhiteSpace(profile.DisplayName) + ? profile.RealName + : profile.DisplayName; + return new YandexProfile(profile.Id, profile.Login, display ?? profile.Login); + } + + private sealed class YandexTokenResponse + { + [JsonPropertyName("access_token")] + public string? AccessToken { get; set; } + } + + private sealed class YandexInfoResponse + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("login")] + public string? Login { get; set; } + + [JsonPropertyName("display_name")] + public string? DisplayName { get; set; } + + [JsonPropertyName("real_name")] + public string? RealName { get; set; } + } +} diff --git a/src/TileServer.Infrastructure/Data/AppDbContext.cs b/src/TileServer.Infrastructure/Data/AppDbContext.cs new file mode 100644 index 0000000..b90ad6f --- /dev/null +++ b/src/TileServer.Infrastructure/Data/AppDbContext.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore; +using TileServer.Domain.Accounts; + +namespace TileServer.Infrastructure.Data; + +public sealed class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Users => Set(); + + public DbSet ApiTokens => Set(); + + public DbSet UserStyles => Set(); + + public DbSet UsageDaily => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Users"); + entity.HasKey(x => x.Id); + entity.Property(x => x.YandexId).HasMaxLength(64).IsRequired(); + entity.Property(x => x.Login).HasMaxLength(128).IsRequired(); + entity.Property(x => x.DisplayName).HasMaxLength(256).IsRequired(); + entity.Property(x => x.Slug).HasMaxLength(64).IsRequired(); + entity.HasIndex(x => x.YandexId).IsUnique(); + entity.HasIndex(x => x.Slug).IsUnique(); + entity.HasMany(x => x.Tokens).WithOne(x => x.User).HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + entity.HasMany(x => x.Styles).WithOne(x => x.User).HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("ApiTokens"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Name).HasMaxLength(64).IsRequired(); + entity.Property(x => x.Hash).HasMaxLength(64).IsRequired(); + entity.Property(x => x.Prefix).HasMaxLength(16).IsRequired(); + entity.HasIndex(x => x.Hash).IsUnique(); + entity.HasIndex(x => x.UserId); + entity.Ignore(x => x.IsRevoked); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("UserStyles"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Name).HasMaxLength(64).IsRequired(); + entity.Property(x => x.Json).HasColumnType("text").IsRequired(); + entity.Property(x => x.CreatedFrom).HasMaxLength(64); + entity.HasIndex(x => new { x.UserId, x.Name }).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("UsageDaily"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => new { x.UserId, x.TokenId, x.Date }).IsUnique(); + entity.HasIndex(x => new { x.UserId, x.Date }); + }); + } +} diff --git a/src/TileServer.Infrastructure/Data/AppDbContextFactory.cs b/src/TileServer.Infrastructure/Data/AppDbContextFactory.cs new file mode 100644 index 0000000..2f1eda5 --- /dev/null +++ b/src/TileServer.Infrastructure/Data/AppDbContextFactory.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace TileServer.Infrastructure.Data; + +public sealed class AppDbContextFactory : IDesignTimeDbContextFactory +{ + public AppDbContext CreateDbContext(string[] args) + { + var connection = Environment.GetEnvironmentVariable("ConnectionStrings__Accounts") + ?? "Host=127.0.0.1;Port=5433;Database=tile_server;Username=tiles;Password=tiles"; + var options = new DbContextOptionsBuilder() + .UseNpgsql(connection) + .Options; + return new AppDbContext(options); + } +} diff --git a/src/TileServer.Infrastructure/Data/Migrations/20260920225316_InitialAccounts.Designer.cs b/src/TileServer.Infrastructure/Data/Migrations/20260920225316_InitialAccounts.Designer.cs new file mode 100644 index 0000000..bbc5cfe --- /dev/null +++ b/src/TileServer.Infrastructure/Data/Migrations/20260920225316_InitialAccounts.Designer.cs @@ -0,0 +1,212 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TileServer.Infrastructure.Data; + +#nullable disable + +namespace TileServer.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260920225316_InitialAccounts")] + partial class InitialAccounts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TileServer.Domain.Accounts.ApiToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Hash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("ApiTokens", (string)null); + }); + + modelBuilder.Entity("TileServer.Domain.Accounts.UsageDaily", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bytes") + .HasColumnType("bigint"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("StyleRequests") + .HasColumnType("bigint"); + + b.Property("TileRequests") + .HasColumnType("bigint"); + + b.Property("TokenId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Date"); + + b.HasIndex("UserId", "TokenId", "Date") + .IsUnique(); + + b.ToTable("UsageDaily", (string)null); + }); + + modelBuilder.Entity("TileServer.Domain.Accounts.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("YandexId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("YandexId") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("TileServer.Domain.Accounts.UserStyle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFrom") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Json") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("UserStyles", (string)null); + }); + + modelBuilder.Entity("TileServer.Domain.Accounts.ApiToken", b => + { + b.HasOne("TileServer.Domain.Accounts.User", "User") + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TileServer.Domain.Accounts.UserStyle", b => + { + b.HasOne("TileServer.Domain.Accounts.User", "User") + .WithMany("Styles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TileServer.Domain.Accounts.User", b => + { + b.Navigation("Styles"); + + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/TileServer.Infrastructure/Data/Migrations/20260920225316_InitialAccounts.cs b/src/TileServer.Infrastructure/Data/Migrations/20260920225316_InitialAccounts.cs new file mode 100644 index 0000000..15b0195 --- /dev/null +++ b/src/TileServer.Infrastructure/Data/Migrations/20260920225316_InitialAccounts.cs @@ -0,0 +1,151 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TileServer.Infrastructure.Data.Migrations +{ + /// + public partial class InitialAccounts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UsageDaily", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + TokenId = table.Column(type: "uuid", nullable: false), + Date = table.Column(type: "date", nullable: false), + TileRequests = table.Column(type: "bigint", nullable: false), + StyleRequests = table.Column(type: "bigint", nullable: false), + Bytes = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UsageDaily", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + YandexId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Login = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + DisplayName = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Slug = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + LastLoginAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ApiTokens", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Hash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Prefix = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ApiTokens", x => x.Id); + table.ForeignKey( + name: "FK_ApiTokens_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserStyles", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Json = table.Column(type: "text", nullable: false), + CreatedFrom = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserStyles", x => x.Id); + table.ForeignKey( + name: "FK_UserStyles_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ApiTokens_Hash", + table: "ApiTokens", + column: "Hash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ApiTokens_UserId", + table: "ApiTokens", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UsageDaily_UserId_Date", + table: "UsageDaily", + columns: new[] { "UserId", "Date" }); + + migrationBuilder.CreateIndex( + name: "IX_UsageDaily_UserId_TokenId_Date", + table: "UsageDaily", + columns: new[] { "UserId", "TokenId", "Date" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_Slug", + table: "Users", + column: "Slug", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_YandexId", + table: "Users", + column: "YandexId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UserStyles_UserId_Name", + table: "UserStyles", + columns: new[] { "UserId", "Name" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ApiTokens"); + + migrationBuilder.DropTable( + name: "UsageDaily"); + + migrationBuilder.DropTable( + name: "UserStyles"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/src/TileServer.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/src/TileServer.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..f363753 --- /dev/null +++ b/src/TileServer.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,209 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TileServer.Infrastructure.Data; + +#nullable disable + +namespace TileServer.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TileServer.Domain.Accounts.ApiToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Hash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("ApiTokens", (string)null); + }); + + modelBuilder.Entity("TileServer.Domain.Accounts.UsageDaily", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bytes") + .HasColumnType("bigint"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("StyleRequests") + .HasColumnType("bigint"); + + b.Property("TileRequests") + .HasColumnType("bigint"); + + b.Property("TokenId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Date"); + + b.HasIndex("UserId", "TokenId", "Date") + .IsUnique(); + + b.ToTable("UsageDaily", (string)null); + }); + + modelBuilder.Entity("TileServer.Domain.Accounts.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("YandexId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("YandexId") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("TileServer.Domain.Accounts.UserStyle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFrom") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Json") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("UserStyles", (string)null); + }); + + modelBuilder.Entity("TileServer.Domain.Accounts.ApiToken", b => + { + b.HasOne("TileServer.Domain.Accounts.User", "User") + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TileServer.Domain.Accounts.UserStyle", b => + { + b.HasOne("TileServer.Domain.Accounts.User", "User") + .WithMany("Styles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TileServer.Domain.Accounts.User", b => + { + b.Navigation("Styles"); + + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/TileServer.Infrastructure/DependencyInjection.cs b/src/TileServer.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..3270d3d --- /dev/null +++ b/src/TileServer.Infrastructure/DependencyInjection.cs @@ -0,0 +1,103 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TileServer.Application; +using TileServer.Application.Accounts; +using TileServer.Application.Configuration; +using TileServer.Application.Extracts; +using TileServer.Application.Styles; +using TileServer.Application.Sync; +using TileServer.Application.Tiles; +using TileServer.Infrastructure.Accounts; +using TileServer.Infrastructure.Auth; +using TileServer.Infrastructure.Data; +using TileServer.Infrastructure.Downloads; +using TileServer.Infrastructure.Extracts; +using TileServer.Infrastructure.Storage; +using TileServer.Infrastructure.Styles; +using TileServer.Infrastructure.Sync; +using TileServer.Infrastructure.Tiles; + +namespace TileServer.Infrastructure; + +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(TileServerOptions.SectionName)) + .PostConfigure(options => + { + // Same flat names as please_pay_me_bot: YANDEX_CLIENT_ID / SECRET / REDIRECT_URI. + var id = configuration["YANDEX_CLIENT_ID"]; + if (!string.IsNullOrWhiteSpace(id)) + { + options.Yandex.ClientId = id.Trim(); + } + + var secret = configuration["YANDEX_CLIENT_SECRET"]; + if (!string.IsNullOrWhiteSpace(secret)) + { + options.Yandex.ClientSecret = secret.Trim(); + } + + var redirect = configuration["YANDEX_REDIRECT_URI"]; + if (!string.IsNullOrWhiteSpace(redirect)) + { + options.Yandex.RedirectUri = redirect.Trim(); + } + }); + + services.AddSingleton(); + services.AddDbContext(options => + { + var connection = configuration.GetConnectionString("Accounts") + ?? throw new InvalidOperationException("Connection string 'Accounts' is not configured."); + options.UseNpgsql(connection); + }); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddHostedService(); + services.AddMemoryCache(cache => + { + var size = configuration.GetSection(TileServerOptions.SectionName) + .GetValue(nameof(TileServerOptions.TileCacheSize), 512); + cache.SizeLimit = Math.Max(64, size); + }); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(); + + services.AddHttpClient("Extracts", client => + { + client.Timeout = Timeout.InfiniteTimeSpan; + client.DefaultRequestHeaders.UserAgent.ParseAdd("TileServer/1.0 (+https://openstreetmap.org/copyright)"); + }); + + services.AddHttpClient("Tools", client => + { + client.Timeout = TimeSpan.FromMinutes(15); + client.DefaultRequestHeaders.UserAgent.ParseAdd("TileServer/1.0"); + }); + + services.AddHttpClient("Yandex", client => + { + client.Timeout = TimeSpan.FromSeconds(20); + client.DefaultRequestHeaders.UserAgent.ParseAdd("TileServer/1.0"); + }); + + return services; + } +} diff --git a/src/TileServer.Infrastructure/Downloads/HttpExtractDownloader.cs b/src/TileServer.Infrastructure/Downloads/HttpExtractDownloader.cs new file mode 100644 index 0000000..4a2fbe1 --- /dev/null +++ b/src/TileServer.Infrastructure/Downloads/HttpExtractDownloader.cs @@ -0,0 +1,90 @@ +using System.Net; +using System.Net.Http.Headers; +using Microsoft.Extensions.Logging; +using TileServer.Application.Extracts; +using TileServer.Domain.Extracts; + +namespace TileServer.Infrastructure.Downloads; + +public sealed class HttpExtractDownloader(IHttpClientFactory httpClientFactory, ILogger logger) + : IExtractDownloader +{ + public async Task DownloadAsync( + Extract extract, + string destinationPath, + ExtractRuntimeState state, + CancellationToken ct) + { + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + var client = httpClientFactory.CreateClient("Extracts"); + using var request = new HttpRequestMessage(HttpMethod.Get, extract.Url); + if (!string.IsNullOrWhiteSpace(state.ETag)) + { + request.Headers.IfNoneMatch.Add(new EntityTagHeaderValue(Quote(state.ETag))); + } + + if (state.RemoteLastModified is { } lastModified) + { + request.Headers.IfModifiedSince = lastModified.UtcDateTime; + } + + using var response = await client + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct) + .ConfigureAwait(false); + + if (response.StatusCode == HttpStatusCode.NotModified && File.Exists(destinationPath)) + { + logger.LogInformation("Extract {ExtractId} was not modified remotely.", extract.Id); + return new DownloadResult(false, new FileInfo(destinationPath).Length, state.ETag, state.RemoteLastModified); + } + + response.EnsureSuccessStatusCode(); + + var etag = response.Headers.ETag?.Tag?.Trim('"'); + var remoteModified = response.Content.Headers.LastModified; + var total = response.Content.Headers.ContentLength; + if (File.Exists(destinationPath) && + total is { } expected && + new FileInfo(destinationPath).Length == expected) + { + logger.LogInformation( + "Extract {ExtractId} already on disk ({Bytes} bytes), skip re-download.", + extract.Id, + expected); + return new DownloadResult(false, expected, etag ?? state.ETag, remoteModified ?? state.RemoteLastModified); + } + + var partialPath = destinationPath + ".partial"; + logger.LogInformation( + "Extract {ExtractId}: headers OK (HTTP {Status}, {TotalMb} MB).", + extract.Id, + (int)response.StatusCode, + total is null ? "?" : (total.Value / 1_000_000d).ToString("F1")); + + await using (var input = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false)) + await using (var output = new FileStream(partialPath, FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024, FileOptions.Asynchronous)) + { + await StreamCopy.CopyWithStallTimeoutAsync( + input, + output, + TimeSpan.FromSeconds(60), + TimeSpan.FromSeconds(15), + (copied, expectedSize) => logger.LogInformation( + "Downloading {ExtractId}: {CopiedMb:F1} / {TotalMb} MB", + extract.Id, + copied / 1_000_000d, + expectedSize is null ? "?" : (expectedSize.Value / 1_000_000d).ToString("F1")), + total, + ct) + .ConfigureAwait(false); + } + + File.Move(partialPath, destinationPath, overwrite: true); + var bytes = new FileInfo(destinationPath).Length; + logger.LogInformation("Downloaded {ExtractId}: {Bytes} bytes.", extract.Id, bytes); + return new DownloadResult(true, bytes, etag, remoteModified); + } + + private static string Quote(string etag) + => etag.StartsWith('"') ? etag : $"\"{etag}\""; +} diff --git a/src/TileServer.Infrastructure/Downloads/StreamCopy.cs b/src/TileServer.Infrastructure/Downloads/StreamCopy.cs new file mode 100644 index 0000000..bed327e --- /dev/null +++ b/src/TileServer.Infrastructure/Downloads/StreamCopy.cs @@ -0,0 +1,62 @@ +using System.Diagnostics; + +namespace TileServer.Infrastructure.Downloads; + +public static class StreamCopy +{ + public static async Task CopyWithStallTimeoutAsync( + Stream input, + Stream output, + TimeSpan stallTimeout, + TimeSpan logInterval, + Action onProgress, + long? totalBytes, + CancellationToken ct) + { + var buffer = new byte[1024 * 1024]; + long copied = 0; + var firstByteLogged = false; + var lastLog = TimeSpan.Zero; + var clock = Stopwatch.StartNew(); + + while (true) + { + int read; + using (var stallCts = CancellationTokenSource.CreateLinkedTokenSource(ct)) + { + stallCts.CancelAfter(stallTimeout); + try + { + read = await input.ReadAsync(buffer.AsMemory(0, buffer.Length), stallCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + throw new TimeoutException( + $"No data received for {stallTimeout.TotalSeconds:0}s after {copied} bytes (stall)."); + } + } + + if (read == 0) + { + break; + } + + await output.WriteAsync(buffer.AsMemory(0, read), ct).ConfigureAwait(false); + copied += read; + + if (!firstByteLogged) + { + firstByteLogged = true; + onProgress(copied, totalBytes); + lastLog = clock.Elapsed; + } + else if (clock.Elapsed - lastLog >= logInterval) + { + onProgress(copied, totalBytes); + lastLog = clock.Elapsed; + } + } + + return copied; + } +} diff --git a/src/TileServer.Infrastructure/Extracts/FileExtractCatalog.cs b/src/TileServer.Infrastructure/Extracts/FileExtractCatalog.cs new file mode 100644 index 0000000..5eff41b --- /dev/null +++ b/src/TileServer.Infrastructure/Extracts/FileExtractCatalog.cs @@ -0,0 +1,188 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; +using TileServer.Application; +using TileServer.Application.Configuration; +using TileServer.Application.Extracts; +using TileServer.Domain; +using TileServer.Domain.Exceptions; +using TileServer.Domain.Extracts; +using TileServer.Domain.Tiles; + +namespace TileServer.Infrastructure.Extracts; + +public sealed class FileExtractCatalog : IExtractCatalog +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + PropertyNameCaseInsensitive = true + }; + + private readonly IOptionsMonitor _options; + private readonly IDataPaths _paths; + private readonly object _gate = new(); + + public FileExtractCatalog(IOptionsMonitor options, IDataPaths paths) + { + _options = options; + _paths = paths; + } + + public IReadOnlyList GetAll() + { + lock (_gate) + { + return MergeUnlocked().Values.OrderBy(e => e.Id, StringComparer.Ordinal).ToArray(); + } + } + + public Extract? Find(string id) + { + lock (_gate) + { + return MergeUnlocked().GetValueOrDefault(id); + } + } + + public Extract GetRequired(string id) + => Find(id) ?? throw new ResourceNotFoundException("Extract", id); + + public async Task AddOrUpdateAsync(Extract extract, CancellationToken ct) + { + ResourceName.Require(extract.Id, nameof(extract.Id)); + List overlay; + lock (_gate) + { + overlay = ReadOverlayUnlocked(); + var existing = overlay.FindIndex(x => string.Equals(x.Id, extract.Id, StringComparison.OrdinalIgnoreCase)); + var mapped = ToOptions(extract); + if (existing >= 0) + { + overlay[existing] = mapped; + } + else + { + overlay.Add(mapped); + } + } + + await WriteOverlayAsync(overlay, ct).ConfigureAwait(false); + } + + public async Task RemoveAsync(string id, CancellationToken ct) + { + List overlay; + lock (_gate) + { + overlay = ReadOverlayUnlocked(); + var removed = overlay.RemoveAll(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)); + if (removed == 0 && _options.CurrentValue.Extracts.Any(x => x.Id == id)) + { + throw new DomainValidationException( + $"Extract '{id}' comes from appsettings and cannot be removed via API. Disable it in configuration instead."); + } + } + + await WriteOverlayAsync(overlay, ct).ConfigureAwait(false); + } + + private Dictionary MergeUnlocked() + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in _options.CurrentValue.Extracts) + { + var extract = ToDomain(item); + if (extract is not null) + { + map[extract.Id] = extract; + } + } + + foreach (var item in ReadOverlayUnlocked()) + { + var extract = ToDomain(item); + if (extract is null) + { + continue; + } + + if (map.TryGetValue(extract.Id, out var configured)) + { + extract = new Extract + { + Id = extract.Id, + Name = extract.Name, + Url = extract.Url, + CenterLon = extract.CenterLon, + CenterLat = extract.CenterLat, + MinZoom = extract.MinZoom, + MaxZoom = Math.Max(extract.MaxZoom, configured.MaxZoom), + Enabled = extract.Enabled + }; + } + + map[extract.Id] = extract; + } + + return map; + } + + private List ReadOverlayUnlocked() + { + if (!File.Exists(_paths.OverlayExtractsFile)) + { + return []; + } + + var json = File.ReadAllText(_paths.OverlayExtractsFile); + return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + } + + private async Task WriteOverlayAsync(List overlay, CancellationToken ct) + { + var json = JsonSerializer.Serialize(overlay, JsonOptions); + var temp = _paths.OverlayExtractsFile + ".tmp"; + await File.WriteAllTextAsync(temp, json, ct).ConfigureAwait(false); + File.Move(temp, _paths.OverlayExtractsFile, overwrite: true); + } + + private static Extract? ToDomain(ExtractOptions options) + { + if (!ResourceName.IsValid(options.Id) || string.IsNullOrWhiteSpace(options.Url)) + { + return null; + } + + if (!Uri.TryCreate(options.Url, UriKind.Absolute, out var uri)) + { + return null; + } + + var centerLon = options.Center is { Length: >= 1 } ? options.Center[0] : 37.6173; + var centerLat = options.Center is { Length: >= 2 } ? options.Center[1] : 55.7558; + + return new Extract + { + Id = options.Id, + Name = string.IsNullOrWhiteSpace(options.Name) ? options.Id : options.Name, + Url = uri, + CenterLon = centerLon, + CenterLat = centerLat, + MinZoom = options.MinZoom, + MaxZoom = PlanetilerLimits.ClampMaxZoom(options.MaxZoom), + Enabled = options.Enabled + }; + } + + private static ExtractOptions ToOptions(Extract extract) => new() + { + Id = extract.Id, + Name = extract.Name, + Url = extract.Url.ToString(), + Center = [extract.CenterLon, extract.CenterLat], + MinZoom = extract.MinZoom, + MaxZoom = extract.MaxZoom, + Enabled = extract.Enabled + }; +} diff --git a/src/TileServer.Infrastructure/Extracts/JsonExtractStateStore.cs b/src/TileServer.Infrastructure/Extracts/JsonExtractStateStore.cs new file mode 100644 index 0000000..61f3063 --- /dev/null +++ b/src/TileServer.Infrastructure/Extracts/JsonExtractStateStore.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using TileServer.Application; +using TileServer.Application.Extracts; +using TileServer.Domain.Extracts; + +namespace TileServer.Infrastructure.Extracts; + +public sealed class JsonExtractStateStore : IExtractStateStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + PropertyNameCaseInsensitive = true + }; + + private readonly IDataPaths _paths; + private readonly object _gate = new(); + + public JsonExtractStateStore(IDataPaths paths) + { + _paths = paths; + } + + public Task GetAsync(string extractId, CancellationToken ct) + { + var path = PathFor(extractId); + lock (_gate) + { + if (!File.Exists(path)) + { + return Task.FromResult(new ExtractRuntimeState { ExtractId = extractId }); + } + + var json = File.ReadAllText(path); + var state = JsonSerializer.Deserialize(json, JsonOptions) + ?? new ExtractRuntimeState { ExtractId = extractId }; + return Task.FromResult(state); + } + } + + public Task SaveAsync(ExtractRuntimeState state, CancellationToken ct) + { + state.UpdatedAt = DateTimeOffset.UtcNow; + var path = PathFor(state.ExtractId); + var json = JsonSerializer.Serialize(state, JsonOptions); + lock (_gate) + { + var temp = path + ".tmp"; + File.WriteAllText(temp, json); + File.Move(temp, path, overwrite: true); + } + + return Task.CompletedTask; + } + + private string PathFor(string extractId) => Path.Combine(_paths.State, $"{extractId}.json"); +} diff --git a/src/TileServer.Infrastructure/Seed/styles/dark.json b/src/TileServer.Infrastructure/Seed/styles/dark.json new file mode 100644 index 0000000..441a19d --- /dev/null +++ b/src/TileServer.Infrastructure/Seed/styles/dark.json @@ -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 } + } + ] +} diff --git a/src/TileServer.Infrastructure/Seed/styles/osm-bright.json b/src/TileServer.Infrastructure/Seed/styles/osm-bright.json new file mode 100644 index 0000000..3def162 --- /dev/null +++ b/src/TileServer.Infrastructure/Seed/styles/osm-bright.json @@ -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 + } + } + ] +} diff --git a/src/TileServer.Infrastructure/Seed/styles/positron.json b/src/TileServer.Infrastructure/Seed/styles/positron.json new file mode 100644 index 0000000..d7fde87 --- /dev/null +++ b/src/TileServer.Infrastructure/Seed/styles/positron.json @@ -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 } + } + ] +} diff --git a/src/TileServer.Infrastructure/Storage/DataPaths.cs b/src/TileServer.Infrastructure/Storage/DataPaths.cs new file mode 100644 index 0000000..14b46c7 --- /dev/null +++ b/src/TileServer.Infrastructure/Storage/DataPaths.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using TileServer.Application; +using TileServer.Application.Configuration; + +namespace TileServer.Infrastructure.Storage; + +public sealed class DataPaths : IDataPaths +{ + public DataPaths(IOptions options, IHostEnvironment environment) + { + var configured = options.Value.DataDirectory; + Root = Path.GetFullPath(Path.IsPathRooted(configured) + ? configured + : Path.Combine(environment.ContentRootPath, configured)); + Extracts = Path.Combine(Root, "extracts"); + Tiles = Path.Combine(Root, "tiles"); + Styles = Path.Combine(Root, "styles"); + Tools = Path.Combine(Root, "tools"); + State = Path.Combine(Root, "state"); + PlanetilerSources = Path.Combine(Root, "planetiler-sources"); + Tmp = Path.Combine(Root, "tmp"); + OverlayExtractsFile = Path.Combine(Root, "extracts.json"); + AppDatabase = Path.Combine(Root, "app.db"); + EnsureCreated(); + } + + public string Root { get; } + + public string Extracts { get; } + + public string Tiles { get; } + + public string Styles { get; } + + public string Tools { get; } + + public string State { get; } + + public string PlanetilerSources { get; } + + public string Tmp { get; } + + public string OverlayExtractsFile { get; } + + public string AppDatabase { get; } + + public string OsmPbf(string extractId) => Path.Combine(Extracts, $"{extractId}.osm.pbf"); + + public string Mbtiles(string extractId) => Path.Combine(Tiles, $"{extractId}.mbtiles"); + + // Planetiler infers archive format from the last suffix; keep ".mbtiles". + public string StagingMbtiles(string extractId) => Path.Combine(Tiles, $"{extractId}.building.mbtiles"); + + public void EnsureCreated() + { + Directory.CreateDirectory(Extracts); + Directory.CreateDirectory(Tiles); + Directory.CreateDirectory(Styles); + Directory.CreateDirectory(Tools); + Directory.CreateDirectory(State); + Directory.CreateDirectory(PlanetilerSources); + Directory.CreateDirectory(Tmp); + } +} diff --git a/src/TileServer.Infrastructure/Styles/FileStyleCatalog.cs b/src/TileServer.Infrastructure/Styles/FileStyleCatalog.cs new file mode 100644 index 0000000..683f424 --- /dev/null +++ b/src/TileServer.Infrastructure/Styles/FileStyleCatalog.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using TileServer.Application; +using TileServer.Application.Styles; +using TileServer.Domain; +using TileServer.Domain.Exceptions; + +namespace TileServer.Infrastructure.Styles; + +public sealed class FileStyleCatalog : IStyleCatalog +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + PropertyNameCaseInsensitive = true + }; + + private readonly IDataPaths _paths; + + public FileStyleCatalog(IDataPaths paths) + { + _paths = paths; + } + + public IReadOnlyList List() + { + if (!Directory.Exists(_paths.Styles)) + { + return []; + } + + return Directory.GetFiles(_paths.Styles, "*.json", SearchOption.TopDirectoryOnly) + .Select(path => new MapStyleInfo( + Path.GetFileNameWithoutExtension(path), + new DateTimeOffset(File.GetLastWriteTimeUtc(path), TimeSpan.Zero))) + .OrderBy(x => x.Name, StringComparer.Ordinal) + .ToArray(); + } + + public JsonObject GetRequired(string name) + { + var path = PathFor(name); + if (!File.Exists(path)) + { + throw new ResourceNotFoundException("Style", name); + } + + var json = File.ReadAllText(path); + var node = JsonNode.Parse(json) as JsonObject + ?? throw new DomainValidationException($"Style '{name}' is not a JSON object."); + return node; + } + + public async Task SaveAsync(string name, JsonObject style, CancellationToken ct) + { + ResourceName.Require(name, nameof(name)); + Directory.CreateDirectory(_paths.Styles); + var path = PathFor(name); + var temp = path + ".tmp"; + await File.WriteAllTextAsync(temp, style.ToJsonString(JsonOptions), ct).ConfigureAwait(false); + File.Move(temp, path, overwrite: true); + } + + public async Task SeedDefaultsAsync(CancellationToken ct) + { + Directory.CreateDirectory(_paths.Styles); + var seedDir = Path.Combine(AppContext.BaseDirectory, "Seed", "styles"); + if (!Directory.Exists(seedDir)) + { + return; + } + + foreach (var source in Directory.GetFiles(seedDir, "*.json", SearchOption.TopDirectoryOnly)) + { + ct.ThrowIfCancellationRequested(); + var destination = Path.Combine(_paths.Styles, Path.GetFileName(source)); + if (File.Exists(destination)) + { + continue; + } + + File.Copy(source, destination); + } + + await Task.CompletedTask.ConfigureAwait(false); + } + + private string PathFor(string name) => Path.Combine(_paths.Styles, $"{name}.json"); +} diff --git a/src/TileServer.Infrastructure/Sync/CronSyncSchedulerClock.cs b/src/TileServer.Infrastructure/Sync/CronSyncSchedulerClock.cs new file mode 100644 index 0000000..ef4d4d0 --- /dev/null +++ b/src/TileServer.Infrastructure/Sync/CronSyncSchedulerClock.cs @@ -0,0 +1,37 @@ +using Cronos; +using Microsoft.Extensions.Options; +using TileServer.Application.Configuration; +using TileServer.Application.Sync; + +namespace TileServer.Infrastructure.Sync; + +public sealed class CronSyncSchedulerClock : ISyncSchedulerClock +{ + private readonly CronExpression _cron; + private readonly TimeZoneInfo _timeZone; + + public CronSyncSchedulerClock(IOptions options) + { + _cron = CronExpression.Parse(options.Value.Sync.Cron); + _timeZone = ResolveTimeZone(options.Value.Sync.TimeZone); + } + + public DateTimeOffset? GetNextOccurrence(DateTimeOffset fromUtc) + => _cron.GetNextOccurrence(fromUtc, _timeZone); + + private static TimeZoneInfo ResolveTimeZone(string id) + { + try + { + return TimeZoneInfo.FindSystemTimeZoneById(id); + } + catch (TimeZoneNotFoundException) + { + return TimeZoneInfo.Utc; + } + catch (InvalidTimeZoneException) + { + return TimeZoneInfo.Utc; + } + } +} diff --git a/src/TileServer.Infrastructure/Sync/MapSyncHostedService.cs b/src/TileServer.Infrastructure/Sync/MapSyncHostedService.cs new file mode 100644 index 0000000..93281c6 --- /dev/null +++ b/src/TileServer.Infrastructure/Sync/MapSyncHostedService.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TileServer.Application.Configuration; +using TileServer.Application.Styles; +using TileServer.Application.Sync; +using TileServer.Domain.Exceptions; + +namespace TileServer.Infrastructure.Sync; + +public sealed class MapSyncHostedService( + IMapSyncService sync, + ISyncSchedulerClock clock, + IStyleCatalog styles, + IOptions options, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await styles.SeedDefaultsAsync(stoppingToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "Failed to seed default map styles."); + } + + sync.SetNextScheduledAt(clock.GetNextOccurrence(DateTimeOffset.UtcNow)); + + if (options.Value.Sync.RunOnStartup) + { + logger.LogInformation("Running initial OSM extract synchronization."); + await RunSafelyAsync(stoppingToken).ConfigureAwait(false); + } + + while (!stoppingToken.IsCancellationRequested) + { + var now = DateTimeOffset.UtcNow; + var next = clock.GetNextOccurrence(now); + sync.SetNextScheduledAt(next); + if (next is null) + { + logger.LogWarning("Sync cron '{Cron}' has no future occurrence.", options.Value.Sync.Cron); + break; + } + + var delay = next.Value - now; + if (delay < TimeSpan.Zero) + { + delay = TimeSpan.Zero; + } + + logger.LogInformation("Next OSM extract sync at {Next}.", next); + try + { + await Task.Delay(delay, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + + await RunSafelyAsync(stoppingToken).ConfigureAwait(false); + } + } + + private async Task RunSafelyAsync(CancellationToken ct) + { + try + { + await sync.RunAllAsync(ct).ConfigureAwait(false); + } + catch (ResourceConflictException) + { + logger.LogInformation("Sync already in progress, skip scheduled run."); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + } + catch (Exception ex) + { + logger.LogError(ex, "Scheduled map synchronization failed."); + } + } +} diff --git a/src/TileServer.Infrastructure/TileServer.Infrastructure.csproj b/src/TileServer.Infrastructure/TileServer.Infrastructure.csproj new file mode 100644 index 0000000..6647858 --- /dev/null +++ b/src/TileServer.Infrastructure/TileServer.Infrastructure.csproj @@ -0,0 +1,28 @@ + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + PreserveNewest + + + + + + diff --git a/src/TileServer.Infrastructure/Tiles/Mvt/MvtCodec.cs b/src/TileServer.Infrastructure/Tiles/Mvt/MvtCodec.cs new file mode 100644 index 0000000..01cd72c --- /dev/null +++ b/src/TileServer.Infrastructure/Tiles/Mvt/MvtCodec.cs @@ -0,0 +1,141 @@ +using System.IO.Compression; +using ProtoBuf; +using TileServer.Domain.Tiles; + +namespace TileServer.Infrastructure.Tiles.Mvt; + +internal static class MvtCodec +{ + public const int MoveTo = 1; + public const int LineTo = 2; + public const int ClosePath = 7; + public const int Point = 1; + public const int LineString = 2; + public const int Polygon = 3; + + public static MvtTile Decode(TileData tile) + { + var payload = tile.IsGzipped ? Gunzip(tile.Bytes) : tile.Bytes; + using var stream = new MemoryStream(payload, writable: false); + return Serializer.Deserialize(stream) ?? new MvtTile(); + } + + public static TileData EncodeGzip(MvtTile tile) + { + using var raw = new MemoryStream(); + Serializer.Serialize(raw, tile); + using var gzipped = new MemoryStream(); + using (var gzip = new GZipStream(gzipped, CompressionLevel.Fastest, leaveOpen: true)) + { + gzip.Write(raw.GetBuffer(), 0, (int)raw.Length); + } + + return new TileData(gzipped.ToArray(), true); + } + + public static List> DecodeGeometry(IReadOnlyList commands) + { + var paths = new List>(); + var current = new List<(int X, int Y)>(); + var x = 0; + var y = 0; + var i = 0; + while (i < commands.Count) + { + var cmdInt = commands[i++]; + var cmd = (int)(cmdInt & 0x7); + var count = (int)(cmdInt >> 3); + if (cmd is MoveTo or LineTo) + { + if (cmd == MoveTo && current.Count > 0) + { + paths.Add(current); + current = []; + } + + for (var n = 0; n < count && i + 1 < commands.Count; n++) + { + x += ZigZagDecode(commands[i++]); + y += ZigZagDecode(commands[i++]); + current.Add((x, y)); + } + } + else if (cmd == ClosePath) + { + if (current.Count > 0) + { + paths.Add(current); + current = []; + } + } + } + + if (current.Count > 0) + { + paths.Add(current); + } + + return paths; + } + + public static List EncodeGeometry(IReadOnlyList> paths, bool close) + { + var commands = new List(); + var cx = 0; + var cy = 0; + foreach (var path in paths) + { + if (path.Count == 0) + { + continue; + } + + var (x0, y0) = path[0]; + commands.Add(Command(MoveTo, 1)); + commands.Add(ZigZagEncode(x0 - cx)); + commands.Add(ZigZagEncode(y0 - cy)); + cx = x0; + cy = y0; + + if (path.Count > 1) + { + commands.Add(Command(LineTo, (uint)(path.Count - 1))); + for (var i = 1; i < path.Count; i++) + { + var (x, y) = path[i]; + commands.Add(ZigZagEncode(x - cx)); + commands.Add(ZigZagEncode(y - cy)); + cx = x; + cy = y; + } + } + + if (close) + { + commands.Add(Command(ClosePath, 1)); + } + } + + return commands; + } + + public static uint Command(int id, uint count) => (uint)((count << 3) | (uint)id); + + public static int ZigZagDecode(uint value) + { + var n = (int)value; + return (n >> 1) ^ -(n & 1); + } + + public static uint ZigZagEncode(int value) + => (uint)((value << 1) ^ (value >> 31)); + + private static byte[] Gunzip(byte[] bytes) + { + using var input = new MemoryStream(bytes, writable: false); + using var gzip = new GZipStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + gzip.CopyTo(output); + return output.ToArray(); + } +} diff --git a/src/TileServer.Infrastructure/Tiles/Mvt/MvtModel.cs b/src/TileServer.Infrastructure/Tiles/Mvt/MvtModel.cs new file mode 100644 index 0000000..ac20018 --- /dev/null +++ b/src/TileServer.Infrastructure/Tiles/Mvt/MvtModel.cs @@ -0,0 +1,73 @@ +using ProtoBuf; + +namespace TileServer.Infrastructure.Tiles.Mvt; + +[ProtoContract] +internal sealed class MvtTile +{ + [ProtoMember(3)] + public List Layers { get; set; } = []; +} + +[ProtoContract] +internal sealed class MvtLayer +{ + [ProtoMember(15)] + public uint Version { get; set; } = 2; + + [ProtoMember(1)] + public string Name { get; set; } = string.Empty; + + [ProtoMember(2)] + public List Features { get; set; } = []; + + [ProtoMember(3)] + public List Keys { get; set; } = []; + + [ProtoMember(4)] + public List Values { get; set; } = []; + + [ProtoMember(5)] + public uint Extent { get; set; } = 4096; +} + +[ProtoContract] +internal sealed class MvtFeature +{ + [ProtoMember(1)] + public ulong Id { get; set; } + + [ProtoMember(2, IsPacked = true)] + public List Tags { get; set; } = []; + + [ProtoMember(3)] + public int Type { get; set; } + + [ProtoMember(4, IsPacked = true)] + public List Geometry { get; set; } = []; +} + +[ProtoContract] +internal sealed class MvtValue +{ + [ProtoMember(1)] + public string? StringValue { get; set; } + + [ProtoMember(2)] + public float? FloatValue { get; set; } + + [ProtoMember(3)] + public double? DoubleValue { get; set; } + + [ProtoMember(4)] + public long? IntValue { get; set; } + + [ProtoMember(5)] + public ulong? UIntValue { get; set; } + + [ProtoMember(6)] + public long? SintValue { get; set; } + + [ProtoMember(7)] + public bool? BoolValue { get; set; } +} diff --git a/src/TileServer.Infrastructure/Tiles/PlanetilerJarBootstrapper.cs b/src/TileServer.Infrastructure/Tiles/PlanetilerJarBootstrapper.cs new file mode 100644 index 0000000..9da0244 --- /dev/null +++ b/src/TileServer.Infrastructure/Tiles/PlanetilerJarBootstrapper.cs @@ -0,0 +1,67 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TileServer.Application; +using TileServer.Application.Configuration; +using TileServer.Application.Tiles; + +namespace TileServer.Infrastructure.Tiles; + +public sealed class PlanetilerJarBootstrapper( + IHttpClientFactory httpClientFactory, + IDataPaths dataPaths, + IOptions options, + ILogger logger) : IPlanetilerBootstrapper +{ + public bool IsJavaAvailable() + { + try + { + var psi = new ProcessStartInfo + { + FileName = options.Value.JavaPath, + ArgumentList = { "-version" }, + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using var process = Process.Start(psi); + if (process is null) + { + return false; + } + + process.WaitForExit(TimeSpan.FromSeconds(10)); + return process.HasExited && process.ExitCode == 0; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Java is not available at '{JavaPath}'.", options.Value.JavaPath); + return false; + } + } + + public async Task EnsureJarAsync(CancellationToken ct) + { + var jarPath = Path.Combine(dataPaths.Tools, options.Value.PlanetilerJarFileName); + if (File.Exists(jarPath) && new FileInfo(jarPath).Length > 1_000_000) + { + return jarPath; + } + + logger.LogInformation("Downloading Planetiler JAR from {Url}", options.Value.PlanetilerJarUrl); + Directory.CreateDirectory(dataPaths.Tools); + var client = httpClientFactory.CreateClient("Tools"); + var temp = jarPath + ".partial"; + await using (var input = await client.GetStreamAsync(options.Value.PlanetilerJarUrl, ct).ConfigureAwait(false)) + await using (var output = File.Create(temp)) + { + await input.CopyToAsync(output, ct).ConfigureAwait(false); + } + + File.Move(temp, jarPath, overwrite: true); + logger.LogInformation("Planetiler JAR saved to {Path}", jarPath); + return jarPath; + } +} diff --git a/src/TileServer.Infrastructure/Tiles/PlanetilerSourceBootstrapper.cs b/src/TileServer.Infrastructure/Tiles/PlanetilerSourceBootstrapper.cs new file mode 100644 index 0000000..bf137dd --- /dev/null +++ b/src/TileServer.Infrastructure/Tiles/PlanetilerSourceBootstrapper.cs @@ -0,0 +1,134 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TileServer.Application; +using TileServer.Application.Configuration; +using TileServer.Application.Tiles; +using TileServer.Infrastructure.Downloads; + +namespace TileServer.Infrastructure.Tiles; + +public sealed class PlanetilerSourceBootstrapper( + IHttpClientFactory httpClientFactory, + IDataPaths dataPaths, + IOptions options, + ILogger logger) : IPlanetilerSourceBootstrapper +{ + public async Task EnsureAsync(CancellationToken ct) + { + Directory.CreateDirectory(dataPaths.PlanetilerSources); + var cfg = options.Value; + await EnsureFileAsync("natural_earth_vector.sqlite.zip", cfg.NaturalEarthUrls, 10_000_000, ct) + .ConfigureAwait(false); + await EnsureFileAsync("lake_centerline.shp.zip", cfg.LakeCenterlinesUrls, 50_000, ct) + .ConfigureAwait(false); + await EnsureFileAsync("water-polygons-split-3857.zip", cfg.WaterPolygonsUrls, 50_000_000, ct) + .ConfigureAwait(false); + } + + private async Task EnsureFileAsync(string fileName, IReadOnlyList urls, long minBytes, CancellationToken ct) + { + var path = Path.Combine(dataPaths.PlanetilerSources, fileName); + if (File.Exists(path) && new FileInfo(path).Length >= minBytes) + { + logger.LogInformation("Planetiler source {File} already present ({Bytes} bytes).", fileName, new FileInfo(path).Length); + return; + } + + if (File.Exists(path)) + { + logger.LogWarning("Removing incomplete Planetiler source {File} ({Bytes} bytes).", fileName, new FileInfo(path).Length); + File.Delete(path); + } + + var candidates = urls.Where(u => !string.IsNullOrWhiteSpace(u)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + if (candidates.Length == 0) + { + throw new InvalidOperationException($"No download URLs configured for '{fileName}'."); + } + + Exception? last = null; + foreach (var url in candidates) + { + try + { + await DownloadAsync(url, path, fileName, ct).ConfigureAwait(false); + var size = new FileInfo(path).Length; + if (size < minBytes) + { + throw new IOException($"Downloaded {fileName} is too small ({size} bytes)."); + } + + return; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + last = ex; + logger.LogWarning(ex, "Could not download {File} from {Url}, trying next mirror.", fileName, url); + TryDelete(path); + TryDelete(path + ".partial"); + } + } + + throw new InvalidOperationException( + $"Failed to download Planetiler source '{fileName}'. Tried: {string.Join(", ", candidates)}", + last); + } + + private async Task DownloadAsync(string url, string destinationPath, string label, CancellationToken ct) + { + logger.LogInformation("Downloading Planetiler source {File} from {Url}", label, url); + var client = httpClientFactory.CreateClient("Extracts"); + using var response = await client + .GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var total = response.Content.Headers.ContentLength; + logger.LogInformation( + "{File}: headers OK (HTTP {Status}, {TotalMb} MB). Waiting for body...", + label, + (int)response.StatusCode, + total is null ? "?" : (total.Value / 1_000_000d).ToString("F1")); + + var stall = TimeSpan.FromSeconds(Math.Max(15, options.Value.DownloadStallTimeoutSeconds)); + var partialPath = destinationPath + ".partial"; + await using (var input = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false)) + await using (var output = new FileStream(partialPath, FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024, FileOptions.Asynchronous)) + { + await StreamCopy.CopyWithStallTimeoutAsync( + input, + output, + stall, + TimeSpan.FromSeconds(5), + (copied, expected) => logger.LogInformation( + "Downloading {File}: {CopiedMb:F1} / {TotalMb} MB", + label, + copied / 1_000_000d, + expected is null ? "?" : (expected.Value / 1_000_000d).ToString("F1")), + total, + ct) + .ConfigureAwait(false); + } + + File.Move(partialPath, destinationPath, overwrite: true); + logger.LogInformation("Saved Planetiler source {File} ({Bytes} bytes).", label, new FileInfo(destinationPath).Length); + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (IOException) + { + } + } +} diff --git a/src/TileServer.Infrastructure/Tiles/PlanetilerTileBuilder.cs b/src/TileServer.Infrastructure/Tiles/PlanetilerTileBuilder.cs new file mode 100644 index 0000000..fa0554e --- /dev/null +++ b/src/TileServer.Infrastructure/Tiles/PlanetilerTileBuilder.cs @@ -0,0 +1,123 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TileServer.Application; +using TileServer.Application.Configuration; +using TileServer.Application.Tiles; +using TileServer.Domain.Extracts; +using TileServer.Domain.Tiles; + +namespace TileServer.Infrastructure.Tiles; + +public sealed class PlanetilerTileBuilder( + IPlanetilerBootstrapper bootstrapper, + IDataPaths dataPaths, + IOptions options, + ILogger logger) : ITileBuilder +{ + public async Task BuildAsync(Extract extract, string osmPbfPath, string outputMbtilesPath, CancellationToken ct) + { + if (!File.Exists(osmPbfPath)) + { + throw new FileNotFoundException("OSM PBF was not downloaded.", osmPbfPath); + } + + var jar = await bootstrapper.EnsureJarAsync(ct).ConfigureAwait(false); + Directory.CreateDirectory(Path.GetDirectoryName(outputMbtilesPath)!); + if (File.Exists(outputMbtilesPath)) + { + File.Delete(outputMbtilesPath); + } + + var cfg = options.Value; + var java = cfg.JavaPath; + var psi = new ProcessStartInfo + { + FileName = java, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + psi.ArgumentList.Add($"-Xmx{cfg.JvmMaxHeap}"); + psi.ArgumentList.Add("-jar"); + psi.ArgumentList.Add(jar); + psi.ArgumentList.Add($"--osm_path={osmPbfPath}"); + psi.ArgumentList.Add($"--output={outputMbtilesPath}"); + psi.ArgumentList.Add($"--tmpdir={dataPaths.Tmp}"); + psi.ArgumentList.Add("--force"); + psi.ArgumentList.Add("--download"); + psi.ArgumentList.Add($"--download_dir={dataPaths.PlanetilerSources}"); + var maxZoom = PlanetilerLimits.ClampMaxZoom(extract.MaxZoom); + var minZoom = Math.Clamp(extract.MinZoom, 0, maxZoom); + psi.ArgumentList.Add($"--maxzoom={maxZoom}"); + psi.ArgumentList.Add($"--render_maxzoom={maxZoom}"); + psi.ArgumentList.Add($"--minzoom={minZoom}"); + psi.ArgumentList.Add($"--http_timeout={cfg.PlanetilerHttpTimeout}"); + psi.ArgumentList.Add("--http_retries=8"); + psi.ArgumentList.Add("--download_osm_tile_weights=false"); + psi.ArgumentList.Add($"--natural_earth_url={cfg.NaturalEarthUrl}"); + psi.ArgumentList.Add($"--lake_centerlines_url={cfg.LakeCenterlinesUrl}"); + psi.ArgumentList.Add($"--water_polygons_url={cfg.WaterPolygonsUrl}"); + + logger.LogInformation("Starting Planetiler for {ExtractId}: {File} {Args}", extract.Id, java, string.Join(' ', psi.ArgumentList)); + + using var process = new Process { StartInfo = psi, EnableRaisingEvents = true }; + process.OutputDataReceived += (_, e) => + { + if (!string.IsNullOrWhiteSpace(e.Data)) + { + logger.LogInformation("[planetiler {ExtractId}] {Line}", extract.Id, e.Data); + } + }; + process.ErrorDataReceived += (_, e) => + { + if (!string.IsNullOrWhiteSpace(e.Data)) + { + logger.LogInformation("[planetiler {ExtractId}] {Line}", extract.Id, e.Data); + } + }; + + if (!process.Start()) + { + throw new InvalidOperationException($"Failed to start '{java}'."); + } + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + try + { + await process.WaitForExitAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + TryKill(process); + throw; + } + + if (process.ExitCode != 0) + { + throw new InvalidOperationException($"Planetiler exited with code {process.ExitCode} for extract '{extract.Id}'."); + } + + if (!File.Exists(outputMbtilesPath)) + { + throw new InvalidOperationException($"Planetiler finished but '{outputMbtilesPath}' was not created."); + } + } + + private static void TryKill(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (InvalidOperationException) + { + } + } +} diff --git a/src/TileServer.Infrastructure/Tiles/SqliteMbtilesTileStore.cs b/src/TileServer.Infrastructure/Tiles/SqliteMbtilesTileStore.cs new file mode 100644 index 0000000..c1b7407 --- /dev/null +++ b/src/TileServer.Infrastructure/Tiles/SqliteMbtilesTileStore.cs @@ -0,0 +1,266 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Text.Json; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; +using TileServer.Application; +using TileServer.Application.Tiles; +using TileServer.Domain.Tiles; + +namespace TileServer.Infrastructure.Tiles; + +public sealed class SqliteMbtilesTileStore : ITileStore, IDisposable +{ + private readonly IDataPaths _paths; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _metadata = new(StringComparer.OrdinalIgnoreCase); + private readonly object _swapLock = new(); + + public SqliteMbtilesTileStore(IDataPaths paths, ILogger logger) + { + _paths = paths; + _logger = logger; + } + + public bool Exists(string extractId) => File.Exists(_paths.Mbtiles(extractId)); + + public string GetActivePath(string extractId) => _paths.Mbtiles(extractId); + + public string GetStagingPath(string extractId) => _paths.StagingMbtiles(extractId); + + public async Task GetTileAsync(string extractId, TileCoordinate tile, CancellationToken ct) + { + var path = _paths.Mbtiles(extractId); + if (!File.Exists(path)) + { + return null; + } + + try + { + await using var connection = Open(path); + await using var command = connection.CreateCommand(); + command.CommandText = + """ + SELECT tile_data + FROM tiles + WHERE zoom_level = $z AND tile_column = $x AND tile_row = $y + LIMIT 1; + """; + command.Parameters.AddWithValue("$z", tile.Z); + command.Parameters.AddWithValue("$x", tile.X); + command.Parameters.AddWithValue("$y", tile.ToTmsY()); + + await using var reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false); + if (!await reader.ReadAsync(ct).ConfigureAwait(false) || await reader.IsDBNullAsync(0, ct).ConfigureAwait(false)) + { + return null; + } + + var bytes = (byte[])reader.GetValue(0); + return new TileData(bytes, TileData.IsGzip(bytes)); + } + catch (SqliteException ex) + { + _logger.LogWarning(ex, "SQLite read failed for {ExtractId} {Tile}", extractId, tile); + return null; + } + } + + public TilesetMetadata? GetMetadata(string extractId) + { + if (_metadata.TryGetValue(extractId, out var cached)) + { + return cached; + } + + var path = _paths.Mbtiles(extractId); + if (!File.Exists(path)) + { + return null; + } + + using var connection = Open(path); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT name, value FROM metadata;"; + using var reader = command.ExecuteReader(); + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + while (reader.Read()) + { + map[reader.GetString(0)] = reader.IsDBNull(1) ? string.Empty : reader.GetString(1); + } + + var metadata = ParseMetadata(extractId, map); + _metadata[extractId] = metadata; + return metadata; + } + + public void Activate(string extractId, string stagingPath) + { + if (!File.Exists(stagingPath)) + { + throw new FileNotFoundException("Staging MBTiles was not produced.", stagingPath); + } + + var active = _paths.Mbtiles(extractId); + lock (_swapLock) + { + SqliteConnection.ClearAllPools(); + if (File.Exists(active)) + { + var backup = active + ".old"; + File.Copy(active, backup, overwrite: true); + File.Replace(stagingPath, active, backup + ".bak"); + TryDelete(backup); + TryDelete(backup + ".bak"); + } + else + { + File.Move(stagingPath, active, overwrite: true); + } + + _metadata.TryRemove(extractId, out _); + } + + _logger.LogInformation("Activated tileset {ExtractId} at {Path}", extractId, active); + } + + public void Dispose() => SqliteConnection.ClearAllPools(); + + private static SqliteConnection Open(string path) + { + var connection = new SqliteConnection(new SqliteConnectionStringBuilder + { + DataSource = path, + Mode = SqliteOpenMode.ReadOnly, + Cache = SqliteCacheMode.Shared, + Pooling = true, + DefaultTimeout = 5 + }.ToString()); + connection.Open(); + return connection; + } + + private static TilesetMetadata ParseMetadata(string extractId, IReadOnlyDictionary map) + { + map.TryGetValue("name", out var name); + map.TryGetValue("format", out var format); + map.TryGetValue("bounds", out var boundsRaw); + map.TryGetValue("center", out var centerRaw); + map.TryGetValue("minzoom", out var minZoomRaw); + map.TryGetValue("maxzoom", out var maxZoomRaw); + map.TryGetValue("json", out var jsonRaw); + + var bounds = ParseDoubles(boundsRaw, 4, [-180, -85.0511, 180, 85.0511]); + var center = ParseDoubles(centerRaw, 3, [(bounds[0] + bounds[2]) / 2d, (bounds[1] + bounds[3]) / 2d, 6]); + var minZoom = ParseInt(minZoomRaw, 0); + var maxZoom = ParseInt(maxZoomRaw, 14); + var layers = ParseVectorLayers(jsonRaw); + + return new TilesetMetadata( + extractId, + string.IsNullOrWhiteSpace(name) ? extractId : name, + string.IsNullOrWhiteSpace(format) ? "pbf" : format, + bounds[0], + bounds[1], + bounds[2], + bounds[3], + center[0], + center[1], + (int)Math.Round(center[2]), + minZoom, + maxZoom, + layers); + } + + private static IReadOnlyList ParseVectorLayers(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + try + { + using var document = JsonDocument.Parse(json); + if (!document.RootElement.TryGetProperty("vector_layers", out var layers) || + layers.ValueKind != JsonValueKind.Array) + { + return []; + } + + var result = new List(); + foreach (var layer in layers.EnumerateArray()) + { + var id = layer.TryGetProperty("id", out var idEl) ? idEl.GetString() : null; + if (string.IsNullOrWhiteSpace(id)) + { + continue; + } + + var min = layer.TryGetProperty("minzoom", out var minEl) ? minEl.GetInt32() : 0; + var max = layer.TryGetProperty("maxzoom", out var maxEl) ? maxEl.GetInt32() : 14; + var fields = new Dictionary(StringComparer.Ordinal); + if (layer.TryGetProperty("fields", out var fieldsEl) && fieldsEl.ValueKind == JsonValueKind.Object) + { + foreach (var field in fieldsEl.EnumerateObject()) + { + fields[field.Name] = field.Value.ValueKind == JsonValueKind.String + ? field.Value.GetString() ?? "String" + : field.Value.ToString(); + } + } + + result.Add(new VectorLayerInfo(id, min, max, fields)); + } + + return result; + } + catch (JsonException) + { + return []; + } + } + + private static double[] ParseDoubles(string? raw, int expected, double[] fallback) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return fallback; + } + + var parts = raw.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < expected) + { + return fallback; + } + + var values = new double[expected]; + for (var i = 0; i < expected; i++) + { + if (!double.TryParse(parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out values[i])) + { + return fallback; + } + } + + return values; + } + + private static int ParseInt(string? raw, int fallback) + => int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : fallback; + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (IOException) + { + } + } +} diff --git a/src/TileServer.Infrastructure/Tiles/VectorTileOverzoomer.cs b/src/TileServer.Infrastructure/Tiles/VectorTileOverzoomer.cs new file mode 100644 index 0000000..330687b --- /dev/null +++ b/src/TileServer.Infrastructure/Tiles/VectorTileOverzoomer.cs @@ -0,0 +1,374 @@ +using Microsoft.Extensions.Logging; +using NetTopologySuite.Geometries; +using TileServer.Application.Tiles; +using TileServer.Domain.Tiles; +using TileServer.Infrastructure.Tiles.Mvt; + +namespace TileServer.Infrastructure.Tiles; + +public sealed class VectorTileOverzoomer(ILogger logger) : IVectorTileOverzoomer +{ + private static readonly GeometryFactory Factory = new(new PrecisionModel(), 0); + + public TileData Overzoom(TileData parent, TileCoordinate parentTile, TileCoordinate childTile) + { + if (childTile.Z < parentTile.Z) + { + throw new ArgumentException("Child zoom must be greater than or equal to parent zoom."); + } + + if (childTile.Z == parentTile.Z) + { + return parent; + } + + try + { + var decoded = MvtCodec.Decode(parent); + var scale = 1 << (childTile.Z - parentTile.Z); + var localX = childTile.X - (parentTile.X << (childTile.Z - parentTile.Z)); + var localY = childTile.Y - (parentTile.Y << (childTile.Z - parentTile.Z)); + var child = new MvtTile(); + + foreach (var layer in decoded.Layers) + { + var extent = layer.Extent == 0 ? 4096 : (int)layer.Extent; + var originX = localX * extent; + var originY = localY * extent; + var buffer = Math.Max(extent / 32, 16); + var clip = Factory.ToGeometry(new Envelope(-buffer, extent + buffer, -buffer, extent + buffer)); + var outLayer = new MvtLayer + { + Version = layer.Version == 0 ? 2 : layer.Version, + Name = layer.Name, + Extent = (uint)extent + }; + + foreach (var feature in layer.Features) + { + var mapped = MapFeature(feature, layer, outLayer, scale, originX, originY, clip); + if (mapped is not null) + { + outLayer.Features.Add(mapped); + } + } + + if (outLayer.Features.Count > 0) + { + child.Layers.Add(outLayer); + } + } + + return MvtCodec.EncodeGzip(child); + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Failed to overzoom tile {Parent} -> {Child}", + $"{parentTile.Z}/{parentTile.X}/{parentTile.Y}", + $"{childTile.Z}/{childTile.X}/{childTile.Y}"); + return MvtCodec.EncodeGzip(new MvtTile()); + } + } + + private static MvtFeature? MapFeature( + MvtFeature feature, + MvtLayer sourceLayer, + MvtLayer targetLayer, + int scale, + int originX, + int originY, + Geometry clip) + { + var paths = MvtCodec.DecodeGeometry(feature.Geometry); + if (paths.Count == 0) + { + return null; + } + + var shifted = paths + .Select(path => path + .Select(p => (X: p.X * scale - originX, Y: p.Y * scale - originY)) + .ToList()) + .ToList(); + + IReadOnlyList> clipped = feature.Type switch + { + MvtCodec.Point => ClipPoints(shifted, clip), + MvtCodec.LineString => ClipLineal(shifted, clip), + MvtCodec.Polygon => ClipPolygonal(shifted, clip), + _ => [] + }; + + if (clipped.Count == 0) + { + return null; + } + + return new MvtFeature + { + Id = feature.Id, + Type = feature.Type, + Tags = CopyTags(feature.Tags, sourceLayer, targetLayer), + Geometry = MvtCodec.EncodeGeometry(clipped, close: feature.Type == MvtCodec.Polygon) + }; + } + + private static List> ClipPoints( + IEnumerable> paths, + Geometry clip) + { + var result = new List>(); + foreach (var path in paths) + { + var kept = new List<(int X, int Y)>(); + foreach (var (x, y) in path) + { + if (clip.Covers(Factory.CreatePoint(new Coordinate(x, y)))) + { + kept.Add((x, y)); + } + } + + if (kept.Count > 0) + { + result.Add(kept); + } + } + + return result; + } + + private static List> ClipLineal( + IEnumerable> paths, + Geometry clip) + { + var result = new List>(); + foreach (var path in paths) + { + if (path.Count < 2) + { + continue; + } + + Geometry? geometry; + try + { + geometry = Factory.CreateLineString(ToCoordinates(path)).Intersection(clip); + } + catch (TopologyException) + { + continue; + } + + foreach (var line in Flatten(geometry, Dimension.Curve)) + { + var coords = FromCoordinates(line.Coordinates, closed: false); + if (coords.Count >= 2) + { + result.Add(coords); + } + } + } + + return result; + } + + private static List> ClipPolygonal( + IReadOnlyList> paths, + Geometry clip) + { + var result = new List>(); + foreach (var polygon in ToPolygons(paths)) + { + Geometry? geometry; + try + { + geometry = polygon.Intersection(clip); + } + catch (TopologyException) + { + continue; + } + + foreach (var part in Flatten(geometry, Dimension.Surface)) + { + if (part is not Polygon poly || poly.IsEmpty || poly.NumPoints < 4) + { + continue; + } + + result.Add(FromCoordinates(poly.ExteriorRing.Coordinates, closed: true)); + for (var i = 0; i < poly.NumInteriorRings; i++) + { + var hole = FromCoordinates(poly.GetInteriorRingN(i).Coordinates, closed: true); + if (hole.Count >= 3) + { + result.Add(hole); + } + } + } + } + + return result; + } + + private static IEnumerable ToPolygons(IReadOnlyList> paths) + { + Polygon? current = null; + var holes = new List(); + foreach (var path in paths) + { + var ring = ToRing(path); + if (ring is null) + { + continue; + } + + if (IsExterior(path)) + { + if (current is not null) + { + yield return current; + } + + current = Factory.CreatePolygon(ring); + holes = []; + } + else if (current is not null) + { + holes.Add(ring); + current = Factory.CreatePolygon(current.Shell, holes.ToArray()); + } + } + + if (current is not null) + { + yield return current; + } + } + + private static LinearRing? ToRing(List<(int X, int Y)> path) + { + if (path.Count < 3) + { + return null; + } + + var coords = new Coordinate[path.Count + 1]; + for (var i = 0; i < path.Count; i++) + { + coords[i] = new Coordinate(path[i].X, path[i].Y); + } + + coords[^1] = new Coordinate(path[0].X, path[0].Y); + try + { + return Factory.CreateLinearRing(coords); + } + catch (ArgumentException) + { + return null; + } + } + + private static bool IsExterior(IReadOnlyList<(int X, int Y)> ring) + { + long area = 0; + for (var i = 0; i < ring.Count; i++) + { + var (x1, y1) = ring[i]; + var (x2, y2) = ring[(i + 1) % ring.Count]; + area += (long)x1 * y2 - (long)x2 * y1; + } + + return area > 0; + } + + private static IEnumerable Flatten(Geometry geometry, Dimension minDimension) + { + if (geometry.IsEmpty || geometry.Dimension < minDimension) + { + yield break; + } + + if (geometry is GeometryCollection collection) + { + for (var i = 0; i < collection.NumGeometries; i++) + { + foreach (var child in Flatten(collection.GetGeometryN(i), minDimension)) + { + yield return child; + } + } + + yield break; + } + + yield return geometry; + } + + private static Coordinate[] ToCoordinates(List<(int X, int Y)> path) + => path.Select(p => new Coordinate(p.X, p.Y)).ToArray(); + + private static List<(int X, int Y)> FromCoordinates(Coordinate[] coordinates, bool closed) + { + var count = closed && coordinates.Length > 1 ? coordinates.Length - 1 : coordinates.Length; + var result = new List<(int X, int Y)>(count); + for (var i = 0; i < count; i++) + { + result.Add(((int)Math.Round(coordinates[i].X), (int)Math.Round(coordinates[i].Y))); + } + + return result; + } + + private static List CopyTags(List tags, MvtLayer source, MvtLayer target) + { + var copied = new List(tags.Count); + for (var i = 0; i + 1 < tags.Count; i += 2) + { + var keyIndex = (int)tags[i]; + var valueIndex = (int)tags[i + 1]; + if (keyIndex >= source.Keys.Count || valueIndex >= source.Values.Count) + { + continue; + } + + copied.Add(InternKey(target, source.Keys[keyIndex])); + copied.Add(InternValue(target, source.Values[valueIndex])); + } + + return copied; + } + + private static uint InternKey(MvtLayer layer, string key) + { + var index = layer.Keys.IndexOf(key); + if (index >= 0) + { + return (uint)index; + } + + layer.Keys.Add(key); + return (uint)(layer.Keys.Count - 1); + } + + private static uint InternValue(MvtLayer layer, MvtValue value) + { + layer.Values.Add(CloneValue(value)); + return (uint)(layer.Values.Count - 1); + } + + private static MvtValue CloneValue(MvtValue value) => new() + { + StringValue = value.StringValue, + FloatValue = value.FloatValue, + DoubleValue = value.DoubleValue, + IntValue = value.IntValue, + UIntValue = value.UIntValue, + SintValue = value.SintValue, + BoolValue = value.BoolValue + }; +} diff --git a/tests/TileServer.UnitTests/AccountTests.cs b/tests/TileServer.UnitTests/AccountTests.cs new file mode 100644 index 0000000..3f0d9eb --- /dev/null +++ b/tests/TileServer.UnitTests/AccountTests.cs @@ -0,0 +1,164 @@ +using TileServer.Application.Accounts; +using TileServer.Domain; +using TileServer.Domain.Accounts; +using TileServer.Domain.Exceptions; + +namespace TileServer.UnitTests; + +public sealed class AccountSlugTests +{ + [Theory] + [InlineData("alice", "alice")] + [InlineData("Alice.K", "alice-k")] + [InlineData("user_name", "user-name")] + [InlineData("42go", "u-42go")] + public void FromLogin_ProducesResourceName(string login, string expected) + { + var slug = AccountSlug.FromLogin(login); + Assert.Equal(expected, slug); + Assert.True(ResourceName.IsValid(slug)); + } + + [Fact] + public void WithSuffix_StaysValid() + { + var slug = AccountSlug.WithSuffix("alice", 2); + Assert.Equal("alice-2", slug); + Assert.True(ResourceName.IsValid(slug)); + } +} + +public sealed class ApiTokenHashTests +{ + [Fact] + public void Compute_Verify_RoundTrip() + { + var plaintext = ApiTokenHash.CreatePlaintext(); + Assert.StartsWith("ts_", plaintext); + var hash = ApiTokenHash.Compute(plaintext); + Assert.True(ApiTokenHash.Verify(plaintext, hash)); + Assert.False(ApiTokenHash.Verify(plaintext + "x", hash)); + Assert.Equal(plaintext[..11], ApiTokenHash.DisplayPrefix(plaintext)); + } +} + +public sealed class ApiTokenServiceTests +{ + [Fact] + public async Task Resolve_RejectsRevokedToken() + { + var user = new User + { + Id = Guid.NewGuid(), + YandexId = "1", + Login = "alice", + DisplayName = "Alice", + Slug = "alice" + }; + var plaintext = ApiTokenHash.CreatePlaintext(); + var token = new ApiToken + { + Id = Guid.NewGuid(), + UserId = user.Id, + Name = "test", + Hash = ApiTokenHash.Compute(plaintext), + Prefix = ApiTokenHash.DisplayPrefix(plaintext), + CreatedAt = DateTimeOffset.UtcNow, + RevokedAt = DateTimeOffset.UtcNow + }; + var service = new ApiTokenService(new MemoryUsers(user), new MemoryTokens(token)); + + var error = await Assert.ThrowsAsync( + () => service.ResolveAsync("alice", plaintext, null, CancellationToken.None)); + Assert.Contains("revoked", error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Resolve_AcceptsActiveTokenForSlug() + { + var user = new User + { + Id = Guid.NewGuid(), + YandexId = "1", + Login = "alice", + DisplayName = "Alice", + Slug = "alice" + }; + var plaintext = ApiTokenHash.CreatePlaintext(); + var token = new ApiToken + { + Id = Guid.NewGuid(), + UserId = user.Id, + Name = "test", + Hash = ApiTokenHash.Compute(plaintext), + Prefix = ApiTokenHash.DisplayPrefix(plaintext), + CreatedAt = DateTimeOffset.UtcNow + }; + var service = new ApiTokenService(new MemoryUsers(user), new MemoryTokens(token)); + + var access = await service.ResolveAsync("alice", null, plaintext, CancellationToken.None); + Assert.Equal(user.Id, access.User.Id); + Assert.Equal(token.Id, access.Token.Id); + } + + private sealed class MemoryUsers(User user) : IUserRepository + { + public Task FindByIdAsync(Guid id, CancellationToken ct) + => Task.FromResult(id == user.Id ? user : null); + + public Task FindByYandexIdAsync(string yandexId, CancellationToken ct) + => Task.FromResult(yandexId == user.YandexId ? user : null); + + public Task FindBySlugAsync(string slug, CancellationToken ct) + => Task.FromResult(slug == user.Slug ? user : null); + + public Task SlugExistsAsync(string slug, Guid? exceptUserId, CancellationToken ct) + => Task.FromResult(slug == user.Slug && exceptUserId != user.Id); + + public Task AddAsync(User incoming, CancellationToken ct) => Task.CompletedTask; + + public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask; + } + + private sealed class MemoryTokens(ApiToken token) : IApiTokenRepository + { + public Task> ListByUserAsync(Guid userId, CancellationToken ct) + => Task.FromResult>(token.UserId == userId ? [token] : []); + + public Task FindByIdAsync(Guid userId, Guid tokenId, CancellationToken ct) + => Task.FromResult(token.UserId == userId && token.Id == tokenId ? token : null); + + public Task FindByHashAsync(string hash, CancellationToken ct) + => Task.FromResult(token.Hash == hash ? token : null); + + public Task CountActiveAsync(Guid userId, CancellationToken ct) + => Task.FromResult(token.UserId == userId && !token.IsRevoked ? 1 : 0); + + public Task AddAsync(ApiToken incoming, CancellationToken ct) => Task.CompletedTask; + + public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask; + } +} + +public sealed class UsageAccumulatorTests +{ + [Fact] + public void Drain_SumsBatchedIncrements() + { + var userId = Guid.NewGuid(); + var tokenId = Guid.NewGuid(); + var acc = new UsageAccumulator(); + acc.RecordTile(userId, tokenId, 100); + acc.RecordTile(userId, tokenId, 40); + acc.RecordStyle(userId, tokenId, 20); + + var deltas = acc.Drain(); + var row = Assert.Single(deltas); + Assert.Equal(userId, row.UserId); + Assert.Equal(tokenId, row.TokenId); + Assert.Equal(2, row.TileRequests); + Assert.Equal(1, row.StyleRequests); + Assert.Equal(160, row.Bytes); + Assert.Empty(acc.Drain()); + } +} diff --git a/tests/TileServer.UnitTests/StyleServiceTests.cs b/tests/TileServer.UnitTests/StyleServiceTests.cs new file mode 100644 index 0000000..6f5b70d --- /dev/null +++ b/tests/TileServer.UnitTests/StyleServiceTests.cs @@ -0,0 +1,177 @@ +using System.Text.Json.Nodes; +using Microsoft.Extensions.Options; +using TileServer.Application.Configuration; +using TileServer.Application.Extracts; +using TileServer.Application.Styles; +using TileServer.Application.Tiles; +using TileServer.Domain.Extracts; +using TileServer.Domain.Tiles; + +namespace TileServer.UnitTests; + +public sealed class StyleServiceTests +{ + [Fact] + public void Get_BindsAbsoluteTilesAndSourceMaxZoom() + { + var extract = new Extract + { + Id = "central-fed-district", + Name = "ЦФО", + Url = new Uri("https://download.geofabrik.de/russia/central-fed-district-latest.osm.pbf"), + CenterLon = 37.6, + CenterLat = 55.7, + MinZoom = 0, + MaxZoom = 14 + }; + + var metadata = new TilesetMetadata( + extract.Id, + extract.Name, + "pbf", + 29.2, + 48.7, + 47.6, + 59.6, + 38.4, + 54.2, + 5, + 0, + 14, + []); + + var template = new JsonObject + { + ["version"] = 8, + ["name"] = "OSM Bright", + ["sources"] = new JsonObject + { + ["openmaptiles"] = new JsonObject + { + ["type"] = "vector", + ["url"] = "/api/v1/tiles/central-fed-district.json" + } + } + }; + + var service = new StyleService( + new MemoryStyleCatalog(template), + new MemoryExtractCatalog(extract), + new MemoryTileStore(metadata), + Options.Create(new TileServerOptions + { + GlyphsUrl = "https://fonts.example/{fontstack}/{range}.pbf" + })); + + var style = service.Get("osm-bright", extract.Id, "https://tile-server.ru"); + var source = style["sources"]!["openmaptiles"]!.AsObject(); + + Assert.Null(source["url"]); + Assert.Equal(0, source["minzoom"]!.GetValue()); + Assert.Equal(14, source["maxzoom"]!.GetValue()); + Assert.Equal("xyz", source["scheme"]!.GetValue()); + Assert.Equal( + "https://tile-server.ru/api/v1/tiles/central-fed-district/{z}/{x}/{y}.pbf?v=3", + source["tiles"]!.AsArray()[0]!.GetValue()); + Assert.Null(source["bounds"]); + } + + [Fact] + public void Bind_UsesPersonalTileUrlAndToken() + { + var extract = new Extract + { + Id = "central-fed-district", + Name = "ЦФО", + Url = new Uri("https://download.geofabrik.de/russia/central-fed-district-latest.osm.pbf"), + CenterLon = 37.6, + CenterLat = 55.7, + MinZoom = 0, + MaxZoom = 14 + }; + + var metadata = new TilesetMetadata( + extract.Id, + extract.Name, + "pbf", + 29.2, + 48.7, + 47.6, + 59.6, + 38.4, + 54.2, + 5, + 0, + 14, + []); + + var template = new JsonObject + { + ["version"] = 8, + ["sources"] = new JsonObject + { + ["openmaptiles"] = new JsonObject + { + ["type"] = "vector" + } + } + }; + + var service = new StyleService( + new MemoryStyleCatalog(template), + new MemoryExtractCatalog(extract), + new MemoryTileStore(metadata), + Options.Create(new TileServerOptions())); + + var tilesUrl = "https://tile-server.ru/u/alice/tiles/central-fed-district/{z}/{x}/{y}.pbf?v=3&token=ts_abc"; + var style = service.Bind(template, extract.Id, tilesUrl); + var tile = style["sources"]!["openmaptiles"]!["tiles"]!.AsArray()[0]!.GetValue(); + + Assert.Contains("/u/alice/", tile, StringComparison.Ordinal); + Assert.Contains("token=ts_abc", tile, StringComparison.Ordinal); + } + + private sealed class MemoryStyleCatalog(JsonObject style) : IStyleCatalog + { + public IReadOnlyList List() => [new("osm-bright", DateTimeOffset.UnixEpoch)]; + + public JsonObject GetRequired(string name) + => JsonNode.Parse(style.ToJsonString())!.AsObject(); + + public Task SaveAsync(string name, JsonObject incoming, CancellationToken ct) => Task.CompletedTask; + + public Task SeedDefaultsAsync(CancellationToken ct) => Task.CompletedTask; + } + + private sealed class MemoryExtractCatalog(Extract extract) : IExtractCatalog + { + public IReadOnlyList GetAll() => [extract]; + + public Extract? Find(string id) => id == extract.Id ? extract : null; + + public Extract GetRequired(string id) + => Find(id) ?? throw new InvalidOperationException(id); + + public Task AddOrUpdateAsync(Extract incoming, CancellationToken ct) => Task.CompletedTask; + + public Task RemoveAsync(string id, CancellationToken ct) => Task.CompletedTask; + } + + private sealed class MemoryTileStore(TilesetMetadata metadata) : ITileStore + { + public bool Exists(string extractId) => true; + + public Task GetTileAsync(string extractId, TileCoordinate tile, CancellationToken ct) + => Task.FromResult(null); + + public TilesetMetadata? GetMetadata(string extractId) => metadata; + + public string GetActivePath(string extractId) => extractId; + + public string GetStagingPath(string extractId) => extractId + ".building.mbtiles"; + + public void Activate(string extractId, string stagingPath) + { + } + } +} diff --git a/tests/TileServer.UnitTests/TileCoordinateTests.cs b/tests/TileServer.UnitTests/TileCoordinateTests.cs new file mode 100644 index 0000000..42a6c9e --- /dev/null +++ b/tests/TileServer.UnitTests/TileCoordinateTests.cs @@ -0,0 +1,49 @@ +using TileServer.Domain; +using TileServer.Domain.Tiles; + +namespace TileServer.UnitTests; + +public sealed class TileCoordinateTests +{ + [Fact] + public void Ancestor_ShiftsToParentZoom() + { + var child = new TileCoordinate(3, 5, 6); + Assert.Equal(new TileCoordinate(1, 1, 1), child.Ancestor(1)); + Assert.Equal(child, child.Ancestor(3)); + } + + [Fact] + public void ToTmsY_FlipsXyzOrigin() + { + var tile = new TileCoordinate(3, 1, 0); + Assert.Equal(7, tile.ToTmsY()); + } + + [Fact] + public void ClampMaxZoom_CapsAtPlanetilerLimit() + { + Assert.Equal(15, PlanetilerLimits.ClampMaxZoom(18)); + Assert.Equal(15, PlanetilerLimits.ClampMaxZoom(15)); + Assert.Equal(0, PlanetilerLimits.ClampMaxZoom(-1)); + } + + [Theory] + [InlineData(0, 0, 0, true)] + [InlineData(1, 2, 0, false)] + [InlineData(-1, 0, 0, false)] + public void IsValid_RespectsZoomBounds(int z, int x, int y, bool expected) + => Assert.Equal(expected, new TileCoordinate(z, x, y).IsValid); +} + +public sealed class ResourceNameTests +{ + [Theory] + [InlineData("central-fed-district", true)] + [InlineData("osm-bright", true)] + [InlineData("Dark", false)] + [InlineData("1south", false)] + [InlineData("", false)] + public void IsValid_AcceptsLowercaseIds(string value, bool expected) + => Assert.Equal(expected, ResourceName.IsValid(value)); +} diff --git a/tests/TileServer.UnitTests/TileDataTests.cs b/tests/TileServer.UnitTests/TileDataTests.cs new file mode 100644 index 0000000..9e6b04b --- /dev/null +++ b/tests/TileServer.UnitTests/TileDataTests.cs @@ -0,0 +1,31 @@ +using System.IO.Compression; +using TileServer.Domain.Tiles; + +namespace TileServer.UnitTests; + +public sealed class TileDataTests +{ + [Fact] + public void Uncompressed_ReturnsRawBytesWhenNotGzipped() + { + var payload = new byte[] { 0x1A, 0x2B, 0x3C }; + var tile = new TileData(payload, false); + + Assert.Same(payload, tile.Uncompressed()); + } + + [Fact] + public void Uncompressed_InflatesGzipPayload() + { + var raw = "mvt-payload"u8.ToArray(); + using var buffer = new MemoryStream(); + using (var gzip = new GZipStream(buffer, CompressionLevel.SmallestSize, leaveOpen: true)) + { + gzip.Write(raw); + } + + var tile = new TileData(buffer.ToArray(), true); + Assert.True(TileData.IsGzip(tile.Bytes)); + Assert.Equal(raw, tile.Uncompressed()); + } +} diff --git a/tests/TileServer.UnitTests/TileServer.UnitTests.csproj b/tests/TileServer.UnitTests/TileServer.UnitTests.csproj new file mode 100644 index 0000000..14a797c --- /dev/null +++ b/tests/TileServer.UnitTests/TileServer.UnitTests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/tests/TileServer.UnitTests/VectorTileOverzoomerTests.cs b/tests/TileServer.UnitTests/VectorTileOverzoomerTests.cs new file mode 100644 index 0000000..0481af1 --- /dev/null +++ b/tests/TileServer.UnitTests/VectorTileOverzoomerTests.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.Logging.Abstractions; +using TileServer.Domain.Tiles; +using TileServer.Infrastructure.Tiles; +using TileServer.Infrastructure.Tiles.Mvt; + +namespace TileServer.UnitTests; + +public sealed class VectorTileOverzoomerTests +{ + [Fact] + public void Overzoom_MapsParentPointIntoNwChild() + { + var parent = EncodePoint(100, 100, "poi"); + var overzoomer = new VectorTileOverzoomer(NullLogger.Instance); + var child = overzoomer.Overzoom(parent, new TileCoordinate(0, 0, 0), new TileCoordinate(1, 0, 0)); + + var decoded = MvtCodec.Decode(child); + var layer = Assert.Single(decoded.Layers); + Assert.Equal("poi", layer.Name); + var feature = Assert.Single(layer.Features); + var path = Assert.Single(MvtCodec.DecodeGeometry(feature.Geometry)); + var point = Assert.Single(path); + Assert.Equal(200, point.X); + Assert.Equal(200, point.Y); + } + + [Fact] + public void Overzoom_DropsPointOutsideChild() + { + var parent = EncodePoint(100, 100, "poi"); + var overzoomer = new VectorTileOverzoomer(NullLogger.Instance); + var child = overzoomer.Overzoom(parent, new TileCoordinate(0, 0, 0), new TileCoordinate(1, 1, 1)); + + var decoded = MvtCodec.Decode(child); + Assert.Empty(decoded.Layers); + } + + private static TileData EncodePoint(int x, int y, string layerName) + { + var tile = new MvtTile + { + Layers = + [ + new MvtLayer + { + Name = layerName, + Extent = 4096, + Version = 2, + Keys = ["class"], + Values = [new MvtValue { StringValue = "test" }], + Features = + [ + new MvtFeature + { + Id = 1, + Type = MvtCodec.Point, + Tags = [0, 0], + Geometry = MvtCodec.EncodeGeometry([[(x, y)]], close: false) + } + ] + } + ] + }; + return MvtCodec.EncodeGzip(tile); + } +} diff --git a/web-demo/index.html b/web-demo/index.html new file mode 100644 index 0000000..c14c9b4 --- /dev/null +++ b/web-demo/index.html @@ -0,0 +1,12 @@ + + + + + + Tile Server — demo + + +
+ + + diff --git a/web-demo/package-lock.json b/web-demo/package-lock.json new file mode 100644 index 0000000..a655517 --- /dev/null +++ b/web-demo/package-lock.json @@ -0,0 +1,2105 @@ +{ + "name": "tile-server-demo", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tile-server-demo", + "version": "1.0.0", + "dependencies": { + "maplibre-gl": "^5.6.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^6.0.7" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz", + "integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.2" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz", + "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.1.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz", + "integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^1.0.0", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", + "license": "BSD-2-Clause" + }, + "node_modules/@maplibre/mlt": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.3.0.tgz", + "integrity": "sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz", + "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.1.0" + } + }, + "node_modules/@maplibre/vt-pbf/node_modules/pbf": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz", + "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz", + "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz", + "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz", + "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz", + "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz", + "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz", + "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz", + "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz", + "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz", + "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz", + "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz", + "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz", + "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz", + "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz", + "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz", + "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz", + "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz", + "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz", + "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz", + "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz", + "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz", + "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz", + "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz", + "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz", + "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", + "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.433", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.433.tgz", + "integrity": "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/maplibre-gl": { + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", + "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.1.0", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^6.1.0", + "@maplibre/maplibre-gl-style-spec": "^24.8.1", + "@maplibre/mlt": "^1.1.8", + "@maplibre/vt-pbf": "^4.3.0", + "@types/geojson": "^7946.0.16", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.56", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz", + "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/pbf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz", + "integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/rollup": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz", + "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.4", + "@rollup/rollup-android-arm64": "4.63.4", + "@rollup/rollup-darwin-arm64": "4.63.4", + "@rollup/rollup-darwin-x64": "4.63.4", + "@rollup/rollup-freebsd-arm64": "4.63.4", + "@rollup/rollup-freebsd-x64": "4.63.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", + "@rollup/rollup-linux-arm-musleabihf": "4.63.4", + "@rollup/rollup-linux-arm64-gnu": "4.63.4", + "@rollup/rollup-linux-arm64-musl": "4.63.4", + "@rollup/rollup-linux-loong64-gnu": "4.63.4", + "@rollup/rollup-linux-loong64-musl": "4.63.4", + "@rollup/rollup-linux-ppc64-gnu": "4.63.4", + "@rollup/rollup-linux-ppc64-musl": "4.63.4", + "@rollup/rollup-linux-riscv64-gnu": "4.63.4", + "@rollup/rollup-linux-riscv64-musl": "4.63.4", + "@rollup/rollup-linux-s390x-gnu": "4.63.4", + "@rollup/rollup-linux-x64-gnu": "4.63.4", + "@rollup/rollup-linux-x64-musl": "4.63.4", + "@rollup/rollup-openbsd-x64": "4.63.4", + "@rollup/rollup-openharmony-arm64": "4.63.4", + "@rollup/rollup-win32-arm64-msvc": "4.63.4", + "@rollup/rollup-win32-ia32-msvc": "4.63.4", + "@rollup/rollup-win32-x64-gnu": "4.63.4", + "@rollup/rollup-win32-x64-msvc": "4.63.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web-demo/package.json b/web-demo/package.json new file mode 100644 index 0000000..befd9ef --- /dev/null +++ b/web-demo/package.json @@ -0,0 +1,23 @@ +{ + "name": "tile-server-demo", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "maplibre-gl": "^5.6.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^6.0.7" + } +} diff --git a/web-demo/src/App.tsx b/web-demo/src/App.tsx new file mode 100644 index 0000000..bdbcf3f --- /dev/null +++ b/web-demo/src/App.tsx @@ -0,0 +1,481 @@ +import { FormEvent, useCallback, useEffect, useRef, useState } from "react"; +import maplibregl, { type Map as MapLibreMap, type StyleSpecification } from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; +import { + API_ORIGIN, + isPersonal, + persistAccess, + readAccess, + rewriteServiceUrl, + serviceUrl, + styleUrl, + stylesListUrl, + type Access +} from "./access"; +import { fetchJson, type SourceItem, type StyleItem, type SyncStatus } from "./api"; + +const STATUS_LABELS: Record = { + Pending: "ожидание", + Downloading: "скачивание PBF", + PreparingSources: "данные Planetiler", + Building: "сборка тайлов", + Ready: "готово", + Failed: "ошибка" +}; + +function formatStatus(value?: string): string { + return STATUS_LABELS[value ?? ""] ?? value ?? "—"; +} + +function formatDate(value?: string | null): string { + return value ? new Date(value).toLocaleString("ru-RU") : "нет"; +} + +function parseNumber(value: string): number | null { + const parsed = Number(value.trim().replace(",", ".")); + return Number.isFinite(parsed) ? parsed : null; +} + +function cameraFromHash(): { zoom: number; lat: number; lon: number } | null { + 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 }; +} + +export function App() { + const mapEl = useRef(null); + const mapRef = useRef(null); + const styleKeyRef = useRef(null); + const suppressGoto = useRef(false); + const latRef = useRef(null); + const lonRef = useRef(null); + const zoomRef = useRef(null); + + const [sources, setSources] = useState([]); + const [styles, setStyles] = useState([]); + const [sync, setSync] = useState(null); + const [sourceId, setSourceId] = useState(""); + const [styleName, setStyleName] = useState("osm-bright"); + const [coords, setCoords] = useState("—"); + const [gotoError, setGotoError] = useState(""); + const [banner, setBanner] = useState(""); + const [syncing, setSyncing] = useState(false); + const [access, setAccess] = useState(() => { + const stored = readAccess(); + persistAccess(stored); + return stored; + }); + const [slugDraft, setSlugDraft] = useState(access.slug); + const [tokenDraft, setTokenDraft] = useState(access.token); + const accessRef = useRef(access); + accessRef.current = access; + const personal = isPersonal(access); + + const source = sources.find((item) => item.id === sourceId) ?? sources[0]; + + const updateHud = useCallback((map: MapLibreMap) => { + const center = map.getCenter(); + const zoom = map.getZoom(); + setCoords(`${center.lat.toFixed(5)}° · ${center.lng.toFixed(5)}° z ${zoom.toFixed(2)}`); + }, []); + + const syncGotoFromMap = useCallback((map: MapLibreMap) => { + if (suppressGoto.current) { + return; + } + const active = document.activeElement; + if (active === latRef.current || active === lonRef.current || active === zoomRef.current) { + return; + } + const center = map.getCenter(); + const zoom = map.getZoom(); + if (zoom < 2 && Math.abs(center.lat) < 1 && Math.abs(center.lng) < 1) { + return; + } + if (latRef.current) { + latRef.current.value = center.lat.toFixed(5); + } + if (lonRef.current) { + lonRef.current.value = center.lng.toFixed(5); + } + if (zoomRef.current) { + zoomRef.current.value = zoom.toFixed(2); + } + }, []); + + const flyTo = (lat: number, lon: number, zoom: number) => { + const map = mapRef.current; + if (!map) { + return; + } + setGotoError(""); + suppressGoto.current = true; + map.flyTo({ center: [lon, lat], zoom }); + map.once("moveend", () => { + suppressGoto.current = false; + updateHud(map); + syncGotoFromMap(map); + }); + }; + + const loadStyle = useCallback(async () => { + if (!personal) { + setBanner("Публичная карта закрыта. Вставь slug и токен из кабинета."); + return; + } + + if (!source || !styleName || !mapEl.current) { + return; + } + + const styleKey = `${personal ? access.slug : "public"}|${source.id}|${styleName}|${source.status}|${source.builtAt ?? ""}`; + if (mapRef.current && styleKeyRef.current === styleKey) { + return; + } + + const style = await fetchJson(styleUrl(access, styleName, 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 (!mapRef.current) { + const map = new maplibregl.Map({ + container: mapEl.current, + style, + center: center as [number, number], + zoom, + maxZoom: 18, + hash: true, + transformRequest: (url) => ({ url: rewriteServiceUrl(url, accessRef.current) }) + }); + map.addControl(new maplibregl.NavigationControl(), "top-right"); + map.addControl(new maplibregl.ScaleControl(), "bottom-right"); + map.on("move", () => updateHud(map)); + map.on("moveend", () => syncGotoFromMap(map)); + map.on("load", () => { + map.resize(); + updateHud(map); + syncGotoFromMap(map); + }); + map.on("error", (event) => { + const message = event.error?.message; + if (message) { + setBanner(message); + } + }); + mapRef.current = map; + } else { + mapRef.current.setStyle(style, { diff: false }); + } + + styleKeyRef.current = styleKey; + const ready = source.status === "Ready"; + setBanner( + ready + ? "" + : `Тайлы источника «${source.name}» ещё не готовы (${formatStatus(source.status)}). Первый прогон может занять часы.` + ); + }, [access, personal, source, styleName, syncGotoFromMap, updateHud]); + + const refresh = useCallback(async () => { + const [sourceList, syncStatus] = await Promise.all([ + fetchJson(serviceUrl("/api/v1/sources")), + fetchJson(serviceUrl("/api/v1/sync/status")) + ]); + const styleList = personal + ? await fetchJson(stylesListUrl(access)) + : []; + setSources(sourceList); + setStyles(styleList); + setSync(syncStatus); + setSourceId((current) => + sourceList.some((item) => item.id === current) ? current : (sourceList[0]?.id ?? "") + ); + setStyleName((current) => + styleList.some((item) => item.name === current) + ? current + : styleList.some((item) => item.name === "osm-bright") + ? "osm-bright" + : (styleList[0]?.name ?? "") + ); + }, [access, personal]); + + useEffect(() => { + void refresh().catch((err: unknown) => setBanner(err instanceof Error ? err.message : "Ошибка загрузки")); + const timer = window.setInterval(() => { + void refresh().catch(() => undefined); + }, 15000); + return () => window.clearInterval(timer); + }, [refresh]); + + useEffect(() => { + void loadStyle().catch((err: unknown) => setBanner(err instanceof Error ? err.message : "Ошибка стиля")); + }, [loadStyle]); + + useEffect(() => { + return () => { + mapRef.current?.remove(); + mapRef.current = null; + }; + }, []); + + const onGoto = (event: FormEvent) => { + event.preventDefault(); + const map = mapRef.current; + if (!map) { + setGotoError("Карта ещё не загружена."); + return; + } + const lat = parseNumber(latRef.current?.value ?? ""); + const lon = parseNumber(lonRef.current?.value ?? ""); + const zoom = parseNumber(zoomRef.current?.value ?? ""); + const maxZoom = map.getMaxZoom(); + if (lat === null || lat < -90 || lat > 90) { + setGotoError("Широта: число от -90 до 90."); + return; + } + if (lon === null || lon < -180 || lon > 180) { + setGotoError("Долгота: число от -180 до 180."); + return; + } + if (zoom === null || zoom < 0 || zoom > maxZoom) { + setGotoError(`Зум: число от 0 до ${maxZoom}.`); + return; + } + flyTo(lat, lon, zoom); + }; + + const fitBounds = () => { + const bounds = source?.bounds; + const map = mapRef.current; + if (!map || !bounds) { + setGotoError("У источника ещё нет bounds."); + return; + } + map.fitBounds( + [ + [bounds.minLon, bounds.minLat], + [bounds.maxLon, bounds.maxLat] + ], + { padding: 48, maxZoom: 10 } + ); + }; + + const startSync = async () => { + setSyncing(true); + try { + const url = source + ? serviceUrl(`/api/v1/sync/${encodeURIComponent(source.id)}`) + : serviceUrl("/api/v1/sync"); + await fetchJson(url, { method: "POST" }); + await refresh(); + } catch (err: unknown) { + setBanner(err instanceof Error ? err.message : "Не удалось запустить синхронизацию"); + } finally { + setSyncing(false); + } + }; + + const status = source?.status; + const pillClass = + status === "Ready" ? "pill pill--ok" : status === "Failed" ? "pill pill--fail" : "pill pill--work"; + const workerClass = [ + "hud", + "hud--status", + sync?.isRunning ? "is-running" : "", + !sync?.isRunning && status === "Ready" ? "is-ready" : "" + ] + .filter(Boolean) + .join(" "); + + return ( + <> +
+ +
+
+ + + Tile Server + {personal ? `личный ${new URL(API_ORIGIN).host}` : new URL(API_ORIGIN).host} + +
+
+ + + + {personal ? ( + + ) : null} + {personal ? "личный" : "публичный"} + + + {formatStatus(status)} + + + OpenAPI + +
+
+ +
+
+
+

Камера

+

{coords}

+
+
+
+ + + +
+
+ + + +
+ + {gotoError ?

{gotoError}

: null} +
+ + + + {banner ?
{banner}
: null} + + ); +} diff --git a/web-demo/src/access.ts b/web-demo/src/access.ts new file mode 100644 index 0000000..e61f2f4 --- /dev/null +++ b/web-demo/src/access.ts @@ -0,0 +1,78 @@ +const SLUG_KEY = "ts.demo.slug"; +const TOKEN_KEY = "ts.demo.token"; + +export const API_ORIGIN = (import.meta.env.VITE_API_ORIGIN ?? "https://tile-server.ru").replace(/\/$/, ""); + +export function serviceUrl(path: string): string { + if (/^https?:\/\//i.test(path)) { + return path; + } + return `${API_ORIGIN}${path.startsWith("/") ? path : `/${path}`}`; +} + +export type Access = { + slug: string; + token: string; +}; + +export function isPersonal(access: Access): boolean { + return access.slug.length > 0 && access.token.length > 0; +} + +export function readAccess(): Access { + const params = new URLSearchParams(window.location.search); + const slug = (params.get("slug") ?? sessionStorage.getItem(SLUG_KEY) ?? "").trim(); + const token = (params.get("token") ?? sessionStorage.getItem(TOKEN_KEY) ?? "").trim(); + return { slug, token }; +} + +export function persistAccess(access: Access): void { + if (access.slug) { + sessionStorage.setItem(SLUG_KEY, access.slug); + } else { + sessionStorage.removeItem(SLUG_KEY); + } + + if (access.token) { + sessionStorage.setItem(TOKEN_KEY, access.token); + } else { + sessionStorage.removeItem(TOKEN_KEY); + } + + const url = new URL(window.location.href); + if (url.searchParams.has("slug") || url.searchParams.has("token")) { + url.searchParams.delete("slug"); + url.searchParams.delete("token"); + window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`); + } +} + +export function stylesListUrl(access: Access): string { + if (isPersonal(access)) { + return serviceUrl(`/u/${encodeURIComponent(access.slug)}/styles?token=${encodeURIComponent(access.token)}`); + } + return serviceUrl("/api/v1/styles"); +} + +export function styleUrl(access: Access, name: string, sourceId: string): string { + if (isPersonal(access)) { + return serviceUrl( + `/u/${encodeURIComponent(access.slug)}/styles/${encodeURIComponent(name)}?source=${encodeURIComponent(sourceId)}&token=${encodeURIComponent(access.token)}` + ); + } + return serviceUrl(`/api/v1/styles/${encodeURIComponent(name)}?source=${encodeURIComponent(sourceId)}`); +} + +export function rewriteServiceUrl(url: string, access: Access): string { + const resolved = new URL(url, `${API_ORIGIN}/`); + const isService = resolved.pathname.startsWith("/u/") || resolved.pathname.startsWith("/api/"); + if (!isService) { + return resolved.href; + } + + if (isPersonal(access) && resolved.pathname.startsWith("/u/") && !resolved.searchParams.has("token")) { + resolved.searchParams.set("token", access.token); + } + + return `${API_ORIGIN}${resolved.pathname}${resolved.search}`; +} diff --git a/web-demo/src/api.ts b/web-demo/src/api.ts new file mode 100644 index 0000000..9139489 --- /dev/null +++ b/web-demo/src/api.ts @@ -0,0 +1,43 @@ +export type TilesetBounds = { + minLon: number; + minLat: number; + maxLon: number; + maxLat: number; +}; + +export type SourceItem = { + id: string; + name: string; + enabled: boolean; + center: number[]; + status: string; + downloadedAt?: string | null; + builtAt?: string | null; + lastError?: string | null; + bounds?: TilesetBounds | null; +}; + +export type StyleItem = { + name: string; + url: string; +}; + +export type SyncStatus = { + isRunning: boolean; + lastStartedAt?: string | null; + lastFinishedAt?: string | null; + nextScheduledAt?: string | null; + lastError?: string | null; +}; + +export async function fetchJson(url: string, options?: RequestInit): Promise { + 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 undefined as T; + } + return (await response.json()) as T; +} diff --git a/web-demo/src/demo.css b/web-demo/src/demo.css new file mode 100644 index 0000000..eabc303 --- /dev/null +++ b/web-demo/src/demo.css @@ -0,0 +1,476 @@ +: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, #root, #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--token { min-width: 14rem; } + +.field--token input { + font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; + font-size: 0.78rem; +} + +.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; + } +} diff --git a/web-demo/src/main.tsx b/web-demo/src/main.tsx new file mode 100644 index 0000000..a383bfe --- /dev/null +++ b/web-demo/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./demo.css"; + +createRoot(document.getElementById("root")!).render( + + + +); diff --git a/web-demo/src/vite-env.d.ts b/web-demo/src/vite-env.d.ts new file mode 100644 index 0000000..c602aa1 --- /dev/null +++ b/web-demo/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_ORIGIN?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/web-demo/tsconfig.json b/web-demo/tsconfig.json new file mode 100644 index 0000000..c351008 --- /dev/null +++ b/web-demo/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/web-demo/vite.config.ts b/web-demo/vite.config.ts new file mode 100644 index 0000000..3cb759f --- /dev/null +++ b/web-demo/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5174 + }, + build: { + outDir: "dist", + emptyOutDir: true + } +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..c82cc1c --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + Tile Server + + +
+ + + diff --git a/web/nginx.conf b/web/nginx.conf new file mode 100644 index 0000000..cf6bf9d --- /dev/null +++ b/web/nginx.conf @@ -0,0 +1,36 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/css application/javascript application/json; + + location /api/ { + proxy_pass http://tile-server:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header Authorization $http_authorization; + proxy_redirect off; + } + + location /u/ { + proxy_pass http://tile-server:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header Authorization $http_authorization; + gzip off; + proxy_buffering off; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..63fe96d --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2239 @@ +{ + "name": "tile-server-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tile-server-web", + "version": "1.0.0", + "dependencies": { + "maplibre-gl": "^5.6.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.1", + "styled-components": "^6.1.19" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^6.0.7" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz", + "integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.2" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz", + "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.1.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz", + "integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^1.0.0", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", + "license": "BSD-2-Clause" + }, + "node_modules/@maplibre/mlt": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.3.0.tgz", + "integrity": "sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz", + "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.1.0" + } + }, + "node_modules/@maplibre/vt-pbf/node_modules/pbf": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.4", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", + "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz", + "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz", + "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz", + "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz", + "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz", + "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz", + "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz", + "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz", + "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz", + "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz", + "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz", + "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz", + "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz", + "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz", + "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz", + "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz", + "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz", + "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz", + "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz", + "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz", + "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz", + "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz", + "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz", + "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz", + "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz", + "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", + "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "license": "MIT", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.433", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.433.tgz", + "integrity": "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/maplibre-gl": { + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", + "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.1.0", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^6.1.0", + "@maplibre/maplibre-gl-style-spec": "^24.8.1", + "@maplibre/mlt": "^1.1.8", + "@maplibre/vt-pbf": "^4.3.0", + "@types/geojson": "^7946.0.16", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.56", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz", + "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/pbf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz", + "integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", + "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz", + "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4", + "react-router": "6.30.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/rollup": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz", + "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.4", + "@rollup/rollup-android-arm64": "4.63.4", + "@rollup/rollup-darwin-arm64": "4.63.4", + "@rollup/rollup-darwin-x64": "4.63.4", + "@rollup/rollup-freebsd-arm64": "4.63.4", + "@rollup/rollup-freebsd-x64": "4.63.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", + "@rollup/rollup-linux-arm-musleabihf": "4.63.4", + "@rollup/rollup-linux-arm64-gnu": "4.63.4", + "@rollup/rollup-linux-arm64-musl": "4.63.4", + "@rollup/rollup-linux-loong64-gnu": "4.63.4", + "@rollup/rollup-linux-loong64-musl": "4.63.4", + "@rollup/rollup-linux-ppc64-gnu": "4.63.4", + "@rollup/rollup-linux-ppc64-musl": "4.63.4", + "@rollup/rollup-linux-riscv64-gnu": "4.63.4", + "@rollup/rollup-linux-riscv64-musl": "4.63.4", + "@rollup/rollup-linux-s390x-gnu": "4.63.4", + "@rollup/rollup-linux-x64-gnu": "4.63.4", + "@rollup/rollup-linux-x64-musl": "4.63.4", + "@rollup/rollup-openbsd-x64": "4.63.4", + "@rollup/rollup-openharmony-arm64": "4.63.4", + "@rollup/rollup-win32-arm64-msvc": "4.63.4", + "@rollup/rollup-win32-ia32-msvc": "4.63.4", + "@rollup/rollup-win32-x64-gnu": "4.63.4", + "@rollup/rollup-win32-x64-msvc": "4.63.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-components": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.5.3.tgz", + "integrity": "sha512-vAX79sfpmUerP9fsTTxoTrBDE0RuO4ahjInyWYoohNgqrdg63Ms4q6FJ/o2Fyity82NU3cujOT8Ewl9TThBdwg==", + "license": "MIT", + "dependencies": { + "@emotion/is-prop-valid": "1.4.0", + "css-to-react-native": "3.2.0", + "csstype": "3.2.3", + "stylis": "4.3.6" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/styled-components" + }, + "peerDependencies": { + "css-to-react-native": ">= 3.2.0", + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0", + "react-native": ">= 0.68.0" + }, + "peerDependenciesMeta": { + "css-to-react-native": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..4c0c342 --- /dev/null +++ b/web/package.json @@ -0,0 +1,25 @@ +{ + "name": "tile-server-web", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "maplibre-gl": "^5.6.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.1", + "styled-components": "^6.1.19" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^6.0.7" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..d4fc7a2 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,82 @@ +import { type ReactNode } from "react"; +import { Navigate, Route, Routes } from "react-router-dom"; +import { AppShell } from "./components/layout/AppShell"; +import { useAuth } from "./features/auth/AuthContext"; +import { LoginPage } from "./features/auth/LoginPage"; +import { DashboardPage } from "./features/usage/DashboardPage"; +import { StylesPage } from "./features/styles/StylesPage"; +import { StyleEditorPage } from "./features/styles/StyleEditorPage"; +import { TokensPage } from "./features/tokens/TokensPage"; +import { GuidesPage } from "./features/guides/GuidesPage"; +import { CookieBanner } from "./legal/CookieBanner"; +import { LegalPage } from "./legal/LegalPage"; + +function Guard({ children }: { children: ReactNode }) { + const { user, loading, logout } = useAuth(); + if (loading) { + return

Загрузка…

; + } + if (!user) { + return ; + } + return ( + void logout()}> + {children} + + ); +} + +export function App() { + const { user, loading } = useAuth(); + + return ( + <> + + : } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + + + ); +} diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..6898f45 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,137 @@ +export class ApiError extends Error { + constructor( + public readonly status: number, + message: string + ) { + super(message); + } +} + +async function request(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + if (init.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + const response = await fetch(path, { + ...init, + headers, + credentials: "include" + }); + + if (response.status === 204) { + return undefined as T; + } + + const text = await response.text(); + const payload = text ? (JSON.parse(text) as unknown) : undefined; + if (!response.ok) { + const detail = + payload && typeof payload === "object" && "detail" in payload + ? String((payload as { detail?: string }).detail) + : response.statusText; + throw new ApiError(response.status, detail || "Request failed"); + } + + return payload as T; +} + +export type Me = { + id: string; + login: string; + displayName: string; + slug: string; + yandexId: string; +}; + +export type AuthResponse = { + user: Me; + cabinetToken?: string | null; +}; + +export type YandexProvider = { + enabled: boolean; + clientId?: string | null; + redirectUri?: string | null; +}; + +export type AuthProviders = { + yandex: YandexProvider; +}; + +export type UsageDay = { + date: string; + tileRequests: number; + styleRequests: number; + bytes: number; +}; + +export type TokenItem = { + id: string; + name: string; + prefix: string; + createdAt: string; + revokedAt?: string | null; +}; + +export type CreatedToken = TokenItem & { token: string }; + +export type StyleItem = { + name: string; + createdFrom?: string | null; + updatedAt: string; + isPreset: boolean; +}; + +export type SourceItem = { + id: string; + name: string; + enabled: boolean; + center: number[]; +}; + +export const api = { + me: () => request("/api/v1/me"), + providers: () => request("/api/v1/auth/providers"), + loginYandex: (code: string, redirectUri: string, state?: string | null) => + request("/api/v1/auth/yandex", { + method: "POST", + body: JSON.stringify({ code, state, redirectUri }) + }), + logout: () => request("/api/v1/auth/logout", { method: "POST" }), + usage: (from: string, to: string) => + request(`/api/v1/me/usage?from=${from}&to=${to}`), + tokens: () => request("/api/v1/me/tokens"), + createToken: (name: string) => + request("/api/v1/me/tokens", { + method: "POST", + body: JSON.stringify({ name }) + }), + revokeToken: (id: string) => + request(`/api/v1/me/tokens/${id}`, { method: "DELETE" }), + styles: () => request("/api/v1/me/styles"), + getStyle: (name: string) => request(`/api/v1/me/styles/${name}`), + cloneStyle: (name: string, from: string) => + request("/api/v1/me/styles", { + method: "POST", + body: JSON.stringify({ name, from }) + }), + saveStyle: (name: string, style: unknown) => + request(`/api/v1/me/styles/${name}`, { + method: "PUT", + body: JSON.stringify(style) + }), + deleteStyle: (name: string) => + request(`/api/v1/me/styles/${name}`, { method: "DELETE" }), + sources: () => request("/api/v1/sources") +}; + +const TOKEN_KEY = "ts.previewToken"; + +export function getPreviewToken(): string | null { + return sessionStorage.getItem(TOKEN_KEY); +} + +export function setPreviewToken(token: string): void { + sessionStorage.setItem(TOKEN_KEY, token); +} diff --git a/web/src/auth/yandexRedirect.ts b/web/src/auth/yandexRedirect.ts new file mode 100644 index 0000000..dd59344 --- /dev/null +++ b/web/src/auth/yandexRedirect.ts @@ -0,0 +1,51 @@ +const YANDEX_AUTHORIZE = "https://oauth.yandex.ru/authorize"; + +export function yandexCodeFromQuery(search: string): string | null { + const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`); + const code = params.get("code")?.trim(); + if (!code) return null; + // Telegram login also lands on /login with id/hash — never treat that as Yandex. + if (params.has("hash") && params.has("id")) return null; + return code; +} + +export function yandexErrorFromQuery(search: string): string | null { + const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`); + if (params.has("hash") && params.has("id")) return null; + return params.get("error_description")?.trim() || params.get("error")?.trim() || null; +} + +export function yandexAuthorizeUrl( + clientId: string, + redirectUri: string, + state?: string | null, +): string { + const params = new URLSearchParams({ + response_type: "code", + client_id: clientId, + redirect_uri: redirectUri, + force_confirm: "yes", + }); + if (state) params.set("state", state); + return `${YANDEX_AUTHORIZE}?${params.toString()}`; +} + +/** Must match the Callback URL registered in the Yandex app (trailing slash). */ +export function yandexRedirectUri( + configured?: string | null, + origin = window.location.origin, +): string { + const local = `${origin.replace(/\/$/, "")}/`; + if (!configured) return local; + try { + if (new URL(configured).origin === new URL(local).origin) return configured; + } catch { + // Fall through to the current origin. + } + return local; +} + +/** @deprecated use yandexRedirectUri */ +export function cabinetLoginRedirectUri(origin = window.location.origin): string { + return yandexRedirectUri(null, origin); +} diff --git a/web/src/brand.ts b/web/src/brand.ts new file mode 100644 index 0000000..b9ca152 --- /dev/null +++ b/web/src/brand.ts @@ -0,0 +1 @@ +export const APP_NAME = "Tile Server"; diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..9b42d16 --- /dev/null +++ b/web/src/components/layout/AppShell.tsx @@ -0,0 +1,73 @@ +import { NavLink, useLocation } from "react-router-dom"; +import type { ReactNode } from "react"; +import { APP_NAME } from "../../brand"; +import { SiteFooter } from "../../legal/SiteFooter"; +import { AppAvatar, AppButton, AppText } from "../../ui"; +import { PRIMARY_NAV } from "./nav"; + +type Props = { + brand?: string; + userLabel: string; + onLogout: () => void; + children: ReactNode; +}; + +function initials(label: string): string { + const parts = label.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) { + return "•"; + } + if (parts.length === 1) { + return parts[0].slice(0, 1).toUpperCase(); + } + return (parts[0][0] + parts[1][0]).toUpperCase(); +} + +export function AppShell({ + brand = APP_NAME, + userLabel, + onLogout, + children, +}: Props) { + const location = useLocation(); + const workspace = /^\/styles\/[^/]+$/.test(location.pathname); + + return ( +
+
+ + {brand} + +
+ + + {userLabel} + + + Выйти + +
+
+ + + +
{children}
+
+ ); +} diff --git a/web/src/components/layout/AuthLayout.tsx b/web/src/components/layout/AuthLayout.tsx new file mode 100644 index 0000000..17eeb0c --- /dev/null +++ b/web/src/components/layout/AuthLayout.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from "react"; +import { APP_NAME } from "../../brand"; +import { AppText } from "../../ui"; + +type Props = { + brand?: string; + title: string; + lead: string; + cta: ReactNode; + footer?: ReactNode; + flash?: ReactNode; +}; + +export function AuthLayout({ + brand = APP_NAME, + title, + lead, + cta, + footer, + flash, +}: Props) { + return ( +
+
+ + {brand} + + + {title} + + {lead} +
{cta}
+ {flash} + {footer ?
{footer}
: null} +
+
+ ); +} diff --git a/web/src/components/layout/nav.ts b/web/src/components/layout/nav.ts new file mode 100644 index 0000000..d3ce5a3 --- /dev/null +++ b/web/src/components/layout/nav.ts @@ -0,0 +1,6 @@ +export const PRIMARY_NAV = [ + { id: "overview", to: "/", label: "Обзор", soon: false }, + { id: "guides", to: "/guides", label: "Инструкции", soon: false }, + { id: "styles", to: "/styles", label: "Стили", soon: false }, + { id: "tokens", to: "/tokens", label: "Токены", soon: false }, +] as const; diff --git a/web/src/components/ui/Banner.tsx b/web/src/components/ui/Banner.tsx new file mode 100644 index 0000000..79f44ef --- /dev/null +++ b/web/src/components/ui/Banner.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from "react"; + +type Props = { + children: ReactNode; + tone?: "warn"; +}; + +export function Banner({ children, tone = "warn" }: Props) { + return ( +

+ {children} +

+ ); +} diff --git a/web/src/components/ui/Button.tsx b/web/src/components/ui/Button.tsx new file mode 100644 index 0000000..168abfc --- /dev/null +++ b/web/src/components/ui/Button.tsx @@ -0,0 +1,45 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import { AppButton, type AppButtonSize, type AppButtonStyle } from "../../ui"; + +type Variant = "primary" | "secondary" | "ghost" | "destructive"; +type Size = "md" | "sm"; + +const STYLE: Record = { + primary: "filled", + secondary: "gray", + ghost: "plain", + destructive: "destructive", +}; + +const SIZE: Record = { + md: "medium", + sm: "small", +}; + +type Props = ButtonHTMLAttributes & { + variant?: Variant; + size?: Size; + expanded?: boolean; + children: ReactNode; +}; + +export function Button({ + variant = "secondary", + size = "md", + expanded = false, + className = "", + children, + ...rest +}: Props) { + return ( + + {children} + + ); +} diff --git a/web/src/components/ui/EmptyState.tsx b/web/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000..45d6af1 --- /dev/null +++ b/web/src/components/ui/EmptyState.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; +import { AppEmptyView } from "../../ui"; + +type Props = { + children: ReactNode; +}; + +export function EmptyState({ children }: Props) { + return ; +} diff --git a/web/src/components/ui/Field.tsx b/web/src/components/ui/Field.tsx new file mode 100644 index 0000000..d1fd25c --- /dev/null +++ b/web/src/components/ui/Field.tsx @@ -0,0 +1,12 @@ +import type { InputHTMLAttributes, ReactNode } from "react"; +import { AppTextField } from "../../ui"; + +type Props = InputHTMLAttributes & { + label: string; + id: string; + hint?: ReactNode; +}; + +export function Field({ label, id, hint, className = "", ...rest }: Props) { + return ; +} diff --git a/web/src/components/ui/Flash.tsx b/web/src/components/ui/Flash.tsx new file mode 100644 index 0000000..baa2aec --- /dev/null +++ b/web/src/components/ui/Flash.tsx @@ -0,0 +1,14 @@ +type Props = { + error?: string | null; + notice?: string | null; +}; + +export function Flash({ error = null, notice = null }: Props) { + if (!error && !notice) return null; + return ( +
+ {error ?

{error}

: null} + {notice ?

{notice}

: null} +
+ ); +} diff --git a/web/src/components/ui/MetricGrid.tsx b/web/src/components/ui/MetricGrid.tsx new file mode 100644 index 0000000..1728d52 --- /dev/null +++ b/web/src/components/ui/MetricGrid.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from "react"; +import { AppListSection, AppListTile } from "../../ui"; + +export type MetricItem = { + label: string; + value: ReactNode; + warn?: boolean; +}; + +type Props = { + items: MetricItem[]; +}; + +export function MetricGrid({ items }: Props) { + return ( + + {items.map((item) => ( + {item.value}} + /> + ))} + + ); +} diff --git a/web/src/components/ui/PageHeader.tsx b/web/src/components/ui/PageHeader.tsx new file mode 100644 index 0000000..b3a2a9b --- /dev/null +++ b/web/src/components/ui/PageHeader.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from "react"; +import { AppText } from "../../ui"; + +type Props = { + eyebrow?: string; + title: string; + description?: ReactNode; +}; + +export function PageHeader({ eyebrow, title, description }: Props) { + return ( +
+ {eyebrow ? {eyebrow} : null} + + {title} + + {description ? {description} : null} +
+ ); +} diff --git a/web/src/components/ui/ProgressBar.tsx b/web/src/components/ui/ProgressBar.tsx new file mode 100644 index 0000000..ba646b1 --- /dev/null +++ b/web/src/components/ui/ProgressBar.tsx @@ -0,0 +1,17 @@ +import { AppProgress, AppText } from "../../ui"; + +type Props = { + spent: number; + total: number; + label?: string; +}; + +export function ProgressBar({ spent, total, label }: Props) { + const pct = total > 0 ? Math.min(100, Math.max(0, (spent / total) * 100)) : 0; + return ( +
+ + {Math.round(pct)}% бюджета использовано +
+ ); +} diff --git a/web/src/components/ui/Section.tsx b/web/src/components/ui/Section.tsx new file mode 100644 index 0000000..fc9811d --- /dev/null +++ b/web/src/components/ui/Section.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from "react"; +import { AppSectionHeader } from "../../ui"; + +type Props = { + title: string; + description?: ReactNode; + actions?: ReactNode; + children: ReactNode; + labelledBy?: string; +}; + +export function Section({ title, description, actions, children, labelledBy }: Props) { + const id = labelledBy ?? `section-${title.toLowerCase().replace(/\s+/g, "-")}`; + return ( +
+
+
+

+ {title} +

+ {description ?

{description}

: null} +
+ {actions} +
+ {children} +
+ ); +} diff --git a/web/src/components/ui/index.ts b/web/src/components/ui/index.ts new file mode 100644 index 0000000..c962fe9 --- /dev/null +++ b/web/src/components/ui/index.ts @@ -0,0 +1,10 @@ +export { Button } from "./Button"; +export { Field } from "./Field"; +export { Flash } from "./Flash"; +export { ProgressBar } from "./ProgressBar"; +export { MetricGrid } from "./MetricGrid"; +export { PageHeader } from "./PageHeader"; +export { Section } from "./Section"; +export { EmptyState } from "./EmptyState"; +export { Banner } from "./Banner"; +export * from "../../ui"; diff --git a/web/src/design/base.css b/web/src/design/base.css new file mode 100644 index 0000000..8790320 --- /dev/null +++ b/web/src/design/base.css @@ -0,0 +1,70 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body, +#root { + min-height: 100%; +} + +body { + margin: 0; + font-family: var(--font-sans); + font-size: var(--text-body); + letter-spacing: -0.41px; + color: var(--label); + background: var(--grouped-background); + line-height: 1.3; + -webkit-font-smoothing: antialiased; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input { + font: inherit; +} + +h1, +h2, +h3 { + margin: 0; + font-weight: 600; + letter-spacing: -0.02em; +} + +p { + margin: 0; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.muted { + color: var(--color-muted); +} + +.mono { + font-family: var(--font-mono); +} + +.is-warn { + color: var(--color-warn) !important; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + } +} diff --git a/web/src/design/components.css b/web/src/design/components.css new file mode 100644 index 0000000..ad0a759 --- /dev/null +++ b/web/src/design/components.css @@ -0,0 +1,993 @@ +/* —— Layout shell —— */ +.app-shell { + min-height: 100vh; + display: grid; + grid-template-columns: var(--sidebar-width) minmax(0, 1fr); + grid-template-rows: auto 1fr; + grid-template-areas: + "header header" + "nav main"; + background: var(--grouped-background); +} + +.app-shell__header { + grid-area: header; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + min-height: 44px; + padding: 0 var(--gutter); + border-bottom: var(--hairline) solid var(--separator); + background: var(--bar-background); + backdrop-filter: saturate(180%) blur(20px); + position: sticky; + top: 0; + z-index: var(--z-header); +} + +.app-shell__brand { + font-size: var(--text-md); + font-weight: 700; + letter-spacing: -0.01em; +} + +.app-shell__user { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.app-shell__chip { + max-width: 12rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-muted); + font-size: var(--text-sm); +} + +.app-shell__nav { + grid-area: nav; + padding: var(--space-2) 0 var(--space-4); + position: sticky; + top: 44px; + height: calc(100vh - 44px); + overflow: auto; +} + +.app-shell__nav-label { + margin: 0 var(--space-2) var(--space-2); + font-size: var(--text-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-muted); +} + +.app-shell__nav-list { + list-style: none; + margin: 0 var(--gutter); + padding: 0; + display: grid; + background: var(--grouped-surface); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.app-shell__nav-link { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + min-height: var(--row-min-height); + padding: 0 var(--gutter); + color: var(--label); + font-size: var(--text-body); + letter-spacing: -0.41px; +} + +.app-shell__nav-link:hover { + background: var(--system-gray5); +} + +.app-shell__nav-link.is-active { + color: var(--accent); + font-weight: 600; +} + +.app-shell__nav-link.is-disabled { + opacity: 0.45; + pointer-events: none; +} + +.app-shell__badge { + font-size: var(--text-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-muted); +} + +.app-shell__main { + grid-area: main; + min-width: 0; +} + +.app-shell--workspace .app-shell__main { + overflow: hidden; +} + +.page { + width: min(var(--content-max), 100%); + margin: 0 auto; + padding: var(--space-5) 0 var(--space-8); +} + +.page > .app-text, +.page > .app-btn { + margin-inline: var(--gutter); +} + +.page-header { + margin-bottom: var(--space-5); + padding: 0 var(--gutter); + display: grid; + gap: var(--space-1); +} + +.page-header__eyebrow { + margin: 0 0 var(--space-2); + font-size: var(--text-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-muted); +} + +.page-header__title { + font-size: var(--text-xl); + line-height: 1.2; +} + +.page-header__desc { + margin-top: var(--space-2); + color: var(--color-muted); + font-size: var(--text-md); + max-width: 36rem; +} + +.section { + margin: 0 0 var(--space-6); +} + +.section:first-child { + border-top: 0; + padding-top: 0; +} + +.section__head { + margin-bottom: var(--space-2); + padding-right: var(--gutter); +} + +.section__head--row { + display: flex; + justify-content: space-between; + gap: var(--space-4); + align-items: end; +} + +.section__title { + margin: 0; +} + +.section__title .app-section-header { + padding-bottom: 0; +} + +.section__desc { + margin: 0; + padding: 0 var(--gutter); + color: var(--secondary-label); + font-size: var(--text-footnote); +} + +/* —— Auth layout —— */ +.auth-layout { + min-height: 100vh; + display: grid; + place-items: center; + padding: var(--space-5); + background: var(--color-bg); +} + +.auth-panel { + width: min(400px, 100%); + padding: var(--space-6) var(--gutter); + display: grid; + gap: var(--space-3); +} + +.auth-panel__brand { + margin: 0 0 var(--space-5); + color: var(--color-accent); + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: var(--text-xs); + font-weight: 600; +} + +.auth-panel__title { + font-size: var(--text-xl); + line-height: 1.25; +} + +.auth-panel__lead { + margin-top: var(--space-2); + color: var(--color-muted); + font-size: var(--text-md); +} + +.auth-panel__cta { + display: grid; + gap: var(--space-3); + margin-top: var(--space-5); + min-height: 48px; +} + +.auth-divider { + margin: 0; + color: var(--color-muted); + font-size: var(--text-sm); + text-align: center; +} + +.auth-panel__foot { + margin-top: var(--space-5); + padding-top: var(--space-4); + border-top: 1px solid var(--color-line); + color: var(--color-muted); + font-size: var(--text-sm); +} + +.auth-panel__foot code { + font-family: var(--font-mono); + font-size: 0.85em; + color: var(--color-ink); +} + +.tg-login { + min-height: 44px; +} + +.btn--yandex { + width: 100%; + gap: var(--space-2); + background: #000; + color: #fff; + text-decoration: none; +} + +.btn--yandex:hover:not(:disabled) { + background: #1a1a1a; + color: #fff; + border-radius: 8px; +} + +.btn--yandex__mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + border-radius: 8px; + background: #fc3f1d; + color: #fff; + font-size: 0.75rem; + font-weight: 700; + line-height: 1; +} + +/* —— UI primitives —— */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: var(--control-height-md); + padding: 0 var(--space-4); + border-radius: var(--radius-lg); + border: 0; + font-weight: 600; + font-size: var(--text-body); + cursor: pointer; + background: var(--system-gray5); + color: var(--label); +} + +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.btn--primary { + background: var(--accent); + color: #fff; +} + +.btn--primary:hover:not(:disabled) { + filter: brightness(1.04); +} + +.btn--secondary { + background: var(--system-gray5); + color: var(--label); +} + +.btn--ghost { + background: transparent; + color: var(--accent); +} + +.btn--sm { + height: var(--control-height-sm); + padding: 0 var(--space-3); + font-size: var(--text-sm); +} + +.field { + display: grid; + gap: var(--space-2); +} + +.field__label { + color: var(--color-ink-secondary); + font-size: var(--text-xs); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.field__control { + width: 100%; + height: var(--control-height-md); + border: 0; + background: var(--grouped-surface); + color: var(--label); + border-radius: var(--radius-md); + padding: 0 var(--space-3); +} + +.field__control:hover:not(:disabled) { + border-color: var(--color-ink-secondary); +} + +.field__control:disabled { + background: var(--color-bg); + color: var(--color-muted); +} + +.form { + display: grid; + gap: var(--space-4); + padding-inline: var(--gutter); +} + +.form__row { + display: grid; + grid-template-columns: 1.4fr 1fr; + gap: var(--space-3); +} + +.form__actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + align-items: center; +} + +.data-list__row .form__actions { + flex-shrink: 0; + justify-content: flex-end; +} + +.check { + display: flex; + align-items: flex-start; + gap: var(--space-2); + color: var(--color-ink-secondary); + font-size: var(--text-md); + cursor: pointer; +} + +.check input { + margin-top: 0.15rem; + accent-color: var(--color-accent); +} + +.flash { + display: grid; + gap: var(--space-2); + margin-bottom: var(--space-4); +} + +.flash { + margin-inline: var(--gutter); +} + +.flash__item { + margin: 0; + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-lg); + font-size: var(--text-subhead); +} + +.flash__item--error { + color: var(--color-danger); + background: var(--color-danger-bg); + border-color: #e2bcbc; +} + +.flash__item--ok { + color: var(--color-ok); + background: var(--color-ok-bg); + border-color: #b9d8c7; +} + +.banner { + margin: var(--space-4) var(--gutter) 0; + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-lg); + font-size: var(--text-subhead); +} + +.banner--warn { + color: var(--color-warn); + background: var(--color-warn-bg); + border-color: #e2d4a8; +} + +.metric-grid { + margin: var(--space-5) 0 0; + padding: 0; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border: 1px solid var(--color-line); + background: var(--color-surface); +} + +.metric-grid > div { + padding: var(--space-3) var(--space-4); + border-right: 1px solid var(--color-line); +} + +.metric-grid > div:last-child { + border-right: 0; +} + +.metric-grid dt { + margin: 0; + color: var(--color-muted); + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 500; +} + +.metric-grid dd { + margin: var(--space-1) 0 0; + font-family: var(--font-mono); + font-size: var(--text-base); + font-weight: 500; +} + +.progress { + margin: var(--space-4) var(--gutter) 0; + display: grid; + gap: var(--space-2); +} + +.progress__track { + height: 4px; + background: var(--color-line); + overflow: hidden; +} + +.progress__bar { + display: block; + height: 100%; + background: var(--color-accent); +} + +.progress__meta { + margin-top: var(--space-2); + font-size: var(--text-xs); + font-family: var(--font-mono); + color: var(--color-muted); +} + +.hero-metric__label { + margin-top: var(--space-4); + color: var(--color-muted); + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 500; +} + +.hero-metric__label, +.hero-metric__value { + padding-inline: var(--gutter); +} + +.meta-row { + margin: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + font-size: var(--text-xs); + font-weight: 500; + color: var(--color-muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.meta-row__dot { + width: 3px; + height: 3px; + border-radius: 50%; + background: var(--color-line-strong); +} + +.data-list { + list-style: none; + margin: 0 var(--gutter); + padding: 0; + background: var(--grouped-surface); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.data-list__row { + display: flex; + justify-content: space-between; + gap: var(--space-4); + align-items: baseline; + min-height: var(--row-min-height); + padding: 10px var(--gutter); + box-shadow: inset 0 var(--hairline) 0 var(--separator); +} + +.data-list__row:last-child { + border-bottom: 0; +} + +.data-list__primary { + display: block; + font-size: var(--text-body); + font-weight: 400; + letter-spacing: -0.41px; + font-variant-numeric: tabular-nums; +} + +.data-list__secondary { + display: block; + margin-top: 0.1rem; + font-size: var(--text-sm); + color: var(--color-muted); +} + +.data-list__meta { + color: var(--color-muted); + font-family: var(--font-mono); + font-size: var(--text-xs); + white-space: nowrap; +} + +.empty-state { + margin: 0 var(--gutter); + padding: var(--space-6) var(--gutter); + color: var(--secondary-label); + font-size: var(--text-subhead); + text-align: center; +} + +.pager { + display: flex; + align-items: center; + gap: var(--space-3); + margin-top: var(--space-4); + padding-inline: var(--gutter); + font-size: var(--text-footnote); +} + +.panel { + border: 1px solid var(--color-line); + background: var(--color-surface); +} + +.panel__head { + padding: var(--space-4); + border-bottom: 1px solid var(--color-line); + font-size: var(--text-sm); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.panel__body { + padding: var(--space-4); +} + +.coming-soon { + border: 1px solid var(--color-line); + background: var(--color-surface); + padding: var(--space-6); +} + +.coming-soon__title { + font-size: var(--text-lg); +} + +.coming-soon__text { + margin-top: var(--space-2); + color: var(--color-muted); + font-size: var(--text-md); + max-width: 28rem; +} + +.skel { + background: var(--color-line); + border-radius: var(--radius-sm); +} + +.skel-line { + height: 0.75rem; +} + +.skel-title { + height: 2rem; + width: min(220px, 55%); + margin: var(--space-3) 0; +} + +.w-20 { + width: 4.5rem; +} +.w-24 { + width: 5.5rem; +} +.w-32 { + width: 7rem; +} +.w-56 { + width: 12rem; +} +.mt-2 { + margin-top: var(--space-2); +} + +.field__hint { + margin: 0; + font-size: var(--text-sm); +} + +.day-chips { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.day-chip { + min-width: 2.5rem; + height: 28px; + padding: 0 var(--space-3); + border: 0; + border-radius: var(--radius-capsule); + background: var(--system-gray5); + color: var(--label); + font-size: var(--text-subhead); + cursor: pointer; +} + +.day-chip:hover:not(:disabled) { + border-color: var(--color-ink-secondary); +} + +.day-chip.is-on { + background: rgb(18 136 90 / 15%); + color: var(--accent); +} + +.day-chip:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.choice-set { + margin: 0; + padding: 0; + border: 0; + display: grid; + gap: var(--space-2); +} + +.choice-set legend { + margin-bottom: var(--space-1); +} + +.split-block { + display: grid; + gap: var(--space-2); +} + +@media (max-width: 860px) { + .app-shell { + grid-template-columns: 1fr; + grid-template-areas: + "header" + "nav" + "main"; + } + + .app-shell__nav { + position: static; + height: auto; + border-right: 0; + border-bottom: 1px solid var(--color-line); + padding: var(--space-3); + } + + .app-shell__nav-label { + display: none; + } + + .app-shell__nav-list { + display: flex; + gap: var(--space-2); + overflow-x: auto; + background: transparent; + margin: 0 var(--gutter); + } + + .app-shell__nav-link { + white-space: nowrap; + background: var(--system-gray5); + border-radius: var(--radius-capsule); + min-height: 32px; + padding: 0 12px; + font-size: var(--text-subhead); + } + + .app-shell__nav-link.is-active { + background: var(--grouped-surface); + } + + .page { + width: min(var(--content-max), calc(100% - 2 * var(--space-4))); + } + + .metric-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .metric-grid > div:nth-child(2) { + border-right: 0; + } + + .metric-grid > div:nth-child(3), + .metric-grid > div:nth-child(4) { + border-top: 1px solid var(--color-line); + } + + .form__row { + grid-template-columns: 1fr; + } + + .section__head--row { + flex-direction: column; + align-items: start; + } + + .cal__day { + min-height: 3.5rem; + } +} + +/* —— Calendar —— */ +.cal { + display: grid; + gap: var(--space-3); +} + +.cal__weekdays, +.cal__grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 2px; +} + +.cal__weekday { + text-align: center; + font-size: var(--text-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-muted); + padding: var(--space-1) 0; +} + +.cal__day { + position: relative; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 2px; + min-height: 4.25rem; + padding: var(--space-2) var(--space-2) var(--space-1); + margin: 0; + border: 0; + border-radius: var(--radius-md); + background: var(--grouped-surface); + color: var(--label); + text-align: left; + cursor: pointer; + font: inherit; +} + +.cal__day:hover { + border-color: var(--color-line-strong); +} + +.cal__day--out { + opacity: 0.42; +} + +.cal__day--today { + border-color: var(--color-accent); + background: var(--color-accent-soft); +} + +.cal__day--selected { + outline: 2px solid var(--color-ink); + outline-offset: -1px; + z-index: 1; +} + +.cal__day-num { + font-size: var(--text-sm); + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 1.2; +} + +.cal__day-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-1); +} + +.cal__spend-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-ink); + flex-shrink: 0; +} + +.cal__spend-sum { + font-size: var(--text-xs); + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--color-ink-secondary); + line-height: 1.2; + letter-spacing: -0.02em; +} + +.cal__day--spent .cal__spend-sum { + color: var(--color-ink); +} + +.cal-day-block + .cal-day-block { + margin-top: var(--space-4); + padding-top: var(--space-4); + border-top: 1px solid var(--color-line); +} + +.cal-day-block__title { + margin: 0 0 var(--space-2); + font-size: var(--text-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-muted); +} + +.cal-legend__swatch--spend { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-ink); + margin-top: 5px; + margin-left: 4px; + margin-right: 4px; +} + +.cal__stripes { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-height: 0; + margin-top: 2px; +} + +.cal__stripe { + display: block; + height: 5px; + border-radius: 1px; + flex: 1; + min-height: 4px; + max-height: 8px; +} + +.cal__pays { + display: flex; + gap: 3px; + justify-content: flex-end; + align-items: center; + min-height: 8px; +} + +.cal__pay { + width: 8px; + height: 8px; + border-radius: 50%; + border: 1.5px solid var(--color-surface); + box-shadow: 0 0 0 1px rgba(18, 23, 20, 0.2); + flex-shrink: 0; +} + +.cal-legend { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: var(--space-2); +} + +.cal-legend__item { + display: flex; + align-items: flex-start; + gap: var(--space-2); + font-size: var(--text-sm); + color: var(--color-ink-secondary); +} + +.cal-legend__swatch { + display: inline-block; + width: 14px; + height: 14px; + flex-shrink: 0; + margin-top: 2px; + border-radius: var(--radius-sm); +} + +.cal-legend__swatch--budget { + border: 1px solid transparent; +} + +.cal-legend__swatch--pay { + width: 10px; + height: 10px; + border-radius: 50%; + margin-top: 4px; + margin-left: 2px; + margin-right: 2px; +} + +.cal-legend__meta { + color: var(--color-muted); +} diff --git a/web/src/design/index.css b/web/src/design/index.css new file mode 100644 index 0000000..db6cb1b --- /dev/null +++ b/web/src/design/index.css @@ -0,0 +1,5 @@ +@import "./tokens.css"; +@import "./base.css"; +@import "../ui/kit.css"; +@import "./components.css"; +@import "../legal/legal.css"; diff --git a/web/src/design/tokens.css b/web/src/design/tokens.css new file mode 100644 index 0000000..46cc69a --- /dev/null +++ b/web/src/design/tokens.css @@ -0,0 +1,117 @@ +:root { + /* 4pt grid — same as mobile/lib/theme/tokens.dart */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 28px; + --space-7: 40px; + --space-8: 56px; + --gutter: 16px; + + /* SF Pro scale (Apple HIG), rendered in Inter */ + --text-caption2: 11px; + --text-caption1: 12px; + --text-footnote: 13px; + --text-subhead: 15px; + --text-callout: 16px; + --text-body: 17px; + --text-headline: 17px; + --text-title3: 20px; + --text-title2: 22px; + --text-title1: 28px; + --text-large-title: 34px; + + /* Legacy aliases used by older product CSS */ + --text-xs: var(--text-caption2); + --text-sm: var(--text-footnote); + --text-md: var(--text-subhead); + --text-base: var(--text-body); + --text-lg: var(--text-title3); + --text-xl: var(--text-title2); + --text-2xl: var(--text-large-title); + + /* Light — CupertinoDynamicColor.color */ + --accent: #12885a; + --accent-soft: #e4f4ec; + --system-red: #ff3b30; + --system-orange: #ff9500; + --system-green: #34c759; + --system-gray: #8e8e93; + --system-gray3: #c7c7cc; + --system-gray5: #e5e5ea; + --system-gray6: #f2f2f7; + --label: #000000; + --secondary-label: rgb(60 60 67 / 60%); + --tertiary-label: rgb(60 60 67 / 30%); + --separator: rgb(60 60 67 / 29%); + --opaque-separator: #c6c6c8; + --grouped-background: #f2f2f7; + --grouped-surface: #ffffff; + --bar-background: rgb(249 249 249 / 94%); + + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-capsule: 999px; + + --control-height: 50px; + --control-height-md: 44px; + --control-height-sm: 34px; + --row-min-height: 44px; + --hairline: 0.5px; + + --shell-max: 960px; + --content-max: 720px; + --sidebar-width: 240px; + + --font-sans: "Inter", "SF Pro Text", "Segoe UI", sans-serif; + --font-mono: "Inter", ui-monospace, monospace; + + --shadow-none: none; + --z-header: 20; + --z-sidebar: 15; + --z-toast: 40; + + /* Backward-compatible names from the previous system UI */ + --color-ink: var(--label); + --color-ink-secondary: var(--secondary-label); + --color-muted: var(--secondary-label); + --color-line: var(--separator); + --color-line-strong: var(--opaque-separator); + --color-bg: var(--grouped-background); + --color-surface: var(--grouped-surface); + --color-accent: var(--accent); + --color-accent-hover: #0e6f4a; + --color-accent-soft: var(--accent-soft); + --color-warn: var(--system-orange); + --color-warn-bg: rgb(255 149 0 / 12%); + --color-danger: var(--system-red); + --color-danger-bg: rgb(255 59 48 / 12%); + --color-ok: var(--system-green); + --color-ok-bg: rgb(52 199 89 / 12%); +} + +@media (prefers-color-scheme: dark) { + :root { + --accent: #3cd68c; + --accent-soft: #14301f; + --system-red: #ff453a; + --system-orange: #ff9f0a; + --system-green: #30d158; + --system-gray3: #48484a; + --system-gray5: #2c2c2e; + --system-gray6: #1c1c1e; + --label: #ffffff; + --secondary-label: rgb(235 235 245 / 60%); + --tertiary-label: rgb(235 235 245 / 30%); + --separator: rgb(84 84 88 / 65%); + --opaque-separator: #38383a; + --grouped-background: #000000; + --grouped-surface: #1c1c1e; + --bar-background: rgb(29 29 29 / 94%); + --color-accent-hover: #5ee0a2; + } +} diff --git a/web/src/features/auth/AuthContext.tsx b/web/src/features/auth/AuthContext.tsx new file mode 100644 index 0000000..9d0b708 --- /dev/null +++ b/web/src/features/auth/AuthContext.tsx @@ -0,0 +1,80 @@ +import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import { api, setPreviewToken, type Me } from "../../api"; +import { yandexRedirectUri } from "../../auth/yandexRedirect"; + +type AuthState = { + user: Me | null; + loading: boolean; + error: string | null; + refresh: () => Promise; + logout: () => Promise; +}; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refresh = async () => { + try { + setUser(await api.me()); + setError(null); + } catch { + setUser(null); + } + }; + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const code = params.get("code"); + const state = params.get("state"); + + const boot = async () => { + try { + if (code) { + const redirectUri = + sessionStorage.getItem("ts.oauthRedirect") ?? yandexRedirectUri("https://tile-server.ru"); + const auth = await api.loginYandex(code, redirectUri, state); + sessionStorage.removeItem("ts.oauthState"); + sessionStorage.removeItem("ts.oauthRedirect"); + setUser(auth.user); + if (auth.cabinetToken) { + setPreviewToken(auth.cabinetToken); + } + window.history.replaceState({}, "", "/"); + } else { + await refresh(); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Ошибка входа"); + setUser(null); + } finally { + setLoading(false); + } + }; + + void boot(); + }, []); + + const logout = async () => { + await api.logout(); + setUser(null); + }; + + const value = useMemo( + () => ({ user, loading, error, refresh, logout }), + [user, loading, error] + ); + + return {children}; +} + +export function useAuth(): AuthState { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("AuthProvider is required"); + } + return ctx; +} diff --git a/web/src/features/auth/LoginPage.tsx b/web/src/features/auth/LoginPage.tsx new file mode 100644 index 0000000..f89305e --- /dev/null +++ b/web/src/features/auth/LoginPage.tsx @@ -0,0 +1,85 @@ +import { useEffect, useState } from "react"; +import { Navigate } from "react-router-dom"; +import { api } from "../../api"; +import { yandexRedirectUri } from "../../auth/yandexRedirect"; +import { AuthLayout } from "../../components/layout/AuthLayout"; +import { Flash } from "../../components/ui"; +import { LegalCheckboxes } from "../../legal/LegalCheckboxes"; +import { SiteFooter } from "../../legal/SiteFooter"; +import { useAuth } from "./AuthContext"; +import { YandexLoginButton } from "./YandexLoginButton"; + +export function LoginPage() { + const { user, loading, error } = useAuth(); + const [yandexClientId, setYandexClientId] = useState(null); + const [configuredRedirect, setConfiguredRedirect] = useState(null); + const [providerError, setProviderError] = useState(null); + const [offerAccepted, setOfferAccepted] = useState(false); + const [consentAccepted, setConsentAccepted] = useState(false); + const redirectUri = yandexRedirectUri(configuredRedirect); + const accepted = offerAccepted && consentAccepted; + + useEffect(() => { + let cancelled = false; + void api + .providers() + .then((providers) => { + if (cancelled) { + return; + } + const yandex = providers.yandex; + const id = yandex?.enabled ? yandex.clientId : null; + setYandexClientId(id && id.length > 0 ? id : null); + setConfiguredRedirect(yandex?.redirectUri ?? null); + if (id) { + sessionStorage.setItem("ts.oauthRedirect", yandexRedirectUri(yandex.redirectUri)); + } + if (!yandex?.enabled) { + setProviderError("Яндекс OAuth не настроен. Задайте YANDEX_CLIENT_SECRET в .env на сервере."); + } + }) + .catch((err: unknown) => { + if (!cancelled) { + setYandexClientId(null); + setProviderError(err instanceof Error ? err.message : "Не удалось проверить вход через Яндекс"); + } + }); + return () => { + cancelled = true; + }; + }, []); + + if (loading) { + return

Загрузка…

; + } + + if (user) { + return ; + } + + return ( + + + + + ) : null + } + flash={} + footer={} + /> + ); +} diff --git a/web/src/features/auth/YandexLoginButton.tsx b/web/src/features/auth/YandexLoginButton.tsx new file mode 100644 index 0000000..3201579 --- /dev/null +++ b/web/src/features/auth/YandexLoginButton.tsx @@ -0,0 +1,38 @@ +import { yandexAuthorizeUrl } from "../../auth/yandexRedirect"; + +type Props = { + clientId: string; + redirectUri: string; + state?: string | null; + disabled?: boolean; +}; + +export function YandexLoginButton({ + clientId, + redirectUri, + state, + disabled = false, +}: Props) { + const href = yandexAuthorizeUrl(clientId, redirectUri, state); + const className = `app-btn app-btn--large app-btn--expanded btn--yandex${disabled ? " is-disabled" : ""}`; + + if (disabled) { + return ( + + + Войти через Яндекс + + ); + } + + return ( + + + Войти через Яндекс + + ); +} diff --git a/web/src/features/guides/GuidesPage.tsx b/web/src/features/guides/GuidesPage.tsx new file mode 100644 index 0000000..e35b4f3 --- /dev/null +++ b/web/src/features/guides/GuidesPage.tsx @@ -0,0 +1,151 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { api, getPreviewToken, setPreviewToken, type SourceItem } from "../../api"; +import { Button, Flash, PageHeader } from "../../components/ui"; +import { useAuth } from "../auth/AuthContext"; +import { StylePreview } from "../styles/StylePreview"; +import "./guides.css"; + +function CopyBlock({ label, value }: { label: string; value: string }) { + const [copied, setCopied] = useState(false); + return ( +
+
+ {label} + +
+
{value}
+
+ ); +} + +export function GuidesPage() { + const { user } = useAuth(); + const [sources, setSources] = useState([]); + const [error, setError] = useState(null); + const [creating, setCreating] = useState(false); + const [token, setToken] = useState(() => getPreviewToken()); + const source = sources[0]?.id ?? "central-fed-district"; + const origin = window.location.origin; + const slug = user?.slug ?? "{slug}"; + + useEffect(() => { + void api + .sources() + .then((list) => setSources(list.filter((item) => item.enabled))) + .catch((err: unknown) => setError(err instanceof Error ? err.message : "Не удалось загрузить источники")); + }, []); + + const styleUrl = `${origin}/u/${slug}/styles/osm-bright?source=${source}${token ? `&token=${token}` : "&token=ts_…"}`; + const tilesUrl = `${origin}/u/${slug}/tiles/${source}/{z}/{x}/{y}.pbf?token=${token ?? "ts_…"}`; + + const html = useMemo( + () => ` + +
+`, + [styleUrl] + ); + + const react = useMemo( + () => `import maplibregl from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; + +const map = new maplibregl.Map({ + container: "map", + style: "${styleUrl}", + center: [37.6173, 55.7558], + zoom: 11 +});`, + [styleUrl] + ); + + return ( +
+ + + +
    +
  1. + 1. Токен +

    В разделе «Токены» создайте ключ. Он показывается один раз и дальше уходит только в query token= или Bearer.

    + {!token ? ( + + ) : ( +

    Превью-токен этой сессии уже есть.

    + )} +
  2. +
  3. + 2. Стиль +

    + Пресет osm-bright доступен сразу. Свой вид — клонируйте в{" "} + Стилях и откройте визуальный редактор. +

    +
  4. +
  5. + 3. URL +

    MapLibre просит style JSON. Сервер сам подставит тайлы с тем же токеном.

    +
  6. +
+ + + + +

HTML

+ + +

React + MapLibre

+ + +
+
+

Результат

+

Живая карта с вашего аккаунта. Если токена нет — сначала шаг 1.

+
+ {user && token ? ( + + ) : ( +
Нужен токен, чтобы нарисовать карту.
+ )} +
+
+ ); +} diff --git a/web/src/features/guides/guides.css b/web/src/features/guides/guides.css new file mode 100644 index 0000000..8271bbb --- /dev/null +++ b/web/src/features/guides/guides.css @@ -0,0 +1,121 @@ +.guides { + width: min(880px, 100%); + margin: 0 auto; + padding: var(--space-5) 0 var(--space-8); +} + +.guides .page-header { + padding-inline: var(--gutter); +} + +.guides__h { + margin: var(--space-6) var(--gutter) var(--space-3); + font-size: var(--text-title3); + letter-spacing: -0.02em; +} + +.guide-steps { + list-style: none; + margin: 0 var(--gutter) var(--space-5); + padding: 0; + display: grid; + gap: var(--space-3); +} + +.guide-steps li { + padding: var(--space-4); + border-radius: var(--radius-xl); + background: var(--grouped-surface); + box-shadow: 0 1px 0 var(--separator); +} + +.guide-steps strong { + display: block; + margin-bottom: 6px; +} + +.guide-steps p { + margin: 0 0 var(--space-3); + color: var(--secondary-label); + font-size: var(--text-subhead); +} + +.guide-steps code { + font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; + font-size: 0.92em; +} + +.guide-ok { + margin: 0; + color: var(--accent); + font-size: var(--text-footnote); +} + +.guide-code { + margin: 0 var(--gutter) var(--space-4); + border-radius: var(--radius-xl); + overflow: hidden; + background: #11161c; + color: #e8eef4; +} + +.guide-code figcaption { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 14px; + font-size: var(--text-caption1); + letter-spacing: 0.04em; + text-transform: uppercase; + color: rgb(232 238 244 / 55%); + border-bottom: 1px solid rgb(255 255 255 / 8%); +} + +.guide-code button { + border: 0; + background: transparent; + color: #3cd68c; + cursor: pointer; + text-transform: none; + letter-spacing: 0; +} + +.guide-code pre { + margin: 0; + padding: 14px 16px 16px; + overflow: auto; + font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; + font-size: 12.5px; + line-height: 1.5; + white-space: pre-wrap; +} + +.guide-result { + margin: var(--space-6) var(--gutter) 0; + padding: var(--space-4); + border-radius: var(--radius-xl); + background: var(--grouped-surface); +} + +.guide-result h2 { + margin: 0 0 6px; + font-size: var(--text-title3); +} + +.guide-result p { + margin: 0 0 var(--space-4); + color: var(--secondary-label); +} + +.guide-result .map-frame { + height: 22rem; +} + +.guide-result__empty { + display: grid; + place-items: center; + height: 14rem; + border-radius: 12px; + background: var(--grouped-background); + color: var(--secondary-label); +} diff --git a/web/src/features/styles/LayerInspector.tsx b/web/src/features/styles/LayerInspector.tsx new file mode 100644 index 0000000..6e7f88b --- /dev/null +++ b/web/src/features/styles/LayerInspector.tsx @@ -0,0 +1,146 @@ +import { fieldsForLayer } from "./layerFields"; +import { + asColor, + asNumber, + isExpression, + LAYER_TYPE_LABEL, + toColorInput, + type StyleLayer +} from "./styleModel"; + +type Props = { + layer: StyleLayer | null; + readOnly: boolean; + onPaint: (property: string, value: unknown) => void; + onLayout: (property: string, value: unknown) => void; +}; + +export function LayerInspector({ layer, readOnly, onPaint, onLayout }: Props) { + if (!layer) { + return ( + + ); + } + + const fields = fieldsForLayer(layer.type); + const hidden = layer.layout?.visibility === "none"; + + return ( + + ); +} diff --git a/web/src/features/styles/LayerList.tsx b/web/src/features/styles/LayerList.tsx new file mode 100644 index 0000000..ac50519 --- /dev/null +++ b/web/src/features/styles/LayerList.tsx @@ -0,0 +1,71 @@ +import { useMemo, useState } from "react"; +import { LAYER_TYPE_LABEL, type StyleLayer } from "./styleModel"; + +type Props = { + layers: StyleLayer[]; + selectedId: string | null; + readOnly: boolean; + onSelect: (id: string) => void; + onToggle: (id: string, visible: boolean) => void; +}; + +export function LayerList({ layers, selectedId, readOnly, onSelect, onToggle }: Props) { + const [query, setQuery] = useState(""); + const visible = useMemo(() => { + const q = query.trim().toLowerCase(); + const items = [...layers].reverse(); + if (!q) { + return items; + } + return items.filter( + (layer) => + layer.id.toLowerCase().includes(q) || + layer.type.toLowerCase().includes(q) || + (layer["source-layer"] ?? "").toLowerCase().includes(q) + ); + }, [layers, query]); + + return ( + + ); +} diff --git a/web/src/features/styles/LiveMap.tsx b/web/src/features/styles/LiveMap.tsx new file mode 100644 index 0000000..fa049cc --- /dev/null +++ b/web/src/features/styles/LiveMap.tsx @@ -0,0 +1,73 @@ +import { useEffect, useRef } from "react"; +import maplibregl, { type Map as MapLibreMap, type StyleSpecification } from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; +import { bindStyleForPreview, type MapStyle, type StyleEdit } from "./styleModel"; + +type Props = { + style: MapStyle; + tilesUrl: string; + selectedLayerId: string | null; + lastEdit: StyleEdit | null; + styleEpoch: number; + onSelectLayer: (layerId: string) => void; +}; + +export function LiveMap({ style, tilesUrl, selectedLayerId, lastEdit, styleEpoch, onSelectLayer }: Props) { + const el = useRef(null); + const mapRef = useRef(null); + + useEffect(() => { + if (!el.current || !tilesUrl) { + return; + } + const map = new maplibregl.Map({ + container: el.current, + style: bindStyleForPreview(style, tilesUrl) as StyleSpecification, + center: [37.6173, 55.7558], + zoom: 11, + maxZoom: 18, + attributionControl: { compact: true, customAttribution: "© OpenStreetMap" }, + transformRequest: (url) => ({ url: new URL(url, window.location.origin).href }) + }); + map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right"); + map.on("click", (event) => { + const hit = map.queryRenderedFeatures(event.point)[0]; + if (hit?.layer?.id) { + onSelectLayer(hit.layer.id); + } + }); + mapRef.current = map; + return () => { + map.remove(); + mapRef.current = null; + }; + // Recreate only when tiles source changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tilesUrl, styleEpoch]); + + useEffect(() => { + const map = mapRef.current; + if (!map || !lastEdit || !map.isStyleLoaded() || !map.getLayer(lastEdit.layerId)) { + return; + } + try { + if (lastEdit.target === "paint") { + map.setPaintProperty(lastEdit.layerId, lastEdit.property, lastEdit.value); + } else { + map.setLayoutProperty(lastEdit.layerId, lastEdit.property, lastEdit.value); + } + } catch { + map.setStyle(bindStyleForPreview(style, tilesUrl) as StyleSpecification, { diff: true }); + } + }, [lastEdit, style, tilesUrl]); + + return ( +
+ ); +} diff --git a/web/src/features/styles/StyleEditorPage.tsx b/web/src/features/styles/StyleEditorPage.tsx new file mode 100644 index 0000000..86a9692 --- /dev/null +++ b/web/src/features/styles/StyleEditorPage.tsx @@ -0,0 +1,195 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { api, getPreviewToken, type SourceItem } from "../../api"; +import { Button, Flash } from "../../components/ui"; +import { useAuth } from "../auth/AuthContext"; +import { LayerInspector } from "./LayerInspector"; +import { LayerList } from "./LayerList"; +import { LiveMap } from "./LiveMap"; +import { + isMapStyle, + tilesTemplate, + updateLayer, + type MapStyle, + type StyleEdit, + type StyleLayer +} from "./styleModel"; +import "./editor.css"; + +export function StyleEditorPage() { + const { name } = useParams<{ name: string }>(); + const navigate = useNavigate(); + const { user } = useAuth(); + const [style, setStyle] = useState(null); + const [sources, setSources] = useState([]); + const [source, setSource] = useState(""); + const [selectedId, setSelectedId] = useState(null); + const [lastEdit, setLastEdit] = useState(null); + const [styleEpoch, setStyleEpoch] = useState(0); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [isPreset, setIsPreset] = useState(true); + const token = getPreviewToken(); + + useEffect(() => { + if (!name) { + return; + } + void Promise.all([api.getStyle(name), api.styles(), api.sources()]) + .then(([raw, list, sourceList]) => { + if (!isMapStyle(raw)) { + throw new Error("Стиль должен быть объектом MapLibre (version 8)."); + } + setStyle(raw); + setIsPreset(list.find((item) => item.name === name)?.isPreset ?? true); + const enabled = sourceList.filter((item) => item.enabled); + setSources(enabled); + setSource((current) => current || enabled[0]?.id || ""); + const first = raw.layers?.[raw.layers.length - 1]?.id ?? null; + setSelectedId(first); + setStyleEpoch((n) => n + 1); + }) + .catch((err: unknown) => setError(err instanceof Error ? err.message : "Не удалось открыть стиль")); + }, [name]); + + const layers = style?.layers ?? []; + const selected: StyleLayer | null = layers.find((layer) => layer.id === selectedId) ?? null; + const tilesUrl = useMemo(() => { + if (!user || !source || !token) { + return ""; + } + return tilesTemplate(user.slug, source, token); + }, [user, source, token]); + + const apply = (edit: StyleEdit) => { + if (!style || isPreset) { + return; + } + setStyle(updateLayer(style, edit)); + setLastEdit(edit); + }; + + if (!name || !user) { + return null; + } + + return ( +
+
+
+ + Стили + +

{name}

+ + {isPreset ? "пресет" : "мой стиль"} + +
+
+ + {!isPreset ? ( + <> + + + + ) : ( + + )} +
+
+ + {error ? ( +
+ +
+ ) : null} + + {isPreset ? ( +

Пресет только для просмотра. Склонируйте его в «Стили», чтобы править цвета и толщины.

+ ) : null} + +
+ selected && apply({ layerId: selected.id, target: "paint", property, value })} + onLayout={(property, value) => selected && apply({ layerId: selected.id, target: "layout", property, value })} + /> +
+ {style && tilesUrl ? ( + + ) : ( +
+ {token + ? "Нет источника тайлов." + : "Создайте токен в разделе «Токены» — без него превью не загрузит персональные тайлы."} +
+ )} +
+ apply({ layerId: id, target: "layout", property: "visibility", value: visible ? "visible" : "none" })} + /> +
+
+ ); +} diff --git a/web/src/features/styles/StylePreview.tsx b/web/src/features/styles/StylePreview.tsx new file mode 100644 index 0000000..1beb64b --- /dev/null +++ b/web/src/features/styles/StylePreview.tsx @@ -0,0 +1,32 @@ +import { useEffect, useRef } from "react"; +import maplibregl from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; + +type Props = { + slug: string; + name: string; + source: string; + token: string; + nonce: number; +}; + +export function StylePreview({ slug, name, source, token, nonce }: Props) { + const ref = useRef(null); + + useEffect(() => { + if (!ref.current || !source) { + return; + } + + const map = new maplibregl.Map({ + container: ref.current, + style: `/u/${slug}/styles/${name}?source=${encodeURIComponent(source)}&token=${encodeURIComponent(token)}&n=${nonce}`, + center: [37.6173, 55.7558], + zoom: 10 + }); + + return () => map.remove(); + }, [slug, name, source, token, nonce]); + + return
; +} diff --git a/web/src/features/styles/StylesPage.tsx b/web/src/features/styles/StylesPage.tsx new file mode 100644 index 0000000..79260f1 --- /dev/null +++ b/web/src/features/styles/StylesPage.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { api, type StyleItem } from "../../api"; +import { Button, Flash, PageHeader, Section } from "../../components/ui"; +import { AppSelect, AppTextField } from "../../ui"; + +export function StylesPage() { + const navigate = useNavigate(); + const [items, setItems] = useState([]); + const [name, setName] = useState(""); + const [from, setFrom] = useState("osm-bright"); + const [error, setError] = useState(null); + + const reload = () => + api + .styles() + .then(setItems) + .catch((err: unknown) => setError(err instanceof Error ? err.message : "Не удалось загрузить стили")); + + useEffect(() => { + void reload(); + }, []); + + const presets = items.filter((item) => item.isPreset); + const cloneFrom = presets.length > 0 ? from : "osm-bright"; + + return ( + <> + +
+
+ setName(e.target.value)} placeholder="my-bright" /> + setFrom(e.target.value)} + > + {(presets.length > 0 ? presets : [{ name: "osm-bright" }]).map((item) => ( + + ))} + + +
+ +
+
+ + + + + + + + + + {items.map((item) => ( + + + + + + ))} + +
ИмяИсточникТип
+ {item.name} + {item.createdFrom ?? "—"}{item.isPreset ? "пресет" : "мой"}
+
+ + ); +} diff --git a/web/src/features/styles/editor.css b/web/src/features/styles/editor.css new file mode 100644 index 0000000..ebc53aa --- /dev/null +++ b/web/src/features/styles/editor.css @@ -0,0 +1,308 @@ +.app-shell--workspace .app-shell__main { + overflow: hidden; +} + +.style-workspace { + display: grid; + grid-template-rows: auto auto 1fr; + height: calc(100vh - 44px); + min-height: 0; + background: var(--grouped-background); +} + +.style-workspace__bar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + min-height: 52px; + padding: 0 var(--gutter); + border-bottom: var(--hairline) solid var(--separator); + background: var(--grouped-surface); +} + +.style-workspace__title { + display: flex; + align-items: center; + gap: var(--space-3); + min-width: 0; +} + +.style-workspace__title h1 { + margin: 0; + font-size: var(--text-title3); + letter-spacing: -0.02em; +} + +.style-workspace__back { + color: var(--accent); + font-size: var(--text-footnote); +} + +.style-workspace__badge { + font-size: var(--text-caption1); + padding: 2px 8px; + border-radius: var(--radius-capsule); + background: var(--accent-soft); + color: var(--accent); +} + +.style-workspace__badge.is-preset { + background: var(--system-gray5); + color: var(--secondary-label); +} + +.style-workspace__actions { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: var(--space-2); +} + +.style-workspace__source { + display: grid; + gap: 2px; + font-size: var(--text-caption1); + color: var(--secondary-label); +} + +.style-workspace__source select { + min-height: 34px; + min-width: 14rem; + border-radius: var(--radius-md); + border: var(--hairline) solid var(--separator); + background: var(--grouped-background); + color: var(--label); + padding: 0 10px; +} + +.style-workspace__flash, +.style-workspace__note { + margin: 0; + padding: var(--space-2) var(--gutter); + font-size: var(--text-footnote); + color: var(--secondary-label); + background: var(--accent-soft); +} + +.style-workspace__body { + display: grid; + grid-template-columns: minmax(260px, 300px) minmax(0, 1fr) minmax(240px, 300px); + min-height: 0; +} + +.style-workspace__map { + min-width: 0; + min-height: 0; + background: #111; +} + +.live-map { + width: 100%; + height: 100%; +} + +.style-workspace__empty { + display: grid; + place-items: center; + height: 100%; + padding: var(--gutter); + color: var(--secondary-label); + text-align: center; +} + +.style-pane { + display: grid; + grid-template-rows: auto 1fr; + min-height: 0; + background: var(--grouped-surface); + border-inline: var(--hairline) solid var(--separator); +} + +.style-pane__head { + padding: var(--space-3) var(--space-4); + border-bottom: var(--hairline) solid var(--separator); +} + +.style-pane__kicker { + margin: 0 0 2px; + font-size: var(--text-caption1); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--secondary-label); +} + +.style-pane__head strong { + display: block; + word-break: break-word; +} + +.style-pane__meta, +.style-pane__hint { + display: block; + margin-top: var(--space-2); + color: var(--secondary-label); + font-size: var(--text-footnote); +} + +.style-pane__search { + width: 100%; + margin-top: var(--space-2); + min-height: 34px; + border: var(--hairline) solid var(--separator); + border-radius: var(--radius-md); + padding: 0 10px; + background: var(--grouped-background); + color: var(--label); +} + +.layer-list { + list-style: none; + margin: 0; + padding: var(--space-2); + overflow: auto; +} + +.layer-list li { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 4px; +} + +.layer-row { + display: grid; + grid-template-columns: 4.6rem 1fr; + align-items: center; + gap: var(--space-2); + width: 100%; + min-height: 36px; + padding: 4px 8px; + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.layer-row:hover, +.layer-row.is-active { + background: var(--system-gray5); +} + +.layer-row.is-hidden .layer-row__id { + opacity: 0.45; +} + +.layer-row__type { + font-size: 10px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--secondary-label); +} + +.layer-row__id { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--text-footnote); +} + +.layer-row__eye { + width: 32px; + height: 32px; + border: 0; + background: transparent; + color: var(--secondary-label); + cursor: pointer; +} + +.prop-list { + overflow: auto; + padding: var(--space-3) var(--space-4) var(--space-5); + display: grid; + gap: var(--space-4); +} + +.prop { + display: grid; + gap: 6px; + font-size: var(--text-footnote); + color: var(--secondary-label); +} + +.prop__color, +.prop__range, +.prop__complex { + display: flex; + align-items: center; + gap: 8px; +} + +.prop__color input[type="color"] { + width: 40px; + height: 34px; + padding: 0; + border: var(--hairline) solid var(--separator); + background: transparent; +} + +.prop input[type="text"], +.prop input[type="number"] { + width: 100%; + min-height: 34px; + border: var(--hairline) solid var(--separator); + border-radius: var(--radius-md); + padding: 0 8px; + background: var(--grouped-background); + color: var(--label); +} + +.prop input[type="range"] { + flex: 1; +} + +.prop__toggle { + min-height: 34px; + border: 0; + border-radius: var(--radius-capsule); + padding: 0 12px; + background: var(--system-gray5); + color: var(--label); +} + +.prop__toggle.is-on { + background: var(--accent-soft); + color: var(--accent); +} + +.prop__complex em { + font-style: normal; + color: var(--system-orange); + font-size: var(--text-caption1); +} + +.prop__complex button { + border: 0; + background: transparent; + color: var(--accent); + cursor: pointer; +} + +@media (max-width: 1100px) { + .style-workspace { + height: auto; + min-height: calc(100vh - 44px); + } + + .style-workspace__body { + grid-template-columns: 1fr; + grid-template-rows: 48vh auto auto; + } + + .style-workspace__map { + min-height: 48vh; + } +} diff --git a/web/src/features/styles/layerFields.ts b/web/src/features/styles/layerFields.ts new file mode 100644 index 0000000..eb90a63 --- /dev/null +++ b/web/src/features/styles/layerFields.ts @@ -0,0 +1,54 @@ +export type LayerField = + | { kind: "color"; property: string; label: string } + | { kind: "number"; property: string; label: string; min: number; max: number; step: number } + | { kind: "visibility" }; + +export function fieldsForLayer(type: string): LayerField[] { + switch (type) { + case "background": + return [ + { kind: "color", property: "background-color", label: "Цвет фона" }, + { kind: "number", property: "background-opacity", label: "Прозрачность", min: 0, max: 1, step: 0.05 } + ]; + case "fill": + return [ + { kind: "color", property: "fill-color", label: "Заливка" }, + { kind: "color", property: "fill-outline-color", label: "Обводка" }, + { kind: "number", property: "fill-opacity", label: "Прозрачность", min: 0, max: 1, step: 0.05 }, + { kind: "visibility" } + ]; + case "line": + return [ + { kind: "color", property: "line-color", label: "Цвет линии" }, + { kind: "number", property: "line-width", label: "Толщина", min: 0, max: 24, step: 0.2 }, + { kind: "number", property: "line-opacity", label: "Прозрачность", min: 0, max: 1, step: 0.05 }, + { kind: "visibility" } + ]; + case "circle": + return [ + { kind: "color", property: "circle-color", label: "Цвет" }, + { kind: "number", property: "circle-radius", label: "Радиус", min: 0, max: 24, step: 0.2 }, + { kind: "number", property: "circle-opacity", label: "Прозрачность", min: 0, max: 1, step: 0.05 }, + { kind: "color", property: "circle-stroke-color", label: "Обводка" }, + { kind: "number", property: "circle-stroke-width", label: "Толщина обводки", min: 0, max: 8, step: 0.2 }, + { kind: "visibility" } + ]; + case "symbol": + return [ + { kind: "color", property: "text-color", label: "Цвет текста" }, + { kind: "color", property: "text-halo-color", label: "Обводка текста" }, + { kind: "number", property: "text-halo-width", label: "Толщина обводки", min: 0, max: 8, step: 0.1 }, + { kind: "number", property: "text-size", label: "Размер текста", min: 8, max: 32, step: 1 }, + { kind: "visibility" } + ]; + case "fill-extrusion": + return [ + { kind: "color", property: "fill-extrusion-color", label: "Цвет" }, + { kind: "number", property: "fill-extrusion-opacity", label: "Прозрачность", min: 0, max: 1, step: 0.05 }, + { kind: "number", property: "fill-extrusion-height", label: "Высота", min: 0, max: 200, step: 1 }, + { kind: "visibility" } + ]; + default: + return [{ kind: "visibility" }]; + } +} diff --git a/web/src/features/styles/styleModel.ts b/web/src/features/styles/styleModel.ts new file mode 100644 index 0000000..3afd37d --- /dev/null +++ b/web/src/features/styles/styleModel.ts @@ -0,0 +1,114 @@ +export type StyleLayer = { + id: string; + type: string; + source?: string; + "source-layer"?: string; + minzoom?: number; + maxzoom?: number; + filter?: unknown; + layout?: Record; + paint?: Record; +}; + +export type MapStyle = { + version: number; + name?: string; + sources?: Record; + glyphs?: string; + sprite?: string; + layers?: StyleLayer[]; + [key: string]: unknown; +}; + +export type StyleEdit = { + layerId: string; + target: "paint" | "layout"; + property: string; + value: unknown; +}; + +export function isMapStyle(value: unknown): value is MapStyle { + return Boolean(value && typeof value === "object" && "version" in value); +} + +export function tilesTemplate(slug: string, source: string, token: string): string { + return `/u/${encodeURIComponent(slug)}/tiles/${encodeURIComponent(source)}/{z}/{x}/{y}.pbf?v=3&token=${encodeURIComponent(token)}`; +} + +export function bindStyleForPreview(style: MapStyle, tilesUrl: string): MapStyle { + const next = structuredClone(style); + const sources = { ...(next.sources ?? {}) }; + for (const key of Object.keys(sources)) { + const source = sources[key]; + if (!source || typeof source !== "object") { + continue; + } + const vector = source as Record; + if (vector.type !== "vector") { + continue; + } + vector.tiles = [tilesUrl]; + vector.scheme = "xyz"; + delete vector.url; + delete vector.bounds; + sources[key] = vector; + } + next.sources = sources; + if (!next.glyphs) { + next.glyphs = "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf"; + } + return next; +} + +export function updateLayer(style: MapStyle, edit: StyleEdit): MapStyle { + const next = structuredClone(style); + const layer = next.layers?.find((item) => item.id === edit.layerId); + if (!layer) { + return style; + } + const bag = { ...(layer[edit.target] ?? {}) }; + if (edit.value === undefined) { + delete bag[edit.property]; + } else { + bag[edit.property] = edit.value; + } + layer[edit.target] = bag; + return next; +} + +export function asColor(value: unknown): string | null { + return typeof value === "string" && (/^#([0-9a-f]{3,8})$/i.test(value) || /^(rgb|hsl)a?\(/i.test(value)) + ? value + : null; +} + +export function asNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +export function isExpression(value: unknown): boolean { + return Array.isArray(value) || (Boolean(value) && typeof value === "object" && !Array.isArray(value)); +} + +export function toColorInput(value: string): string { + if (/^#[0-9a-f]{6}$/i.test(value)) { + return value; + } + if (/^#[0-9a-f]{3}$/i.test(value)) { + const [, a, b, c] = value; + return `#${a}${a}${b}${b}${c}${c}`; + } + return "#888888"; +} + +export const LAYER_TYPE_LABEL: Record = { + background: "фон", + fill: "заливка", + line: "линия", + symbol: "подпись", + circle: "точка", + "fill-extrusion": "объём", + heatmap: "тепло", + hillshade: "рельеф", + raster: "растр" +}; diff --git a/web/src/features/tokens/TokensPage.tsx b/web/src/features/tokens/TokensPage.tsx new file mode 100644 index 0000000..902479e --- /dev/null +++ b/web/src/features/tokens/TokensPage.tsx @@ -0,0 +1,94 @@ +import { useEffect, useState } from "react"; +import { api, getPreviewToken, setPreviewToken, type CreatedToken, type TokenItem } from "../../api"; +import { Button, Flash, PageHeader, Section } from "../../components/ui"; +import { AppTextField } from "../../ui"; + +export function TokensPage() { + const [items, setItems] = useState([]); + const [name, setName] = useState("MapLibre"); + const [created, setCreated] = useState(null); + const [error, setError] = useState(null); + const preview = getPreviewToken(); + + const reload = () => + api + .tokens() + .then(setItems) + .catch((err: unknown) => setError(err instanceof Error ? err.message : "Не удалось загрузить токены")); + + useEffect(() => { + void reload(); + }, []); + + return ( + <> + +
+
+ setName(e.target.value)} /> + +
+ {created ? ( +
{`Новый ключ (показывается один раз):\n${created.token}\n\nОн сохранён как превью-токен этой сессии.`}
+ ) : null} + {preview && !created ?
{`Превью этой сессии: ${preview.slice(0, 11)}…`}
: null} + +
+
+ + + + + + + + + + + {items.map((item) => ( + + + + + + + ))} + +
ИмяПрефиксСоздан
{item.name}{item.prefix}…{new Date(item.createdAt).toLocaleString("ru-RU")} + {item.revokedAt ? ( + "отозван" + ) : ( + + )} +
+
+ + ); +} diff --git a/web/src/features/usage/DashboardPage.tsx b/web/src/features/usage/DashboardPage.tsx new file mode 100644 index 0000000..7513f17 --- /dev/null +++ b/web/src/features/usage/DashboardPage.tsx @@ -0,0 +1,96 @@ +import { useEffect, useMemo, useState } from "react"; +import { api, type UsageDay } from "../../api"; +import { Link } from "react-router-dom"; +import { Flash, MetricGrid, PageHeader, Section } from "../../components/ui"; +import { useAuth } from "../auth/AuthContext"; + +function iso(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function formatBytes(value: number): string { + if (value < 1024) { + return `${value} B`; + } + if (value < 1024 * 1024) { + return `${(value / 1024).toFixed(1)} KB`; + } + return `${(value / (1024 * 1024)).toFixed(1)} MB`; +} + +export function DashboardPage() { + const { user } = useAuth(); + const [days, setDays] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + const to = new Date(); + const from = new Date(); + from.setUTCDate(from.getUTCDate() - 6); + void api + .usage(iso(from), iso(to)) + .then(setDays) + .catch((err: unknown) => setError(err instanceof Error ? err.message : "Не удалось загрузить данные")); + }, []); + + const today = iso(new Date()); + const todayRow = days.find((d) => d.date === today); + const week = useMemo( + () => + days.reduce( + (acc, day) => ({ + tileRequests: acc.tileRequests + day.tileRequests, + styleRequests: acc.styleRequests + day.styleRequests, + bytes: acc.bytes + day.bytes + }), + { tileRequests: 0, styleRequests: 0, bytes: 0 } + ), + [days] + ); + + return ( + <> + + + +
+

+ Примеры кода, HTML и живая карта — в разделе Инструкции. +

+
+
+ + + + + + + + + + + {days.map((day) => ( + + + + + + + ))} + +
ДатаТайлыСтилиБайты
{day.date}{day.tileRequests}{day.styleRequests}{formatBytes(day.bytes)}
+
+ + ); +} diff --git a/web/src/legal/CookieBanner.tsx b/web/src/legal/CookieBanner.tsx new file mode 100644 index 0000000..5092051 --- /dev/null +++ b/web/src/legal/CookieBanner.tsx @@ -0,0 +1,60 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { AppButton, AppText } from "../ui"; +import { + readCookieChoice, + resetCookieChoice, + subscribeCookieChoice, + writeCookieChoice, +} from "./cookieConsent"; +import { LEGAL_PATHS } from "./operator"; + +export function CookieBanner() { + const [choice, setChoice] = useState(() => readCookieChoice()); + + useEffect(() => subscribeCookieChoice(() => setChoice(readCookieChoice())), []); + + if (choice) return null; + + return ( +
+ + Мы используем технические cookie, чтобы вы оставались в кабинете. Аналитические cookie + (Яндекс.Метрика) включаются только с вашего согласия.{" "} + + Подробнее + + +
+ writeCookieChoice("necessary")} + > + Только необходимые + + writeCookieChoice("all")}> + Принять все + +
+
+ ); +} + +export function CookieSettings() { + return ( +
+ { + resetCookieChoice(); + }} + > + Изменить выбор cookie + +
+ ); +} diff --git a/web/src/legal/LegalCheckboxes.tsx b/web/src/legal/LegalCheckboxes.tsx new file mode 100644 index 0000000..52c7916 --- /dev/null +++ b/web/src/legal/LegalCheckboxes.tsx @@ -0,0 +1,37 @@ +import { Link } from "react-router-dom"; +import { LEGAL_PATHS } from "./operator"; + +type Props = { + offer: boolean; + consent: boolean; + onOffer: (value: boolean) => void; + onConsent: (value: boolean) => void; +}; + +export function LegalCheckboxes({ offer, consent, onOffer, onConsent }: Props) { + return ( +
+ + +
+ ); +} diff --git a/web/src/legal/LegalPage.tsx b/web/src/legal/LegalPage.tsx new file mode 100644 index 0000000..2f4cfc6 --- /dev/null +++ b/web/src/legal/LegalPage.tsx @@ -0,0 +1,48 @@ +import { Link, Navigate, useParams } from "react-router-dom"; +import { AppText } from "../ui"; +import { CookieSettings } from "./CookieBanner"; +import { legalDocumentBySlug } from "./documents"; +import { SiteFooter } from "./SiteFooter"; + +export function LegalPage() { + const { slug } = useParams(); + const doc = legalDocumentBySlug(slug); + + if (!doc) { + return ; + } + + return ( +
+
+ + ← Назад + +
+ + {doc.title} + + {doc.lead} +
+ {doc.sections.map((section) => ( +
+

{section.heading}

+ {section.blocks.map((block, index) => + block.type === "ul" ? ( +
    + {block.items.map((item) => ( +
  • {item}
  • + ))} +
+ ) : ( +

{block.text}

+ ), + )} +
+ ))} + {doc.slug === "cookies" ? : null} +
+ +
+ ); +} diff --git a/web/src/legal/SiteFooter.tsx b/web/src/legal/SiteFooter.tsx new file mode 100644 index 0000000..82365cf --- /dev/null +++ b/web/src/legal/SiteFooter.tsx @@ -0,0 +1,28 @@ +import { Link } from "react-router-dom"; +import { LEGAL_PATHS, OPERATOR } from "./operator"; + +const LINKS = [ + { to: LEGAL_PATHS.offer, label: "Оферта" }, + { to: LEGAL_PATHS.privacy, label: "Конфиденциальность" }, + { to: LEGAL_PATHS.consent, label: "Согласие" }, + { to: LEGAL_PATHS.cookies, label: "Cookie" }, +] as const; + +export function SiteFooter() { + return ( +
+
+ +

+ {OPERATOR.shortName} · ИНН {OPERATOR.inn} · {OPERATOR.email} +

+
+
+ ); +} diff --git a/web/src/legal/cookieConsent.ts b/web/src/legal/cookieConsent.ts new file mode 100644 index 0000000..f600b76 --- /dev/null +++ b/web/src/legal/cookieConsent.ts @@ -0,0 +1,43 @@ +export const COOKIE_CONSENT_KEY = "ts_cookie_consent"; +export type CookieChoice = "necessary" | "all"; + +const EVENT = "ts-cookie-consent"; + +export function readCookieChoice(): CookieChoice | null { + try { + const raw = localStorage.getItem(COOKIE_CONSENT_KEY); + if (raw === "necessary" || raw === "all") { + return raw; + } + } catch { + /* private mode */ + } + return null; +} + +export function writeCookieChoice(choice: CookieChoice): void { + try { + localStorage.setItem(COOKIE_CONSENT_KEY, choice); + } catch { + /* ignore */ + } + window.dispatchEvent(new Event(EVENT)); +} + +export function resetCookieChoice(): void { + try { + localStorage.removeItem(COOKIE_CONSENT_KEY); + } catch { + /* ignore */ + } + window.dispatchEvent(new Event(EVENT)); +} + +export function subscribeCookieChoice(listener: () => void): () => void { + window.addEventListener(EVENT, listener); + window.addEventListener("storage", listener); + return () => { + window.removeEventListener(EVENT, listener); + window.removeEventListener("storage", listener); + }; +} diff --git a/web/src/legal/documents.ts b/web/src/legal/documents.ts new file mode 100644 index 0000000..c49c1de --- /dev/null +++ b/web/src/legal/documents.ts @@ -0,0 +1,261 @@ +import { LEGAL_PATHS, OPERATOR } from "./operator"; + +export type LegalBlock = { type: "p"; text: string } | { type: "ul"; items: string[] }; + +export type LegalSection = { + heading: string; + blocks: LegalBlock[]; +}; + +export type LegalDocument = { + slug: keyof typeof LEGAL_PATHS; + title: string; + lead: string; + sections: LegalSection[]; +}; + +const headerLines = [ + OPERATOR.name, + `ИНН: ${OPERATOR.inn}`, + `ОГРНИП: ${OPERATOR.ogrnip}`, + `Адрес: ${OPERATOR.address}`, + `Email для обращений: ${OPERATOR.email}`, +]; + +export const legalDocuments: LegalDocument[] = [ + { + slug: "offer", + title: "Пользовательское соглашение (публичная оферта)", + lead: `Настоящий документ является официальным предложением (${OPERATOR.shortName}) заключить договор на изложенных ниже условиях. Дата публикации: ${OPERATOR.updated}.`, + sections: [ + { + heading: "Реквизиты исполнителя", + blocks: [{ type: "ul", items: [...headerLines] }], + }, + { + heading: "1. Термины", + blocks: [ + { + type: "ul", + items: [ + "Пользователь — дееспособное физическое лицо, принявшее условия настоящей Оферты и использующее Сервис.", + `Сервис — веб-кабинет и программные интерфейсы «${OPERATOR.serviceName}» по адресу ${OPERATOR.site}, предназначенные для выдачи векторных тайлов OpenStreetMap, стилей MapLibre и учёта запросов.`, + "Оферта — настоящий документ.", + ], + }, + ], + }, + { + heading: "2. Предмет", + blocks: [ + { + type: "p", + text: "Исполнитель предоставляет Пользователю доступ к Сервису: личный кабинет, персональные токены, стили карты и URL выдачи тайлов. Исходные выгрузки OSM являются общими и не создаются Пользователем.", + }, + { + type: "p", + text: "Данные OpenStreetMap предоставляются на условиях ODbL. Исполнитель не является правообладателем картографических данных OSM.", + }, + ], + }, + { + heading: "3. Акцепт", + blocks: [ + { + type: "p", + text: "Вход в Сервис через Яндекс ID и начало использования функционала являются акцептом настоящей Оферты.", + }, + { + type: "p", + text: "Используя Сервис, Пользователь подтверждает, что ознакомился с Политикой конфиденциальности и дал согласие на обработку персональных данных в необходимом объёме.", + }, + ], + }, + { + heading: "4. Ответственность", + blocks: [ + { + type: "ul", + items: [ + "Сервис предоставляется «как есть». Исполнитель не гарантирует бесперебойную работу и полноту покрытия карты.", + "Исполнитель не отвечает за действия третьих лиц, получивших токен Пользователя, и за последствия разглашения токена или доступа к Яндекс ID.", + "Пользователь обязан соблюдать условия OpenStreetMap и не использовать Сервис для противоправных целей.", + ], + }, + ], + }, + { + heading: "5. Прочие условия", + blocks: [ + { + type: "p", + text: "Исполнитель вправе изменять Оферту, публикуя новую редакцию на этой странице. Продолжение использования Сервиса после публикации означает принятие новой редакции, если иное не требуется законом.", + }, + { + type: "p", + text: `Споры решаются путём переговоров, а при недостижении согласия — в порядке, установленном законодательством РФ. Контакт: ${OPERATOR.email}.`, + }, + ], + }, + ], + }, + { + slug: "privacy", + title: "Политика конфиденциальности", + lead: `Настоящая Политика описывает, какие персональные данные обрабатывает ${OPERATOR.shortName} при работе Сервиса «${OPERATOR.serviceName}». Дата публикации: ${OPERATOR.updated}.`, + sections: [ + { + heading: "Реквизиты оператора", + blocks: [{ type: "ul", items: [...headerLines] }], + }, + { + heading: "1. Оператор", + blocks: [ + { + type: "p", + text: `${OPERATOR.name} (ИНН ${OPERATOR.inn}, ОГРНИП ${OPERATOR.ogrnip}), адрес: ${OPERATOR.address}, email: ${OPERATOR.email}, является оператором персональных данных Пользователей Сервиса.`, + }, + ], + }, + { + heading: "2. Какие данные собираются", + blocks: [ + { + type: "ul", + items: [ + "От Яндекса (OAuth / Яндекс ID): идентификатор, логин, отображаемое имя.", + "Введённые Пользователем: названия и JSON стилей карты, имена токенов.", + "Автоматически: cookie сессии, IP-адрес, технические журналы и агрегаты запросов тайлов/стилей (число запросов, объём).", + ], + }, + ], + }, + { + heading: "3. Цели обработки", + blocks: [ + { + type: "ul", + items: [ + "Исполнение договора: вход в кабинет, выдача тайлов и стилей, учёт использования.", + "Связь с Пользователем: ответы на обращения в поддержку.", + "Улучшение работы Сервиса: обезличенная аналитика сбоев (при наличии согласия на аналитические cookie).", + ], + }, + ], + }, + { + heading: "4. Передача третьим лицам", + blocks: [ + { + type: "p", + text: "Яндекс — для авторизации через Яндекс ID; Исполнитель получает от Яндекса указанные выше сведения профиля. Данные банковских карт Исполнитель не получает и не хранит.", + }, + ], + }, + { + heading: "5. Права Пользователя и сроки", + blocks: [ + { + type: "p", + text: `Пользователь вправе запросить доступ к своим данным, их уточнение, ограничение обработки, удаление аккаунта и отзыв согласия, направив письмо на ${OPERATOR.email}.`, + }, + { + type: "p", + text: "После удаления аккаунта персональные данные хранятся не дольше 3 лет — в объёме, необходимом для исполнения требований законодательства, затем уничтожаются либо обезличиваются.", + }, + ], + }, + { + heading: "6. Cookie", + blocks: [ + { + type: "p", + text: "Сервис использует технические cookie для входа и сохранения сессии, а также — только после согласия — аналитические cookie (Яндекс.Метрика). Подробности: Политика использования cookie.", + }, + ], + }, + ], + }, + { + slug: "consent", + title: "Согласие на обработку персональных данных", + lead: "Текст согласия, которое Пользователь даёт, отмечая соответствующий чек-бокс при входе в Сервис.", + sections: [ + { + heading: "Реквизиты оператора", + blocks: [{ type: "ul", items: [...headerLines] }], + }, + { + heading: "Текст согласия", + blocks: [ + { + type: "p", + text: `Я, Пользователь Сервиса «${OPERATOR.serviceName}», даю согласие ${OPERATOR.shortName} (ИНН ${OPERATOR.inn}) на обработку моих персональных данных: логин и отображаемое имя Яндекс ID, стили карт, токены доступа и статистика запросов.`, + }, + { + type: "p", + text: "Цели: предоставление доступа к Сервису, связь со мной, улучшение Сервиса.", + }, + { + type: "p", + text: "Способы обработки: сбор, запись, систематизация, хранение, уточнение, использование, передача (Яндекс — для авторизации), удаление — в том числе автоматизированно.", + }, + { + type: "p", + text: `Согласие действует до его отзыва. Отзыв направляется по email ${OPERATOR.email}. Отзыв не влияет на законность обработки, осуществлённой до его получения, и может сделать невозможным дальнейшее использование Сервиса.`, + }, + ], + }, + ], + }, + { + slug: "cookies", + title: "Политика использования cookie", + lead: `Документ описывает, какие cookie использует Сервис «${OPERATOR.serviceName}» и как отказаться от аналитических. Дата публикации: ${OPERATOR.updated}.`, + sections: [ + { + heading: "Реквизиты оператора", + blocks: [{ type: "ul", items: [...headerLines] }], + }, + { + heading: "1. Что такое cookie", + blocks: [ + { + type: "p", + text: "Cookie — небольшие файлы, которые сайт сохраняет в браузере. Они помогают запомнить вход и понять, как пользуются Сервисом.", + }, + ], + }, + { + heading: "2. Какие cookie мы используем", + blocks: [ + { + type: "ul", + items: [ + "Технические — обязательны для входа и работы кабинета (сессия ts_session, выбор cookie). Без них авторизация невозможна.", + "Аналитические — Яндекс.Метрика: посещения страниц, источник перехода, тип устройства. Ставятся только после согласия «Принять все».", + "Рекламные cookie сейчас не используются.", + ], + }, + ], + }, + { + heading: "3. Как отказаться", + blocks: [ + { + type: "p", + text: "При первом заходе показывается баннер. Можно выбрать «Только необходимые» — аналитические cookie не устанавливаются. Выбор можно изменить кнопкой ниже или очистив данные сайта в браузере.", + }, + { + type: "p", + text: `Вопросы: ${OPERATOR.email}.`, + }, + ], + }, + ], + }, +]; + +export function legalDocumentBySlug(slug: string | undefined): LegalDocument | undefined { + return legalDocuments.find((doc) => doc.slug === slug); +} diff --git a/web/src/legal/legal.css b/web/src/legal/legal.css new file mode 100644 index 0000000..8c02a55 --- /dev/null +++ b/web/src/legal/legal.css @@ -0,0 +1,173 @@ +.legal-page { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--grouped-background); +} + +.legal-page__inner { + width: min(720px, 100%); + margin: 0 auto; + padding: var(--space-6) var(--gutter) var(--space-8); + display: grid; + gap: var(--space-5); +} + +.legal-page__back { + color: var(--accent); + font-size: var(--text-subhead); + font-weight: 600; + width: fit-content; +} + +.legal-page__meta { + display: grid; + gap: var(--space-1); +} + +.legal-section { + display: grid; + gap: var(--space-3); +} + +.legal-section h2 { + font-size: var(--text-headline); +} + +.legal-section p, +.legal-section li { + font-size: var(--text-body); + line-height: 1.45; + color: var(--label); +} + +.legal-section ul { + margin: 0; + padding-left: 1.2em; + display: grid; + gap: var(--space-2); +} + +.legal-link { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 2px; +} + +.site-footer { + margin-top: auto; + padding: var(--space-4) var(--gutter) calc(var(--space-5) + env(safe-area-inset-bottom, 0px)); + border-top: var(--hairline) solid var(--separator); + background: var(--bar-background); +} + +.site-footer__inner { + width: min(720px, 100%); + margin: 0 auto; + display: grid; + gap: var(--space-2); +} + +.site-footer__links { + display: flex; + flex-wrap: wrap; + gap: var(--space-2) var(--space-4); +} + +.site-footer__links a { + color: var(--secondary-label); + font-size: var(--text-footnote); +} + +.site-footer__links a:hover { + color: var(--accent); +} + +.site-footer__copy { + margin: 0; + color: var(--tertiary-label); + font-size: var(--text-caption1); +} + +.app-shell .site-footer { + margin-top: var(--space-4); + border-top: 0; + background: transparent; + padding: var(--space-3) var(--gutter) 0; +} + +.auth-panel__foot .site-footer { + margin: 0; + padding: 0; + border: 0; + background: transparent; +} + +.auth-panel__foot .site-footer__inner { + width: 100%; +} + +.app-shell .site-footer__inner { + width: 100%; +} + +.legal-check { + display: grid; + grid-template-columns: 22px 1fr; + gap: var(--space-2); + align-items: start; + margin: 0; + color: var(--secondary-label); + font-size: var(--text-footnote); + line-height: 1.4; + cursor: pointer; +} + +.legal-check input { + margin-top: 2px; + accent-color: var(--accent); +} + +.legal-checks { + display: grid; + gap: var(--space-3); +} + +.cookie-banner { + position: fixed; + left: var(--gutter); + right: var(--gutter); + bottom: calc(var(--gutter) + env(safe-area-inset-bottom, 0px)); + z-index: 50; + max-width: 560px; + margin: 0 auto; + padding: var(--space-4); + border-radius: var(--radius-lg); + background: var(--grouped-surface); + box-shadow: 0 12px 40px rgb(0 0 0 / 18%); + display: grid; + gap: var(--space-3); +} + +.cookie-banner__actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.btn--yandex.is-disabled, +.btn--yandex[aria-disabled="true"] { + opacity: 0.45; + pointer-events: none; +} + +@media (max-width: 860px) { + .app-shell .site-footer { + padding-inline: 0; + } + + .app-shell .site-footer__links { + flex-wrap: nowrap; + overflow-x: auto; + } +} diff --git a/web/src/legal/operator.ts b/web/src/legal/operator.ts new file mode 100644 index 0000000..39f4875 --- /dev/null +++ b/web/src/legal/operator.ts @@ -0,0 +1,20 @@ +export const OPERATOR = { + name: "Индивидуальный предприниматель Архангельский Владимир Александрович", + shortName: "ИП Архангельский В.А.", + inn: "772270222393", + ogrnip: "323774600438338", + address: "105037, г. Москва, ул. 2-я Парковая, д. 16, кв. 8", + email: "hohnergold@yandex.ru", + serviceName: "Tile Server", + site: "https://tile-server.ru", + updated: "21 сентября 2026 г.", +} as const; + +export const LEGAL_PATHS = { + offer: "/legal/offer", + privacy: "/legal/privacy", + consent: "/legal/consent", + cookies: "/legal/cookies", +} as const; + +export type LegalSlug = keyof typeof LEGAL_PATHS; diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..3e39596 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,16 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { App } from "./App"; +import { AuthProvider } from "./features/auth/AuthContext"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render( + + + + + + + +); diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..1e4231f --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,60 @@ +@import "./design/index.css"; + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: var(--text-subhead); +} + +.data-table th, +.data-table td { + text-align: left; + padding: var(--space-3) var(--space-2); + border-bottom: 1px solid var(--separator, rgba(60, 60, 67, 0.12)); +} + +.data-table th { + color: var(--label-secondary); + font-weight: 500; +} + +.code-block { + overflow: auto; + margin: 0; + padding: var(--space-4); + border-radius: 12px; + background: var(--fill-secondary, rgba(120, 120, 128, 0.12)); + font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; + font-size: 13px; + line-height: 1.45; + white-space: pre-wrap; +} + +.style-json { + width: 100%; + min-height: 22rem; + resize: vertical; + font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; + font-size: 13px; + line-height: 1.45; + padding: var(--space-4); + border-radius: 12px; + border: 1px solid var(--separator, rgba(60, 60, 67, 0.12)); + background: var(--fill-secondary, rgba(120, 120, 128, 0.08)); + color: inherit; +} + +.map-frame { + height: 28rem; + border-radius: 12px; + overflow: hidden; + border: 1px solid var(--separator, rgba(60, 60, 67, 0.12)); +} + +.form-row { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + align-items: end; + margin-bottom: var(--space-4); +} diff --git a/web/src/ui/AppButton.tsx b/web/src/ui/AppButton.tsx new file mode 100644 index 0000000..aef1b77 --- /dev/null +++ b/web/src/ui/AppButton.tsx @@ -0,0 +1,42 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +export type AppButtonStyle = "filled" | "tinted" | "gray" | "plain" | "destructive"; +export type AppButtonSize = "large" | "medium" | "small"; + +type Props = ButtonHTMLAttributes & { + label?: ReactNode; + children?: ReactNode; + buttonStyle?: AppButtonStyle; + size?: AppButtonSize; + expanded?: boolean; + loading?: boolean; +}; + +export function AppButton({ + label, + children, + buttonStyle = "filled", + size = "large", + expanded = true, + loading = false, + className = "", + disabled, + type = "button", + ...rest +}: Props) { + const classes = [ + "app-btn", + `app-btn--${buttonStyle}`, + `app-btn--${size}`, + expanded ? "app-btn--expanded" : "", + className, + ] + .filter(Boolean) + .join(" "); + + return ( + + ); +} diff --git a/web/src/ui/AppFeedback.tsx b/web/src/ui/AppFeedback.tsx new file mode 100644 index 0000000..95eec41 --- /dev/null +++ b/web/src/ui/AppFeedback.tsx @@ -0,0 +1,96 @@ +import type { ReactNode } from "react"; +import { AppButton } from "./AppButton"; +import { AppText } from "./AppText"; + +export function AppSpinner() { + return ; +} + +export function AppSkeleton({ width = "100%", height = 12 }: { width?: string | number; height?: number }) { + return ; +} + +export function AppProgress({ + spent, + total, + label, +}: { + spent: number; + total: number; + label?: string; +}) { + const pct = total > 0 ? Math.min(100, Math.max(0, (spent / total) * 100)) : 0; + const tone = pct >= 100 ? "is-danger" : pct >= 85 ? "is-warn" : ""; + return ( +
+
+ +
+
+ ); +} + +export function AppEmptyView({ + title = "Пусто", + message, + action, +}: { + title?: string; + message?: ReactNode; + action?: ReactNode; +}) { + return ( +
+ {title} + {message ? {message} : null} + {action} +
+ ); +} + +export function AppErrorView({ + message, + onRetry, +}: { + message: string; + onRetry?: () => void; +}) { + return ( +
+ Не удалось загрузить + {message} + {onRetry ? ( + + ) : null} +
+ ); +} + +export function AppChip({ + label, + selected = false, + onClick, + disabled, +}: { + label: string; + selected?: boolean; + onClick?: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + +export function AppAvatar({ initials }: { initials: string }) { + return {initials}; +} diff --git a/web/src/ui/AppList.tsx b/web/src/ui/AppList.tsx new file mode 100644 index 0000000..9f87438 --- /dev/null +++ b/web/src/ui/AppList.tsx @@ -0,0 +1,75 @@ +import type { ReactNode } from "react"; +import { AppSectionHeader } from "./AppText"; + +type SectionProps = { + header?: string; + footer?: ReactNode; + children: ReactNode; + flush?: boolean; +}; + +export function AppListSection({ header, footer, children, flush = false }: SectionProps) { + return ( +
+ {header ? {header} : null} +
{children}
+ {footer ?

{footer}

: null} +
+ ); +} + +type TileProps = { + title: ReactNode; + subtitle?: ReactNode; + value?: ReactNode; + leading?: ReactNode; + trailing?: ReactNode; + chevron?: boolean; + destructive?: boolean; + onClick?: () => void; + href?: string; +}; + +export function AppListTile({ + title, + subtitle, + value, + leading, + trailing, + chevron = false, + destructive = false, + onClick, + href, +}: TileProps) { + const interactive = Boolean(onClick || href); + const className = `app-tile${destructive ? " app-tile--destructive" : ""}`; + const body = ( + <> + {leading} +
+

{title}

+ {subtitle ?

{subtitle}

: null} +
+ {trailing ?? (value != null ?

{value}

: null)} + {chevron ? : null} + + ); + + if (href) { + return ( + + {body} + + ); + } + + if (interactive) { + return ( + + ); + } + + return
{body}
; +} diff --git a/web/src/ui/AppSegmented.tsx b/web/src/ui/AppSegmented.tsx new file mode 100644 index 0000000..cc59b86 --- /dev/null +++ b/web/src/ui/AppSegmented.tsx @@ -0,0 +1,32 @@ +type Option = { value: T; label: string }; + +type Props = { + value: T; + options: Option[]; + onChange: (value: T) => void; + ariaLabel?: string; +}; + +export function AppSegmented({ + value, + options, + onChange, + ariaLabel, +}: Props) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} diff --git a/web/src/ui/AppText.tsx b/web/src/ui/AppText.tsx new file mode 100644 index 0000000..547062e --- /dev/null +++ b/web/src/ui/AppText.tsx @@ -0,0 +1,59 @@ +import type { ElementType, ReactNode } from "react"; + +export type AppTextVariant = + | "largeTitle" + | "title1" + | "title2" + | "title3" + | "headline" + | "body" + | "callout" + | "subhead" + | "footnote" + | "caption"; + +export type AppTextTone = "label" | "secondary" | "tertiary" | "accent" | "danger"; + +const DEFAULT_TONE: Record = { + largeTitle: "label", + title1: "label", + title2: "label", + title3: "label", + headline: "label", + body: "label", + callout: "secondary", + subhead: "secondary", + footnote: "secondary", + caption: "tertiary", +}; + +type Props = { + children: ReactNode; + variant?: AppTextVariant; + tone?: AppTextTone; + as?: ElementType; + className?: string; +}; + +export function AppText({ + children, + variant = "body", + tone, + as: Tag = "p", + className = "", +}: Props) { + const color = tone ?? DEFAULT_TONE[variant]; + return ( + + {children} + + ); +} + +export function AppSectionHeader({ children }: { children: ReactNode }) { + return ( + + {typeof children === "string" ? children.toUpperCase() : children} + + ); +} diff --git a/web/src/ui/AppTextField.tsx b/web/src/ui/AppTextField.tsx new file mode 100644 index 0000000..c1e0126 --- /dev/null +++ b/web/src/ui/AppTextField.tsx @@ -0,0 +1,54 @@ +import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react"; +import { AppSectionHeader } from "./AppText"; + +type FieldProps = { + label?: string; + hint?: ReactNode; + error?: string; + className?: string; +}; + +export function AppTextField({ + label, + id, + hint, + error, + className = "", + ...rest +}: InputHTMLAttributes & FieldProps & { id: string }) { + return ( +
+ {label ? ( + + ) : null} + + {error ?

{error}

: hint} +
+ ); +} + +export function AppSelect({ + label, + id, + hint, + error, + className = "", + children, + ...rest +}: SelectHTMLAttributes & FieldProps & { id: string }) { + return ( +
+ {label ? ( + + ) : null} + + {error ?

{error}

: hint} +
+ ); +} diff --git a/web/src/ui/index.ts b/web/src/ui/index.ts new file mode 100644 index 0000000..51807e0 --- /dev/null +++ b/web/src/ui/index.ts @@ -0,0 +1,15 @@ +export { AppText, AppSectionHeader } from "./AppText"; +export { AppButton } from "./AppButton"; +export type { AppButtonSize, AppButtonStyle } from "./AppButton"; +export { AppTextField, AppSelect } from "./AppTextField"; +export { AppListSection, AppListTile } from "./AppList"; +export { AppSegmented } from "./AppSegmented"; +export { + AppSpinner, + AppSkeleton, + AppProgress, + AppEmptyView, + AppErrorView, + AppChip, + AppAvatar, +} from "./AppFeedback"; diff --git a/web/src/ui/kit.css b/web/src/ui/kit.css new file mode 100644 index 0000000..dd627b8 --- /dev/null +++ b/web/src/ui/kit.css @@ -0,0 +1,346 @@ +/* iOS kit — mirrors mobile/lib/ui */ + +.app-text { + margin: 0; + font-family: var(--font-sans); +} + +.app-text--largeTitle { + font-size: var(--text-large-title); + font-weight: 700; + letter-spacing: 0.37px; + line-height: 1.2; +} +.app-text--title1 { + font-size: var(--text-title1); + font-weight: 700; + letter-spacing: 0.36px; + line-height: 1.2; +} +.app-text--title2 { + font-size: var(--text-title2); + font-weight: 700; + letter-spacing: 0.35px; + line-height: 1.25; +} +.app-text--title3 { + font-size: var(--text-title3); + font-weight: 600; + letter-spacing: 0.38px; + line-height: 1.25; +} +.app-text--headline { + font-size: var(--text-headline); + font-weight: 600; + letter-spacing: -0.41px; + line-height: 1.3; +} +.app-text--body { + font-size: var(--text-body); + font-weight: 400; + letter-spacing: -0.41px; + line-height: 1.3; +} +.app-text--callout { + font-size: var(--text-callout); + font-weight: 400; + letter-spacing: -0.32px; + line-height: 1.3; +} +.app-text--subhead { + font-size: var(--text-subhead); + font-weight: 400; + letter-spacing: -0.24px; + line-height: 1.3; +} +.app-text--footnote { + font-size: var(--text-footnote); + font-weight: 400; + letter-spacing: -0.08px; + line-height: 1.3; +} +.app-text--caption { + font-size: var(--text-caption1); + font-weight: 400; + line-height: 1.3; +} + +.app-text--label { color: var(--label); } +.app-text--secondary { color: var(--secondary-label); } +.app-text--tertiary { color: var(--tertiary-label); } +.app-text--accent { color: var(--accent); } +.app-text--danger { color: var(--system-red); } + +.app-section-header { + display: block; + margin: 0; + padding: var(--space-4) var(--gutter) var(--space-2); + font-size: var(--text-footnote); + font-weight: 400; + letter-spacing: 0.4px; + text-transform: uppercase; + color: var(--secondary-label); +} + +.app-section-footer { + margin: 0; + padding: var(--space-2) var(--gutter) 0; + font-size: var(--text-footnote); + color: var(--secondary-label); +} + +.app-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + border: 0; + cursor: pointer; + font-family: var(--font-sans); + font-weight: 600; + letter-spacing: -0.41px; + text-decoration: none; + color: inherit; + transition: filter 140ms ease, opacity 140ms ease; +} + +.app-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.app-btn--expanded { width: 100%; } +.app-btn--large { + height: var(--control-height); + padding: 0 var(--space-4); + border-radius: var(--radius-lg); + font-size: var(--text-headline); +} +.app-btn--medium { + height: var(--control-height-md); + padding: 0 var(--space-4); + border-radius: var(--radius-lg); + font-size: var(--text-body); +} +.app-btn--small { + height: var(--control-height-sm); + padding: 0 var(--space-3); + border-radius: var(--radius-md); + font-size: var(--text-subhead); +} + +.app-btn--filled { background: var(--accent); color: #fff; } +.app-btn--tinted { background: rgb(18 136 90 / 15%); color: var(--accent); } +.app-btn--gray { background: var(--system-gray5); color: var(--label); } +.app-btn--plain { background: transparent; color: var(--accent); } +.app-btn--destructive { background: rgb(255 59 48 / 15%); color: var(--system-red); } + +.app-btn:hover:not(:disabled) { filter: brightness(1.04); } + +.app-field { display: grid; gap: var(--space-2); } +.app-field .app-section-header { + padding: 0; +} +.app-field__control { + width: 100%; + height: var(--control-height-md); + border: 0; + border-radius: var(--radius-md); + background: var(--grouped-surface); + color: var(--label); + padding: 0 var(--space-3); + font-size: var(--text-body); + letter-spacing: -0.41px; +} +.app-field__control:disabled { + opacity: 0.4; +} +.app-field--error .app-field__control { + box-shadow: inset 0 0 0 1px var(--system-red); +} +.app-field__error { + margin: 0; + font-size: var(--text-footnote); + color: var(--system-red); +} + +.app-list { + margin: 0 var(--gutter); + padding: 0; + list-style: none; + background: var(--grouped-surface); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.app-list--flush { + margin-inline: 0; +} + +.app-tile { + display: flex; + align-items: center; + gap: var(--space-3); + width: 100%; + min-height: var(--row-min-height); + padding: 10px var(--gutter); + border: 0; + background: var(--grouped-surface); + color: inherit; + text-align: left; + font: inherit; + cursor: default; +} +button.app-tile, +a.app-tile { + cursor: pointer; +} +button.app-tile:hover, +a.app-tile:hover { + background: var(--system-gray5); +} +.app-tile + .app-tile { + box-shadow: inset 0 var(--hairline) 0 var(--separator); + background-clip: padding-box; +} +.app-tile__body { flex: 1; min-width: 0; } +.app-tile__title { + margin: 0; + font-size: var(--text-body); + letter-spacing: -0.41px; + color: var(--label); +} +.app-tile--destructive .app-tile__title { color: var(--system-red); } +.app-tile__subtitle { + margin: 2px 0 0; + font-size: var(--text-footnote); + color: var(--secondary-label); +} +.app-tile__value { + margin: 0; + font-size: var(--text-body); + color: var(--secondary-label); + font-variant-numeric: tabular-nums; +} +.app-tile__chevron { + color: var(--tertiary-label); + font-size: 17px; + line-height: 1; +} + +.app-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 28px; + padding: 0 12px; + border: 0; + border-radius: var(--radius-capsule); + background: var(--system-gray5); + color: var(--label); + font-size: var(--text-subhead); + cursor: pointer; +} +.app-chip.is-on { + background: rgb(18 136 90 / 15%); + color: var(--accent); +} + +.app-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: var(--radius-capsule); + background: var(--accent-soft); + color: var(--accent); + font-size: var(--text-subhead); + font-weight: 600; +} + +.app-progress__track { + height: 4px; + border-radius: var(--radius-capsule); + background: var(--system-gray5); + overflow: hidden; +} +.app-progress__bar { + display: block; + height: 100%; + background: var(--accent); + border-radius: inherit; +} +.app-progress__bar.is-warn { background: var(--system-orange); } +.app-progress__bar.is-danger { background: var(--system-red); } + +.app-spinner { + width: 20px; + height: 20px; + border: 2px solid var(--system-gray3); + border-top-color: var(--secondary-label); + border-radius: 50%; + animation: app-spin 700ms linear infinite; +} +@keyframes app-spin { to { transform: rotate(360deg); } } + +.app-skeleton { + display: block; + height: 12px; + border-radius: var(--radius-sm); + background: var(--system-gray5); +} + +.app-empty, +.app-error { + display: grid; + justify-items: center; + gap: var(--space-3); + padding: var(--space-7) var(--gutter); + text-align: center; + color: var(--secondary-label); +} + +.app-segmented { + display: flex; + padding: 2px; + border-radius: 9px; + background: var(--system-gray5); +} +.app-segmented__item { + flex: 1; + min-height: 32px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--label); + font-size: var(--text-subhead); + font-weight: 500; + cursor: pointer; +} +.app-segmented__item.is-on { + background: var(--grouped-surface); + box-shadow: 0 1px 3px rgb(0 0 0 / 12%); +} + +.app-toast { + position: fixed; + left: 50%; + bottom: 24px; + transform: translateX(-50%); + z-index: var(--z-toast); + display: flex; + align-items: center; + gap: var(--space-2); + min-height: 44px; + padding: 0 16px; + border-radius: var(--radius-capsule); + background: rgb(28 28 30 / 88%); + color: #fff; + font-size: var(--text-subhead); + backdrop-filter: blur(16px); +} + +@media (prefers-color-scheme: dark) { + .app-btn--tinted { background: rgb(60 214 140 / 18%); } +} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..c351008 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..59e73c8 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..89507e3 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + "/api": { target: "http://127.0.0.1:5088", changeOrigin: true }, + "/u": { target: "http://127.0.0.1:5088", changeOrigin: true } + } + }, + build: { + outDir: "dist", + emptyOutDir: true + } +});