diff --git a/bun.lock b/bun.lock index c7e36a1..07fa922 100644 --- a/bun.lock +++ b/bun.lock @@ -29,10 +29,13 @@ "@tanstack/react-router": "^1.163.3", "@tanstack/react-router-devtools": "^1.163.3", "@tanstack/react-table": "^8.21.3", + "@types/leaflet.markercluster": "^1.5.6", "axios": "1.13.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "form-data": "4.0.5", + "leaflet": "^1.9.4", + "leaflet.markercluster": "^1.5.3", "lucide-react": "^0.563.0", "next-themes": "^0.4.6", "react": "^19.1.1", @@ -40,6 +43,8 @@ "react-error-boundary": "^6.0.0", "react-hook-form": "^7.68.0", "react-icons": "^5.5.0", + "react-leaflet": "^5.0.0", + "react-leaflet-cluster": "^4.0.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.2.0", @@ -51,6 +56,7 @@ "@playwright/test": "1.58.2", "@tanstack/router-devtools": "^1.159.10", "@tanstack/router-plugin": "^1.140.0", + "@types/leaflet": "^1.9.21", "@types/node": "^25.3.2", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -281,6 +287,8 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@react-leaflet/core": ["@react-leaflet/core@3.0.0", "", { "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.2", "", { "os": "android", "cpu": "arm" }, "sha512-21J6xzayjy3O6NdnlO6aXi/urvSRjm6nCI6+nF6ra2YofKruGixN9kfT+dt55HVNwfDmpDHJcaS3JuP/boNnlA=="], @@ -429,8 +437,14 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/leaflet": ["@types/leaflet@1.9.21", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w=="], + + "@types/leaflet.markercluster": ["@types/leaflet.markercluster@1.5.6", "", { "dependencies": { "@types/leaflet": "^1.9" } }, "sha512-I7hZjO2+isVXGYWzKxBp8PsCzAYCJBc29qBdFpquOCkS7zFDqUsUvkEOyQHedsk/Cy5tocQzf+Ndorm5W9YKTQ=="], + "@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="], "@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="], @@ -613,6 +627,10 @@ "json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="], + + "leaflet.markercluster": ["leaflet.markercluster@1.5.3", "", { "peerDependencies": { "leaflet": "^1.3.1" } }, "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA=="], + "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], @@ -713,6 +731,10 @@ "react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="], + "react-leaflet": ["react-leaflet@5.0.0", "", { "dependencies": { "@react-leaflet/core": "^3.0.0" }, "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw=="], + + "react-leaflet-cluster": ["react-leaflet-cluster@4.0.0", "", { "dependencies": { "leaflet.markercluster": "^1.5.3" }, "peerDependencies": { "@react-leaflet/core": "^3.0.0", "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-leaflet": "^5.0.0" } }, "sha512-Lu75+KOu2ruGyAx8LoCQvlHuw+3CLLJQGEoSk01ymsDN/YnCiRV6ChkpsvaruVyYBPzUHwiskFw4Jo7WHj5qNw=="], + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], diff --git a/frontend/package.json b/frontend/package.json index 4ed71ff..4fb4ce0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,10 +33,13 @@ "@tanstack/react-router": "^1.163.3", "@tanstack/react-router-devtools": "^1.163.3", "@tanstack/react-table": "^8.21.3", + "@types/leaflet.markercluster": "^1.5.6", "axios": "1.13.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "form-data": "4.0.5", + "leaflet": "^1.9.4", + "leaflet.markercluster": "^1.5.3", "lucide-react": "^0.563.0", "next-themes": "^0.4.6", "react": "^19.1.1", @@ -44,6 +47,8 @@ "react-error-boundary": "^6.0.0", "react-hook-form": "^7.68.0", "react-icons": "^5.5.0", + "react-leaflet": "^5.0.0", + "react-leaflet-cluster": "^4.0.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.2.0", @@ -55,6 +60,7 @@ "@playwright/test": "1.58.2", "@tanstack/router-devtools": "^1.159.10", "@tanstack/router-plugin": "^1.140.0", + "@types/leaflet": "^1.9.21", "@types/node": "^25.3.2", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/frontend/src/lib/coord-transform.ts b/frontend/src/lib/coord-transform.ts new file mode 100644 index 0000000..be09cf6 --- /dev/null +++ b/frontend/src/lib/coord-transform.ts @@ -0,0 +1,76 @@ +/** + * WGS-84 ↔ GCJ-02 coordinate transformation. + * + * GCJ-02 (国测局坐标系 / "Mars coordinates") is the mandatory coordinate system + * used by Chinese mapping services including Gaode (AutoNavi / 高德地图). + * + * GPS devices output WGS-84 coordinates. When overlaying markers on a GCJ-02 + * base map the coordinates must be transformed, otherwise markers will appear + * shifted by tens to hundreds of metres in mainland China. + */ + +const PI = Math.PI +const SEMI_MAJOR = 6378245.0 // Semi-major axis of Krasovsky ellipsoid +const EE = 0.006693421622965943 // Eccentricity squared + +function outOfChina(lat: number, lon: number): boolean { + return lon < 72.004 || lon > 137.8347 || lat < 0.8293 || lat > 55.8271 +} + +function transformLat(x: number, y: number): number { + let ret = + -100.0 + + 2.0 * x + + 3.0 * y + + 0.2 * y * y + + 0.1 * x * y + + 0.2 * Math.sqrt(Math.abs(x)) + ret += + ((20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0) / + 3.0 + ret += + ((20.0 * Math.sin(y * PI) + 40.0 * Math.sin((y / 3.0) * PI)) * 2.0) / 3.0 + ret += + ((160.0 * Math.sin((y / 12.0) * PI) + 320 * Math.sin((y * PI) / 30.0)) * + 2.0) / + 3.0 + return ret +} + +function transformLon(x: number, y: number): number { + let ret = + 300.0 + + x + + 2.0 * y + + 0.1 * x * x + + 0.1 * x * y + + 0.1 * Math.sqrt(Math.abs(x)) + ret += + ((20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0) / + 3.0 + ret += + ((20.0 * Math.sin(x * PI) + 40.0 * Math.sin((x / 3.0) * PI)) * 2.0) / 3.0 + ret += + ((150.0 * Math.sin((x / 12.0) * PI) + 300.0 * Math.sin((x / 30.0) * PI)) * + 2.0) / + 3.0 + return ret +} + +/** Convert WGS-84 → GCJ-02 */ +export function wgs84ToGcj02(wgsLat: number, wgsLon: number): [number, number] { + if (outOfChina(wgsLat, wgsLon)) { + return [wgsLat, wgsLon] + } + + let dLat = transformLat(wgsLon - 105.0, wgsLat - 35.0) + let dLon = transformLon(wgsLon - 105.0, wgsLat - 35.0) + const radLat = (wgsLat / 180.0) * PI + let magic = Math.sin(radLat) + magic = 1 - EE * magic * magic + const sqrtMagic = Math.sqrt(magic) + dLat = (dLat * 180.0) / (((SEMI_MAJOR * (1 - EE)) / (magic * sqrtMagic)) * PI) + dLon = (dLon * 180.0) / ((SEMI_MAJOR / sqrtMagic) * Math.cos(radLat) * PI) + + return [wgsLat + dLat, wgsLon + dLon] +} diff --git a/frontend/src/routes/_layout.tsx b/frontend/src/routes/_layout.tsx index 3871ed5..1ae2e85 100644 --- a/frontend/src/routes/_layout.tsx +++ b/frontend/src/routes/_layout.tsx @@ -29,23 +29,25 @@ function Layout() { const pathname = useRouterState({ select: (state) => state.location.pathname, }) - const isGnssMonitorPage = pathname === "/gnss-monitor" + const isFullScreenPage = pathname === "/gnss-monitor" || pathname === "/" return ( - -
+ +
-
+
-