diff --git a/package-lock.json b/package-lock.json index 3513286..3baa125 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,10 +7,12 @@ "": { "name": "beacon-web", "version": "0.0.0", + "license": "AGPL-3.0-or-later", "dependencies": { "@nazka/map-gl-js-spiderfy": "^2.0.0", "@tanstack/react-query": "^5.100.11", "@tanstack/react-virtual": "^3.13.25", + "echarts": "5.5.1", "maplibre-gl": "^5.24.0", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -456,9 +458,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", - "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", "dev": true, "funding": [ { @@ -508,12 +510,19 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, + "node_modules/@emnapi/core/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==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/@emnapi/runtime": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", @@ -521,11 +530,18 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } }, + "node_modules/@emnapi/runtime/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==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -537,6 +553,14 @@ "tslib": "^2.4.0" } }, + "node_modules/@emnapi/wasi-threads/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==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -855,13 +879,13 @@ } }, "node_modules/@maplibre/maplibre-gl-style-spec": { - "version": "24.8.5", - "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.8.5.tgz", - "integrity": "sha512-EzEJmMt6thioRH7GI9LWS7ahXTcAhAPGWCe6oTP2Ps4YnsXOOAfeqx854lZaiDnwURfHmcCKV1mr6oo0i23x6w==", + "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": "^0.0.1", + "@mapbox/unitbezier": "^1.0.0", "json-stringify-pretty-compact": "^4.0.0", "minimist": "^1.2.8", "quickselect": "^3.0.0", @@ -873,6 +897,12 @@ "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.1.11", "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.11.tgz", @@ -883,25 +913,27 @@ } }, "node_modules/@maplibre/vt-pbf": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz", - "integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==", + "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", - "@mapbox/vector-tile": "^2.0.4", - "@maplibre/geojson-vt": "^5.0.4", "@types/geojson": "^7946.0.16", - "@types/supercluster": "^7.1.3", - "pbf": "^4.0.1", - "supercluster": "^8.0.1" + "pbf": "^5.1.0" } }, - "node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz", - "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==", - "license": "ISC" + "node_modules/@maplibre/vt-pbf/node_modules/pbf": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.0.tgz", + "integrity": "sha512-Wv0yo0+uZepnoNEKsquhar1F18LogB8oeEikIhUXG16udbiXG7JecHGySwoo6kuMgjmbQYzdrTZlO+/K9t8eZg==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", @@ -929,9 +961,9 @@ "license": "BSD-3-Clause License" }, "node_modules/@oxc-project/types": { - "version": "0.132.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", - "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", "funding": { @@ -939,9 +971,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", - "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -956,9 +988,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -973,9 +1005,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", - "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -990,9 +1022,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", - "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -1007,9 +1039,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", - "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -1024,9 +1056,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", - "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], @@ -1041,9 +1073,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", - "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], @@ -1058,9 +1090,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", - "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], @@ -1075,9 +1107,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", - "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], @@ -1092,9 +1124,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", - "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], @@ -1109,9 +1141,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", - "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], @@ -1126,9 +1158,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", - "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -1143,9 +1175,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", - "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ "wasm32" ], @@ -1162,9 +1194,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", - "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], @@ -1179,9 +1211,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", - "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -1482,9 +1514,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.100.14", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz", - "integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==", + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", "license": "MIT", "funding": { "type": "github", @@ -1492,12 +1524,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.100.14", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz", - "integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==", + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.100.14" + "@tanstack/query-core": "5.101.0" }, "funding": { "type": "github", @@ -1508,12 +1540,12 @@ } }, "node_modules/@tanstack/react-virtual": { - "version": "3.13.26", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.26.tgz", - "integrity": "sha512-DosdgjOxCLahkn0o+ilmZYwEjo1glfMGuRT/j3PQ18yr5XqA8N/BCaL9IJ3B5TRl+nnzyK2IOFgAILwzN3a9xQ==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz", + "integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==", "license": "MIT", "dependencies": { - "@tanstack/virtual-core": "3.16.0" + "@tanstack/virtual-core": "3.17.0" }, "funding": { "type": "github", @@ -1525,9 +1557,9 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.16.0", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.16.0.tgz", - "integrity": "sha512-Er2N7q3WOiH6y2JLxsxNX+u2/sLqSsL0bxFgDjuiPiA7vKhZRm+IzcS17vRee3GNXr64UsesA5CAp9yTiIYw9A==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz", + "integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==", "license": "MIT", "funding": { "type": "github", @@ -1621,6 +1653,14 @@ "tslib": "^2.4.0" } }, + "node_modules/@tybys/wasm-util/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==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -1674,20 +1714,20 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.12.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", - "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "version": "24.13.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.1.tgz", + "integrity": "sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/react": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", - "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", "peer": true, @@ -1706,27 +1746,18 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/supercluster": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", - "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", - "license": "MIT", - "dependencies": { - "@types/geojson": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", - "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", + "integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.60.0", - "@typescript-eslint/type-utils": "8.60.0", - "@typescript-eslint/utils": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/type-utils": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1739,7 +1770,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.60.0", + "@typescript-eslint/parser": "^8.61.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -1755,17 +1786,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", - "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz", + "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.60.0", - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3" }, "engines": { @@ -1781,14 +1812,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", - "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz", + "integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.60.0", - "@typescript-eslint/types": "^8.60.0", + "@typescript-eslint/tsconfig-utils": "^8.61.0", + "@typescript-eslint/types": "^8.61.0", "debug": "^4.4.3" }, "engines": { @@ -1803,14 +1834,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", - "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz", + "integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0" + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1821,9 +1852,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", - "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz", + "integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==", "dev": true, "license": "MIT", "engines": { @@ -1838,15 +1869,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", - "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz", + "integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0", - "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1863,9 +1894,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", - "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz", + "integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==", "dev": true, "license": "MIT", "engines": { @@ -1877,16 +1908,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", - "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz", + "integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.60.0", - "@typescript-eslint/tsconfig-utils": "8.60.0", - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0", + "@typescript-eslint/project-service": "8.61.0", + "@typescript-eslint/tsconfig-utils": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1905,9 +1936,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "dev": true, "license": "ISC", "bin": { @@ -1918,16 +1949,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", - "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz", + "integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.60.0", - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0" + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1942,13 +1973,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", - "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz", + "integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/types": "8.61.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1986,16 +2017,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", - "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2004,13 +2035,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", - "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.7", + "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2031,9 +2062,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", - "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", "dev": true, "license": "MIT", "dependencies": { @@ -2044,13 +2075,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", - "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.8", "pathe": "^2.0.3" }, "funding": { @@ -2058,14 +2089,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", - "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2074,9 +2105,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", - "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", "dev": true, "license": "MIT", "funding": { @@ -2084,13 +2115,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", - "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", + "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2193,9 +2224,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.33", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", - "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "version": "2.10.35", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", + "integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2264,9 +2295,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", "dev": true, "funding": [ { @@ -2436,17 +2467,27 @@ "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", "license": "ISC" }, + "node_modules/echarts": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.5.1.tgz", + "integrity": "sha512-Fce8upazaAXUVUVsjgV6mBnGuqgO+JNDlcgF79Dksy4+wgGpQB2lmYoO4TSweFg/mZITdpGHomw/cNBJZj1icA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.0" + } + }, "node_modules/electron-to-chromium": { - "version": "1.5.364", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", - "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", + "version": "1.5.371", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz", + "integrity": "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w==", "dev": true, "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.22.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", - "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==", + "version": "5.23.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz", + "integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==", "dev": true, "license": "MIT", "dependencies": { @@ -3528,9 +3569,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.46", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", - "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", "dev": true, "license": "MIT", "engines": { @@ -3538,15 +3579,18 @@ } }, "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } }, "node_modules/optionator": { "version": "0.9.4", @@ -3754,9 +3798,9 @@ "license": "ISC" }, "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", "peer": true, "engines": { @@ -3764,16 +3808,16 @@ } }, "node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", "peer": true, "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.6" + "react": "^19.2.7" } }, "node_modules/react-is": { @@ -3784,9 +3828,9 @@ "license": "MIT" }, "node_modules/react-router": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz", - "integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", + "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -3806,12 +3850,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz", - "integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz", + "integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==", "license": "MIT", "dependencies": { - "react-router": "7.16.0" + "react-router": "7.17.0" }, "engines": { "node": ">=20.0.0" @@ -3855,13 +3899,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", - "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.132.0", + "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -3871,21 +3915,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.2", - "@rolldown/binding-darwin-arm64": "1.0.2", - "@rolldown/binding-darwin-x64": "1.0.2", - "@rolldown/binding-freebsd-x64": "1.0.2", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", - "@rolldown/binding-linux-arm64-gnu": "1.0.2", - "@rolldown/binding-linux-arm64-musl": "1.0.2", - "@rolldown/binding-linux-ppc64-gnu": "1.0.2", - "@rolldown/binding-linux-s390x-gnu": "1.0.2", - "@rolldown/binding-linux-x64-gnu": "1.0.2", - "@rolldown/binding-linux-x64-musl": "1.0.2", - "@rolldown/binding-openharmony-arm64": "1.0.2", - "@rolldown/binding-wasm32-wasi": "1.0.2", - "@rolldown/binding-win32-arm64-msvc": "1.0.2", - "@rolldown/binding-win32-x64-msvc": "1.0.2" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/saxes": { @@ -3990,15 +4034,6 @@ "node": ">=8" } }, - "node_modules/supercluster": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", - "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", - "license": "ISC", - "dependencies": { - "kdbush": "^4.0.2" - } - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -4137,12 +4172,10 @@ } }, "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==", - "dev": true, - "license": "0BSD", - "optional": true + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -4173,16 +4206,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", - "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz", + "integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.60.0", - "@typescript-eslint/parser": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0", - "@typescript-eslint/utils": "8.60.0" + "@typescript-eslint/eslint-plugin": "8.61.0", + "@typescript-eslint/parser": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4197,9 +4230,9 @@ } }, "node_modules/undici": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", - "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", + "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", "dev": true, "license": "MIT", "engines": { @@ -4207,9 +4240,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, @@ -4255,9 +4288,9 @@ } }, "node_modules/vite": { - "version": "8.0.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", - "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "peer": true, @@ -4265,8 +4298,8 @@ "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", - "rolldown": "1.0.2", - "tinyglobby": "^0.2.16" + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -4334,19 +4367,19 @@ } }, "node_modules/vitest": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", - "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.7", - "@vitest/mocker": "4.1.7", - "@vitest/pretty-format": "4.1.7", - "@vitest/runner": "4.1.7", - "@vitest/snapshot": "4.1.7", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -4374,12 +4407,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.7", - "@vitest/browser-preview": "4.1.7", - "@vitest/browser-webdriverio": "4.1.7", - "@vitest/coverage-istanbul": "4.1.7", - "@vitest/coverage-v8": "4.1.7", - "@vitest/ui": "4.1.7", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -4574,6 +4607,15 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zrender": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.0.tgz", + "integrity": "sha512-uzgraf4njmmHAbEUxMJ8Oxg+P3fT04O+9p7gY+wJRVxo8Ge+KmYv0WJev945EH4wFuc4OY2NLXz46FZrWS9xJg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } } } } diff --git a/package.json b/package.json index cd0c3fb..f763552 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@nazka/map-gl-js-spiderfy": "^2.0.0", "@tanstack/react-query": "^5.100.11", "@tanstack/react-virtual": "^3.13.25", + "echarts": "5.5.1", "maplibre-gl": "^5.24.0", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/src/App.tsx b/src/App.tsx index bfcf8b8..0f17bb4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,7 +25,6 @@ import { ObserverTable } from "./features/observers/ObserverTable"; import { RouteTable } from "./features/routes/RouteTable"; import { TraceList } from "./features/traces/TraceList"; import { ChannelList } from "./features/channels/ChannelList"; -import { StatsOverview } from "./features/stats/StatsOverview"; import { EmptyState } from "./components/EmptyState"; import { getPacketDetail } from "./api/client"; import { WsManager } from "./api/ws-manager"; @@ -35,6 +34,9 @@ import { WS_URL } from "./lib/constants"; // first time someone opens the Map tab instead of bloating the initial bundle. const MapView = lazy(() => import("./features/map/MapView").then((m) => ({ default: m.MapView }))); +// Stats pulls in ECharts (~150-200KB gz), so lazy-load it too — the chunk loads on first visit to Stats. +const StatsOverview = lazy(() => import("./features/stats/StatsOverview").then((m) => ({ default: m.StatsOverview }))); + // global singletons const queryClient = new QueryClient({ @@ -192,7 +194,7 @@ function AppInner() { // master/detail layout and renders on any tab — same path NodeDetailPanel's onAnalyzePacket uses Traces: , Channels: , - Stats: , + Stats: , Map: , }; diff --git a/src/api/client.ts b/src/api/client.ts index b945890..4b6e8e0 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -3,6 +3,16 @@ import type { CursorPage, PacketSummary, PacketDetail, IataCode, RegionSummary, import type { ChannelSummary, ChannelMessage } from "../features/channels/types"; import type { ObserverSummary, Observer, AdvertObservation } from "../features/observers/types"; import type { NodeSummary, Node, NodeObservation, NodeNeighbor } from "../features/nodes/types"; +import type { + StatsOverview, + ObservationPoint, + PayloadBreakdownItem, + TopNode, + TopObserver, + RadioPreset, + ScopeStats, + ObserverTelemetry, +} from "../features/stats/types"; // typed fetch wrapper with query params @@ -239,4 +249,46 @@ export function getNodeNeighbors(nodeId: string): Promise { return request(`/nodes/${nodeId}/neighbors`); } +// stats endpoints. `iata` is a single code (undefined = all regions); the /stats/* endpoints filter +// by one IATA only, unlike the comma-separated `iatas` used elsewhere. + +export function getStatsOverview(iata?: string): Promise { + return request("/stats/overview", { iata }); +} + +export function getStatsObservations(iata?: string, since?: number): Promise { + return request("/stats/observations", { iata, since }); +} + +export function getPayloadBreakdown(iata?: string, since?: number): Promise { + return request("/stats/payload-breakdown", { iata, since }); +} + +export function getTopNodes(iata?: string, limit = 10): Promise { + return request("/stats/top-nodes", { iata, limit }); +} + +export function getTopObservers(iata?: string, since?: number, limit = 10): Promise { + return request("/stats/top-observers", { iata, since, limit }); +} + +export function getRadioPresets(iata?: string): Promise { + return request("/stats/radio-presets", { iata }); +} + +// renamed from getScopes to avoid colliding with the /scopes name list; this is the /stats/scopes +// aggregate (packet/observer/node counts), reported globally regardless of the active region. +export function getStatsScopes(): Promise { + return request("/stats/scopes"); +} + +export function getObserverTelemetry( + observerId: string, + range: string, + interval?: string, + afterId?: number, +): Promise { + return request(`/observers/${observerId}/telemetry`, { range, interval, afterId }); +} + export { ApiError }; diff --git a/src/features/stats/EChart.tsx b/src/features/stats/EChart.tsx new file mode 100644 index 0000000..71b9f27 --- /dev/null +++ b/src/features/stats/EChart.tsx @@ -0,0 +1,47 @@ +import { useEffect, useRef } from "react"; +import { echarts, type EChartsInstance, type EChartsOption } from "./echarts-setup"; + +interface EChartProps { + option: EChartsOption; + className?: string; + style?: React.CSSProperties; + // Map of ECharts event name -> handler (e.g. { click: (p) => ... }). Kept stable by the caller. + onEvents?: Record void>; +} + +// Thin React wrapper over the core ECharts API: init once, resize via ResizeObserver, dispose on +// unmount, and re-apply the (memoized) option with notMerge so theme/data swaps fully replace state. +// Hand-rolled on purpose — we avoid the echarts-for-react dependency. +export function EChart({ option, className, style, onEvents }: EChartProps) { + const elRef = useRef(null); + const chartRef = useRef(null); + + useEffect(() => { + if (!elRef.current) return; + const chart = echarts.init(elRef.current, null, { renderer: "canvas" }); + chartRef.current = chart; + const ro = new ResizeObserver(() => chart.resize()); + ro.observe(elRef.current); + return () => { + ro.disconnect(); + chart.dispose(); + chartRef.current = null; + }; + }, []); + + useEffect(() => { + chartRef.current?.setOption(option, { notMerge: true }); + }, [option]); + + useEffect(() => { + const chart = chartRef.current; + if (!chart || !onEvents) return; + const entries = Object.entries(onEvents); + for (const [ev, handler] of entries) chart.on(ev, handler); + return () => { + for (const [ev, handler] of entries) chart.off(ev, handler); + }; + }, [onEvents]); + + return
; +} diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx new file mode 100644 index 0000000..0c4da0b --- /dev/null +++ b/src/features/stats/MeshTab.tsx @@ -0,0 +1,179 @@ +import { useMemo } from "react"; +import { formatCount } from "../../lib/formatters"; +import { useChartColors, type ChartColors } from "./chartTheme"; +import { useStatsOverview, useStatsObservations, usePayloadBreakdown, useTopNodes, useTopObservers, useRadioPresets, useScopes } from "./useStats"; +import { observationsAreaOption, leaderboardOption, donutOption } from "./chartOptions"; +import { Card, ChartCard, StatCard } from "./cards"; +import { useLiveOverview } from "./useLiveStats"; +import { aggregatePresets, formatPreset } from "./transforms"; +import type { WsManager } from "../../api/ws-manager"; +import type { ObservationPoint, StatsRange } from "./types"; + +// The observations endpoint returns one row per hour+iata; collapse to one row per hour (a no-op for a +// single selected region). uniquePackets / activeObservers summed across iatas are approximate. +function aggregateByHour(points: ObservationPoint[]) { + const byHour = new Map(); + for (const p of points) { + const cur = byHour.get(p.hour) ?? { hour: p.hour, observationCount: 0, uniquePackets: 0, activeObservers: 0 }; + cur.observationCount += p.observationCount; + cur.uniquePackets += p.uniquePackets; + cur.activeObservers += p.activeObservers; + byHour.set(p.hour, cur); + } + return [...byHour.values()].sort((a, b) => a.hour - b.hour); +} + +function nodeTypeColor(typeName: string, c: ChartColors): string { + switch (typeName) { + case "companion": return c.primary; + case "repeater": return c.green; + case "room_server": return c.secondary; + case "sensor": return c.warn; + default: return c.primaryDim; + } +} + +interface MeshTabProps { + range: StatsRange; + onSelectObserver: (observerId: string) => void; + wsManager: WsManager; +} + +export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { + const colors = useChartColors(); + useLiveOverview(wsManager); + const overview = useStatsOverview(); + const observations = useStatsObservations(range); + const payload = usePayloadBreakdown(range); + const topNodes = useTopNodes(10); + const topObservers = useTopObservers(range, 8); + const radioPresets = useRadioPresets(); + const scopes = useScopes(); + + const obs = useMemo(() => aggregateByHour(observations.data ?? []), [observations.data]); + const obsOption = useMemo(() => observationsAreaOption(obs, colors), [obs, colors]); + + const nodeRows = useMemo( + () => + (topNodes.data ?? []).map((n) => ({ + name: n.nodeName ?? n.nodeId.slice(0, 8), + value: n.observationCount, + color: nodeTypeColor(n.nodeTypeName, colors), + })), + [topNodes.data, colors], + ); + const nodesOption = useMemo(() => leaderboardOption(nodeRows, colors), [nodeRows, colors]); + + const payloadItems = useMemo( + () => (payload.data ?? []).map((p) => ({ name: p.payloadTypeName.toLowerCase(), value: p.count })), + [payload.data], + ); + const payloadTotal = useMemo(() => payloadItems.reduce((a, p) => a + p.value, 0), [payloadItems]); + const payloadOption = useMemo( + () => donutOption(payloadItems, colors, formatCount(payloadTotal), "OBS"), + [payloadItems, payloadTotal, colors], + ); + + const observerRows = useMemo( + () => (topObservers.data ?? []).map((o) => ({ name: o.displayName ?? o.observerId.slice(0, 8), value: o.observationCount, color: colors.secondary })), + [topObservers.data, colors], + ); + const observersOption = useMemo(() => leaderboardOption(observerRows, colors), [observerRows, colors]); + const observerIds = useMemo(() => (topObservers.data ?? []).map((o) => o.observerId), [topObservers.data]); + const observerEvents = useMemo( + () => ({ + click: (params: unknown) => { + const idx = (params as { dataIndex?: number }).dataIndex; + if (idx != null && observerIds[idx]) onSelectObserver(observerIds[idx]); + }, + }), + [observerIds, onSelectObserver], + ); + + const nodeTypeData = useMemo(() => { + const counts = new Map(); + for (const n of topNodes.data ?? []) counts.set(n.nodeTypeName, (counts.get(n.nodeTypeName) ?? 0) + 1); + return [...counts.entries()].map(([name, value]) => ({ name, value, color: nodeTypeColor(name, colors) })); + }, [topNodes.data, colors]); + const nodeTypeTotal = useMemo(() => nodeTypeData.reduce((a, d) => a + d.value, 0), [nodeTypeData]); + const nodeTypeOption = useMemo( + () => donutOption(nodeTypeData, colors, String(nodeTypeTotal), "NODES"), + [nodeTypeData, nodeTypeTotal, colors], + ); + + const presetRows = useMemo( + () => aggregatePresets(radioPresets.data ?? []).slice(0, 8).map((r) => ({ name: formatPreset(r.preset), value: r.value, color: colors.primary })), + [radioPresets.data, colors], + ); + const presetsOption = useMemo(() => leaderboardOption(presetRows, colors, 150), [presetRows, colors]); + + const scopeRows = useMemo( + () => [...(scopes.data ?? [])].sort((a, b) => b.packetCount - a.packetCount), + [scopes.data], + ); + + const obsSpark = useMemo(() => obs.slice(-24).map((p) => p.observationCount), [obs]); + const observerSpark = useMemo(() => obs.slice(-24).map((p) => p.activeObservers), [obs]); + + const ov = overview.data; + const kpiLoading = overview.isLoading; + + return ( +
+
+ + + + +
+ + Observations · {range}} + height={200} + option={obsOption} + isLoading={observations.isLoading} + isError={observations.isError} + isEmpty={obs.length === 0} + /> + +
+ + Payload types · {range}} height={208} option={payloadOption} isLoading={payload.isLoading} isError={payload.isError} isEmpty={payloadItems.length === 0} /> + Top observers · {range}} height={208} option={observersOption} isLoading={topObservers.isLoading} isError={topObservers.isError} isEmpty={observerRows.length === 0} onEvents={observerEvents} /> + + + + Scopes · all regions}> + {scopes.isError ? ( +
Failed to load
+ ) : scopes.isLoading ? ( +
Loading…
+ ) : scopeRows.length === 0 ? ( +
No data
+ ) : ( + + + + + + + + + + + {scopeRows.map((s) => ( + + + + + + + ))} + +
ScopePacketsObserversNodes
{s.name}{formatCount(s.packetCount)}{formatCount(s.observerCount)}{formatCount(s.nodeCount)}
+ )} +
+
+
+ ); +} diff --git a/src/features/stats/ObserverTab.tsx b/src/features/stats/ObserverTab.tsx new file mode 100644 index 0000000..c69d1ce --- /dev/null +++ b/src/features/stats/ObserverTab.tsx @@ -0,0 +1,181 @@ +import { useEffect, useMemo } from "react"; +import { Badge } from "../../components/Badge"; +import { EmptyState } from "../../components/EmptyState"; +import { formatBattery, formatCount, formatUptime } from "../../lib/formatters"; +import { useChartColors } from "./chartTheme"; +import { useTopObservers } from "./useStats"; +import { useObserver, useObserverTelemetry } from "./useTelemetry"; +import { airtimeOption, batteryOption, noiseFloorOption, queueOption, receiveErrorsOption } from "./chartOptions"; +import { Card, ChartCard } from "./cards"; +import { hasTelemetry } from "./transforms"; +import { useLiveObserver } from "./useLiveStats"; +import type { WsManager } from "../../api/ws-manager"; +import type { Observer } from "../observers/types"; +import type { StatsRange } from "./types"; + +function ObserverList({ + range, + selectedId, + onSelect, +}: { + range: StatsRange; + selectedId: string | null; + onSelect: (id: string) => void; +}) { + const { data, isLoading } = useTopObservers(range, 15); + const max = useMemo(() => Math.max(1, ...(data ?? []).map((o) => o.observationCount)), [data]); + + return ( + +
+ {isLoading &&
Loading…
} + {!isLoading && (data ?? []).length === 0 && ( +
No observers
+ )} + {(data ?? []).map((o) => { + const active = o.observerId === selectedId; + const name = o.displayName ?? o.observerId.slice(0, 8); + return ( + + ); + })} +
+
+ ); +} + +function ObserverHeader({ observer }: { observer: Observer }) { + const radio = [ + observer.radioFreqMhz && `${observer.radioFreqMhz} MHz`, + observer.radioSf && `SF${observer.radioSf}`, + observer.radioBwKhz && `${observer.radioBwKhz} kHz`, + observer.radioCr && `CR 4/${observer.radioCr}`, + ].filter(Boolean) as string[]; + + return ( + + {observer.displayName ?? observer.id.slice(0, 8)} + {observer.status} + {observer.observerType && {observer.observerType}} + + } + right={ + {observer.iata} + } + > +
+ + + + {radio.length > 0 && } +
+
+ ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +interface ObserverTabProps { + range: StatsRange; + selectedObserverId: string | null; + onSelectObserver: (observerId: string) => void; + wsManager: WsManager; +} + +export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsManager }: ObserverTabProps) { + const colors = useChartColors(); + useLiveObserver(wsManager, selectedObserverId, range); + const topObservers = useTopObservers(range, 15); + const observer = useObserver(selectedObserverId); + const telemetry = useObserverTelemetry(selectedObserverId, range); + + // default to the busiest observer once the list loads and nothing is selected + useEffect(() => { + if (selectedObserverId) return; + const first = topObservers.data?.[0]; + if (first) onSelectObserver(first.observerId); + }, [selectedObserverId, topObservers.data, onSelectObserver]); + + const points = telemetry.data?.points ?? []; + const airtime = useMemo(() => airtimeOption(points, colors), [points, colors]); + const battery = useMemo(() => batteryOption(points, colors), [points, colors]); + const noise = useMemo(() => noiseFloorOption(points, colors), [points, colors]); + const queue = useMemo(() => queueOption(points, colors), [points, colors]); + const recvErrors = useMemo(() => receiveErrorsOption(points, colors), [points, colors]); + + // Bots / MQTT bridges report status but no device telemetry — show one clear empty state rather + // than five flat-zero charts. When some telemetry exists, gate each chart on its own metric. + const ready = !telemetry.isLoading && !telemetry.isError; + const noTelemetry = ready && !hasTelemetry(points); + // a chart is empty when none of its metric(s) have a non-null value across the window + const missing = (...accessors: ((p: (typeof points)[number]) => number | null)[]) => + ready && !points.some((p) => accessors.some((a) => a(p) != null)); + + return ( +
+
+ +
+ +
+ {!selectedObserverId ? ( + + + + ) : ( + <> + {observer.data && } + {noTelemetry ? ( + + + + ) : ( + <> + Airtime TX / RX · {range}} + height={180} + option={airtime} + isLoading={telemetry.isLoading} + isError={telemetry.isError} + isEmpty={missing((p) => p.airtimeTxPct, (p) => p.airtimeRxPct)} + /> +
+ p.batteryMv)} /> + p.noiseFloorDb)} /> + p.queueLength)} /> + p.receiveErrors)} /> +
+ + )} + + )} +
+
+ ); +} diff --git a/src/features/stats/Segmented.tsx b/src/features/stats/Segmented.tsx new file mode 100644 index 0000000..00baa1b --- /dev/null +++ b/src/features/stats/Segmented.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from "react"; + +export interface SegmentedOption { + value: string; + label: string; + icon?: ReactNode; +} + +interface SegmentedProps { + options: SegmentedOption[]; + value: string; + onChange: (value: string) => void; + ariaLabel: string; + size?: "sm" | "md"; + className?: string; +} + +// A contained "pill group" segmented control bound to the palette: the active pill gets a primary +// tint + inset ring, inactive pills are muted. Used for the Stats sub-tabs (md, with icons) and the +// time-range selector (sm). Active state is conveyed with aria-pressed, not color alone. +export function Segmented({ options, value, onChange, ariaLabel, size = "sm", className }: SegmentedProps) { + const pad = size === "md" ? "px-3 py-1.5 text-xs" : "px-2.5 py-1 text-[11px]"; + return ( +
+ {options.map((o) => { + const active = o.value === value; + return ( + + ); + })} +
+ ); +} diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index ffd36da..8e1fa7d 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -1,5 +1,61 @@ -import { EmptyState } from "../../components/EmptyState"; +import { useCallback } from "react"; +import { useSearchParams } from "react-router-dom"; +import type { WsManager } from "../../api/ws-manager"; +import { StatsSubHeader } from "./StatsSubHeader"; +import { MeshTab } from "./MeshTab"; +import { ObserverTab } from "./ObserverTab"; +import type { StatsRange, StatsTab } from "./types"; -export function StatsOverview() { - return ; +const TABS: StatsTab[] = ["mesh", "observer"]; +const RANGES: StatsRange[] = ["24h", "7d", "30d"]; + +const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh"); +const asRange = (v: string | null): StatsRange => (RANGES.includes(v as StatsRange) ? (v as StatsRange) : "7d"); + +interface StatsOverviewProps { + wsManager: WsManager; +} + +// Stats page shell: a sub-header bar (Mesh / Observer pills + range + live dot) over the active +// sub-tab. Sub-tab, range, and selected observer live in the URL (?statsTab/?range/?observerId) so the +// view is shareable; replace:true keeps it out of history. Queries are cached, so switching is instant. +export function StatsOverview({ wsManager }: StatsOverviewProps) { + const [params, setParams] = useSearchParams(); + const tab = asTab(params.get("statsTab")); + const range = asRange(params.get("range")); + const observerId = params.get("observerId"); + + const patch = useCallback( + (updates: Record) => { + setParams( + (prev) => { + const next = new URLSearchParams(prev); + for (const [k, v] of Object.entries(updates)) { + if (v == null) next.delete(k); + else next.set(k, v); + } + return next; + }, + { replace: true }, + ); + }, + [setParams], + ); + + const handleTab = useCallback((t: StatsTab) => patch({ statsTab: t }), [patch]); + const handleRange = useCallback((r: StatsRange) => patch({ range: r }), [patch]); + const handleSelectObserver = useCallback((id: string) => patch({ statsTab: "observer", observerId: id }), [patch]); + + return ( +
+ +
+ {tab === "mesh" ? ( + + ) : ( + + )} +
+
+ ); } diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx new file mode 100644 index 0000000..a5793e1 --- /dev/null +++ b/src/features/stats/StatsSubHeader.tsx @@ -0,0 +1,76 @@ +import type { WsManager } from "../../api/ws-manager"; +import { useWsStatus } from "../../hooks/useWsStatus"; +import { Segmented } from "./Segmented"; +import type { StatsRange, StatsTab } from "./types"; + +function MeshIcon() { + return ( + + + + + + + ); +} + +function ObserverIcon() { + return ( + + + + + ); +} + +const TAB_OPTIONS = [ + { value: "mesh", label: "Mesh", icon: }, + { value: "observer", label: "Observer", icon: }, +]; + +const RANGE_OPTIONS = [ + { value: "24h", label: "24h" }, + { value: "7d", label: "7d" }, + { value: "30d", label: "30d" }, +]; + +interface Props { + tab: StatsTab; + onTabChange: (tab: StatsTab) => void; + range: StatsRange; + onRangeChange: (range: StatsRange) => void; + wsManager: WsManager; +} + +export function StatsSubHeader({ tab, onTabChange, range, onRangeChange, wsManager }: Props) { + const { status } = useWsStatus(wsManager); + const live = status === "connected"; + const connecting = status === "connecting"; + const dotColor = live ? "bg-green" : connecting ? "bg-warn" : "bg-text-dim"; + const label = live ? "LIVE" : connecting ? "LIVE" : "OFFLINE"; + const labelColor = live ? "text-green" : connecting ? "text-warn" : "text-text-dim"; + + return ( +
+ onTabChange(v as StatsTab)} + ariaLabel="Stats section" + size="md" + /> +
+ onRangeChange(v as StatsRange)} + ariaLabel="Time range" + /> +
+ + {label} +
+
+
+ ); +} diff --git a/src/features/stats/cards.tsx b/src/features/stats/cards.tsx new file mode 100644 index 0000000..6dd4611 --- /dev/null +++ b/src/features/stats/cards.tsx @@ -0,0 +1,114 @@ +import type { ReactNode } from "react"; +import { EChart } from "./EChart"; +import type { EChartsOption } from "./echarts-setup"; + +// Titled surface card matching the app's panel language. +export function Card({ + title, + right, + children, + className, +}: { + title: ReactNode; + right?: ReactNode; + children: ReactNode; + className?: string; +}) { + return ( +
+
+
{title}
+ {right} +
+ {children} +
+ ); +} + +function Sparkline({ values, color }: { values: number[]; color: string }) { + if (values.length < 2) return
; + const w = 120; + const h = 20; + const max = Math.max(...values); + const min = Math.min(...values); + const range = max - min || 1; + const pts = values + .map((v, i) => `${(i / (values.length - 1)) * w},${h - 1 - ((v - min) / range) * (h - 2)}`) + .join(" "); + return ( + + + + ); +} + +// KPI tile: label, big mono value, optional sparkline + sub-label. +export function StatCard({ + label, + value, + accent, + spark, + sublabel, +}: { + label: string; + value: ReactNode; + accent: string; // CSS color for the sparkline, e.g. "var(--color-primary)" + spark?: number[]; + sublabel?: ReactNode; +}) { + return ( +
+
+ {label} + {sublabel && {sublabel}} +
+
{value}
+ {spark ? :
} +
+ ); +} + +function Centered({ children }: { children: ReactNode }) { + return ( +
{children}
+ ); +} + +// Card whose body is a fixed-height ECharts chart, with loading/empty/error states. +export function ChartCard({ + title, + right, + height = 200, + option, + isLoading, + isEmpty, + isError, + onEvents, + className, +}: { + title: ReactNode; + right?: ReactNode; + height?: number; + option: EChartsOption; + isLoading?: boolean; + isEmpty?: boolean; + isError?: boolean; + onEvents?: Record void>; + className?: string; +}) { + return ( + +
+ {isError ? ( + Failed to load + ) : isLoading ? ( + Loading… + ) : isEmpty ? ( + No data + ) : ( + + )} +
+
+ ); +} diff --git a/src/features/stats/chartOptions.ts b/src/features/stats/chartOptions.ts new file mode 100644 index 0000000..558f0e8 --- /dev/null +++ b/src/features/stats/chartOptions.ts @@ -0,0 +1,252 @@ +import type { EChartsOption } from "./echarts-setup"; +import { type ChartColors, tooltipStyle, withAlpha } from "./chartTheme"; +import type { TelemetryPoint } from "./types"; + +const MONO = "JetBrains Mono, monospace"; + +function timeAxis(c: ChartColors) { + return { + type: "time" as const, + boundaryGap: false, + axisLine: { lineStyle: { color: c.border } }, + axisLabel: { color: c.textMuted, fontFamily: MONO, fontSize: 10, hideOverlap: true }, + splitLine: { show: false }, + }; +} + +function valueAxis(c: ChartColors, extra: Record = {}) { + return { + type: "value" as const, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: c.textMuted, fontFamily: MONO, fontSize: 10 }, + splitLine: { lineStyle: { color: c.border, opacity: 0.4 } }, + ...extra, + }; +} + +// ---- Mesh ---- + +export function observationsAreaOption( + points: { hour: number; observationCount: number; uniquePackets: number }[], + c: ChartColors, +): EChartsOption { + const obs = points.map((p) => [p.hour, p.observationCount]); + const uniq = points.map((p) => [p.hour, p.uniquePackets]); + return { + animation: false, + backgroundColor: "transparent", + grid: { left: 48, right: 14, top: 12, bottom: 24 }, + tooltip: { trigger: "axis", ...tooltipStyle(c), axisPointer: { type: "line", lineStyle: { color: c.primary } } }, + legend: { + data: ["Observations", "Unique packets"], + right: 8, + top: 0, + itemWidth: 10, + itemHeight: 10, + textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 }, + inactiveColor: c.textDim, + }, + xAxis: timeAxis(c), + yAxis: valueAxis(c), + series: [ + { + name: "Observations", + type: "line", + smooth: true, + symbol: "none", + data: obs, + lineStyle: { color: c.primary, width: 2 }, + itemStyle: { color: c.primary }, + areaStyle: { + color: { + type: "linear", + x: 0, y: 0, x2: 0, y2: 1, + colorStops: [ + { offset: 0, color: withAlpha(c.primary, 0.42) }, + { offset: 1, color: withAlpha(c.primary, 0.01) }, + ], + }, + }, + }, + { + name: "Unique packets", + type: "line", + smooth: true, + symbol: "none", + data: uniq, + lineStyle: { color: c.secondary, width: 1.3, type: "dashed" }, + itemStyle: { color: c.secondary }, + }, + ], + }; +} + +export function leaderboardOption( + rows: { name: string; value: number; color: string }[], + c: ChartColors, + gridLeft = 116, // widen for longer category labels (e.g. radio presets) +): EChartsOption { + return { + animation: false, + backgroundColor: "transparent", + grid: { left: gridLeft, right: 56, top: 6, bottom: 6 }, + tooltip: { trigger: "item", ...tooltipStyle(c) }, + xAxis: { type: "value", axisLabel: { show: false }, splitLine: { show: false }, axisLine: { show: false }, axisTick: { show: false } }, + yAxis: { + type: "category", + inverse: true, + data: rows.map((r) => r.name), + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: c.textNormal, fontFamily: MONO, fontSize: 11 }, + }, + series: [ + { + type: "bar", + barMaxWidth: 22, + barCategoryGap: "42%", + data: rows.map((r) => ({ value: r.value, itemStyle: { color: r.color, borderRadius: [0, 4, 4, 0] } })), + label: { + show: true, + position: "right", + color: c.textBright, + fontFamily: MONO, + fontSize: 11, + formatter: (p: { value: number }) => p.value.toLocaleString(), + }, + }, + ], + }; +} + +export function donutOption( + items: { name: string; value: number; color?: string }[], + c: ChartColors, + centerValue: string, + centerLabel: string, +): EChartsOption { + return { + animation: false, + backgroundColor: "transparent", + tooltip: { trigger: "item", ...tooltipStyle(c), formatter: "{b}: {c} ({d}%)" }, + legend: { + type: "scroll", + orient: "vertical", + right: 4, + top: "middle", + itemWidth: 9, + itemHeight: 9, + itemGap: 7, + // cap label width so long names (e.g. "anonymous_request") can't overrun into the donut + formatter: (name: string) => (name.length > 15 ? `${name.slice(0, 14)}…` : name), + textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 }, + pageIconColor: c.textMuted, + pageIconInactiveColor: c.textDim, + pageTextStyle: { color: c.textMuted, fontFamily: MONO, fontSize: 9 }, + inactiveColor: c.textDim, + }, + graphic: [ + { type: "text", left: "24%", top: "42%", style: { text: centerValue, fill: c.textBright, font: `700 21px ${MONO}` } }, + { type: "text", left: "24%", top: "56%", style: { text: centerLabel, fill: c.textMuted, font: `9px ${MONO}` } }, + ], + series: [ + { + type: "pie", + radius: ["46%", "68%"], + center: ["27%", "50%"], + avoidLabelOverlap: false, + itemStyle: { borderColor: c.bgSurface, borderWidth: 2, borderRadius: 4 }, + label: { show: false }, + emphasis: { scaleSize: 5 }, + data: items.map((it, i) => ({ name: it.name, value: it.value, itemStyle: { color: it.color ?? c.series[i % c.series.length] } })), + }, + ], + }; +} + +// ---- Observer telemetry ---- +// `t` arrives in epoch ms (normalized in useObserverTelemetry). + +// airtimeTx/RxPct are cumulative counters, so chart the per-report delta (airtime used per interval), +// clamped at 0 to ignore counter resets. Caveat: under bucketing (7d/30d) the backend AVGs these +// counters, so the delta is approximate — pending a backend MAX−MIN fix (beacon-docs ticket). +function deltaSeries(points: TelemetryPoint[], key: "airtimeRxPct" | "airtimeTxPct") { + const out: [number, number | null][] = []; + for (let i = 1; i < points.length; i++) { + const prev = points[i - 1]![key]; + const cur = points[i]![key]; + const d = prev != null && cur != null ? Math.max(0, cur - prev) : null; + out.push([points[i]!.t, d]); + } + return out; +} + +export function airtimeOption(points: TelemetryPoint[], c: ChartColors): EChartsOption { + return { + animation: false, + backgroundColor: "transparent", + grid: { left: 44, right: 14, top: 24, bottom: 22 }, + legend: { data: ["RX", "TX"], right: 6, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 } }, + tooltip: { trigger: "axis", ...tooltipStyle(c) }, + xAxis: timeAxis(c), + yAxis: valueAxis(c), + series: [ + { name: "RX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: deltaSeries(points, "airtimeRxPct"), lineStyle: { width: 1, color: c.green }, areaStyle: { color: withAlpha(c.green, 0.35) }, itemStyle: { color: c.green } }, + { name: "TX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: deltaSeries(points, "airtimeTxPct"), lineStyle: { width: 1, color: c.primary }, areaStyle: { color: withAlpha(c.primary, 0.35) }, itemStyle: { color: c.primary } }, + ], + }; +} + +// Single-metric line chart (small multiple). `delta` charts the per-report increase of a cumulative +// counter; `area` adds a fill (use only for counters that sit near zero, not offset ranges like dBm/V). +function seriesData(points: TelemetryPoint[], accessor: (p: TelemetryPoint) => number | null, delta: boolean) { + if (!delta) return points.map((p) => [p.t, accessor(p)]); + const out: [number, number | null][] = []; + for (let i = 1; i < points.length; i++) { + const a = accessor(points[i - 1]!); + const b = accessor(points[i]!); + out.push([points[i]!.t, a != null && b != null ? Math.max(0, b - a) : null]); + } + return out; +} + +function metricLineOption( + points: TelemetryPoint[], + c: ChartColors, + o: { name: string; color: string; accessor: (p: TelemetryPoint) => number | null; delta?: boolean; area?: boolean }, +): EChartsOption { + return { + animation: false, + backgroundColor: "transparent", + grid: { left: 50, right: 14, top: 14, bottom: 22 }, + tooltip: { trigger: "axis", ...tooltipStyle(c) }, + xAxis: timeAxis(c), + yAxis: valueAxis(c, { scale: true }), + series: [ + { + name: o.name, + type: "line", + smooth: true, + symbol: "none", + connectNulls: true, + data: seriesData(points, o.accessor, o.delta ?? false), + lineStyle: { color: o.color, width: 1.8 }, + itemStyle: { color: o.color }, + ...(o.area ? { areaStyle: { color: withAlpha(o.color, 0.16) } } : {}), + }, + ], + }; +} + +export const batteryOption = (p: TelemetryPoint[], c: ChartColors) => + metricLineOption(p, c, { name: "Battery V", color: c.primary, accessor: (x) => (x.batteryMv == null ? null : +(x.batteryMv / 1000).toFixed(3)) }); + +export const noiseFloorOption = (p: TelemetryPoint[], c: ChartColors) => + metricLineOption(p, c, { name: "Noise dBm", color: c.warn, accessor: (x) => x.noiseFloorDb }); + +export const queueOption = (p: TelemetryPoint[], c: ChartColors) => + metricLineOption(p, c, { name: "Queue", color: c.secondary, accessor: (x) => x.queueLength, area: true }); + +export const receiveErrorsOption = (p: TelemetryPoint[], c: ChartColors) => + metricLineOption(p, c, { name: "Recv errors / report", color: c.danger, accessor: (x) => x.receiveErrors, delta: true, area: true }); diff --git a/src/features/stats/chartTheme.ts b/src/features/stats/chartTheme.ts new file mode 100644 index 0000000..3edb46c --- /dev/null +++ b/src/features/stats/chartTheme.ts @@ -0,0 +1,110 @@ +import { useMemo } from "react"; +import { useTheme } from "../../hooks/useTheme"; + +// ECharts paints to a canvas and can't inherit our CSS variables, so we read the active palette's +// resolved `--color-*` tokens (defined in index.css `@theme`, which always resolve — palette value or +// fallback) and hand them to the option builders. `useChartColors()` re-reads whenever the theme id +// changes, so every chart recolors on theme switch. + +export interface ChartColors { + primary: string; + primaryDim: string; + secondary: string; + green: string; + warn: string; + danger: string; + textBright: string; + textNormal: string; + textMuted: string; + textDim: string; + bgBase: string; + bgSurface: string; + bgRaised: string; + border: string; + borderSubtle: string; + // categorical palette for donuts / multi-series, derived from the theme so it stays on-brand. + series: string[]; +} + +function readVar(name: string): string { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); +} + +type RGB = [number, number, number]; + +function parseColor(c: string): RGB { + const s = c.trim(); + if (s.startsWith("#")) { + let h = s.slice(1); + if (h.length === 3) h = h.split("").map((ch) => ch + ch).join(""); + const n = parseInt(h, 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; + } + const m = s.match(/rgba?\(([^)]+)\)/i); + if (m && m[1]) { + const parts = m[1].split(",").map((p) => parseFloat(p) || 0); + return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]; + } + return [128, 128, 128]; +} + +export function withAlpha(color: string, a: number): string { + const [r, g, b] = parseColor(color); + return `rgba(${r}, ${g}, ${b}, ${a})`; +} + +function blend(a: string, b: string, t = 0.5): string { + const [r1, g1, b1] = parseColor(a); + const [r2, g2, b2] = parseColor(b); + const mix = (x: number, y: number) => Math.round(x + (y - x) * t); + return `rgb(${mix(r1, r2)}, ${mix(g1, g2)}, ${mix(b1, b2)})`; +} + +export function readChartColors(): ChartColors { + const c = { + primary: readVar("--color-primary") || "#3B82F6", + primaryDim: readVar("--color-primary-dim") || "#1D4ED8", + secondary: readVar("--color-secondary") || "#A78BFA", + green: readVar("--color-green") || "#22C55E", + warn: readVar("--color-warn") || "#EAB308", + danger: readVar("--color-danger") || "#EF4444", + textBright: readVar("--color-text-bright") || "#FAFAFA", + textNormal: readVar("--color-text-normal") || "#A1A1AA", + textMuted: readVar("--color-text-muted") || "#73737B", + textDim: readVar("--color-text-dim") || "#5F5F65", + bgBase: readVar("--color-bg-base") || "#09090B", + bgSurface: readVar("--color-bg-surface") || "#111114", + bgRaised: readVar("--color-bg-raised") || "#1A1A1F", + border: readVar("--color-border") || "#27272A", + borderSubtle: readVar("--color-border-subtle") || "#1E1E22", + }; + // 8 categorical colors blended from the palette so any theme stays cohesive. + const series = [ + c.primary, + c.green, + c.secondary, + c.warn, + c.danger, + c.primaryDim, + blend(c.primary, c.secondary), + blend(c.green, c.warn), + ]; + return { ...c, series }; +} + +export function useChartColors(): ChartColors { + const { themeId } = useTheme(); + // themeId changes after the palette CSS vars are applied, so re-reading here is correct. + return useMemo(() => readChartColors(), [themeId]); +} + +// A reusable ECharts tooltip style block bound to the active palette. +export function tooltipStyle(c: ChartColors) { + return { + backgroundColor: c.bgRaised, + borderColor: c.border, + borderWidth: 1, + padding: [7, 11] as [number, number], + textStyle: { color: c.textBright, fontFamily: "JetBrains Mono, monospace", fontSize: 11 }, + }; +} diff --git a/src/features/stats/echarts-setup.ts b/src/features/stats/echarts-setup.ts new file mode 100644 index 0000000..e703520 --- /dev/null +++ b/src/features/stats/echarts-setup.ts @@ -0,0 +1,33 @@ +// Tree-shaken ECharts registration. Import `echarts` from here (never the `echarts` barrel) so the +// bundle only pulls the chart types + components the Stats page actually uses. We deliberately avoid +// the `echarts-for-react` wrapper (it was hit by a supply-chain attack 2026-05-19); EChart.tsx wraps +// the core API directly instead. +import * as echarts from "echarts/core"; +import { LineChart, BarChart, PieChart, GaugeChart } from "echarts/charts"; +import { + GridComponent, + TooltipComponent, + LegendComponent, + GraphicComponent, + DataZoomComponent, + MarkLineComponent, +} from "echarts/components"; +import { CanvasRenderer } from "echarts/renderers"; + +echarts.use([ + LineChart, + BarChart, + PieChart, + GaugeChart, + GridComponent, + TooltipComponent, + LegendComponent, + GraphicComponent, + DataZoomComponent, + MarkLineComponent, + CanvasRenderer, +]); + +export { echarts }; +export type EChartsInstance = ReturnType; +export type EChartsOption = Parameters[0]; diff --git a/src/features/stats/transforms.ts b/src/features/stats/transforms.ts new file mode 100644 index 0000000..211f8e9 --- /dev/null +++ b/src/features/stats/transforms.ts @@ -0,0 +1,45 @@ +import type { RadioPreset, TelemetryPoint } from "./types"; + +// Collapse radio presets (one row per preset+iata+sourceType) into one row per preset, summing +// counts, sorted by descending total. Junk presets (all-zero "0,0,0" from unconfigured radios) are +// dropped so they don't clutter the chart. +export function aggregatePresets(rows: RadioPreset[]): { preset: string; value: number }[] { + const byPreset = new Map(); + for (const r of rows) { + if (isJunkPreset(r.preset)) continue; + byPreset.set(r.preset, (byPreset.get(r.preset) ?? 0) + r.count); + } + return [...byPreset.entries()] + .map(([preset, value]) => ({ preset, value })) + .sort((a, b) => b.value - a.value); +} + +function isJunkPreset(preset: string): boolean { + return preset.split(",").every((n) => Number(n) === 0); +} + +// "freqMhz,bwKhz,sf" -> "910.525 · 62.5k · SF7" (freq is MHz by convention); anything that isn't a +// freq,bw,sf triple is shown as-is. +export function formatPreset(preset: string): string { + const parts = preset.split(","); + if (parts.length !== 3 || parts.some((p) => p === "" || Number.isNaN(Number(p)))) return preset; + const [freq, bw, sf] = parts; + return `${freq} · ${bw}k · SF${sf}`; +} + +// True if any point carries at least one meaningful (non-null, non-zero) metric. Bots / MQTT bridges +// report telemetry rows that are all zeros (no real radio hardware); those count as "no telemetry" +// so we show an empty state rather than a wall of flat-zero charts. +export function hasTelemetry(points: TelemetryPoint[]): boolean { + const live = (v: number | null) => v != null && v !== 0; + return points.some( + (p) => + live(p.batteryMv) || + live(p.airtimeTxPct) || + live(p.airtimeRxPct) || + live(p.noiseFloorDb) || + live(p.uptimeSeconds) || + live(p.queueLength) || + live(p.receiveErrors), + ); +} diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts new file mode 100644 index 0000000..405d1bf --- /dev/null +++ b/src/features/stats/types.ts @@ -0,0 +1,82 @@ +// Response shapes for the /stats/* endpoints and observer telemetry. Verified against tower-server. + +export interface StatsOverview { + totalPackets: number; + totalObservations: number; + activeObservers: number; + activeIatas: number; + windowHours: number; +} + +export interface ObservationPoint { + hour: number; // epoch ms, start of the hourly bucket + iata: string; + observationCount: number; + uniquePackets: number; + activeObservers: number; +} + +export interface PayloadBreakdownItem { + payloadType: number; + payloadTypeName: string; + count: number; +} + +export interface TopNode { + nodeId: string; + nodeName: string | null; + nodeType: number; + nodeTypeName: string; + iata: string; + observationCount: number; + lastHeard: number; // epoch ms +} + +export interface TopObserver { + observerId: string; + displayName: string | null; + observerType: string | null; + iata: string; + observationCount: number; +} + +export interface RadioPreset { + preset: string; // "freqMhz,bwKhz,sf" e.g. "910.525,62.5,7" + iata: string; + sourceType: string; // "observer" or "node" + count: number; +} + +export interface ScopeStats { + name: string; // normalized scope name e.g. "#bc" + packetCount: number; + observerCount: number; + nodeCount: number; +} + +export interface TelemetryPoint { + t: number; // epoch ms (normalized in useObserverTelemetry — backend raw path emits seconds) + batteryMv: number | null; + airtimeTxPct: number | null; + airtimeRxPct: number | null; + noiseFloorDb: number | null; + uptimeSeconds: number | null; + queueLength: number | null; + receiveErrors: number | null; +} + +export interface ObserverTelemetry { + range: string; + interval: string; + points: TelemetryPoint[]; +} + +// Sub-tab + time-range identifiers shared across the Stats page. +export type StatsTab = "mesh" | "observer"; +export type StatsRange = "24h" | "7d" | "30d"; + +export const RANGE_MS: Record = { + "24h": 24 * 60 * 60 * 1000, + "7d": 7 * 24 * 60 * 60 * 1000, + "30d": 30 * 24 * 60 * 60 * 1000, +}; diff --git a/src/features/stats/useLiveStats.ts b/src/features/stats/useLiveStats.ts new file mode 100644 index 0000000..39d21fb --- /dev/null +++ b/src/features/stats/useLiveStats.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useRef } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useRegion } from "../../hooks/useRegion"; +import { useWsPacketHandler, useWsObserverStatusHandler } from "../../hooks/useWsHandlers"; +import type { WsManager } from "../../api/ws-manager"; +import type { WsPacketObservation, WsObserverStatus } from "../../types/ws"; +import type { StatsOverview, StatsRange } from "./types"; + +// Live overview KPIs: every packetObservation bumps the cached overview counters (no refetch). High +// frequency, so increments are coalesced and flushed once per animation frame. The overview query also +// refetches periodically (useStatsOverview) so the live deltas self-correct against the server. +export function useLiveOverview(wsManager: WsManager) { + const region = useRegion(); + const qc = useQueryClient(); + const pending = useRef({ packets: 0, obs: 0 }); + const raf = useRef(null); + + const flush = useCallback(() => { + raf.current = null; + const { packets, obs } = pending.current; + if (!packets && !obs) return; + pending.current = { packets: 0, obs: 0 }; + qc.setQueryData(["stats-overview", region], (old) => + old + ? { ...old, totalPackets: old.totalPackets + packets, totalObservations: old.totalObservations + obs } + : old, + ); + }, [qc, region]); + + const onPacket = useCallback( + (data: WsPacketObservation["data"]) => { + pending.current.obs += 1; + if (data.packet?.isFirstObservation) pending.current.packets += 1; + if (raf.current == null) raf.current = requestAnimationFrame(flush); + }, + [flush], + ); + + useWsPacketHandler(wsManager, onPacket); + + useEffect( + () => () => { + if (raf.current != null) cancelAnimationFrame(raf.current); + }, + [], + ); +} + +// When the selected observer reports a status update, refresh its header + telemetry so battery, +// uptime, and the newest points reflect the change. Status messages are infrequent, so a refetch is fine. +export function useLiveObserver(wsManager: WsManager, observerId: string | null, range: StatsRange) { + const qc = useQueryClient(); + + const onStatus = useCallback( + (data: WsObserverStatus["data"]) => { + if (!observerId || data.observerId !== observerId) return; + qc.invalidateQueries({ queryKey: ["observer", observerId] }); + qc.invalidateQueries({ queryKey: ["observer-telemetry", observerId, range] }); + }, + [qc, observerId, range], + ); + + useWsObserverStatusHandler(wsManager, onStatus); +} diff --git a/src/features/stats/useStats.ts b/src/features/stats/useStats.ts new file mode 100644 index 0000000..e4f09aa --- /dev/null +++ b/src/features/stats/useStats.ts @@ -0,0 +1,94 @@ +import { useQuery, keepPreviousData } from "@tanstack/react-query"; +import { useRegion } from "../../hooks/useRegion"; +import { + getStatsOverview, + getStatsObservations, + getPayloadBreakdown, + getTopNodes, + getTopObservers, + getRadioPresets, + getStatsScopes, +} from "../../api/client"; +import { RANGE_MS, type StatsRange } from "./types"; + +// Shared query options: cache for 30s, keep previous data so region/range switches don't flash. +const common = { + staleTime: 30_000, + placeholderData: keepPreviousData, + refetchOnWindowFocus: false, +} as const; + +// `since` is computed inside queryFn so refetches use a fresh window without churning the query key. +const sinceFor = (range: StatsRange) => Date.now() - RANGE_MS[range]; + +// The /stats/* endpoints filter by a single IATA. Map the region selection to one: a single selected +// IATA filters; "all regions" or a multi-IATA region passes nothing (the endpoints then span all). +function useStatsIata(): { iata: string | undefined; regionKey: string } { + const { iatas, regionKey } = useRegion(); + return { iata: iatas?.length === 1 ? iatas[0] : undefined, regionKey }; +} + +export function useStatsOverview() { + const { iata, regionKey } = useStatsIata(); + return useQuery({ + queryKey: ["stats-overview", regionKey], + queryFn: () => getStatsOverview(iata), + ...common, + // self-correct the WS-accumulated live counters against the server + refetchInterval: 60_000, + }); +} + +export function useStatsObservations(range: StatsRange) { + const { iata, regionKey } = useStatsIata(); + return useQuery({ + queryKey: ["stats-observations", regionKey, range], + queryFn: () => getStatsObservations(iata, sinceFor(range)), + ...common, + }); +} + +export function usePayloadBreakdown(range: StatsRange) { + const { iata, regionKey } = useStatsIata(); + return useQuery({ + queryKey: ["stats-payload", regionKey, range], + queryFn: () => getPayloadBreakdown(iata, sinceFor(range)), + ...common, + }); +} + +export function useTopNodes(limit = 10) { + const { iata, regionKey } = useStatsIata(); + return useQuery({ + queryKey: ["stats-top-nodes", regionKey, limit], + queryFn: () => getTopNodes(iata, limit), + ...common, + }); +} + +export function useTopObservers(range: StatsRange, limit = 10) { + const { iata, regionKey } = useStatsIata(); + return useQuery({ + queryKey: ["stats-top-observers", regionKey, range, limit], + queryFn: () => getTopObservers(iata, sinceFor(range), limit), + ...common, + }); +} + +export function useRadioPresets() { + const { iata, regionKey } = useStatsIata(); + return useQuery({ + queryKey: ["stats-radio-presets", regionKey], + queryFn: () => getRadioPresets(iata), + ...common, + }); +} + +// scopes are reported globally by the backend (no region filter), so the key is region-independent +export function useScopes() { + return useQuery({ + queryKey: ["stats-scopes"], + queryFn: getStatsScopes, + ...common, + }); +} diff --git a/src/features/stats/useTelemetry.ts b/src/features/stats/useTelemetry.ts new file mode 100644 index 0000000..158f833 --- /dev/null +++ b/src/features/stats/useTelemetry.ts @@ -0,0 +1,47 @@ +import { useQuery, keepPreviousData } from "@tanstack/react-query"; +import { getObserver, getObserverTelemetry } from "../../api/client"; +import type { ObserverTelemetry, StatsRange } from "./types"; + +// Go time.ParseDuration strings the telemetry endpoint expects, per selected range. +const RANGE_PARAM: Record = { + "24h": "24h", + "7d": "168h", + "30d": "720h", +}; + +// Bucketing interval per range: raw 1h points at 24h, coarser buckets for the longer windows so the +// charts don't drown in points. +const INTERVAL_PARAM: Record = { + "24h": "1h", + "7d": "6h", + "30d": "24h", +}; + +// The backend's raw (interval=1h) path emits `t` in epoch SECONDS while the bucketed path emits ms. +// Normalize everything to ms here so chart code is unit-agnostic. (Tracked: beacon-docs ticket.) +export function normalizeTelemetry(data: ObserverTelemetry, interval: string): ObserverTelemetry { + if (interval !== "1h") return data; + return { ...data, points: data.points.map((p) => ({ ...p, t: p.t * 1000 })) }; +} + +export function useObserver(observerId: string | null) { + return useQuery({ + queryKey: ["observer", observerId], + queryFn: () => getObserver(observerId!), + enabled: !!observerId, + staleTime: 30_000, + refetchOnWindowFocus: false, + }); +} + +export function useObserverTelemetry(observerId: string | null, range: StatsRange) { + const interval = INTERVAL_PARAM[range]; + return useQuery({ + queryKey: ["observer-telemetry", observerId, range, interval], + queryFn: async () => normalizeTelemetry(await getObserverTelemetry(observerId!, RANGE_PARAM[range], interval), interval), + enabled: !!observerId, + staleTime: 30_000, + placeholderData: keepPreviousData, + refetchOnWindowFocus: false, + }); +} diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index 7a5e219..0ae0d18 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -56,6 +56,16 @@ export function formatBattery(volts: number): string { return `${volts.toFixed(2)}V`; } +// Compact large counts for KPI/stat displays: 932 -> "932", 14732 -> "14.7k", 8_900_000 -> "8.9M". +export function formatCount(n: number | null | undefined): string { + if (n == null) return "—"; + const abs = Math.abs(n); + if (abs < 1000) return String(n); + if (abs < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`; + if (abs < 1_000_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; + return `${(n / 1_000_000_000).toFixed(1).replace(/\.0$/, "")}B`; +} + // /nodes sends lat/lng as integer microdegrees (45141660 = 45.141660); scale those to decimal. // Values that are already decimal pass through untouched — the integer check tells them apart. export function microToDeg(v: number): number { diff --git a/tests/features/stats/has-telemetry.test.ts b/tests/features/stats/has-telemetry.test.ts new file mode 100644 index 0000000..419301c --- /dev/null +++ b/tests/features/stats/has-telemetry.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { hasTelemetry } from "../../../src/features/stats/transforms"; +import type { TelemetryPoint } from "../../../src/features/stats/types"; + +const empty = (t: number): TelemetryPoint => ({ t, batteryMv: null, airtimeTxPct: null, airtimeRxPct: null, noiseFloorDb: null, uptimeSeconds: null, queueLength: null, receiveErrors: null }); + +describe("hasTelemetry", () => { + it("is false for no points", () => { + expect(hasTelemetry([])).toBe(false); + }); + + it("is false when every metric on every point is null", () => { + expect(hasTelemetry([empty(1), empty(2)])).toBe(false); + }); + + it("is true when any metric on any point is a meaningful non-zero value", () => { + expect(hasTelemetry([empty(1), { ...empty(2), noiseFloorDb: -110 }])).toBe(true); + }); + + it("is false when every metric is zero (bots / MQTT bridges report all-zero rows)", () => { + const allZero: TelemetryPoint = { t: 1, batteryMv: 0, airtimeTxPct: 0, airtimeRxPct: 0, noiseFloorDb: 0, uptimeSeconds: 0, queueLength: 0, receiveErrors: 0 }; + expect(hasTelemetry([allZero, allZero])).toBe(false); + }); +}); diff --git a/tests/features/stats/radio-presets.test.ts b/tests/features/stats/radio-presets.test.ts new file mode 100644 index 0000000..4f11d26 --- /dev/null +++ b/tests/features/stats/radio-presets.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { aggregatePresets, formatPreset } from "../../../src/features/stats/transforms"; +import type { RadioPreset } from "../../../src/features/stats/types"; + +const row = (preset: string, sourceType: string, iata: string, count: number): RadioPreset => ({ preset, sourceType, iata, count }); + +describe("aggregatePresets", () => { + it("sums counts for the same preset across sourceType and iata", () => { + const rows = [ + row("910.525,62.5,7", "observer", "YVR", 3), + row("910.525,62.5,7", "node", "YVR", 5), + row("910.525,62.5,7", "observer", "YYJ", 2), + row("869.525,250,11", "node", "YVR", 4), + ]; + const out = aggregatePresets(rows); + const byPreset = Object.fromEntries(out.map((r) => [r.preset, r.value])); + expect(byPreset["910.525,62.5,7"]).toBe(10); + expect(byPreset["869.525,250,11"]).toBe(4); + }); + + it("returns rows sorted by descending count", () => { + const rows = [row("910.5,62.5,7", "node", "YVR", 1), row("868,250,11", "node", "YVR", 9), row("915,125,9", "node", "YVR", 5)]; + expect(aggregatePresets(rows).map((r) => r.preset)).toEqual(["868,250,11", "915,125,9", "910.5,62.5,7"]); + }); + + it("drops junk all-zero presets", () => { + const rows = [row("910.525,62.5,7", "node", "YVR", 6), row("0,0,0", "observer", "YVR", 1)]; + expect(aggregatePresets(rows).map((r) => r.preset)).toEqual(["910.525,62.5,7"]); + }); + + it("handles an empty input", () => { + expect(aggregatePresets([])).toEqual([]); + }); +}); + +describe("formatPreset", () => { + it("renders freq/bw/sf in a human-readable label", () => { + expect(formatPreset("910.525,62.5,7")).toBe("910.525 · 62.5k · SF7"); + }); + + it("falls back to the raw string when it is not the expected triple", () => { + expect(formatPreset("weird")).toBe("weird"); + }); +}); diff --git a/tests/features/stats/telemetry-normalize.test.ts b/tests/features/stats/telemetry-normalize.test.ts new file mode 100644 index 0000000..1c7fced --- /dev/null +++ b/tests/features/stats/telemetry-normalize.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { normalizeTelemetry } from "../../../src/features/stats/useTelemetry"; +import type { ObserverTelemetry } from "../../../src/features/stats/types"; + +const SEC = 1_700_000_000; // a second-scale epoch +const MS = SEC * 1000; + +describe("normalizeTelemetry", () => { + it("scales raw (1h) second-epoch points up to ms", () => { + const raw: ObserverTelemetry = { + range: "24h", + interval: "1h", + points: [{ t: SEC, batteryMv: 3700, airtimeTxPct: null, airtimeRxPct: null, noiseFloorDb: null, uptimeSeconds: null, queueLength: null, receiveErrors: null }], + }; + expect(normalizeTelemetry(raw, "1h").points[0]!.t).toBe(MS); + }); + + it("leaves bucketed (6h/24h) ms-epoch points untouched", () => { + const bucketed: ObserverTelemetry = { + range: "7d", + interval: "6h", + points: [{ t: MS, batteryMv: 3700, airtimeTxPct: null, airtimeRxPct: null, noiseFloorDb: null, uptimeSeconds: null, queueLength: null, receiveErrors: null }], + }; + expect(normalizeTelemetry(bucketed, "6h").points[0]!.t).toBe(MS); + }); + + it("does not mutate other fields", () => { + const raw: ObserverTelemetry = { + range: "24h", + interval: "1h", + points: [{ t: SEC, batteryMv: 3700, airtimeTxPct: 1.5, airtimeRxPct: 2.5, noiseFloorDb: -110, uptimeSeconds: 42, queueLength: 3, receiveErrors: 1 }], + }; + const p = normalizeTelemetry(raw, "1h").points[0]!; + expect(p.batteryMv).toBe(3700); + expect(p.airtimeTxPct).toBe(1.5); + expect(p.receiveErrors).toBe(1); + }); +});