From 60fa1eec06ec3246a4b535515cdfa9e7db2107be Mon Sep 17 00:00:00 2001 From: "s.galanskiy" Date: Thu, 18 Dec 2025 17:47:37 +0500 Subject: [PATCH] add vector mbtiles --- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 214 +++++++++++++++++++++++++++++ frontend/src/App.css | 12 ++ frontend/src/App.tsx | 230 ++++++++++++++++++++++++++++++-- gnss/api/dependencies.py | 4 +- gnss/api/routes/config_route.py | 2 + gnss/api/routes/vector_tiles.py | 55 ++++++++ gnss/api/state.py | 15 ++- gnss/main.py | 7 +- gnss/services/vector_mbtiles.py | 106 +++++++++++++++ 10 files changed, 628 insertions(+), 18 deletions(-) create mode 100644 gnss/api/routes/vector_tiles.py create mode 100644 gnss/services/vector_mbtiles.py diff --git a/frontend/package.json b/frontend/package.json index e1ddd90..a520f34 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-dialog": "^2", "leaflet": "^1.9.4", + "maplibre-gl": "^5.7.2", "react": "^19.1.0", "react-dom": "^19.1.0", "react-leaflet": "^4.2.1" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index f307732..4842711 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: leaflet: specifier: ^1.9.4 version: 1.9.4 + maplibre-gl: + specifier: ^5.7.2 + version: 5.15.0 react: specifier: ^19.1.0 version: 19.2.3 @@ -312,6 +315,40 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mapbox/geojson-rewind@0.5.2': + resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==} + hasBin: true + + '@mapbox/jsonlint-lines-primitives@2.0.2': + resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==} + engines: {node: '>= 0.6'} + + '@mapbox/point-geometry@1.1.0': + resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==} + + '@mapbox/tiny-sdf@2.0.7': + resolution: {integrity: sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==} + + '@mapbox/unitbezier@0.0.1': + resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==} + + '@mapbox/vector-tile@2.0.4': + resolution: {integrity: sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==} + + '@mapbox/whoots-js@3.1.0': + resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==} + engines: {node: '>=6.0.0'} + + '@maplibre/maplibre-gl-style-spec@24.4.1': + resolution: {integrity: sha512-UKhA4qv1h30XT768ccSv5NjNCX+dgfoq2qlLVmKejspPcSQTYD4SrVucgqegmYcKcmwf06wcNAa/kRd0NHWbUg==} + hasBin: true + + '@maplibre/mlt@1.1.2': + resolution: {integrity: sha512-SQKdJ909VGROkA6ovJgtHNs9YXV4YXUPS+VaZ50I2Mt951SLlUm2Cv34x5Xwc1HiFlsd3h2Yrs5cn7xzqBmENw==} + + '@maplibre/vt-pbf@4.2.0': + resolution: {integrity: sha512-bxrk/kQUwWXZgmqYgwOCnZCMONCRi3MJMqJdza4T3E4AeR5i+VyMnaJ8iDWtWxdfEAJRtrzIOeJtxZSy5mFrFA==} + '@react-leaflet/core@2.1.0': resolution: {integrity: sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==} peerDependencies: @@ -527,6 +564,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/geojson-vt@3.2.5': + resolution: {integrity: sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==} + '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} @@ -544,6 +584,9 @@ packages: '@types/react@19.2.7': resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + '@types/supercluster@7.1.3': + resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -577,6 +620,9 @@ packages: supports-color: optional: true + earcut@3.0.2: + resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} + electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} @@ -607,6 +653,16 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + geojson-vt@4.0.2: + resolution: {integrity: sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + gl-matrix@3.4.4: + resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -615,20 +671,36 @@ packages: engines: {node: '>=6'} hasBin: true + json-stringify-pretty-compact@4.0.0: + resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true + kdbush@4.0.2: + resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==} + leaflet@1.9.4: resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + maplibre-gl@5.15.0: + resolution: {integrity: sha512-pPeu/t4yPDX/+Uf9ibLUdmaKbNMlGxMAX+tBednYukol2qNk2TZXAlhdohWxjVvTO3is8crrUYv3Ok02oAaKzA==} + engines: {node: '>=16.14.0', npm: '>=8.1.0'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + murmurhash-js@1.0.0: + resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -637,6 +709,10 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + pbf@4.0.1: + resolution: {integrity: sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==} + hasBin: true + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -648,6 +724,15 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + potpack@2.1.0: + resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==} + + protocol-buffers-schema@3.6.0: + resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==} + + quickselect@3.0.0: + resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} + react-dom@19.2.3: resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} peerDependencies: @@ -668,11 +753,17 @@ packages: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} + resolve-protobuf-schema@2.1.0: + resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} + rollup@4.53.5: resolution: {integrity: sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -684,10 +775,16 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + supercluster@8.0.1: + resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyqueue@3.0.0: + resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} + typescript@5.8.3: resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} engines: {node: '>=14.17'} @@ -953,6 +1050,51 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mapbox/geojson-rewind@0.5.2': + dependencies: + get-stream: 6.0.1 + minimist: 1.2.8 + + '@mapbox/jsonlint-lines-primitives@2.0.2': {} + + '@mapbox/point-geometry@1.1.0': {} + + '@mapbox/tiny-sdf@2.0.7': {} + + '@mapbox/unitbezier@0.0.1': {} + + '@mapbox/vector-tile@2.0.4': + dependencies: + '@mapbox/point-geometry': 1.1.0 + '@types/geojson': 7946.0.16 + pbf: 4.0.1 + + '@mapbox/whoots-js@3.1.0': {} + + '@maplibre/maplibre-gl-style-spec@24.4.1': + dependencies: + '@mapbox/jsonlint-lines-primitives': 2.0.2 + '@mapbox/unitbezier': 0.0.1 + json-stringify-pretty-compact: 4.0.0 + minimist: 1.2.8 + quickselect: 3.0.0 + rw: 1.3.3 + tinyqueue: 3.0.0 + + '@maplibre/mlt@1.1.2': + dependencies: + '@mapbox/point-geometry': 1.1.0 + + '@maplibre/vt-pbf@4.2.0': + dependencies: + '@mapbox/point-geometry': 1.1.0 + '@mapbox/vector-tile': 2.0.4 + '@types/geojson-vt': 3.2.5 + '@types/supercluster': 7.1.3 + geojson-vt: 4.0.2 + pbf: 4.0.1 + supercluster: 8.0.1 + '@react-leaflet/core@2.1.0(leaflet@1.9.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: leaflet: 1.9.4 @@ -1107,6 +1249,10 @@ snapshots: '@types/estree@1.0.8': {} + '@types/geojson-vt@3.2.5': + dependencies: + '@types/geojson': 7946.0.16 + '@types/geojson@7946.0.16': {} '@types/leaflet.markercluster@1.5.6': @@ -1125,6 +1271,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/supercluster@7.1.3': + dependencies: + '@types/geojson': 7946.0.16 + '@vitejs/plugin-react@4.7.0(vite@7.3.0)': dependencies: '@babel/core': 7.28.5 @@ -1157,6 +1307,8 @@ snapshots: dependencies: ms: 2.1.3 + earcut@3.0.2: {} + electron-to-chromium@1.5.267: {} esbuild@0.27.1: @@ -1199,24 +1351,68 @@ snapshots: gensync@1.0.0-beta.2: {} + geojson-vt@4.0.2: {} + + get-stream@6.0.1: {} + + gl-matrix@3.4.4: {} + js-tokens@4.0.0: {} jsesc@3.1.0: {} + json-stringify-pretty-compact@4.0.0: {} + json5@2.2.3: {} + kdbush@4.0.2: {} + leaflet@1.9.4: {} lru-cache@5.1.1: dependencies: yallist: 3.1.1 + maplibre-gl@5.15.0: + dependencies: + '@mapbox/geojson-rewind': 0.5.2 + '@mapbox/jsonlint-lines-primitives': 2.0.2 + '@mapbox/point-geometry': 1.1.0 + '@mapbox/tiny-sdf': 2.0.7 + '@mapbox/unitbezier': 0.0.1 + '@mapbox/vector-tile': 2.0.4 + '@mapbox/whoots-js': 3.1.0 + '@maplibre/maplibre-gl-style-spec': 24.4.1 + '@maplibre/mlt': 1.1.2 + '@maplibre/vt-pbf': 4.2.0 + '@types/geojson': 7946.0.16 + '@types/geojson-vt': 3.2.5 + '@types/supercluster': 7.1.3 + earcut: 3.0.2 + geojson-vt: 4.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 + supercluster: 8.0.1 + tinyqueue: 3.0.0 + + minimist@1.2.8: {} + ms@2.1.3: {} + murmurhash-js@1.0.0: {} + nanoid@3.3.11: {} node-releases@2.0.27: {} + pbf@4.0.1: + dependencies: + resolve-protobuf-schema: 2.1.0 + picocolors@1.1.1: {} picomatch@4.0.3: {} @@ -1227,6 +1423,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + potpack@2.1.0: {} + + protocol-buffers-schema@3.6.0: {} + + quickselect@3.0.0: {} + react-dom@19.2.3(react@19.2.3): dependencies: react: 19.2.3 @@ -1243,6 +1445,10 @@ snapshots: react@19.2.3: {} + resolve-protobuf-schema@2.1.0: + dependencies: + protocol-buffers-schema: 3.6.0 + rollup@4.53.5: dependencies: '@types/estree': 1.0.8 @@ -1271,17 +1477,25 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.53.5 fsevents: 2.3.3 + rw@1.3.3: {} + scheduler@0.27.0: {} semver@6.3.1: {} source-map-js@1.2.1: {} + supercluster@8.0.1: + dependencies: + kdbush: 4.0.2 + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyqueue@3.0.0: {} + typescript@5.8.3: {} update-browserslist-db@1.2.2(browserslist@4.28.1): diff --git a/frontend/src/App.css b/frontend/src/App.css index 6163ccd..f151588 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -146,6 +146,18 @@ body, #root { overflow: hidden; } +.maplibre { + height: 70vh; + border-radius: 12px; + overflow: hidden; +} + +.map-switch { + display: flex; + gap: 10px; + padding: 12px; +} + .empty { padding: 24px; text-align: center; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 492c113..03eae76 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { MapContainer, Marker, Polyline, TileLayer, useMap } from "react-leaflet import L, { LatLngExpression } from "leaflet"; import "./App.css"; import "leaflet/dist/leaflet.css"; +import "maplibre-gl/dist/maplibre-gl.css"; type FixMessage = { type: "fix"; @@ -27,6 +28,8 @@ type IncomingMessage = FixMessage | StatusMessage; type AppConfig = { tiles_url: string; tiles_url_tms?: string; + vector_tiles_url?: string; + vector_metadata_url?: string; ws_url: string; gnss_source: string; }; @@ -73,6 +76,7 @@ export default function App() { const [status, setStatus] = useState("connecting"); const [track, setTrack] = useState([]); const wsRef = useRef(null); + const [mapMode, setMapMode] = useState<"raster" | "vector">("raster"); const [selectedFileName, setSelectedFileName] = useState(""); const fileInputRef = useRef(null); const [sliceParams, setSliceParams] = useState({ @@ -85,6 +89,8 @@ export default function App() { source_format: "geotiff" as "geotiff" | "mbtiles" | "tms" | "xyz", }); const [sliceStatus, setSliceStatus] = useState("idle"); + const maplibreContainerRef = useRef(null); + const maplibreRef = useRef(null); const uploadFileBlob = async (file: File) => { setSliceStatus("uploading"); @@ -100,6 +106,11 @@ export default function App() { setSelectedFileName(res.filename); setSliceStatus("idle"); sendFrontLog(`uploaded source ${res.filename}`, "info"); + if (res.filename?.toLowerCase?.().endsWith(".mbtiles")) { + activateVectorMbtiles(res.path).catch((err) => { + sendFrontLog(`vector activate failed: ${err}`, "warn"); + }); + } }) .catch((err) => { setSliceStatus("error"); @@ -201,6 +212,181 @@ export default function App() { : config.tiles_url || "/tiles/{z}/{x}/{y}.png" }`; + const vectorTileUrl = + config && `${backendBase.replace(/\/+$/, "")}${config.vector_tiles_url || "/tiles/vector/{z}/{x}/{y}.pbf"}`; + + const vectorMetaUrl = + config && `${backendBase.replace(/\/+$/, "")}${config.vector_metadata_url || "/tiles/vector/metadata"}`; + + const activateVectorMbtiles = async (path: string) => { + const res = await fetch(`${backendBase}/tiles/vector/activate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.detail || `status ${res.status}`); + sendFrontLog(`vector mbtiles activated: ${path}`, "info"); + setMapMode("vector"); + }; + + useEffect(() => { + if (mapMode !== "vector") return; + if (!maplibreContainerRef.current) return; + if (!vectorMetaUrl || !vectorTileUrl) return; + + let canceled = false; + (async () => { + const metaRes = await fetch(vectorMetaUrl, { cache: "no-store" }); + if (!metaRes.ok) return; + const meta = await metaRes.json(); + const vectorLayersRaw = meta.vector_layers || meta["json"] || null; + let sourceLayers: string[] = []; + try { + const parsed = typeof vectorLayersRaw === "string" ? JSON.parse(vectorLayersRaw) : vectorLayersRaw; + const layers = parsed?.vector_layers ?? parsed?.layers ?? parsed ?? []; + if (Array.isArray(layers)) { + sourceLayers = layers.map((l: any) => l.id).filter(Boolean); + } + } catch { + sourceLayers = []; + } + + const { default: maplibregl } = await import("maplibre-gl"); + if (canceled) return; + + if (maplibreRef.current) { + try { + maplibreRef.current.remove(); + } catch {} + maplibreRef.current = null; + } + + const style: any = { + version: 8, + sources: { + osm: { + type: "vector", + tiles: [vectorTileUrl], + minzoom: Number(meta.minzoom ?? 0), + maxzoom: Number(meta.maxzoom ?? 14), + }, + track: { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }, + }, + layers: [ + { id: "bg", type: "background", paint: { "background-color": "#050816" } }, + ], + glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf", + }; + + for (const id of sourceLayers) { + style.layers.push({ + id: `fill-${id}`, + type: "fill", + source: "osm", + "source-layer": id, + filter: ["==", ["geometry-type"], "Polygon"], + paint: { "fill-color": "#1f2937", "fill-opacity": 0.35 }, + }); + style.layers.push({ + id: `linepoly-${id}`, + type: "line", + source: "osm", + "source-layer": id, + filter: ["==", ["geometry-type"], "Polygon"], + paint: { "line-color": "#334155", "line-width": 1 }, + }); + style.layers.push({ + id: `line-${id}`, + type: "line", + source: "osm", + "source-layer": id, + filter: ["==", ["geometry-type"], "LineString"], + paint: { "line-color": "#475569", "line-width": 1.2 }, + }); + style.layers.push({ + id: `point-${id}`, + type: "circle", + source: "osm", + "source-layer": id, + filter: ["==", ["geometry-type"], "Point"], + paint: { "circle-radius": 2, "circle-color": "#94a3b8", "circle-opacity": 0.8 }, + }); + } + + style.layers.push( + { + id: "track-line", + type: "line", + source: "track", + paint: { "line-color": "#7dd3fc", "line-width": 4, "line-opacity": 0.9 }, + }, + { + id: "track-point", + type: "circle", + source: "track", + paint: { "circle-radius": 6, "circle-color": "#a78bfa", "circle-stroke-color": "#ffffff", "circle-stroke-width": 1 }, + }, + ); + + const map = new maplibregl.Map({ + container: maplibreContainerRef.current!, + style, + center: [37.618423, 55.751244], + zoom: 11, + }); + map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), "top-right"); + + map.on("load", () => { + const bounds = meta.bounds; + if (typeof bounds === "string") { + const parts = bounds.split(",").map((p: string) => Number(p.trim())); + if (parts.length === 4 && parts.every((n: number) => Number.isFinite(n))) { + map.fitBounds( + [ + [parts[0], parts[1]], + [parts[2], parts[3]], + ], + { padding: 30, duration: 0 }, + ); + } + } + }); + + maplibreRef.current = map; + })().catch((err) => { + sendFrontLog(`maplibre init error: ${err}`, "error"); + }); + + return () => { + canceled = true; + }; + }, [mapMode, vectorMetaUrl, vectorTileUrl]); + + useEffect(() => { + if (mapMode !== "vector") return; + const map = maplibreRef.current; + if (!map) return; + const geojson = { + type: "FeatureCollection", + features: [ + positions.length + ? { type: "Feature", geometry: { type: "LineString", coordinates: positions.map((p: any) => [p[1], p[0]]) } } + : null, + lastPoint ? { type: "Feature", geometry: { type: "Point", coordinates: [lastPoint.lon, lastPoint.lat] } } : null, + ].filter(Boolean), + }; + try { + const src = map.getSource("track"); + if (src && src.setData) { + src.setData(geojson); + } + } catch {} + }, [mapMode, positions, lastPoint]); + return (
@@ -236,19 +422,39 @@ export default function App() {
- {tileUrl ? ( - - - {positions.length > 0 && ( - <> - - - - )} - - +
+ + +
+ {mapMode === "raster" ? ( + tileUrl ? ( + + + {positions.length > 0 && ( + <> + + + + )} + + + ) : ( +
Нет конфигурации тайлов
+ ) ) : ( -
Нет конфигурации тайлов
+
)}
diff --git a/gnss/api/dependencies.py b/gnss/api/dependencies.py index f95a5e7..d42379b 100644 --- a/gnss/api/dependencies.py +++ b/gnss/api/dependencies.py @@ -1,3 +1,3 @@ -from .state import get_gnss_service, get_log_service, get_tile_service +from .state import get_gnss_service, get_log_service, get_tile_service, get_vector_mbtiles_service -__all__ = ["get_gnss_service", "get_tile_service", "get_log_service"] +__all__ = ["get_gnss_service", "get_tile_service", "get_log_service", "get_vector_mbtiles_service"] diff --git a/gnss/api/routes/config_route.py b/gnss/api/routes/config_route.py index 17cffd8..747b158 100644 --- a/gnss/api/routes/config_route.py +++ b/gnss/api/routes/config_route.py @@ -10,6 +10,8 @@ async def get_config() -> dict: return { "tiles_url": "/tiles/{z}/{x}/{y}.png", "tiles_url_tms": "/tiles/tms/{z}/{x}/{y}.png", + "vector_tiles_url": "/tiles/vector/{z}/{x}/{y}.pbf", + "vector_metadata_url": "/tiles/vector/metadata", "ws_url": "/ws/track", "gnss_source": config.GNSS_SOURCE, } diff --git a/gnss/api/routes/vector_tiles.py b/gnss/api/routes/vector_tiles.py new file mode 100644 index 0000000..97af228 --- /dev/null +++ b/gnss/api/routes/vector_tiles.py @@ -0,0 +1,55 @@ +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, Response +from pydantic import BaseModel + +from ..dependencies import get_vector_mbtiles_service +from ...services.vector_mbtiles import VectorMBTilesError, VectorMBTilesService + + +router = APIRouter(tags=["vector-tiles"]) + + +class ActivateVectorMBTiles(BaseModel): + path: str + + +@router.post("/tiles/vector/activate") +async def activate_vector_mbtiles( + payload: ActivateVectorMBTiles, + service: VectorMBTilesService = Depends(get_vector_mbtiles_service), +): + try: + info = service.activate(Path(payload.path)) + except VectorMBTilesError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return {"status": "ok", "path": str(info.path), "format": info.metadata.get("format")} + + +@router.get("/tiles/vector/metadata") +async def vector_metadata(service: VectorMBTilesService = Depends(get_vector_mbtiles_service)): + try: + return service.get_metadata() + except VectorMBTilesError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@router.get("/tiles/vector/{z}/{x}/{y}.pbf") +async def vector_tile( + z: int, + x: int, + y: int, + service: VectorMBTilesService = Depends(get_vector_mbtiles_service), +): + try: + data = service.get_tile(z=z, x=x, y_xyz=y) + except KeyError: + raise HTTPException(status_code=404, detail="tile not found") + except VectorMBTilesError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + headers = {} + if len(data) >= 2 and data[0] == 0x1F and data[1] == 0x8B: + headers["Content-Encoding"] = "gzip" + return Response(content=data, media_type="application/x-protobuf", headers=headers) + diff --git a/gnss/api/state.py b/gnss/api/state.py index c9de76b..007fc1d 100644 --- a/gnss/api/state.py +++ b/gnss/api/state.py @@ -3,17 +3,22 @@ from typing import Optional from ..services.gnss import GNSSService from ..services.logs import LogService from ..services.tiles import TileService +from ..services.vector_mbtiles import VectorMBTilesService _gnss_service: Optional[GNSSService] = None _tile_service: Optional[TileService] = None _log_service: Optional[LogService] = None +_vector_mbtiles_service: Optional[VectorMBTilesService] = None -def set_services(gnss: GNSSService, tiles: TileService, logger: LogService) -> None: - global _gnss_service, _tile_service, _log_service +def set_services( + gnss: GNSSService, tiles: TileService, logger: LogService, vector_mbtiles: VectorMBTilesService +) -> None: + global _gnss_service, _tile_service, _log_service, _vector_mbtiles_service _gnss_service = gnss _tile_service = tiles _log_service = logger + _vector_mbtiles_service = vector_mbtiles def get_gnss_service() -> GNSSService: @@ -32,3 +37,9 @@ def get_log_service() -> LogService: if _log_service is None: raise RuntimeError("Log service is not initialized") return _log_service + + +def get_vector_mbtiles_service() -> VectorMBTilesService: + if _vector_mbtiles_service is None: + raise RuntimeError("Vector MBTiles service is not initialized") + return _vector_mbtiles_service diff --git a/gnss/main.py b/gnss/main.py index 7776cd1..b2a89bc 100644 --- a/gnss/main.py +++ b/gnss/main.py @@ -6,10 +6,11 @@ from fastapi.middleware.cors import CORSMiddleware from . import config from .api import state -from .api.routes import config_route, health, logs, tiles, ws +from .api.routes import config_route, health, logs, tiles, vector_tiles, ws from .services.gnss import GNSSService, SerialNMEASource, SimulatedPathSource from .services.logs import LogService from .services.tiles import TileService +from .services.vector_mbtiles import VectorMBTilesService logger = logging.getLogger("gnss") logging.basicConfig(level=logging.INFO) @@ -37,8 +38,9 @@ tiles_dir = Path(config.TILES_DIR) placeholder_tile = Path(config.TILE_PLACEHOLDER) tile_service = TileService(tiles_dir=tiles_dir, placeholder=placeholder_tile, upload_dir=Path(config.UPLOAD_DIR)) log_service = LogService(base_dir=Path("logs")) +vector_mbtiles_service = VectorMBTilesService() gnss_service = create_gnss_service() -state.set_services(gnss=gnss_service, tiles=tile_service, logger=log_service) +state.set_services(gnss=gnss_service, tiles=tile_service, logger=log_service, vector_mbtiles=vector_mbtiles_service) app = FastAPI(title="GNSS Backend", version="0.2.0") @@ -60,6 +62,7 @@ app.include_router(config_route.router) app.include_router(tiles.router) app.include_router(ws.router) app.include_router(logs.router) +app.include_router(vector_tiles.router) @app.on_event("startup") diff --git a/gnss/services/vector_mbtiles.py b/gnss/services/vector_mbtiles.py new file mode 100644 index 0000000..496979b --- /dev/null +++ b/gnss/services/vector_mbtiles.py @@ -0,0 +1,106 @@ +import sqlite3 +import threading +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional + + +class VectorMBTilesError(RuntimeError): + pass + + +@dataclass(frozen=True) +class VectorMBTilesInfo: + path: Path + metadata: dict[str, Any] + + +def _read_metadata(conn: sqlite3.Connection) -> dict[str, str]: + cur = conn.execute("SELECT name, value FROM metadata") + return {name: value for name, value in cur.fetchall()} + + +def _has_table(conn: sqlite3.Connection, table: str) -> bool: + cur = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1", + (table,), + ) + return cur.fetchone() is not None + + +class VectorMBTilesService: + def __init__(self): + self._lock = threading.Lock() + self._active: Optional[VectorMBTilesInfo] = None + + def activate(self, path: Path) -> VectorMBTilesInfo: + p = Path(path).expanduser().resolve() + if not p.exists(): + raise VectorMBTilesError(f"mbtiles not found: {p}") + if p.suffix.lower() != ".mbtiles": + raise VectorMBTilesError("expected .mbtiles file") + + conn = sqlite3.connect(f"file:{p}?mode=ro", uri=True) + try: + metadata = _read_metadata(conn) + finally: + conn.close() + + fmt = (metadata.get("format") or "").lower() + if fmt not in {"pbf", "mvt"}: + raise VectorMBTilesError( + f"mbtiles format is '{fmt or 'unknown'}'; expected vector 'pbf'/'mvt' (raster mbtiles are not supported here)" + ) + + info = VectorMBTilesInfo(path=p, metadata=metadata) + with self._lock: + self._active = info + return info + + def get_active(self) -> VectorMBTilesInfo: + with self._lock: + if self._active is None: + raise VectorMBTilesError("no active vector mbtiles; upload/activate first") + return self._active + + def get_metadata(self) -> dict[str, Any]: + info = self.get_active() + meta = dict(info.metadata) + meta["path"] = str(info.path) + return meta + + def get_tile(self, z: int, x: int, y_xyz: int) -> bytes: + info = self.get_active() + if z < 0: + raise VectorMBTilesError("invalid z") + max_index = (2**z) - 1 + if x < 0 or x > max_index or y_xyz < 0 or y_xyz > max_index: + raise VectorMBTilesError("invalid x/y for z") + + y_tms = max_index - y_xyz + conn = sqlite3.connect(f"file:{info.path}?mode=ro", uri=True) + try: + if _has_table(conn, "tiles"): + cur = conn.execute( + "SELECT tile_data FROM tiles WHERE zoom_level=? AND tile_column=? AND tile_row=? LIMIT 1", + (z, x, y_tms), + ) + row = cur.fetchone() + if row is None: + raise KeyError("tile not found") + return row[0] + + if _has_table(conn, "map") and _has_table(conn, "images"): + cur = conn.execute( + "SELECT images.tile_data FROM map JOIN images ON map.tile_id = images.tile_id " + "WHERE map.zoom_level=? AND map.tile_column=? AND map.tile_row=? LIMIT 1", + (z, x, y_tms), + ) + row = cur.fetchone() + if row is None: + raise KeyError("tile not found") + return row[0] + + raise VectorMBTilesError("unsupported mbtiles schema (no tiles table and no map/images tables)") + finally: + conn.close()