diff --git a/web/package-lock.json b/web/package-lock.json index dc8c0e1..83d8cbb 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@mkkellogg/gaussian-splats-3d": "^0.4.6", "axios": "^1.7.9", - "cesium": "^1.124.0", + "maplibre-gl": "^4.7.1", "oidc-client-ts": "^3.1.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -21,13 +21,13 @@ }, "devDependencies": { "@types/geojson": "^7946.0.16", + "@types/maplibre-gl": "^1.0.0", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", "@types/three": "^0.171.0", "@vitejs/plugin-react": "^4.3.4", "typescript": "^5.7.2", - "vite": "^5.4.11", - "vite-plugin-cesium": "^1.2.22" + "vite": "^5.4.11" } }, "node_modules/@babel/code-frame": { @@ -312,69 +312,6 @@ "node": ">=6.9.0" } }, - "node_modules/@cesium/engine": { - "version": "24.0.0", - "resolved": "https://registry.npmjs.org/@cesium/engine/-/engine-24.0.0.tgz", - "integrity": "sha512-zJ2gl0tyw/FFhBtvp6UYw+0JQJb2J9EiTJYvVSndc6+6qPR5GHLFzFjA1msLLxucfcpc7uI9R2pXNEPluheR/g==", - "license": "Apache-2.0", - "dependencies": { - "@cesium/wasm-splats": "^0.1.0-alpha.2", - "@spz-loader/core": "0.3.1", - "@tweenjs/tween.js": "^25.0.0", - "@zip.js/zip.js": "^2.8.1", - "autolinker": "^4.0.0", - "bitmap-sdf": "^1.0.3", - "dompurify": "^3.3.0", - "draco3d": "^1.5.1", - "earcut": "^3.0.0", - "grapheme-splitter": "^1.0.4", - "jsep": "^1.3.8", - "kdbush": "^4.0.1", - "ktx-parse": "^1.0.0", - "lerc": "^2.0.0", - "mersenne-twister": "^1.1.0", - "meshoptimizer": "^1.0.1", - "pako": "^2.0.4", - "protobufjs": "^8.0.0", - "rbush": "^4.0.1", - "topojson-client": "^3.1.0", - "urijs": "^1.19.7" - }, - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@cesium/engine/node_modules/@tweenjs/tween.js": { - "version": "25.0.0", - "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", - "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", - "license": "MIT" - }, - "node_modules/@cesium/engine/node_modules/meshoptimizer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.0.tgz", - "integrity": "sha512-KYYsWvWduDIm86HVtTKW1luZSusEv+tgSqYvgSJcKzaHIUNKL9/qQ+48YKcLxknAE8GQAeVi68mgvmEGrjwqjA==", - "license": "MIT" - }, - "node_modules/@cesium/wasm-splats": { - "version": "0.1.0-alpha.2", - "resolved": "https://registry.npmjs.org/@cesium/wasm-splats/-/wasm-splats-0.1.0-alpha.2.tgz", - "integrity": "sha512-t9pMkknv31hhIbLpMa8yPvmqfpvs5UkUjgqlQv9SeO8VerCXOYnyP8/486BDaFrztM0A7FMbRjsXtNeKvqQghA==", - "license": "Apache-2.0" - }, - "node_modules/@cesium/widgets": { - "version": "14.5.0", - "resolved": "https://registry.npmjs.org/@cesium/widgets/-/widgets-14.5.0.tgz", - "integrity": "sha512-h/hKVooXyOtUQJUtrfyBFGMIXb+Q3RLwqE6FzXfzyC0JQuuThLXCz8nRzCPbyiRuAR/aAYT/jbbhQrUxfiWqhQ==", - "license": "Apache-2.0", - "dependencies": { - "@cesium/engine": "^24.0.0", - "nosleep.js": "^0.12.0" - }, - "engines": { - "node": ">=20.19.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -816,6 +753,89 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/geojson-rewind": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "license": "ISC", + "dependencies": { + "get-stream": "^6.0.1", + "minimist": "^1.2.6" + }, + "bin": { + "geojson-rewind": "geojson-rewind" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.1.0.tgz", + "integrity": "sha512-uFJhNh36BR4OCuWIEiWaEix9CA2WzT6CAIcqVjWYpnx8+QDtS+oC4QehRrx5cX4mgWs37MmKnwUejeHxVymzNg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~0.1.0" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "20.4.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz", + "integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "license": "ISC" + }, "node_modules/@mkkellogg/gaussian-splats-3d": { "version": "0.4.7", "resolved": "https://registry.npmjs.org/@mkkellogg/gaussian-splats-3d/-/gaussian-splats-3d-0.4.7.tgz", @@ -825,70 +845,6 @@ "three": ">=0.160.0" } }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", - "license": "BSD-3-Clause" - }, "node_modules/@reduxjs/toolkit": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", @@ -932,20 +888,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@rollup/pluginutils": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", - "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "estree-walker": "^2.0.1", - "picomatch": "^2.2.2" - }, - "engines": { - "node": ">= 8.0.0" - } - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", @@ -1296,16 +1238,6 @@ "win32" ] }, - "node_modules/@spz-loader/core": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@spz-loader/core/-/core-0.3.1.tgz", - "integrity": "sha512-8qJ1WIBXaJu8HjnJAjYniE0kYcr0kCe5Hp7kDzYiGVvvd7zyrOBwbF5imoW5mvwx1Qba0hxGEK5R9jEoaHKJFA==", - "license": "Apache-2.0", - "engines": { - "node": ">=16", - "pnpm": ">=8" - } - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1444,18 +1376,50 @@ "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "dev": true, "license": "MIT" }, - "node_modules/@types/node": { - "version": "25.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", - "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "node_modules/@types/geojson-vt": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "@types/geojson": "*" } }, + "node_modules/@types/mapbox__point-geometry": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz", + "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==", + "license": "MIT" + }, + "node_modules/@types/mapbox__vector-tile": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz", + "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/mapbox__point-geometry": "*", + "@types/pbf": "*" + } + }, + "node_modules/@types/maplibre-gl": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@types/maplibre-gl/-/maplibre-gl-1.13.2.tgz", + "integrity": "sha512-IC1RBMhKXpGDpiFsEwt17c/hbff0GCS/VmzqmrY6G+kyy2wfv2e7BoSQRAfqrvhBQPCoO8yc0SNCi5HkmCcVqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/pbf": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -1483,6 +1447,15 @@ "dev": true, "license": "MIT" }, + "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/@types/three": { "version": "0.171.0", "resolved": "https://registry.npmjs.org/@types/three/-/three-0.171.0.tgz", @@ -1498,13 +1471,6 @@ "meshoptimizer": "~0.18.1" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -1546,45 +1512,12 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@zip.js/zip.js": { - "version": "2.8.26", - "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.26.tgz", - "integrity": "sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==", - "license": "BSD-3-Clause", - "engines": { - "bun": ">=0.7.0", - "deno": ">=1.0.0", - "node": ">=18.0.0" - } - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, - "node_modules/at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/autolinker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/autolinker/-/autolinker-4.1.5.tgz", - "integrity": "sha512-vEfYZPmvVOIuE567XBVCsx8SBgOYtjB2+S1iAaJ+HgH+DNjAcrHem2hmAeC9yaNGWayicv4yR+9UaJlkF3pvtw==", - "license": "MIT", - "dependencies": { - "tslib": "^2.8.1" - }, - "engines": { - "pnpm": ">=10.10.0" - } - }, "node_modules/axios": { "version": "1.14.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", @@ -1609,12 +1542,6 @@ "node": ">=6.0.0" } }, - "node_modules/bitmap-sdf": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz", - "integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg==", - "license": "MIT" - }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -1683,24 +1610,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/cesium": { - "version": "1.140.0", - "resolved": "https://registry.npmjs.org/cesium/-/cesium-1.140.0.tgz", - "integrity": "sha512-3RvW0rvZWuXiS6regtNE5u9vt0uXohgpsRBIo6Qc922IIIamkitYiEdr4fg+u4qX4EoK9xS3BosCza7iPOExEQ==", - "license": "Apache-2.0", - "workspaces": [ - "packages/engine", - "packages/widgets", - "packages/sandcastle" - ], - "dependencies": { - "@cesium/engine": "^24.0.0", - "@cesium/widgets": "^14.5.0" - }, - "engines": { - "node": ">=20.19.0" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1722,12 +1631,6 @@ "node": ">= 0.8" } }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1909,42 +1812,6 @@ "node": ">=0.4.0" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/dompurify": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", - "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, - "node_modules/draco3d": { - "version": "1.5.7", - "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", - "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", - "license": "Apache-2.0" - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1965,13 +1832,6 @@ "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", "license": "ISC" }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, - "license": "MIT" - }, "node_modules/electron-to-chromium": { "version": "1.5.331", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", @@ -1979,16 +1839,6 @@ "dev": true, "license": "ISC" }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2093,30 +1943,6 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -2166,32 +1992,6 @@ "node": ">= 6" } }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2226,6 +2026,12 @@ "node": ">=6.9.0" } }, + "node_modules/geojson-vt": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", + "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==", + "license": "ISC" + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -2263,6 +2069,38 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/global-prefix": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz", + "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==", + "license": "MIT", + "dependencies": { + "ini": "^4.1.3", + "kind-of": "^6.0.3", + "which": "^4.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2275,19 +2113,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/grapheme-splitter": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", - "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", - "license": "MIT" - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2327,26 +2152,25 @@ "node": ">= 0.4" } }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" }, "node_modules/immer": { "version": "10.2.0", @@ -2358,12 +2182,14 @@ "url": "https://opencollective.com/immer" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } }, "node_modules/internmap": { "version": "2.0.3", @@ -2374,14 +2200,13 @@ "node": ">=12" } }, - "node_modules/is-reference": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", - "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "*" + "node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" } }, "node_modules/js-tokens": { @@ -2391,15 +2216,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jsep": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", - "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", - "license": "MIT", - "engines": { - "node": ">= 10.16.0" - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2413,6 +2229,12 @@ "node": ">=6" } }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2426,19 +2248,6 @@ "node": ">=6" } }, - "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/jwt-decode": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", @@ -2454,23 +2263,14 @@ "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", "license": "ISC" }, - "node_modules/ktx-parse": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-1.1.0.tgz", - "integrity": "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ==", - "license": "MIT" - }, - "node_modules/lerc": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lerc/-/lerc-2.0.0.tgz", - "integrity": "sha512-7qo1Mq8ZNmaR4USHHm615nEW2lPeeWJ3bTyoqFbd35DLx0LUH7C6ptt5FDCTAlbIzs3+WKrk5SkJvw8AFDE2hg==", - "license": "Apache-2.0" - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } }, "node_modules/lru-cache": { "version": "5.1.1", @@ -2482,14 +2282,45 @@ "yallist": "^3.0.2" } }, - "node_modules/magic-string": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", - "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", - "dev": true, - "license": "MIT", + "node_modules/maplibre-gl": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz", + "integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==", + "license": "BSD-3-Clause", "dependencies": { - "sourcemap-codec": "^1.4.8" + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^2.0.6", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/maplibre-gl-style-spec": "^20.3.1", + "@types/geojson": "^7946.0.14", + "@types/geojson-vt": "3.2.5", + "@types/mapbox__point-geometry": "^0.1.4", + "@types/mapbox__vector-tile": "^1.3.4", + "@types/pbf": "^3.0.5", + "@types/supercluster": "^7.1.3", + "earcut": "^3.0.0", + "geojson-vt": "^4.0.2", + "gl-matrix": "^3.4.3", + "global-prefix": "^4.0.0", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^3.3.0", + "potpack": "^2.0.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0", + "vt-pbf": "^3.1.3" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" } }, "node_modules/math-intrinsics": { @@ -2501,12 +2332,6 @@ "node": ">= 0.4" } }, - "node_modules/mersenne-twister": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/mersenne-twister/-/mersenne-twister-1.1.0.tgz", - "integrity": "sha512-mUYWsMKNrm4lfygPkL3OfGzOPTR2DBlTkBNHM//F6hGp8cLThY897crAlk3/Jo17LEOOjQUrNAx6DvgO77QJkA==", - "license": "MIT" - }, "node_modules/meshoptimizer": { "version": "0.18.1", "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", @@ -2514,19 +2339,6 @@ "dev": true, "license": "MIT" }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -2548,6 +2360,15 @@ "node": ">= 0.6" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2555,6 +2376,12 @@ "dev": true, "license": "MIT" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -2581,12 +2408,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nosleep.js": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/nosleep.js/-/nosleep.js-0.12.0.tgz", - "integrity": "sha512-9d1HbpKLh3sdWlhXMhU6MMH+wQzKkrgfRkYV0EBdvt99YJfj0ilCJrWRDYG2130Tm4GXbEoTCx5b34JSaP+HhA==", - "license": "MIT" - }, "node_modules/oidc-client-ts": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz", @@ -2599,33 +2420,17 @@ "node": ">=18" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "license": "MIT", + "node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", "dependencies": { - "ee-first": "1.1.1" + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/pako": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", - "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", - "license": "(MIT AND Zlib)" - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" + "bin": { + "pbf": "bin/pbf" } }, "node_modules/picocolors": { @@ -2635,19 +2440,6 @@ "dev": true, "license": "ISC" }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -2677,29 +2469,17 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/protobufjs": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.0.1.tgz", - "integrity": "sha512-NWWCCscLjs+cOKF/s/XVNFRW7Yih0fdH+9brffR5NZCy8k42yRdl5KlWKMVXuI1vfCoy4o1z80XR/W/QUb3V3w==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" - } + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" }, "node_modules/proxy-from-env": { "version": "2.1.0", @@ -2716,25 +2496,6 @@ "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", "license": "ISC" }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/rbush": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", - "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", - "license": "MIT", - "dependencies": { - "quickselect": "^3.0.0" - } - }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -2885,6 +2646,15 @@ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "license": "MIT" }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -2930,6 +2700,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2946,77 +2722,12 @@ "semver": "bin/semver.js" } }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, - "license": "ISC" - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3027,22 +2738,13 @@ "node": ">=0.10.0" } }, - "node_modules/sourcemap-codec": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", - "deprecated": "Please use @jridgewell/sourcemap-codec instead", - "dev": true, - "license": "MIT" - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.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/three": { @@ -3057,35 +2759,11 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/topojson-client": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", - "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", - "license": "ISC", - "dependencies": { - "commander": "2" - }, - "bin": { - "topo2geo": "bin/topo2geo", - "topomerge": "bin/topomerge", - "topoquantize": "bin/topoquantize" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" }, "node_modules/typescript": { "version": "5.9.3", @@ -3101,22 +2779,6 @@ "node": ">=14.17" } }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "license": "MIT" - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3148,12 +2810,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/urijs": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", - "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", - "license": "MIT" - }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -3245,53 +2901,30 @@ } } }, - "node_modules/vite-plugin-cesium": { - "version": "1.2.23", - "resolved": "https://registry.npmjs.org/vite-plugin-cesium/-/vite-plugin-cesium-1.2.23.tgz", - "integrity": "sha512-x9A8ZCEoegceXg/E+LnxKr0XBsI9CR4cgYWQ2Dd3cUEYwKcTnHQ3kBfpol7BUcGtgQnQos/mtVrRmuVQBXFjHw==", - "dev": true, + "node_modules/vt-pbf": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", + "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", "license": "MIT", "dependencies": { - "fs-extra": "^9.1.0", - "rollup-plugin-external-globals": "^0.6.1", - "serve-static": "^1.14.1" - }, - "peerDependencies": { - "cesium": "^1.95.0", - "vite": ">=2.7.1" + "@mapbox/point-geometry": "0.1.0", + "@mapbox/vector-tile": "^1.3.1", + "pbf": "^3.2.1" } }, - "node_modules/vite-plugin-cesium/node_modules/rollup": { - "version": "2.80.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", - "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", - "dev": true, - "license": "MIT", - "peer": true, + "node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, "bin": { - "rollup": "dist/bin/rollup" + "node-which": "bin/which.js" }, "engines": { - "node": ">=10.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/vite-plugin-cesium/node_modules/rollup-plugin-external-globals": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/rollup-plugin-external-globals/-/rollup-plugin-external-globals-0.6.1.tgz", - "integrity": "sha512-mlp3KNa5sE4Sp9UUR2rjBrxjG79OyZAh/QC18RHIjM+iYkbBwNXSo8DHRMZWtzJTrH8GxQ+SJvCTN3i14uMXIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^4.0.0", - "estree-walker": "^2.0.1", - "is-reference": "^1.2.1", - "magic-string": "^0.25.7" - }, - "peerDependencies": { - "rollup": "^2.25.0" + "node": "^16.13.0 || >=18.0.0" } }, "node_modules/yallist": { diff --git a/web/package.json b/web/package.json index 509934b..0136de3 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,7 @@ "dependencies": { "@mkkellogg/gaussian-splats-3d": "^0.4.6", "axios": "^1.7.9", - "cesium": "^1.124.0", + "maplibre-gl": "^4.7.1", "oidc-client-ts": "^3.1.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -22,12 +22,12 @@ }, "devDependencies": { "@types/geojson": "^7946.0.16", + "@types/maplibre-gl": "^1.0.0", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", "@types/three": "^0.171.0", "@vitejs/plugin-react": "^4.3.4", "typescript": "^5.7.2", - "vite": "^5.4.11", - "vite-plugin-cesium": "^1.2.22" + "vite": "^5.4.11" } } diff --git a/web/src/App.tsx b/web/src/App.tsx index d07523b..fcb6cd3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,7 +1,7 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom' import { AuthProvider } from './auth/AuthProvider' import { CallbackPage } from './auth/CallbackPage' -import { CesiumViewer } from './cesium/CesiumViewer' +import { MapLibreViewer } from './maplibre/MapLibreViewer' import { SplatLayer } from './splat/SplatLayer' import { SplatRenderer } from './splat/SplatRenderer' import { ChallengeLayer } from './challenges/ChallengeLayer' @@ -17,17 +17,17 @@ import { UserProfilePage } from './users/UserProfilePage' function MapPage() { return ( - - {/* Imperative Cesium layers — render no DOM, manage entities */} + + {/* Map-layer components — render no DOM, manage MapLibre sources */} - {/* Three.js splat overlay — portalled canvas above Cesium */} + {/* Three.js splat overlay — portalled canvas above the map */} - {/* React UI — z-indexed above Cesium canvas */} + {/* React UI — z-indexed above the map canvas */} - + ) } diff --git a/web/src/cesium/CesiumViewer.tsx b/web/src/cesium/CesiumViewer.tsx deleted file mode 100644 index c5c43b6..0000000 --- a/web/src/cesium/CesiumViewer.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useEffect, useRef, useState } from 'react' -import * as Cesium from 'cesium' -import 'cesium/Build/Cesium/Widgets/widgets.css' -import { CesiumContext } from './cesiumContext' - -interface Props { - children?: React.ReactNode -} - -export function CesiumViewer({ children }: Props) { - const containerRef = useRef(null) - const [viewer, setViewer] = useState(null) - - useEffect(() => { - // Guard: only create if the container is mounted and no viewer yet - if (!containerRef.current || viewer) return - - Cesium.Ion.defaultAccessToken = import.meta.env.VITE_CESIUM_ION_TOKEN ?? '' - - const v = new Cesium.Viewer(containerRef.current, { - terrainProvider: new Cesium.EllipsoidTerrainProvider(), - homeButton: false, - baseLayerPicker: false, - navigationHelpButton: false, - animation: false, - timeline: false, - geocoder: false, - sceneModePicker: false, - fullscreenButton: false, - infoBox: false, - selectionIndicator: false, - }) - - // Async: upgrade to world terrain after initial load - Cesium.createWorldTerrainAsync() - .then((tp) => { - if (!v.isDestroyed()) v.terrainProvider = tp - }) - .catch(() => {/* non-fatal: fall back to ellipsoid */}) - - // Async: switch base imagery to Bing Aerial with Labels - Cesium.createWorldImageryAsync({ style: Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS }) - .then((ip) => { - if (!v.isDestroyed()) v.imageryLayers.get(0).imageryProvider = ip - }) - .catch(() => {/* non-fatal: keep default imagery */}) - - setViewer(v) - - return () => { - if (!v.isDestroyed()) v.destroy() - setViewer(null) - } - }, []) // eslint-disable-line react-hooks/exhaustive-deps - - return ( - <> - {/* Cesium mounts itself into this div and fills it completely */} - - {/* Provide viewer to all children; only render children once viewer is ready */} - {viewer && ( - - {children} - - )} - > - ) -} diff --git a/web/src/cesium/cesiumContext.ts b/web/src/cesium/cesiumContext.ts deleted file mode 100644 index 28d6ff9..0000000 --- a/web/src/cesium/cesiumContext.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createContext, useContext } from 'react' -import type * as CesiumType from 'cesium' - -type Viewer = InstanceType - -export const CesiumContext = createContext(null) - -export function useCesiumViewer(): Viewer { - const viewer = useContext(CesiumContext) - if (!viewer) { - throw new Error('useCesiumViewer must be used inside ') - } - return viewer -} diff --git a/web/src/cesium/geoUtils.ts b/web/src/cesium/geoUtils.ts deleted file mode 100644 index a2ec58b..0000000 --- a/web/src/cesium/geoUtils.ts +++ /dev/null @@ -1,63 +0,0 @@ -import * as Cesium from 'cesium' -import * as THREE from 'three' - -/** - * Build a Three.js Matrix4 that positions and orients a local scene - * at the given geographic coordinate. - * - * The returned matrix transforms from local ENU space (metres from the - * anchor point, X=East, Y=North, Z=Up) to Cesium ECEF space (metres - * from Earth centre). Apply it to a Three.js Object3D.matrixWorld and - * set matrixAutoUpdate = false. - * - * @param lon Longitude in degrees - * @param lat Latitude in degrees - * @param alt Altitude in metres above WGS-84 ellipsoid - * @param headingDeg Clockwise heading in degrees (0 = North, 90 = East) - */ -export function buildSplatWorldMatrix( - lon: number, - lat: number, - alt: number, - headingDeg: number, -): THREE.Matrix4 { - const position = Cesium.Cartesian3.fromDegrees(lon, lat, alt) - - // 4×4 column-major matrix: local ENU → ECEF - const enuToEcef = Cesium.Transforms.eastNorthUpToFixedFrame(position) - - // Apply a rotation around local Up (Z in ENU) for heading. - // Cesium heading is clockwise from North, which is –Z rotation in ENU. - const headingRad = Cesium.Math.toRadians(-headingDeg) - const headingRotation = Cesium.Matrix4.fromRotationTranslation( - Cesium.Matrix3.fromRotationZ(headingRad), - ) - const finalCesiumMatrix = new Cesium.Matrix4() - Cesium.Matrix4.multiply(enuToEcef, headingRotation, finalCesiumMatrix) - - // Cesium Matrix4 is a Float64Array in column-major order. - // Three.js Matrix4 uses Float32Array, also column-major. - // Direct cast works since both use the same element layout. - const threeMatrix = new THREE.Matrix4() - threeMatrix.set( - finalCesiumMatrix[0], finalCesiumMatrix[4], finalCesiumMatrix[8], finalCesiumMatrix[12], - finalCesiumMatrix[1], finalCesiumMatrix[5], finalCesiumMatrix[9], finalCesiumMatrix[13], - finalCesiumMatrix[2], finalCesiumMatrix[6], finalCesiumMatrix[10], finalCesiumMatrix[14], - finalCesiumMatrix[3], finalCesiumMatrix[7], finalCesiumMatrix[11], finalCesiumMatrix[15], - ) - return threeMatrix -} - -/** - * Convert a Cesium Rectangle (radians) to a bbox tuple (degrees). - */ -export function rectangleToBbox( - rect: Cesium.Rectangle, -): [number, number, number, number] { - return [ - Cesium.Math.toDegrees(rect.west), - Cesium.Math.toDegrees(rect.south), - Cesium.Math.toDegrees(rect.east), - Cesium.Math.toDegrees(rect.north), - ] -} diff --git a/web/src/cesium/useCesiumCamera.ts b/web/src/cesium/useCesiumCamera.ts deleted file mode 100644 index b1ea1ad..0000000 --- a/web/src/cesium/useCesiumCamera.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { useEffect } from 'react' -import { useCesiumViewer } from './cesiumContext' -import { useMapStore } from '../store/mapStore' -import { rectangleToBbox } from './geoUtils' - -/** - * Attaches a scene.preUpdate listener that writes camera height and - * the current view bbox to mapStore on every frame. - * - * Throttled so the store update fires at most once per 200 ms to avoid - * triggering expensive API queries on every rendered frame. - */ -export function useCesiumCamera() { - const viewer = useCesiumViewer() - const setCameraState = useMapStore((s) => s.setCameraState) - - useEffect(() => { - let lastFired = 0 - const THROTTLE_MS = 200 - - const removeListener = viewer.scene.preUpdate.addEventListener(() => { - const now = Date.now() - if (now - lastFired < THROTTLE_MS) return - lastFired = now - - const height = viewer.camera.positionCartographic.height - const rect = viewer.camera.computeViewRectangle() - if (rect) { - setCameraState(height, rectangleToBbox(rect)) - } - }) - - return () => { - removeListener() - } - }, [viewer, setCameraState]) -} diff --git a/web/src/challenges/ChallengeLayer.tsx b/web/src/challenges/ChallengeLayer.tsx index cc1d779..f9e31be 100644 --- a/web/src/challenges/ChallengeLayer.tsx +++ b/web/src/challenges/ChallengeLayer.tsx @@ -1,31 +1,53 @@ import { useEffect, useRef } from 'react' -import * as Cesium from 'cesium' -import { useCesiumViewer } from '../cesium/cesiumContext' +import maplibregl, { type GeoJSONSource } from 'maplibre-gl' +import { useMapLibreMap } from '../maplibre/maplibreContext' +import { safeRemoveLayers } from '../maplibre/geoUtils' import { useMapStore } from '../store/mapStore' import { useChallengeStore } from '../store/challengeStore' import { usePolygonDraw } from './usePolygonDraw' -import { fetchChallenges } from '../api/challenges' +import { fetchChallenges, fetchChallengeDetail } from '../api/challenges' import type { BBox } from '../types/geo' import type { ChallengeMapProperties } from '../types/api' const CHALLENGE_VISIBLE_HEIGHT = 200_000 +const SRC_REGION = 'challenge-region' +const LYR_REGION_FILL = 'challenge-region-fill' +const LYR_REGION_LINE = 'challenge-region-line' + +function emptyFC(): GeoJSON.FeatureCollection { return { type: 'FeatureCollection', features: [] } } + export function ChallengeLayer() { - const viewer = useCesiumViewer() + const map = useMapLibreMap() usePolygonDraw() const { bbox, cameraHeight, setLoadedChallenges } = useMapStore() const { selectedChallengeId, setSelectedChallengeId } = useChallengeStore() - const entityMapRef = useRef>(new Map()) - const regionEntityRef = useRef(null) + const markersRef = useRef>(new Map()) const lastBboxRef = useRef(null) - // Fetch and render challenge pins + // ── Set up region polygon sources + layers ───────────────────────────────── + useEffect(() => { + map.addSource(SRC_REGION, { type: 'geojson', data: emptyFC() }) + map.addLayer({ id: LYR_REGION_FILL, type: 'fill', source: SRC_REGION, + paint: { 'fill-color': '#fbbf24', 'fill-opacity': 0.15 } }) + map.addLayer({ id: LYR_REGION_LINE, type: 'line', source: SRC_REGION, + paint: { 'line-color': '#fbbf24', 'line-width': 2 } }) + + return () => { + safeRemoveLayers(map, + [LYR_REGION_FILL, LYR_REGION_LINE], + [SRC_REGION], + ) + } + }, [map]) + + // ── Fetch and render challenge markers ───────────────────────────────────── useEffect(() => { if (!bbox || cameraHeight > CHALLENGE_VISIBLE_HEIGHT) { - entityMapRef.current.forEach((e) => viewer.entities.remove(e)) - entityMapRef.current.clear() + markersRef.current.forEach(m => m.remove()) + markersRef.current.clear() setLoadedChallenges([]) return } @@ -43,101 +65,68 @@ export function ChallengeLayer() { fetchChallenges({ bbox }).then((fc) => { const incoming = new Set(fc.features.map((f: GeoJSON.Feature) => String(f.id))) - entityMapRef.current.forEach((entity, id) => { - if (!incoming.has(id)) { - viewer.entities.remove(entity) - entityMapRef.current.delete(id) - } + markersRef.current.forEach((marker, id) => { + if (!incoming.has(id)) { marker.remove(); markersRef.current.delete(id) } }) fc.features.forEach((feature: GeoJSON.Feature) => { const id = String(feature.id) - if (entityMapRef.current.has(id)) return + if (markersRef.current.has(id)) return const [lon, lat] = feature.geometry.coordinates + const el = createChallengePinElement() + el.addEventListener('click', () => setSelectedChallengeId(id)) - const entity = viewer.entities.add({ - id: `challenge-${id}`, - position: Cesium.Cartesian3.fromDegrees(lon, lat), - billboard: { - image: createChallengePinSvg(), - width: 36, - height: 36, - verticalOrigin: Cesium.VerticalOrigin.BOTTOM, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - properties: { challengeId: id }, - }) - entityMapRef.current.set(id, entity) + const marker = new maplibregl.Marker({ element: el, anchor: 'bottom' }) + .setLngLat([lon, lat]) + .addTo(map) + markersRef.current.set(id, marker) }) setLoadedChallenges(fc.features) }).catch(console.error) }, [bbox, cameraHeight]) // eslint-disable-line react-hooks/exhaustive-deps - // Show region polygon for selected challenge + // ── Show region polygon for selected challenge ───────────────────────────── useEffect(() => { - if (regionEntityRef.current) { - viewer.entities.remove(regionEntityRef.current) - regionEntityRef.current = null + const src = map.getSource(SRC_REGION) as GeoJSONSource | undefined + if (!src) return + + if (!selectedChallengeId) { + src.setData(emptyFC()) + return } - if (!selectedChallengeId) return - - // We need the full detail to get the region polygon — ChallengePanel fetches - // it; we read from the DOM or re-fetch. For simplicity, re-fetch here. - import('../api/challenges').then(({ fetchChallengeDetail }) => - fetchChallengeDetail(selectedChallengeId), - ).then((detail) => { + fetchChallengeDetail(selectedChallengeId).then((detail) => { if (!detail.region) return - const coords = detail.region.coordinates[0] - const positions = coords.map((c) => - Cesium.Cartesian3.fromDegrees(c[0], c[1]), - ) - - regionEntityRef.current = viewer.entities.add({ - polygon: { - hierarchy: new Cesium.PolygonHierarchy(positions), - material: Cesium.Color.YELLOW.withAlpha(0.15), - outline: true, - outlineColor: Cesium.Color.YELLOW, - outlineWidth: 2, - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - }, + src.setData({ + type: 'Feature', + geometry: detail.region, + properties: {}, }) }).catch(console.error) - }, [selectedChallengeId, viewer]) + }, [selectedChallengeId, map]) - // Wire up entity selection - useEffect(() => { - const remove = viewer.selectedEntityChanged.addEventListener((entity) => { - if (!entity) return - const challengeId = entity.properties?.challengeId?.getValue() - if (challengeId) setSelectedChallengeId(challengeId) - }) - return () => remove() - }, [viewer, setSelectedChallengeId]) - - // Cleanup on unmount + // ── Cleanup on unmount ───────────────────────────────────────────────────── useEffect(() => { return () => { - if (viewer.isDestroyed()) return - entityMapRef.current.forEach((e) => viewer.entities.remove(e)) - entityMapRef.current.clear() - if (regionEntityRef.current) viewer.entities.remove(regionEntityRef.current) + markersRef.current.forEach(m => m.remove()) + markersRef.current.clear() } - }, [viewer]) + }, []) return null } -function createChallengePinSvg(): string { - const svg = ` +function createChallengePinElement(): HTMLElement { + const el = document.createElement('div') + el.style.cssText = 'width:36px;height:36px;cursor:pointer' + el.innerHTML = ` ! ` - return `data:image/svg+xml;base64,${btoa(svg)}` + return el } diff --git a/web/src/challenges/ChallengePanel.tsx b/web/src/challenges/ChallengePanel.tsx index b2193d4..4aacbd5 100644 --- a/web/src/challenges/ChallengePanel.tsx +++ b/web/src/challenges/ChallengePanel.tsx @@ -1,15 +1,14 @@ import { useEffect, useState } from 'react' -import * as Cesium from 'cesium' import { useNavigate } from 'react-router-dom' import { Panel } from '../ui/Panel' -import { useCesiumViewer } from '../cesium/cesiumContext' +import { useMapLibreMap } from '../maplibre/maplibreContext' import { useChallengeStore } from '../store/challengeStore' import { fetchChallengeDetail, participateInChallenge } from '../api/challenges' import type { ChallengeDetail } from '../types/api' import styles from './ChallengePanel.module.css' export function ChallengePanel() { - const viewer = useCesiumViewer() + const map = useMapLibreMap() const navigate = useNavigate() const { selectedChallengeId, setSelectedChallengeId } = useChallengeStore() const [detail, setDetail] = useState(null) @@ -31,10 +30,7 @@ export function ChallengePanel() { function handleCenterMap() { if (!detail?.region_centroid) return const [lon, lat] = detail.region_centroid.coordinates - viewer.camera.flyTo({ - destination: Cesium.Cartesian3.fromDegrees(lon, lat, 2000), - duration: 1.5, - }) + map.flyTo({ center: [lon, lat], zoom: 14, pitch: 50, duration: 1500 }) } async function handleParticipate() { diff --git a/web/src/challenges/usePolygonDraw.ts b/web/src/challenges/usePolygonDraw.ts index 2961e90..07f4989 100644 --- a/web/src/challenges/usePolygonDraw.ts +++ b/web/src/challenges/usePolygonDraw.ts @@ -1,158 +1,157 @@ import { useEffect, useRef } from 'react' -import * as Cesium from 'cesium' -import { useCesiumViewer } from '../cesium/cesiumContext' +import type { Map, MapMouseEvent, GeoJSONSource, MapLayerMouseEvent } from 'maplibre-gl' +import { useMapLibreMap } from '../maplibre/maplibreContext' +import { safeRemoveLayers } from '../maplibre/geoUtils' import { useChallengeStore } from '../store/challengeStore' -function vertsToGeoJson(verts: Cesium.Cartesian3[]): GeoJSON.Polygon { - const coords: [number, number][] = verts.map((v) => { - const c = Cesium.Cartographic.fromCartesian(v) - return [Cesium.Math.toDegrees(c.longitude), Cesium.Math.toDegrees(c.latitude)] - }) - return { type: 'Polygon', coordinates: [[...coords, coords[0]]] } +// ── Layer / source IDs ──────────────────────────────────────────────────────── + +const SRC_FILL = 'pd-fill' +const SRC_OUTLINE = 'pd-outline' +const SRC_RUBBER = 'pd-rubber' +const SRC_VERTICES = 'pd-vertices' +const LYR_FILL = 'pd-fill-lyr' +const LYR_OUTLINE = 'pd-outline-lyr' +const LYR_RUBBER = 'pd-rubber-lyr' +const LYR_VERTICES = 'pd-vertices-lyr' + +function emptyFC(): GeoJSON.FeatureCollection { + return { type: 'FeatureCollection', features: [] } } -function geoJsonToVerts(polygon: GeoJSON.Polygon): Cesium.Cartesian3[] { - const ring = polygon.coordinates[0] - // Drop the closing duplicate point - return ring.slice(0, -1).map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat)) +function addDrawLayers(map: Map) { + map.addSource(SRC_FILL, { type: 'geojson', data: emptyFC() }) + map.addSource(SRC_OUTLINE, { type: 'geojson', data: emptyFC() }) + map.addSource(SRC_RUBBER, { type: 'geojson', data: emptyFC() }) + map.addSource(SRC_VERTICES, { type: 'geojson', data: emptyFC() }) + + map.addLayer({ id: LYR_FILL, type: 'fill', source: SRC_FILL, + paint: { 'fill-color': '#fbbf24', 'fill-opacity': 0.15 } }) + map.addLayer({ id: LYR_OUTLINE, type: 'line', source: SRC_OUTLINE, + paint: { 'line-color': '#fbbf24', 'line-width': 2 } }) + map.addLayer({ id: LYR_RUBBER, type: 'line', source: SRC_RUBBER, + paint: { 'line-color': '#ffffff', 'line-width': 1.5, 'line-dasharray': [4, 4] } }) + map.addLayer({ id: LYR_VERTICES, type: 'circle', source: SRC_VERTICES, + paint: { + 'circle-radius': 6, + 'circle-color': '#ffffff', + 'circle-stroke-color': '#000000', + 'circle-stroke-width': 2, + } }) } -function pickGlobe( - viewer: Cesium.Viewer, - windowPos: Cesium.Cartesian2, -): Cesium.Cartesian3 | null { - const ray = viewer.camera.getPickRay(windowPos) - if (!ray) return null - return viewer.scene.globe.pick(ray, viewer.scene) ?? null +function removeDrawLayers(map: Map) { + safeRemoveLayers(map, + [LYR_FILL, LYR_OUTLINE, LYR_RUBBER, LYR_VERTICES], + [SRC_FILL, SRC_OUTLINE, SRC_RUBBER, SRC_VERTICES], + ) +} + +function setSource(map: Map, id: string, data: GeoJSON.GeoJSON) { + (map.getSource(id) as GeoJSONSource | undefined)?.setData(data) } /** * Manages two phases of polygon interaction: * * Drawing (drawingMode=true) - * LEFT_CLICK → place vertex - * MOUSE_MOVE → rubber-band line from last vertex to cursor - * RIGHT_CLICK → close polygon (≥3 verts), enter edit phase + * click → place vertex + * mousemove → rubber-band line from last vertex to cursor + * contextmenu → close polygon (≥3 verts), enter edit phase * * Editing (drawingMode=false, draftPolygon set) * Drag vertex handles to reposition them. - * Changes are written back to the store on mouse-up so the submit - * form always reads the latest geometry. */ export function usePolygonDraw() { - const viewer = useCesiumViewer() + const map = useMapLibreMap() const { drawingMode, setDrawingMode, setDraftPolygon, draftPolygon } = useChallengeStore() - // Persists vertex positions across edit-phase effect re-runs that are - // triggered by setDraftPolygon being called after each drag. - const editVertsRef = useRef([]) + const editVertsRef = useRef<[number, number][]>([]) + + // ── Set up / tear down MapLibre sources + layers ────────────────────────── + useEffect(() => { + addDrawLayers(map) + return () => { removeDrawLayers(map) } + }, [map]) // ── Drawing phase ────────────────────────────────────────────────────────── useEffect(() => { if (!drawingMode) return - const verts: Cesium.Cartesian3[] = [] - const vertPointEntities: Cesium.Entity[] = [] - let outlineEntity: Cesium.Entity | null = null - let rubberBandEntity: Cesium.Entity | null = null + const verts: [number, number][] = [] + const canvas = map.getCanvas() - const canvas = viewer.scene.canvas - - // Prevent the browser context menu so right-click can close the polygon. - const suppressContextMenu = (e: MouseEvent) => e.preventDefault() - canvas.addEventListener('contextmenu', suppressContextMenu) - - const handler = new Cesium.ScreenSpaceEventHandler(canvas) + canvas.style.cursor = 'crosshair' function refreshOutline() { - if (outlineEntity) { - viewer.entities.remove(outlineEntity) - outlineEntity = null + if (verts.length < 2) { + setSource(map, SRC_OUTLINE, emptyFC()) + return } - if (verts.length < 2) return - outlineEntity = viewer.entities.add({ - polyline: { - positions: [...verts, verts[0]], - width: 2, - material: new Cesium.ColorMaterialProperty( - Cesium.Color.YELLOW.withAlpha(0.9), - ), - clampToGround: true, - }, + setSource(map, SRC_OUTLINE, { + type: 'Feature', + geometry: { type: 'LineString', coordinates: [...verts, verts[0]] }, + properties: {}, }) } - function refreshRubberBand(mousePos: Cesium.Cartesian3) { - if (rubberBandEntity) { - viewer.entities.remove(rubberBandEntity) - rubberBandEntity = null - } + function refreshVertices() { + setSource(map, SRC_VERTICES, { + type: 'FeatureCollection', + features: verts.map((v, i) => ({ + type: 'Feature', + id: i, + geometry: { type: 'Point', coordinates: v }, + properties: {}, + })), + }) + } + + const onMouseMove = (e: MapMouseEvent) => { if (verts.length === 0) return - rubberBandEntity = viewer.entities.add({ - polyline: { - positions: [verts[verts.length - 1], mousePos], - width: 1.5, - material: new Cesium.ColorMaterialProperty( - Cesium.Color.WHITE.withAlpha(0.5), - ), - clampToGround: true, - }, + setSource(map, SRC_RUBBER, { + type: 'Feature', + geometry: { type: 'LineString', coordinates: [verts[verts.length - 1], [e.lngLat.lng, e.lngLat.lat]] }, + properties: {}, }) } - handler.setInputAction((e: { endPosition: Cesium.Cartesian2 }) => { - const pos = pickGlobe(viewer, e.endPosition) - if (pos) refreshRubberBand(pos) - }, Cesium.ScreenSpaceEventType.MOUSE_MOVE) - - handler.setInputAction((e: { position: Cesium.Cartesian2 }) => { - const pos = pickGlobe(viewer, e.position) - if (!pos) return - verts.push(pos.clone()) - vertPointEntities.push( - viewer.entities.add({ - position: pos, - point: { - pixelSize: 8, - color: Cesium.Color.YELLOW, - outlineColor: Cesium.Color.BLACK, - outlineWidth: 1, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - }), - ) + const onClick = (e: MapMouseEvent) => { + verts.push([e.lngLat.lng, e.lngLat.lat]) refreshOutline() - }, Cesium.ScreenSpaceEventType.LEFT_CLICK) + refreshVertices() + } - handler.setInputAction(() => { + const onContextMenu = (e: MouseEvent) => { + e.preventDefault() if (verts.length < 3) return - const polygon = vertsToGeoJson(verts) + const polygon: GeoJSON.Polygon = { + type: 'Polygon', + coordinates: [[...verts, verts[0]]], + } cleanup() setDraftPolygon(polygon) setDrawingMode(false) - }, Cesium.ScreenSpaceEventType.RIGHT_CLICK) + } + + canvas.addEventListener('contextmenu', onContextMenu) + map.on('click', onClick) + map.on('mousemove', onMouseMove) function cleanup() { - if (viewer.isDestroyed()) return - vertPointEntities.forEach((e) => viewer.entities.remove(e)) - vertPointEntities.length = 0 - if (outlineEntity) { - viewer.entities.remove(outlineEntity) - outlineEntity = null - } - if (rubberBandEntity) { - viewer.entities.remove(rubberBandEntity) - rubberBandEntity = null - } + canvas.style.cursor = '' + map.off('click', onClick) + map.off('mousemove', onMouseMove) + canvas.removeEventListener('contextmenu', onContextMenu) + setSource(map, SRC_FILL, emptyFC()) + setSource(map, SRC_OUTLINE, emptyFC()) + setSource(map, SRC_RUBBER, emptyFC()) + setSource(map, SRC_VERTICES, emptyFC()) } - return () => { - handler.destroy() - canvas.removeEventListener('contextmenu', suppressContextMenu) - cleanup() - } - }, [drawingMode, viewer, setDrawingMode, setDraftPolygon]) + return cleanup + }, [drawingMode, map, setDrawingMode, setDraftPolygon]) // ── Edit phase ───────────────────────────────────────────────────────────── useEffect(() => { @@ -161,120 +160,84 @@ export function usePolygonDraw() { return } - // Only initialise from the store on the first entry into edit mode. - // Subsequent runs (triggered by setDraftPolygon after each drag) reuse - // the already-mutated ref so vertex positions are not reset. + // Only initialise from the store on first entry into edit mode. if (editVertsRef.current.length === 0) { - editVertsRef.current = geoJsonToVerts(draftPolygon) + const ring = draftPolygon.coordinates[0] + editVertsRef.current = ring.slice(0, -1) as [number, number][] } const verts = editVertsRef.current let draggingIndex = -1 - const entities: Cesium.Entity[] = [] - const canvas = viewer.scene.canvas - const suppressContextMenu = (e: MouseEvent) => e.preventDefault() - canvas.addEventListener('contextmenu', suppressContextMenu) - - // Filled polygon - entities.push( - viewer.entities.add({ - polygon: { - hierarchy: new Cesium.CallbackProperty( - () => new Cesium.PolygonHierarchy(verts), - false, - ), - material: Cesium.Color.YELLOW.withAlpha(0.15), - }, - }), - ) - - // Outline - entities.push( - viewer.entities.add({ - polyline: { - positions: new Cesium.CallbackProperty( - () => [...verts, verts[0]], - false, - ), - width: 2, - material: new Cesium.ColorMaterialProperty( - Cesium.Color.YELLOW.withAlpha(0.9), - ), - clampToGround: true, - }, - }), - ) - - // Vertex handles — one point entity per vertex, driven by CallbackProperty - // so they track the mutable verts array without entity recreation. - const vertEntities: Cesium.Entity[] = verts.map((_, i) => { - const e = viewer.entities.add({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - position: new Cesium.CallbackProperty(() => verts[i], false) as any, - point: { - pixelSize: 10, - color: Cesium.Color.WHITE, - outlineColor: Cesium.Color.YELLOW, - outlineWidth: 2, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, + function refreshAll() { + setSource(map, SRC_FILL, { + type: 'Feature', + geometry: { type: 'Polygon', coordinates: [[...verts, verts[0]]] }, + properties: {}, }) - entities.push(e) - return e - }) + setSource(map, SRC_OUTLINE, { + type: 'Feature', + geometry: { type: 'LineString', coordinates: [...verts, verts[0]] }, + properties: {}, + }) + setSource(map, SRC_VERTICES, { + type: 'FeatureCollection', + features: verts.map((v, i) => ({ + type: 'Feature', + id: i, + geometry: { type: 'Point', coordinates: v }, + properties: { idx: i }, + })), + }) + } - const handler = new Cesium.ScreenSpaceEventHandler(canvas) + refreshAll() - handler.setInputAction((e: { endPosition: Cesium.Cartesian2 }) => { - if (draggingIndex !== -1) { - // Update dragged vertex - const pos = pickGlobe(viewer, e.endPosition) - if (pos) verts[draggingIndex] = pos - return - } - // Cursor feedback - const picked = viewer.scene.pick(e.endPosition) - const overVertex = - picked?.id instanceof Cesium.Entity && - vertEntities.includes(picked.id) - canvas.style.cursor = overVertex ? 'grab' : 'default' - }, Cesium.ScreenSpaceEventType.MOUSE_MOVE) + const canvas = map.getCanvas() - handler.setInputAction((e: { position: Cesium.Cartesian2 }) => { - const picked = viewer.scene.pick(e.position) - if (!(picked?.id instanceof Cesium.Entity)) return - const idx = vertEntities.indexOf(picked.id) - if (idx === -1) return + const onVertexMouseDown = (e: MapLayerMouseEvent) => { + const idx = e.features?.[0]?.properties?.idx as number | undefined + if (idx == null) return + e.preventDefault() draggingIndex = idx canvas.style.cursor = 'grabbing' - viewer.scene.screenSpaceCameraController.enableRotate = false - viewer.scene.screenSpaceCameraController.enableTranslate = false - }, Cesium.ScreenSpaceEventType.LEFT_DOWN) + map.dragPan.disable() + } - handler.setInputAction(() => { + const onMouseMove = (e: MapMouseEvent) => { + if (draggingIndex !== -1) { + verts[draggingIndex] = [e.lngLat.lng, e.lngLat.lat] + refreshAll() + return + } + const features = map.queryRenderedFeatures(e.point, { layers: [LYR_VERTICES] }) + canvas.style.cursor = features.length ? 'grab' : '' + } + + const onMouseUp = () => { if (draggingIndex === -1) return draggingIndex = -1 - canvas.style.cursor = 'default' - viewer.scene.screenSpaceCameraController.enableRotate = true - viewer.scene.screenSpaceCameraController.enableTranslate = true - // Sync updated geometry back to the store for the submit form. - setDraftPolygon(vertsToGeoJson(verts)) - }, Cesium.ScreenSpaceEventType.LEFT_UP) - - function cleanup() { - if (viewer.isDestroyed()) return - entities.forEach((e) => viewer.entities.remove(e)) - entities.length = 0 - canvas.style.cursor = 'default' - viewer.scene.screenSpaceCameraController.enableRotate = true - viewer.scene.screenSpaceCameraController.enableTranslate = true + canvas.style.cursor = '' + map.dragPan.enable() + setDraftPolygon({ + type: 'Polygon', + coordinates: [[...verts, verts[0]]], + }) } + map.on('mousedown', LYR_VERTICES, onVertexMouseDown) + map.on('mousemove', onMouseMove) + map.on('mouseup', onMouseUp) + return () => { - handler.destroy() - canvas.removeEventListener('contextmenu', suppressContextMenu) - cleanup() + map.off('mousedown', LYR_VERTICES, onVertexMouseDown) + map.off('mousemove', onMouseMove) + map.off('mouseup', onMouseUp) + map.dragPan.enable() + canvas.style.cursor = '' + setSource(map, SRC_FILL, emptyFC()) + setSource(map, SRC_OUTLINE, emptyFC()) + setSource(map, SRC_VERTICES, emptyFC()) } - }, [drawingMode, draftPolygon, viewer, setDraftPolygon]) + }, [drawingMode, draftPolygon, map, setDraftPolygon]) } diff --git a/web/src/coaster/CoasterEditorPage.module.css b/web/src/coaster/CoasterEditorPage.module.css index 015975a..5b38b6f 100644 --- a/web/src/coaster/CoasterEditorPage.module.css +++ b/web/src/coaster/CoasterEditorPage.module.css @@ -521,15 +521,33 @@ .rightColumn { position: fixed; right: 16px; - top: 60px; - z-index: 600; + top: 116px; + z-index: 150; display: flex; flex-direction: column; gap: 8px; - max-height: calc(100vh - 76px); + max-height: calc(100vh - 132px); overflow-y: auto; } +/* ── Path hover tooltip ──────────────────────────────────────────────────────── */ + +.pathTooltip { + position: fixed; + z-index: 300; + pointer-events: none; + padding: 3px 8px; + background: rgba(8, 8, 12, 0.85); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + font-size: 11px; + font-weight: 600; + color: #fbbf24; + white-space: nowrap; +} + /* ── Loading overlay ─────────────────────────────────────────────────────────── */ .loading { diff --git a/web/src/coaster/CoasterEditorPage.tsx b/web/src/coaster/CoasterEditorPage.tsx index 151fda1..3dd9c75 100644 --- a/web/src/coaster/CoasterEditorPage.tsx +++ b/web/src/coaster/CoasterEditorPage.tsx @@ -1,20 +1,24 @@ import { useEffect, useRef, useState } from 'react' import { useParams, useNavigate } from 'react-router-dom' -import * as Cesium from 'cesium' -import { CesiumViewer } from '../cesium/CesiumViewer' -import { useCesiumViewer } from '../cesium/cesiumContext' +import type { Map, GeoJSONSource } from 'maplibre-gl' +import { MapLibreViewer } from '../maplibre/MapLibreViewer' +import { useMapLibreMap } from '../maplibre/maplibreContext' +import { safeRemoveLayers } from '../maplibre/geoUtils' +import { createCustom3DLayer } from '../maplibre/custom3DLayer' +import type { Layer3DHandle } from '../maplibre/custom3DLayer' import { fetchChallengeDetail } from '../api/challenges' import { simulateCoaster, saveCoaster, listCoasters } from '../api/coaster' import { useCoasterPath } from './useCoasterPath' -import { useAccelerationStrips } from './useAccelerationStrips' +import { useAccelerationStrips, computeArcLengths, snapToPath } from './useAccelerationStrips' import { useTerrainCapture } from './useTerrainCapture' import { RideRenderer } from './RideRenderer' import { SimulationPlots } from './SimulationPlots' import { AccelerationStripsPanel } from './AccelerationStripsPanel' import { CoasterListPanel } from './CoasterListPanel' -import { effectivePosition } from './bezierUtils' +import { effectiveLngLatAlt } from './bezierUtils' import { useAuthStore } from '../store/authStore' import type { ChallengeDetail, CoasterSimulationResult, SavedCoaster } from '../types/api' +import type { AnchorPoint } from './bezierUtils' import styles from './CoasterEditorPage.module.css' // ── Route pages ─────────────────────────────────────────────────────────────── @@ -29,9 +33,9 @@ export function CoasterEditorPage() { }, [id]) return ( - + - + ) } @@ -52,32 +56,30 @@ export function CoasterViewerPage() { }, [challengeId, coasterId]) return ( - + - + ) } -// ── Helpers ─────────────────────────────────────────────────────────────────── +// ── Source / layer IDs for editor-specific overlays ─────────────────────────── -/** Build a circle cross-section shape for PolylineVolumeGraphics. */ -function buildCircleShape(radius: number, segments = 8): Cesium.Cartesian2[] { - const pts: Cesium.Cartesian2[] = [] - for (let i = 0; i < segments; i++) { - const angle = (2 * Math.PI * i) / segments - pts.push(new Cesium.Cartesian2(Math.cos(angle) * radius, Math.sin(angle) * radius)) - } - return pts +const SRC_REGION = 'editor-region' +const LYR_REGION_F = 'editor-region-fill' +const LYR_REGION_L = 'editor-region-line' +const LYR_SIM_RAILS = 'sim-rails-3d' + +function emptyFC(): GeoJSON.FeatureCollection { return { type: 'FeatureCollection', features: [] } } +function setData(map: Map, src: string, data: GeoJSON.GeoJSON) { + (map.getSource(src) as GeoJSONSource | undefined)?.setData(data) } -const RAIL_SHAPE = buildCircleShape(0.075, 8) // 7.5 cm radius = 15 cm diameter - -// ── Inner scene (needs viewer context) ──────────────────────────────────────── +// ── Inner scene (needs map context) ────────────────────────────────────────── interface SceneProps { challengeId: string | undefined @@ -87,9 +89,11 @@ interface SceneProps { } function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadCoaster }: SceneProps) { - const viewer = useCesiumViewer() + const map = useMapLibreMap() const navigate = useNavigate() + const simRailLayerRef = useRef(null) + const authUser = useAuthStore(s => s.user) const currentUsername = authUser?.profile?.preferred_username as string | undefined @@ -104,148 +108,107 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC const [coasterListKey, setCoasterListKey] = useState(0) const [isRideMode, setIsRideMode] = useState(false) const [rideCursor, setRideCursor] = useState(null) + const [pathHover, setPathHover] = useState<{ x: number; y: number; pct: number } | null>(null) - const path = useCoasterPath(viewer, showPath, showAnchors) - const accel = useAccelerationStrips(viewer, path.pathPts, path.mode === 'strip', showStrips) - const terrain = useTerrainCapture(viewer, simResult) + const path = useCoasterPath(map, showPath, showAnchors) + const accel = useAccelerationStrips(map, path.pathPts, path.mode === 'strip', showStrips) + const terrain = useTerrainCapture(map, simResult) - // Auto-load a preloaded coaster (viewer mode) + // Auto-load preloaded coaster (viewer mode) useEffect(() => { if (preloadCoaster) handleLoad(preloadCoaster) // eslint-disable-next-line react-hooks/exhaustive-deps }, [preloadCoaster]) - // Exit ride mode whenever the sim result changes; clear cursor on exit + // Exit ride mode when sim result changes useEffect(() => { setIsRideMode(false); setRideCursor(null) }, [simResult]) - // Suspend Cesium while Three.js ride view is active: stop the render loop AND - // disable the globe so Cesium's terrain web-workers stop posting decode results - // back to the main thread (those async completions caused periodic ~5 s spikes). - useEffect(() => { - viewer.useDefaultRenderLoop = !isRideMode - viewer.scene.globe.enabled = !isRideMode - }, [isRideMode, viewer]) - - // Refs for simulation result entities (cleared on each new run / unmount) - const simEntitiesRef = useRef([]) - const simPrimitivesRef = useRef([]) - - // ── Fly to challenge region ─────────────────────────────────────────────── + // ── Set up region + sim-rail sources ────────────────────────────────────── useEffect(() => { - if (!challenge) return - const coords = challenge.region.coordinates[0] - const positions = coords.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat)) - const sphere = Cesium.BoundingSphere.fromPoints(positions, new Cesium.BoundingSphere()) - // Add 25 % padding so the polygon doesn't butt right up against the frame - // edges, then let Cesium compute the exact range (range = 0 = auto-fit). - const padded = new Cesium.BoundingSphere(sphere.center, sphere.radius * 1.25) + map.addSource(SRC_REGION, { type: 'geojson', data: emptyFC() }) + map.addLayer({ id: LYR_REGION_F, type: 'fill', source: SRC_REGION, + paint: { 'fill-color': '#06b6d4', 'fill-opacity': 0.04 } }) + map.addLayer({ id: LYR_REGION_L, type: 'line', source: SRC_REGION, + paint: { 'line-color': '#06b6d4', 'line-opacity': 0.45, 'line-width': 2 } }) - viewer.camera.flyToBoundingSphere(padded, { - offset: new Cesium.HeadingPitchRange(0, Cesium.Math.toRadians(-60), 0), - duration: 1.2, - }) - }, [challenge, viewer]) - - // ── Challenge boundary polygon ──────────────────────────────────────────── - - const regionEntityRef = useRef(null) - useEffect(() => { - if (regionEntityRef.current) { - viewer.entities.remove(regionEntityRef.current) - regionEntityRef.current = null - } - if (!challenge) return - - const coords = challenge.region.coordinates[0] - const positions = coords.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat)) - - regionEntityRef.current = viewer.entities.add({ - polygon: { - hierarchy: new Cesium.PolygonHierarchy(positions), - material: Cesium.Color.CYAN.withAlpha(0.04), - outline: true, - outlineColor: Cesium.Color.CYAN.withAlpha(0.45), - outlineWidth: 2, - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - }, - }) + const handle = createCustom3DLayer(LYR_SIM_RAILS, map) + simRailLayerRef.current = handle return () => { - if (regionEntityRef.current && !viewer.isDestroyed()) { - viewer.entities.remove(regionEntityRef.current) - regionEntityRef.current = null - } + handle.destroy() + simRailLayerRef.current = null + safeRemoveLayers(map, [LYR_REGION_F, LYR_REGION_L], [SRC_REGION]) } - }, [challenge, viewer]) + }, [map]) - // ── Render simulation result rails ──────────────────────────────────────── + // ── Fly to challenge region ──────────────────────────────────────────────── useEffect(() => { - // Clear previous simulation entities - if (!viewer.isDestroyed()) { - simEntitiesRef.current.forEach(e => viewer.entities.remove(e)) - simPrimitivesRef.current.forEach(p => viewer.scene.primitives.remove(p)) + if (!challenge) return + + const coords = challenge.region.coordinates[0] as [number, number][] + const lons = coords.map(c => c[0]) + const lats = coords.map(c => c[1]) + map.fitBounds( + [[Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)]], + { padding: 60, pitch: 55, bearing: 0, duration: 1200 }, + ) + + setData(map, SRC_REGION, { type: 'Feature', geometry: challenge.region, properties: {} }) + }, [challenge, map]) + + // ── Simulation result rails (custom 3D layer at absolute altitude) ──────── + + useEffect(() => { + if (!simResult) { + simRailLayerRef.current?.update([]) + return } - simEntitiesRef.current = [] - simPrimitivesRef.current = [] - if (!simResult) return - - const toC3 = ([lon, lat, alt]: [number, number, number]) => - Cesium.Cartesian3.fromDegrees(lon, lat, alt) - - const r1Pts = simResult.rail_1.map(toC3) - const r2Pts = simResult.rail_2.map(toC3) - - const rail1 = viewer.entities.add({ - polylineVolume: { - positions: r1Pts, - shape: RAIL_SHAPE, - material: Cesium.Color.fromCssColorString('#ef4444'), - cornerType: Cesium.CornerType.ROUNDED, + simRailLayerRef.current?.update([ + { + pts: simResult.rail_1 as [number, number, number][], + color: 0xef4444, + radiusMeters: 0.12, }, - }) - const rail2 = viewer.entities.add({ - polylineVolume: { - positions: r2Pts, - shape: RAIL_SHAPE, - material: Cesium.Color.fromCssColorString('#ef4444'), - cornerType: Cesium.CornerType.ROUNDED, + { + pts: simResult.rail_2 as [number, number, number][], + color: 0xef4444, + radiusMeters: 0.12, }, - }) - simEntitiesRef.current = [rail1, rail2] + ]) + }, [simResult]) - // Load GLB model if available - if (simResult.model_url) { - const [lon0, lat0, alt0] = simResult.origin - const modelMatrix = Cesium.Transforms.eastNorthUpToFixedFrame( - Cesium.Cartesian3.fromDegrees(lon0, lat0, alt0), + // ── Path hover tooltip ──────────────────────────────────────────────────── + + useEffect(() => { + if (path.pathPts.length < 2) { setPathHover(null); return } + + const arcs = computeArcLengths(path.pathPts) + + function onMove(e: { point: { x: number; y: number }; lngLat: { lng: number; lat: number } }) { + const { x, y } = e.point + const hits = map.queryRenderedFeatures( + [{ x: x - 4, y: y - 4 }, { x: x + 4, y: y + 4 }], + { layers: ['coaster-path-lyr'] }, ) - Cesium.Model.fromGltfAsync({ url: simResult.model_url, modelMatrix }) - .then(model => { - if (viewer.isDestroyed()) return - viewer.scene.primitives.add(model) - simPrimitivesRef.current.push(model as unknown as Cesium.Primitive) - }) - .catch(err => console.error('GLB model load failed:', err)) + if (hits.length === 0) { setPathHover(null); return } + const { frac } = snapToPath([e.lngLat.lng, e.lngLat.lat], path.pathPts, arcs) + setPathHover({ x, y, pct: frac * 100 }) } - return () => { - if (viewer.isDestroyed()) return - simEntitiesRef.current.forEach(e => viewer.entities.remove(e)) - simPrimitivesRef.current.forEach(p => viewer.scene.primitives.remove(p)) - simEntitiesRef.current = [] - simPrimitivesRef.current = [] - } - }, [simResult, viewer]) + map.on('mousemove', onMove) + return () => { map.off('mousemove', onMove) } + }, [map, path.pathPts]) // ── Load / Save handlers ───────────────────────────────────────────────── function handleLoad(coaster: SavedCoaster) { - const anchorPoints = coaster.anchors.map(a => ({ + const anchorPoints: AnchorPoint[] = coaster.anchors.map(a => ({ id: a.id, - position: Cesium.Cartesian3.fromDegrees(a.lon, a.lat, a.terrainAlt), + lngLat: [a.lon, a.lat] as [number, number], + terrainHeight: a.terrainAlt, heightOffset: a.heightOffset, })) path.loadAnchors(anchorPoints) @@ -256,16 +219,13 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC async function handleSave() { if (!challengeId || path.anchors.length < 2) return - const storedAnchors = path.anchors.map(a => { - const carto = Cesium.Cartographic.fromCartesian(effectivePosition(a)) - return { - id: a.id, - lon: Cesium.Math.toDegrees(carto.longitude), - lat: Cesium.Math.toDegrees(carto.latitude), - terrainAlt: carto.height, - heightOffset: a.heightOffset, - } - }) + const storedAnchors = path.anchors.map(a => ({ + id: a.id, + lon: a.lngLat[0], + lat: a.lngLat[1], + terrainAlt: a.terrainHeight, + heightOffset: a.heightOffset, + })) await saveCoaster(challengeId, { name: coasterName.trim(), anchors: storedAnchors, @@ -282,19 +242,7 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC setSimError(null) try { - // Send only the anchor control points (not the pre-sampled Bezier - // polyline). The backend fits its own smooth B-spline with analytical - // derivatives, which avoids the curvature spikes that arise from - // finite-differencing a piecewise-linear polyline approximation. - const geoPath = path.anchors.map(anchor => { - const pos = effectivePosition(anchor) - const carto = Cesium.Cartographic.fromCartesian(pos) - return [ - Cesium.Math.toDegrees(carto.longitude), - Cesium.Math.toDegrees(carto.latitude), - carto.height, - ] as [number, number, number] - }) + const geoPath = path.anchors.map(anchor => effectiveLngLatAlt(anchor)) const result = await simulateCoaster({ path: geoPath, @@ -322,7 +270,7 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC return ( <> - {/* ── Three.js ride renderer (fullscreen, mounts over Cesium) ─────── */} + {/* ── Three.js ride renderer (fullscreen, mounts over map) ──────────── */} {isRideMode && simResult && terrain.captureData && ( {readonly ? 'Viewer' : 'Coaster Editor'} + {challengeId && ( + + )} {/* ── Mode toolbar (hidden while riding) ──────────────────────────── */} @@ -434,9 +391,7 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC )} {/* ── Simulation error ─────────────────────────────────────────────── */} - {simError && ( - {simError} - )} + {simError && {simError}} {/* ── Diagnostics strip ────────────────────────────────────────────── */} {diag && !simError && ( @@ -507,25 +462,25 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC /> )} - {/* ── Right panel column (acceleration strips + coaster list) ────── */} - {!isRideMode && ( + {/* ── Right panel column (acceleration strips) ─────────────────────── */} + {!isRideMode && accel.strips.length > 0 && ( - {accel.strips.length > 0 && ( - - )} - {challengeId && ( - - )} + + + )} + + {/* ── Path hover tooltip ──────────────────────────────────────────── */} + {!isRideMode && pathHover && ( + + {pathHover.pct.toFixed(1)}% )} diff --git a/web/src/coaster/CoasterListPanel.module.css b/web/src/coaster/CoasterListPanel.module.css index a82e96d..17b7016 100644 --- a/web/src/coaster/CoasterListPanel.module.css +++ b/web/src/coaster/CoasterListPanel.module.css @@ -118,3 +118,45 @@ color: rgba(255, 255, 255, 0.25); padding: 6px 0 2px; } + +/* ── Top-bar menu mode ───────────────────────────────────────────────────────── */ + +.menuWrapper { + position: relative; + flex-shrink: 0; +} + +.menuToggle { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: rgba(255, 255, 255, 0.75); + font-size: 13px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s, color 0.15s; +} +.menuToggle:hover { + background: rgba(255, 255, 255, 0.12); + color: #fff; +} + +.menuDropdown { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 400; + width: 260px; + background: rgba(8, 8, 12, 0.92); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 12px; + padding: 6px 12px 10px; + overflow: hidden; +} diff --git a/web/src/coaster/CoasterListPanel.tsx b/web/src/coaster/CoasterListPanel.tsx index bf4a3e4..d0b1b21 100644 --- a/web/src/coaster/CoasterListPanel.tsx +++ b/web/src/coaster/CoasterListPanel.tsx @@ -8,9 +8,11 @@ interface Props { currentUsername: string | undefined onLoad: (coaster: SavedCoaster) => void refreshKey: number + /** Render as a top-bar dropdown instead of a sidebar panel */ + menuMode?: boolean } -export function CoasterListPanel({ challengeId, currentUsername, onLoad, refreshKey }: Props) { +export function CoasterListPanel({ challengeId, currentUsername, onLoad, refreshKey, menuMode }: Props) { const [open, setOpen] = useState(false) const [coasters, setCoasters] = useState([]) @@ -23,6 +25,46 @@ export function CoasterListPanel({ challengeId, currentUsername, onLoad, refresh setCoasters(prev => prev.filter(c => c.id !== id)) } + if (menuMode) { + return ( + + setOpen(o => !o)}> + Coasters ({coasters.length}) + ▶ + + {open && ( + + {coasters.length === 0 ? ( + No coasters yet. + ) : ( + coasters.map(c => { + const isOwn = c.creator_username === currentUsername + return ( + + + {c.name || 'Unnamed coaster'} + + @{c.creator_username} + + + { onLoad(c); setOpen(false) }}> + Load + + {isOwn && ( + handleDelete(c.id)}> + Del + + )} + + ) + }) + )} + + )} + + ) + } + return ( setOpen(o => !o)}> diff --git a/web/src/coaster/RideRenderer.tsx b/web/src/coaster/RideRenderer.tsx index c7fa31d..3bc4ebe 100644 --- a/web/src/coaster/RideRenderer.tsx +++ b/web/src/coaster/RideRenderer.tsx @@ -154,8 +154,8 @@ function computeCameraTarget( function buildTerrainMesh( patch: TerrainCaptureData, + patchIdx: number, renderer: THREE.WebGLRenderer, - visible: boolean, ): { mesh: THREE.Mesh; geo: THREE.BufferGeometry; mat: THREE.Material; tex: THREE.Texture } { const GRID = patch.gridSize const verts = patch.terrainVertices @@ -168,7 +168,7 @@ function buildTerrainMesh( const idx = j * GRID + i const v = verts[idx] posArr[idx * 3] = v.x - posArr[idx * 3 + 1] = v.y - 0.5 + posArr[idx * 3 + 1] = v.y posArr[idx * 3 + 2] = v.z uvArr[idx * 2] = i / (GRID - 1) uvArr[idx * 2 + 1] = j / (GRID - 1) @@ -197,9 +197,16 @@ function buildTerrainMesh( tex.magFilter = THREE.LinearFilter tex.needsUpdate = true - const mat = new THREE.MeshLambertMaterial({ map: tex, side: THREE.FrontSide }) + // Polygon offset: higher-indexed patches render on top of lower-indexed ones in + // overlap zones so depth-fighting never causes flickering under the camera. + const mat = new THREE.MeshLambertMaterial({ + map: tex, + side: THREE.FrontSide, + polygonOffset: true, + polygonOffsetFactor: -(patchIdx + 1), + polygonOffsetUnits: -(patchIdx + 1), + }) const mesh = new THREE.Mesh(geo, mat) - mesh.visible = visible return { mesh, geo, mat, tex } } @@ -218,23 +225,17 @@ function buildScene(captureData: TerrainCaptureData[], rideData: RideData, rende const mats: THREE.Material[] = [] const texes: THREE.Texture[] = [] - // ── Terrain patches — one mesh per patch, only the active one visible ──── - const terrainMeshes = captureData.map((patch, i) => { - const { mesh, geo, mat, tex } = buildTerrainMesh(patch, renderer, i === 0) + // ── Terrain patches — all meshes permanently visible ───────────────────── + // Polygon offset (set per-mesh in buildTerrainMesh) ensures higher-indexed + // patches win the depth test in overlap zones without flickering. + captureData.forEach((patch, i) => { + const { mesh, geo, mat, tex } = buildTerrainMesh(patch, i, renderer) geos.push(geo) mats.push(mat) texes.push(tex) scene.add(mesh) - // Pre-upload texture to GPU so visibility swaps have zero hitch - renderer.initTexture(tex) - return mesh }) - function setActivePatch(idx: number) { - const clamped = Math.max(0, Math.min(idx, terrainMeshes.length - 1)) - terrainMeshes.forEach((m, i) => { m.visible = i === clamped }) - } - // ── Coaster rails ────────────────────────────────────────────────────────── function addRail(pts: THREE.Vector3[]) { const step = Math.max(1, Math.floor(pts.length / 200)) @@ -249,20 +250,8 @@ function buildScene(captureData: TerrainCaptureData[], rideData: RideData, rende addRail(rideData.rail1) addRail(rideData.rail2) - // Expose a warm-up helper: makes every terrain patch visible so the caller can - // do one render that forces the GPU driver to flush all queued texImage2D / - // generateMipmap commands for every patch at once. Without this, those - // commands are deferred and cause a GPU pipeline stall the first time each - // patch becomes the active (visible) one during the ride — which at high - // coaster speeds can be every few seconds. - function warmUpPatches() { - terrainMeshes.forEach(m => { m.visible = true }) - } - return { scene, - setActivePatch, - warmUpPatches, disposeAll: () => { geos.forEach(g => g.dispose()) mats.forEach(m => m.dispose()) @@ -299,10 +288,6 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }: const smoothQuatRef = useRef(new THREE.Quaternion()) const needsInitRef = useRef(true) - // Active terrain patch switching - const setActivePatchRef = useRef<((idx: number) => void) | null>(null) - const activePatchIdxRef = useRef(0) - // First-person drag rotation state const userYawRef = useRef(0) // radians — current yaw offset from track forward const userPitchRef = useRef(0) // radians — current pitch offset @@ -333,10 +318,8 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }: rideDataRef.current = rideData setTotalDuration(rideData.totalDuration) - const { scene, setActivePatch, warmUpPatches, disposeAll } = buildScene(captureData, rideData, renderer) - sceneRef.current = scene - setActivePatchRef.current = setActivePatch - activePatchIdxRef.current = 0 + const { scene, disposeAll } = buildScene(captureData, rideData, renderer) + sceneRef.current = scene function onResize() { const w = window.innerWidth, h = window.innerHeight @@ -356,13 +339,8 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }: cameraRef.current.updateMatrixWorld() } - // Warm-up: make every terrain patch visible and render once. This forces the - // GPU driver to flush all pending texImage2D / generateMipmap commands for - // every patch right now, so patch-visibility switches during the ride have - // zero GPU stall cost. Then reset to only the first patch. - warmUpPatches() + // Initial render — all patches are permanently visible so no warm-up needed. renderer.render(scene, cameraRef.current) - setActivePatch(0) return () => { window.removeEventListener('resize', onResize) @@ -436,24 +414,6 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }: } } - // ── Active terrain patch ────────────────────────────────────────────── - if (captureData.length > 1 && setActivePatchRef.current) { - const d = rideDataRef.current - if (d) { - const pi = bisect(d.timeArray, rideT) - const pi1 = Math.min(pi + 1, d.count - 1) - const pa = d.timeArray[pi1] > d.timeArray[pi] - ? (rideT - d.timeArray[pi]) / (d.timeArray[pi1] - d.timeArray[pi]) - : 0 - const frac = d.sFrac[pi] + (d.sFrac[pi1] - d.sFrac[pi]) * pa - const target = Math.round(frac * (captureData.length - 1)) - if (target !== activePatchIdxRef.current) { - activePatchIdxRef.current = target - setActivePatchRef.current(target) - } - } - } - // ── Camera ──────────────────────────────────────────────────────────── const data = rideDataRef.current if (data) { diff --git a/web/src/coaster/SimulationPlots.module.css b/web/src/coaster/SimulationPlots.module.css index e15d05c..41ebec8 100644 --- a/web/src/coaster/SimulationPlots.module.css +++ b/web/src/coaster/SimulationPlots.module.css @@ -1,7 +1,7 @@ .plotsPanel { position: fixed; left: 16px; - top: 60px; + top: 116px; z-index: 600; /* above Three.js ride canvas (z-index 500) */ width: 340px; background: rgba(8, 8, 12, 0.84); diff --git a/web/src/coaster/SimulationPlots.tsx b/web/src/coaster/SimulationPlots.tsx index 99d0595..c659cd8 100644 --- a/web/src/coaster/SimulationPlots.tsx +++ b/web/src/coaster/SimulationPlots.tsx @@ -94,8 +94,10 @@ function ProfileChart({ data, dataKey, color, unit, strips, showZero, rideCursor /> [`${v.toFixed(2)} ${unit}`, dataKey]} - labelFormatter={(l: number) => `s = ${pct(l)}`} + // eslint-disable-next-line @typescript-eslint/no-explicit-any + formatter={(v: any) => [`${Number(v).toFixed(2)} ${unit}`, dataKey]} + // eslint-disable-next-line @typescript-eslint/no-explicit-any + labelFormatter={(l: any) => `s = ${pct(Number(l))}`} /> {strips && } {showZero && ( diff --git a/web/src/coaster/bezierUtils.ts b/web/src/coaster/bezierUtils.ts index 43c4fc6..938367c 100644 --- a/web/src/coaster/bezierUtils.ts +++ b/web/src/coaster/bezierUtils.ts @@ -1,54 +1,46 @@ -import * as Cesium from 'cesium' +import * as THREE from 'three' +import { lngLatAltToECEF, ecefToLngLatAlt } from '../maplibre/geoUtils' export interface AnchorPoint { id: string - position: Cesium.Cartesian3 // base position on terrain - heightOffset: number // meters above terrain surface + lngLat: [number, number] // [longitude, latitude] + terrainHeight: number // metres above WGS-84 ellipsoid (from terrain query) + heightOffset: number // metres above terrain surface (user-adjustable) } // ── helpers ────────────────────────────────────────────────────────────────── -function v3add(a: Cesium.Cartesian3, b: Cesium.Cartesian3): Cesium.Cartesian3 { - return Cesium.Cartesian3.add(a, b, new Cesium.Cartesian3()) -} -function v3sub(a: Cesium.Cartesian3, b: Cesium.Cartesian3): Cesium.Cartesian3 { - return Cesium.Cartesian3.subtract(a, b, new Cesium.Cartesian3()) -} -function v3scale(v: Cesium.Cartesian3, s: number): Cesium.Cartesian3 { - return Cesium.Cartesian3.multiplyByScalar(v, s, new Cesium.Cartesian3()) -} -function v3norm(v: Cesium.Cartesian3): Cesium.Cartesian3 { - return Cesium.Cartesian3.normalize(v, new Cesium.Cartesian3()) -} -function v3cross(a: Cesium.Cartesian3, b: Cesium.Cartesian3): Cesium.Cartesian3 { - return Cesium.Cartesian3.cross(a, b, new Cesium.Cartesian3()) +/** Return the effective 3-D position of an anchor in ECEF metres. */ +export function effectivePosition(anchor: AnchorPoint): THREE.Vector3 { + const [lon, lat] = anchor.lngLat + const alt = anchor.terrainHeight + anchor.heightOffset + return lngLatAltToECEF(lon, lat, alt) } -/** Return the effective 3-D position of an anchor including its height offset. */ -export function effectivePosition(anchor: AnchorPoint): Cesium.Cartesian3 { - if (anchor.heightOffset === 0) return anchor.position.clone() - const up = v3norm(anchor.position) - return v3add(anchor.position, v3scale(up, anchor.heightOffset)) +/** Return effective position as [lon, lat, alt]. */ +export function effectiveLngLatAlt(anchor: AnchorPoint): [number, number, number] { + return [anchor.lngLat[0], anchor.lngLat[1], anchor.terrainHeight + anchor.heightOffset] } // ── Catmull-Rom → cubic Bézier ─────────────────────────────────────────────── interface Segment { - p0: Cesium.Cartesian3 - c1: Cesium.Cartesian3 - c2: Cesium.Cartesian3 - p1: Cesium.Cartesian3 + p0: THREE.Vector3 + c1: THREE.Vector3 + c2: THREE.Vector3 + p1: THREE.Vector3 } /** * Convert anchor points to cubic Bézier segments via Catmull-Rom. * The curve passes through every anchor point with C1 continuity. + * All coordinates are in ECEF metres. */ export function computeSegments(anchors: AnchorPoint[]): Segment[] { if (anchors.length < 2) return [] const pts = anchors.map(effectivePosition) const n = pts.length - // phantom end-points so the curve starts/ends at the first/last anchor + // phantom end-points so curve starts/ends at first/last anchor const ext = [pts[0], ...pts, pts[n - 1]] const segments: Segment[] = [] @@ -57,33 +49,36 @@ export function computeSegments(anchors: AnchorPoint[]): Segment[] { const p0 = ext[i + 1] const p1 = ext[i + 2] const p2 = ext[i + 3] - // Catmull-Rom tangent handles converted to cubic Bézier control points - const c1 = v3add(p0, v3scale(v3sub(p1, pm1), 1 / 6)) - const c2 = v3add(p1, v3scale(v3sub(p0, p2), 1 / 6)) + // Catmull-Rom tangent handles → cubic Bézier control points + const c1 = p0.clone().addScaledVector(p1.clone().sub(pm1), 1 / 6) + const c2 = p1.clone().addScaledVector(p0.clone().sub(p2), 1 / 6) segments.push({ p0, c1, c2, p1 }) } return segments } -function evalBezier(seg: Segment, t: number): Cesium.Cartesian3 { +function evalBezier(seg: Segment, t: number): THREE.Vector3 { const { p0, c1, c2, p1 } = seg const mt = 1 - t - return new Cesium.Cartesian3( + return new THREE.Vector3( mt ** 3 * p0.x + 3 * mt ** 2 * t * c1.x + 3 * mt * t ** 2 * c2.x + t ** 3 * p1.x, mt ** 3 * p0.y + 3 * mt ** 2 * t * c1.y + 3 * mt * t ** 2 * c2.y + t ** 3 * p1.y, mt ** 3 * p0.z + 3 * mt ** 2 * t * c1.z + 3 * mt * t ** 2 * c2.z + t ** 3 * p1.z, ) } -/** Sample the full spline as a polyline (all segments concatenated). */ +/** + * Sample the full spline as a polyline (all segments concatenated). + * Returns ECEF THREE.Vector3 positions. + */ export function samplePath( anchors: AnchorPoint[], samplesPerSegment = 40, -): Cesium.Cartesian3[] { +): THREE.Vector3[] { const segs = computeSegments(anchors) if (segs.length === 0) return anchors.map(effectivePosition) - const pts: Cesium.Cartesian3[] = [] + const pts: THREE.Vector3[] = [] segs.forEach((seg, i) => { const from = i === 0 ? 0 : 1 for (let s = from; s <= samplesPerSegment; s++) { @@ -93,23 +88,33 @@ export function samplePath( return pts } +/** + * Sample the full spline as geographic [lon, lat, alt] tuples. + */ +export function samplePathGeo( + anchors: AnchorPoint[], + samplesPerSegment = 40, +): [number, number, number][] { + return samplePath(anchors, samplesPerSegment).map(v => ecefToLngLatAlt(v)) +} + // ── Rail geometry ───────────────────────────────────────────────────────────── export interface RailPositions { - left: Cesium.Cartesian3[] - right: Cesium.Cartesian3[] + left: THREE.Vector3[] + right: THREE.Vector3[] } /** - * Given a centre-line path compute parallel left/right rail positions. + * Given a centre-line path (ECEF) compute parallel left/right rail positions. * @param gauge distance between rails in metres (default 2.5 for visual clarity) */ export function computeRails( - path: Cesium.Cartesian3[], + path: THREE.Vector3[], gauge = 2.5, ): RailPositions { - const left: Cesium.Cartesian3[] = [] - const right: Cesium.Cartesian3[] = [] + const left: THREE.Vector3[] = [] + const right: THREE.Vector3[] = [] const half = gauge / 2 const n = path.length @@ -117,19 +122,19 @@ export function computeRails( const pt = path[i] // Finite-difference tangent along the curve - let tangent: Cesium.Cartesian3 - if (i === 0) tangent = v3sub(path[1], path[0]) - else if (i === n - 1) tangent = v3sub(path[n - 1], path[n - 2]) - else tangent = v3sub(path[i + 1], path[i - 1]) - tangent = v3norm(tangent) + let tangent: THREE.Vector3 + if (i === 0) tangent = path[1].clone().sub(path[0]) + else if (i === n - 1) tangent = path[n - 1].clone().sub(path[n - 2]) + else tangent = path[i + 1].clone().sub(path[i - 1]) + tangent.normalize() - // Local up = radially outward from Earth centre - const up = v3norm(pt) - // Track-right = tangent × up (right-hand rule → points right when facing forward) - const rightDir = v3norm(v3cross(tangent, up)) + // Local up = radially outward from Earth centre (normalise ECEF position) + const up = pt.clone().normalize() + // Track-right = tangent × up (right-hand rule) + const rightDir = new THREE.Vector3().crossVectors(tangent, up).normalize() - left.push(v3add(pt, v3scale(rightDir, -half))) - right.push(v3add(pt, v3scale(rightDir, half))) + left.push(pt.clone().addScaledVector(rightDir, -half)) + right.push(pt.clone().addScaledVector(rightDir, half)) } return { left, right } diff --git a/web/src/coaster/useAccelerationStrips.ts b/web/src/coaster/useAccelerationStrips.ts index aa96776..52dde65 100644 --- a/web/src/coaster/useAccelerationStrips.ts +++ b/web/src/coaster/useAccelerationStrips.ts @@ -1,26 +1,40 @@ import { useState, useEffect, useRef, useCallback } from 'react' -import * as Cesium from 'cesium' +import type { Map, MapMouseEvent, GeoJSONSource } from 'maplibre-gl' +import maplibregl from 'maplibre-gl' import type { AccelerationStrip } from '../types/api' +import { safeRemoveLayers } from '../maplibre/geoUtils' +import { createCustom3DLayer } from '../maplibre/custom3DLayer' +import type { Layer3DHandle } from '../maplibre/custom3DLayer' -// ── Arc-length utilities ─────────────────────────────────────────────────────── +// ── Arc-length utilities (geographic [lon, lat, alt] inputs) ────────────────── -function computeArcLengths(pts: Cesium.Cartesian3[]): number[] { +function geoDist(a: [number, number, number], b: [number, number, number]): number { + const midLat = (a[1] + b[1]) / 2 + const dlat = (b[1] - a[1]) * 111320 + const dlon = (b[0] - a[0]) * 111320 * Math.cos(midLat * Math.PI / 180) + return Math.sqrt(dlat * dlat + dlon * dlon) +} + +export function computeArcLengths(pts: [number, number, number][]): number[] { const s = [0] for (let i = 1; i < pts.length; i++) { - s.push(s[i - 1] + Cesium.Cartesian3.distance(pts[i - 1], pts[i])) + s.push(s[i - 1] + geoDist(pts[i - 1], pts[i])) } return s } -function snapToPath( - pos: Cesium.Cartesian3, - pts: Cesium.Cartesian3[], +export function snapToPath( + lngLat: [number, number], + pts: [number, number, number][], arcs: number[], -): { frac: number; pt: Cesium.Cartesian3 } { +): { frac: number; pt: [number, number, number] } { let minDist = Infinity let best = 0 + const refLat = lngLat[1] for (let i = 0; i < pts.length; i++) { - const d = Cesium.Cartesian3.distance(pos, pts[i]) + const dlat = (pts[i][1] - lngLat[1]) * 111320 + const dlon = (pts[i][0] - lngLat[0]) * 111320 * Math.cos(refLat * Math.PI / 180) + const d = Math.sqrt(dlat * dlat + dlon * dlon) if (d < minDist) { minDist = d; best = i } } const total = arcs[arcs.length - 1] @@ -37,6 +51,19 @@ function fracToIndex(frac: number, arcs: number[]): number { return best } +// ── Source / layer IDs ──────────────────────────────────────────────────────── + +const SRC_STRIPS = 'accel-strips-src' +const LYR_STRIPS = 'accel-strips-lyr' // invisible draped line for right-click hit test +const SRC_PENDING = 'accel-pending-src' +const LYR_PENDING = 'accel-pending-lyr' // draped circle for pending start point +const LYR_STRIPS_3D = 'accel-strips-3d' // custom 3D layer for visual rendering + +function emptyFC(): GeoJSON.FeatureCollection { return { type: 'FeatureCollection', features: [] } } +function setData(map: Map, src: string, data: GeoJSON.GeoJSON) { + (map.getSource(src) as GeoJSONSource | undefined)?.setData(data) +} + // ── Hook ────────────────────────────────────────────────────────────────────── export interface AccelerationStripsHandle { @@ -48,23 +75,22 @@ export interface AccelerationStripsHandle { loadStrips: (strips: AccelerationStrip[]) => void } -interface PendingStrip { - startFrac: number - marker: Cesium.Entity -} +interface PendingStrip { startFrac: number; pt: [number, number, number] } export function useAccelerationStrips( - viewer: Cesium.Viewer, - pathPts: Cesium.Cartesian3[], + map: Map, + pathPts: [number, number, number][], isActive: boolean, showStrips = true, ): AccelerationStripsHandle { const [strips, setStrips] = useState([]) - const pendingRef = useRef(null) - const stripEntities = useRef>(new Map()) - const pathPtsRef = useRef(pathPts) - pathPtsRef.current = pathPts + const pendingRef = useRef(null) + const pathPtsRef = useRef(pathPts) + pathPtsRef.current = pathPts + const isActiveRef = useRef(isActive) + isActiveRef.current = isActive + const layer3DRef = useRef(null) // ── Public callbacks ─────────────────────────────────────────────────────── @@ -80,166 +106,163 @@ export function useAccelerationStrips( setStrips(prev => prev.map(s => s.id === id ? { ...s, startFrac, endFrac } : s)) }, []) - const clearStrips = useCallback(() => { - setStrips([]) - }, []) + const clearStrips = useCallback(() => setStrips([]), []) const loadStrips = useCallback((newStrips: AccelerationStrip[]) => { setStrips(newStrips) }, []) + // ── Set up MapLibre sources + layers ─────────────────────────────────────── + + useEffect(() => { + map.addSource(SRC_STRIPS, { type: 'geojson', data: emptyFC() }) + map.addSource(SRC_PENDING, { type: 'geojson', data: emptyFC() }) + + // Invisible line kept only for right-click hit-testing (queryRenderedFeatures). + map.addLayer({ id: LYR_STRIPS, type: 'line', source: SRC_STRIPS, + paint: { 'line-color': '#f59e0b', 'line-width': 8, 'line-opacity': 0.01 } }) + + // Draped circle showing the pending strip start point. + map.addLayer({ id: LYR_PENDING, type: 'circle', source: SRC_PENDING, + paint: { + 'circle-radius': 8, + 'circle-color': '#f59e0b', + 'circle-stroke-color': 'rgba(0,0,0,0.6)', + 'circle-stroke-width': 2, + } }) + + return () => { + safeRemoveLayers(map, + [LYR_STRIPS, LYR_PENDING], + [SRC_STRIPS, SRC_PENDING], + ) + } + }, [map]) + + // ── Custom 3D layer for strip visual rendering ───────────────────────────── + + useEffect(() => { + const handle = createCustom3DLayer(LYR_STRIPS_3D, map) + layer3DRef.current = handle + return () => { + handle.destroy() + layer3DRef.current = null + } + }, [map]) + // ── Click handler for strip placement ───────────────────────────────────── useEffect(() => { - const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas) - - function pickTerrain(pos: Cesium.Cartesian2): Cesium.Cartesian3 | null { - const ray = viewer.camera.getPickRay(pos) - if (!ray) return null - return viewer.scene.globe.pick(ray, viewer.scene) ?? null - } - - handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.PositionedEvent) => { - if (!isActive) return + const onClick = (e: MapMouseEvent) => { + if (!isActiveRef.current) return const pts = pathPtsRef.current if (pts.length < 2) return - const worldPos = pickTerrain(e.position) - if (!worldPos) return - const arcs = computeArcLengths(pts) - const { frac, pt } = snapToPath(worldPos, pts, arcs) + const { frac, pt } = snapToPath([e.lngLat.lng, e.lngLat.lat], pts, arcs) if (!pendingRef.current) { - // First click — place start marker - const marker = viewer.entities.add({ - position: new Cesium.ConstantPositionProperty(pt), - point: { - pixelSize: 14, - color: Cesium.Color.fromCssColorString('#f59e0b'), - outlineColor: Cesium.Color.BLACK.withAlpha(0.6), - outlineWidth: 2, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - label: { - text: 'Strip start', - font: '12px sans-serif', - fillColor: Cesium.Color.fromCssColorString('#f59e0b'), - outlineColor: Cesium.Color.BLACK, - outlineWidth: 2, - style: Cesium.LabelStyle.FILL_AND_OUTLINE, - pixelOffset: new Cesium.Cartesian2(0, -22), - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, + pendingRef.current = { startFrac: frac, pt } + setData(map, SRC_PENDING, { + type: 'Feature', + geometry: { type: 'Point', coordinates: pt }, + properties: {}, }) - pendingRef.current = { startFrac: frac, marker } } else { - // Second click — complete the strip - const { startFrac, marker } = pendingRef.current - viewer.entities.remove(marker) + const { startFrac } = pendingRef.current pendingRef.current = null + setData(map, SRC_PENDING, emptyFC()) let sf = startFrac, ef = frac if (sf > ef) [sf, ef] = [ef, sf] - if (sf === ef) return // degenerate — same point + if (sf === ef) return const id = crypto.randomUUID() setStrips(prev => [...prev, { id, startFrac: sf, endFrac: ef, accel_ms2: 5.0 }]) } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK) + } - // Right-click: cancel pending - handler.setInputAction(() => { - if (!isActive || !pendingRef.current) return - viewer.entities.remove(pendingRef.current.marker) + const onContextMenu = () => { + if (!isActiveRef.current || !pendingRef.current) return pendingRef.current = null - }, Cesium.ScreenSpaceEventType.RIGHT_CLICK) + setData(map, SRC_PENDING, emptyFC()) + } + + map.on('click', onClick) + map.getCanvas().addEventListener('contextmenu', onContextMenu) return () => { - if (!handler.isDestroyed()) handler.destroy() + map.off('click', onClick) + map.getCanvas().removeEventListener('contextmenu', onContextMenu) } - }, [viewer, isActive]) // pathPts via ref — intentional + }, [map]) - // ── Cesium polyline entity sync ──────────────────────────────────────────── + // ── Right-click on strip to delete ──────────────────────────────────────── useEffect(() => { - if (viewer.isDestroyed()) return - - const currentIds = new Set(strips.map(s => s.id)) - - // Remove entities for deleted strips - stripEntities.current.forEach((entity, id) => { - if (!currentIds.has(id)) { - viewer.entities.remove(entity) - stripEntities.current.delete(id) - } - }) - - if (pathPts.length < 2) return - - const arcs = computeArcLengths(pathPts) - - // Remove all existing strip entities and rebuild — pathPts may have changed - stripEntities.current.forEach(entity => viewer.entities.remove(entity)) - stripEntities.current.clear() - - for (const strip of strips) { - const si = fracToIndex(strip.startFrac, arcs) - const ei = fracToIndex(strip.endFrac, arcs) - const sliced = pathPts.slice(Math.min(si, ei), Math.max(si, ei) + 1) - if (sliced.length < 2) continue - - const entity = viewer.entities.add({ - id: `accel-strip-${strip.id}`, - polyline: { - positions: sliced, - width: 7, - material: Cesium.Color.fromCssColorString('#f59e0b'), - arcType: Cesium.ArcType.NONE, - clampToGround: false, - }, - properties: { stripId: strip.id }, - }) - stripEntities.current.set(strip.id, entity) - } - }, [strips, pathPts, viewer]) - - // ── Right-click on strip entity to delete ───────────────────────────────── - - useEffect(() => { - const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas) - - handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.PositionedEvent) => { - if (isActive) return // handled by placement handler above - const picked = viewer.scene.pick(e.position) - if (!Cesium.defined(picked)) return - const entity = picked.id as Cesium.Entity | undefined - if (!(entity instanceof Cesium.Entity)) return - const stripId = entity.properties?.stripId?.getValue() as string | undefined + const onContextMenuStrip = (e: MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => { + if (isActiveRef.current) return + const features = map.queryRenderedFeatures(e.point, { layers: [LYR_STRIPS] }) + if (!features.length) return + const stripId = features[0].properties?.stripId as string | undefined if (stripId) removeStrip(stripId) - }, Cesium.ScreenSpaceEventType.RIGHT_CLICK) - - return () => { - if (!handler.isDestroyed()) handler.destroy() } - }, [viewer, isActive, removeStrip]) - // ── Strip visibility toggle ──────────────────────────────────────────────── + map.on('contextmenu', LYR_STRIPS, onContextMenuStrip as Parameters[1]) + return () => { + map.off('contextmenu', LYR_STRIPS, onContextMenuStrip as Parameters[1]) + } + }, [map, removeStrip]) + + // ── Sync strip GeoJSON + 3D tubes ───────────────────────────────────────── useEffect(() => { - stripEntities.current.forEach(e => { e.show = showStrips }) - }, [showStrips]) + if (!showStrips) { + layer3DRef.current?.update([]) + return + } + const pts = pathPts + if (pts.length < 2) { + setData(map, SRC_STRIPS, emptyFC()) + layer3DRef.current?.update([]) + return + } + const arcs = computeArcLengths(pts) - // ── Cleanup on unmount ───────────────────────────────────────────────────── + const segments: { coords: [number, number, number][]; id: string }[] = [] + + const features: GeoJSON.Feature[] = strips.map(strip => { + const si = fracToIndex(strip.startFrac, arcs) + const ei = fracToIndex(strip.endFrac, arcs) + const lo = Math.min(si, ei) + const hi = Math.max(si, ei) + const sliced = pts.slice(lo, hi + 1) as [number, number, number][] + if (sliced.length >= 2) segments.push({ coords: sliced, id: strip.id }) + return { + type: 'Feature' as const, + geometry: { type: 'LineString' as const, coordinates: sliced }, + properties: { stripId: strip.id }, + } + }).filter(f => (f.geometry as GeoJSON.LineString).coordinates.length >= 2) + + // Invisible draped geometry for right-click hit-testing + setData(map, SRC_STRIPS, { type: 'FeatureCollection', features }) + + // 3D tube rendering on the path + layer3DRef.current?.update( + segments.map(s => ({ pts: s.coords, color: 0xf59e0b, radiusMeters: 0.4 })) + ) + }, [strips, pathPts, showStrips, map]) + + // ── Visibility toggle ───────────────────────────────────────────────────── useEffect(() => { - return () => { - if (viewer.isDestroyed()) return - if (pendingRef.current) viewer.entities.remove(pendingRef.current.marker) - stripEntities.current.forEach(e => viewer.entities.remove(e)) - stripEntities.current.clear() - } - }, [viewer]) + const vis = showStrips ? 'visible' : 'none' + if (map.getLayer(LYR_STRIPS)) map.setLayoutProperty(LYR_STRIPS, 'visibility', vis) + if (map.getLayer(LYR_PENDING)) map.setLayoutProperty(LYR_PENDING, 'visibility', vis) + // 3D layer: update with empty when hidden, restore on show + // (handled by the strip sync effect which runs on showStrips via the deps below) + }, [showStrips, map]) return { strips, removeStrip, updateStrip, updateStripFrac, clearStrips, loadStrips } } diff --git a/web/src/coaster/useCoasterPath.ts b/web/src/coaster/useCoasterPath.ts index 610b994..148ccd1 100644 --- a/web/src/coaster/useCoasterPath.ts +++ b/web/src/coaster/useCoasterPath.ts @@ -1,13 +1,35 @@ import { useState, useEffect, useRef, useCallback } from 'react' -import * as Cesium from 'cesium' +import type { Map, MapMouseEvent, GeoJSONSource } from 'maplibre-gl' import type { AnchorPoint } from './bezierUtils' -import { effectivePosition, samplePath, computeRails } from './bezierUtils' +import { effectivePosition, effectiveLngLatAlt, samplePath, computeRails } from './bezierUtils' +import { ecefToLngLatAlt, safeRemoveLayers } from '../maplibre/geoUtils' +import { createCustom3DLayer } from '../maplibre/custom3DLayer' +import type { Layer3DHandle, Point3D } from '../maplibre/custom3DLayer' export type EditorMode = 'add' | 'select' | 'strip' +// ── Altitude helper ─────────────────────────────────────────────────────────── +// +// map.queryTerrainElevation() returns a RELATIVE value: +// (DEM_at_point − DEM_at_center) × exaggeration +// +// To get absolute MSL elevation in metres (matching Terrarium tile encoding and +// MercatorCoordinate.fromLngLat's altitude parameter), we add back the centre +// elevation stored in the transform and divide out the exaggeration factor. +// +// The exaggeration constant must match the value in MapLibreViewer.tsx. +const TERRAIN_EXAGGERATION = 1.5 + +function getAbsoluteTerrainElevation(map: Map, lngLat: { lng: number; lat: number }): number { + const relative = map.queryTerrainElevation(lngLat) ?? 0 + const centerElev = (map as unknown as { transform: { elevation: number } }).transform?.elevation ?? 0 + return (relative + centerElev) / TERRAIN_EXAGGERATION +} + export interface CoasterPathHandle { anchors: AnchorPoint[] - pathPts: Cesium.Cartesian3[] + /** Sampled path as geographic [lon, lat, alt] tuples */ + pathPts: [number, number, number][] selectedId: string | null mode: EditorMode setMode: (m: EditorMode) => void @@ -18,37 +40,54 @@ export interface CoasterPathHandle { clearAll: () => void } -let _counter = 0 -function genId(): string { - return `a${++_counter}-${Math.random().toString(36).slice(2, 6)}` +// ── Source / layer IDs ──────────────────────────────────────────────────────── + +const SRC_PATH = 'coaster-path-src' // thin draped line for queryRenderedFeatures hover +const SRC_LABEL = 'coaster-label-src' +const LYR_PATH = 'coaster-path-lyr' +const LYR_LABEL = 'coaster-label-lyr' +const LYR_PATH_3D = 'coaster-path-3d' // custom 3D layer id + +function emptyFC(): GeoJSON.FeatureCollection { return { type: 'FeatureCollection', features: [] } } + +function setData(map: Map, src: string, data: GeoJSON.GeoJSON) { + (map.getSource(src) as GeoJSONSource | undefined)?.setData(data) } -export function useCoasterPath(viewer: Cesium.Viewer, showPath = true, showAnchors = true): CoasterPathHandle { - const [anchors, setAnchors] = useState([]) - const [pathPts, setPathPts] = useState([]) - const [selectedId, setSelectedId] = useState(null) - const [mode, setMode] = useState('add') +let _counter = 0 +function genId(): string { return `a${++_counter}-${Math.random().toString(36).slice(2, 6)}` } + +// Sphere radius in metres for anchor markers at each selection state. +const ANCHOR_RADIUS_NORMAL = 4 +const ANCHOR_RADIUS_SELECTED = 6 + +export function useCoasterPath( + map: Map, + showPath = true, + showAnchors = true, +): CoasterPathHandle { + const [anchors, setAnchors] = useState([]) + const [pathPts, setPathPts] = useState<[number, number, number][]>([]) + const [selectedId, setSelectedId] = useState(null) + const [mode, setMode] = useState('add') - // Keep refs in sync so event-handler closures always see current values const anchorsRef = useRef(anchors); anchorsRef.current = anchors const modeRef = useRef(mode); modeRef.current = mode const selectedRef = useRef(selectedId); selectedRef.current = selectedId + const layer3DRef = useRef(null) - // Cesium entity refs - const sphereMapRef = useRef>(new Map()) - const pathEntities = useRef([]) - const startLabelRef = useRef(null) - - // Drag state (refs to avoid triggering re-renders) + // Drag state (no React re-renders during drag) const isDragging = useRef(false) const dragAnchorId = useRef(null) - const dragPos = useRef(null) + const dragLngLat = useRef<[number, number] | null>(null) const didMoveDuringDrag = useRef(false) - // ── public callbacks ─────────────────────────────────────────────────────── + // ── Public callbacks ─────────────────────────────────────────────────────── const updateAnchorHeight = useCallback((id: string, delta: number) => { - setAnchors(prev => prev.map(a => a.id === id ? { ...a, heightOffset: Math.max(0, a.heightOffset + delta) } : a)) + setAnchors(prev => prev.map(a => + a.id === id ? { ...a, heightOffset: Math.max(0, a.heightOffset + delta) } : a, + )) }, []) const removeAnchor = useCallback((id: string) => { @@ -70,237 +109,229 @@ export function useCoasterPath(viewer: Cesium.Viewer, showPath = true, showAncho }) }, []) - const clearAll = useCallback(() => { - setAnchors([]) - setSelectedId(null) - }, []) + const clearAll = useCallback(() => { setAnchors([]); setSelectedId(null) }, []) - // ── cursor style ─────────────────────────────────────────────────────────── + // ── Set up MapLibre sources + layers ─────────────────────────────────────── useEffect(() => { - viewer.scene.canvas.style.cursor = + map.addSource(SRC_PATH, { type: 'geojson', data: emptyFC() }) + map.addSource(SRC_LABEL, { type: 'geojson', data: emptyFC() }) + + // Thin draped line — used only so queryRenderedFeatures can pick up hover events + map.addLayer({ id: LYR_PATH, type: 'line', source: SRC_PATH, + layout: { 'line-cap': 'round' }, + paint: { 'line-color': '#fbbf24', 'line-width': 2, 'line-opacity': 0.7 } }) + + map.addLayer({ id: LYR_LABEL, type: 'symbol', source: SRC_LABEL, + layout: { + 'text-field': ['get', 'label'], + 'text-size': 13, + 'text-anchor': 'bottom', + 'text-offset': [0, -1.2], + }, + paint: { + 'text-color': '#4ade80', + 'text-halo-color': '#000000', + 'text-halo-width': 1.5, + } }) + + return () => { + safeRemoveLayers(map, + [LYR_PATH, LYR_LABEL], + [SRC_PATH, SRC_LABEL], + ) + } + }, [map]) + + // ── Custom 3D layer for path, rails, and anchor spheres ─────────────────── + + useEffect(() => { + const handle = createCustom3DLayer(LYR_PATH_3D, map) + layer3DRef.current = handle + return () => { + handle.destroy() + layer3DRef.current = null + } + }, [map]) + + // ── Cursor style ─────────────────────────────────────────────────────────── + + useEffect(() => { + const canvas = map.getCanvas() + canvas.style.cursor = mode === 'add' ? 'crosshair' : - mode === 'strip' ? 'cell' : 'default' - }, [mode, viewer]) + mode === 'strip' ? 'cell' : '' + }, [mode, map]) - // ── entity sync ──────────────────────────────────────────────────────────── + // ── Sync anchors + path to GeoJSON sources and 3D layer ─────────────────── useEffect(() => { - const existingIds = new Set(anchors.map(a => a.id)) - - // Remove spheres for deleted anchors - sphereMapRef.current.forEach((entity, id) => { - if (!existingIds.has(id)) { - viewer.entities.remove(entity) - sphereMapRef.current.delete(id) - } - }) - - // Add or update anchor spheres - anchors.forEach((anchor) => { - const pos = effectivePosition(anchor) - const isSelected = anchor.id === selectedId - const color = isSelected ? Cesium.Color.fromCssColorString('#f59e0b') : Cesium.Color.WHITE - const size = isSelected ? 15 : 10 - - if (!sphereMapRef.current.has(anchor.id)) { - const entity = viewer.entities.add({ - id: `coaster-anchor-${anchor.id}`, - position: new Cesium.ConstantPositionProperty(pos), - point: { - pixelSize: size, - color, - outlineColor: Cesium.Color.BLACK.withAlpha(0.6), - outlineWidth: 2, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - properties: { anchorId: anchor.id }, - }) - sphereMapRef.current.set(anchor.id, entity) - } else { - const entity = sphereMapRef.current.get(anchor.id)! - entity.position = new Cesium.ConstantPositionProperty(pos) - entity.point!.color = new Cesium.ConstantProperty(color) - entity.point!.pixelSize = new Cesium.ConstantProperty(size) - } - }) - - // Rebuild path + rails - pathEntities.current.forEach(e => viewer.entities.remove(e)) - pathEntities.current = [] + // Draped label for the start point + if (anchors.length > 0) { + const [lon, lat, alt] = ecefToLngLatAlt(effectivePosition(anchors[0])) + setData(map, SRC_LABEL, { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + geometry: { type: 'Point', coordinates: [lon, lat, alt] }, + properties: { label: '▶ Start' }, + }], + }) + } else { + setData(map, SRC_LABEL, emptyFC()) + } if (anchors.length >= 2) { - const pts = samplePath(anchors) - setPathPts(pts) - const { left, right } = computeRails(pts) + const ecefPts = samplePath(anchors) + const geoPts = ecefPts.map(v => ecefToLngLatAlt(v)) as [number, number, number][] + setPathPts(geoPts) - const centre = viewer.entities.add({ - polyline: { - positions: pts, - width: 2, - material: Cesium.Color.YELLOW.withAlpha(0.55), - arcType: Cesium.ArcType.NONE, - }, + // Thin draped line for hover hit-testing (queryRenderedFeatures) + setData(map, SRC_PATH, { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + geometry: { type: 'LineString', coordinates: geoPts }, + properties: {}, + }], }) - const leftRail = viewer.entities.add({ - polyline: { - positions: left, - width: 3, - material: Cesium.Color.fromCssColorString('#b0b8c1'), - arcType: Cesium.ArcType.NONE, - }, - }) - const rightRail = viewer.entities.add({ - polyline: { - positions: right, - width: 3, - material: Cesium.Color.fromCssColorString('#b0b8c1'), - arcType: Cesium.ArcType.NONE, - }, - }) - pathEntities.current = [centre, leftRail, rightRail] + + const { left, right } = computeRails(ecefPts) + const leftPts = left.map(v => ecefToLngLatAlt(v)) as [number, number, number][] + const rightPts = right.map(v => ecefToLngLatAlt(v)) as [number, number, number][] + + layer3DRef.current?.update([ + { pts: geoPts, color: 0xfbbf24, radiusMeters: 0.15 }, + { pts: leftPts, color: 0xb0b8c1, radiusMeters: 0.08 }, + { pts: rightPts, color: 0xb0b8c1, radiusMeters: 0.08 }, + ]) } else { + setData(map, SRC_PATH, emptyFC()) + layer3DRef.current?.update([]) setPathPts([]) } - // Start label — always at first anchor - if (startLabelRef.current) { - viewer.entities.remove(startLabelRef.current) - startLabelRef.current = null + // Anchor sphere markers rendered in 3D at path altitude + if (showAnchors && anchors.length > 0) { + const spheres: Point3D[] = anchors.map(a => ({ + id: a.id, + pt: effectiveLngLatAlt(a), + color: a.id === selectedId ? 0xf59e0b : 0xffffff, + radiusMeters: a.id === selectedId ? ANCHOR_RADIUS_SELECTED : ANCHOR_RADIUS_NORMAL, + })) + layer3DRef.current?.updatePoints(spheres) + } else { + layer3DRef.current?.updatePoints([]) } - if (anchors.length > 0) { - startLabelRef.current = viewer.entities.add({ - position: new Cesium.ConstantPositionProperty(effectivePosition(anchors[0])), - label: { - text: '\u25B6 Start', - font: '13px sans-serif', - fillColor: Cesium.Color.fromCssColorString('#4ade80'), - outlineColor: Cesium.Color.BLACK, - outlineWidth: 2, - style: Cesium.LabelStyle.FILL_AND_OUTLINE, - pixelOffset: new Cesium.Cartesian2(0, -22), - disableDepthTestDistance: Number.POSITIVE_INFINITY, - showBackground: true, - backgroundColor: Cesium.Color.BLACK.withAlpha(0.45), - backgroundPadding: new Cesium.Cartesian2(6, 4), - }, - }) - } - }, [anchors, selectedId, viewer]) + }, [anchors, selectedId, showAnchors, map]) - // ── path visibility toggle ───────────────────────────────────────────────── + // ── Visibility toggles ───────────────────────────────────────────────────── useEffect(() => { - pathEntities.current.forEach(e => { e.show = showPath }) - }, [showPath]) + const vis = showPath ? 'visible' : 'none' + if (map.getLayer(LYR_PATH)) map.setLayoutProperty(LYR_PATH, 'visibility', vis) + }, [showPath, map]) - // ── anchor visibility toggle ─────────────────────────────────────────────── + // ── Input handling ───────────────────────────────────────────────────────── useEffect(() => { - sphereMapRef.current.forEach(e => { e.show = showAnchors }) - if (startLabelRef.current) startLabelRef.current.show = showAnchors - }, [showAnchors]) - - // ── input handling ───────────────────────────────────────────────────────── - - useEffect(() => { - const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas) - const canvas = viewer.scene.canvas - - // Prevent browser context-menu in the viewer - const suppressCtx = (e: Event) => e.preventDefault() + const canvas = map.getCanvas() + const suppressCtx = (e: Event) => { e.preventDefault() } canvas.addEventListener('contextmenu', suppressCtx) - const disableCam = () => { - const c = viewer.scene.screenSpaceCameraController - c.enableRotate = c.enableZoom = c.enableTranslate = c.enableTilt = false - } - const enableCam = () => { - const c = viewer.scene.screenSpaceCameraController - c.enableRotate = c.enableZoom = c.enableTranslate = c.enableTilt = true - } - - function pickAnchorId(pos: Cesium.Cartesian2): string | null { - const picked = viewer.scene.pick(pos) - if (!Cesium.defined(picked)) return null - const entity = picked.id as Cesium.Entity | undefined - if (!(entity instanceof Cesium.Entity)) return null - return entity.properties?.anchorId?.getValue() ?? null - } - - function pickTerrain(pos: Cesium.Cartesian2): Cesium.Cartesian3 | null { - const ray = viewer.camera.getPickRay(pos) - if (!ray) return null - return viewer.scene.globe.pick(ray, viewer.scene) ?? null - } - - // LEFT_DOWN – start drag in select mode - handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.PositionedEvent) => { + // Mousedown — begin drag on anchor sphere (select mode only) + const onMouseDown = (e: MapMouseEvent) => { if (modeRef.current !== 'select') return - const anchorId = pickAnchorId(e.position) + const { clientWidth: w, clientHeight: h } = canvas + const anchorId = layer3DRef.current?.hitTestPoints(e.point.x, e.point.y, w, h) ?? null if (!anchorId) return + e.preventDefault() isDragging.current = true dragAnchorId.current = anchorId - dragPos.current = null + dragLngLat.current = null didMoveDuringDrag.current = false - disableCam() - }, Cesium.ScreenSpaceEventType.LEFT_DOWN) + map.dragPan.disable() + } - // MOUSE_MOVE – live-drag the sphere - handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.MotionEvent) => { - if (!isDragging.current || !dragAnchorId.current) return - const newPos = pickTerrain(e.endPosition) - if (!newPos) return - didMoveDuringDrag.current = true - dragPos.current = newPos - // Move the sphere entity directly (no React re-render during drag) - const entity = sphereMapRef.current.get(dragAnchorId.current) - if (entity) entity.position = new Cesium.ConstantPositionProperty(newPos) - }, Cesium.ScreenSpaceEventType.MOUSE_MOVE) - - // LEFT_UP – commit drag - handler.setInputAction(() => { - if (isDragging.current) { - if (didMoveDuringDrag.current && dragAnchorId.current && dragPos.current) { - const id = dragAnchorId.current - const pos = dragPos.current - setAnchors(prev => prev.map(a => a.id === id ? { ...a, position: pos } : a)) - } - isDragging.current = false - dragAnchorId.current = null - dragPos.current = null - enableCam() - } - }, Cesium.ScreenSpaceEventType.LEFT_UP) - - // LEFT_CLICK – add point or select - handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.PositionedEvent) => { - // Suppress click that immediately follows a completed drag - if (didMoveDuringDrag.current) { - didMoveDuringDrag.current = false + // Mousemove → drag anchor live, or update cursor + const onMouseMove = (e: MapMouseEvent) => { + if (isDragging.current && dragAnchorId.current) { + didMoveDuringDrag.current = true + const ll: [number, number] = [e.lngLat.lng, e.lngLat.lat] + dragLngLat.current = ll + // Live-update anchor sphere position without React re-render + const id = dragAnchorId.current + const terrainAlt = getAbsoluteTerrainElevation(map, e.lngLat) + const current = anchorsRef.current + const spheres: Point3D[] = current.map(a => { + const pt: [number, number, number] = a.id === id + ? [ll[0], ll[1], terrainAlt + a.heightOffset] + : effectiveLngLatAlt(a) + return { + id: a.id, + pt, + color: a.id === selectedRef.current ? 0xf59e0b : 0xffffff, + radiusMeters: a.id === selectedRef.current ? ANCHOR_RADIUS_SELECTED : ANCHOR_RADIUS_NORMAL, + } + }) + layer3DRef.current?.updatePoints(spheres) return } + if (modeRef.current === 'select') { + const { clientWidth: w, clientHeight: h } = canvas + const hit = layer3DRef.current?.hitTestPoints(e.point.x, e.point.y, w, h) + canvas.style.cursor = hit ? 'grab' : '' + } + } - const anchorId = pickAnchorId(e.position) + // Mouseup → commit drag to React state + const onMouseUp = (e: MapMouseEvent) => { + if (!isDragging.current) return + if (didMoveDuringDrag.current && dragAnchorId.current && dragLngLat.current) { + const id = dragAnchorId.current + const [lng, lat] = dragLngLat.current + const terrainAlt = getAbsoluteTerrainElevation(map, e.lngLat) + setAnchors(prev => prev.map(a => + a.id === id ? { ...a, lngLat: [lng, lat], terrainHeight: terrainAlt } : a, + )) + } + isDragging.current = false + dragAnchorId.current = null + dragLngLat.current = null + map.dragPan.enable() + if (modeRef.current === 'select') canvas.style.cursor = '' + } + + // Click → add anchor or select/deselect + const onClick = (e: MapMouseEvent) => { + if (didMoveDuringDrag.current) { didMoveDuringDrag.current = false; return } + + // 3D sphere hit test for anchor selection + const { clientWidth: w, clientHeight: h } = canvas + const anchorId = layer3DRef.current?.hitTestPoints(e.point.x, e.point.y, w, h) ?? null if (anchorId) { setSelectedId(prev => prev === anchorId ? null : anchorId) return } if (modeRef.current === 'add') { - const pos = pickTerrain(e.position) - if (!pos) return + const terrainAlt = getAbsoluteTerrainElevation(map, e.lngLat) const id = genId() - setAnchors(prev => [...prev, { id, position: pos, heightOffset: 1 }]) + setAnchors(prev => [...prev, { + id, + lngLat: [e.lngLat.lng, e.lngLat.lat], + terrainHeight: terrainAlt, + heightOffset: 1, + }]) setSelectedId(null) } else if (modeRef.current === 'select') { setSelectedId(null) } - // 'strip' mode clicks are handled by useAccelerationStrips - }, Cesium.ScreenSpaceEventType.LEFT_CLICK) + // 'strip' mode clicks handled by useAccelerationStrips + } - // RIGHT_CLICK – undo / remove selected - handler.setInputAction(() => { + // Right-click on canvas → undo / delete selected + const onContextMenu = () => { if (selectedRef.current) { const id = selectedRef.current setAnchors(prev => prev.filter(a => a.id !== id)) @@ -308,33 +339,26 @@ export function useCoasterPath(viewer: Cesium.Viewer, showPath = true, showAncho } else { setAnchors(prev => prev.slice(0, -1)) } - }, Cesium.ScreenSpaceEventType.RIGHT_CLICK) + } + canvas.addEventListener('contextmenu', onContextMenu) + + map.on('mousedown', onMouseDown) + map.on('mousemove', onMouseMove) + map.on('mouseup', onMouseUp) + map.on('click', onClick) return () => { - if (!handler.isDestroyed()) handler.destroy() canvas.removeEventListener('contextmenu', suppressCtx) - if (!viewer.isDestroyed()) { - enableCam() - viewer.scene.canvas.style.cursor = 'default' - } + canvas.removeEventListener('contextmenu', onContextMenu) + map.off('mousedown', onMouseDown) + map.off('mousemove', onMouseMove) + map.off('mouseup', onMouseUp) + map.off('click', onClick) + map.dragPan.enable() + canvas.style.cursor = '' } - }, [viewer]) // eslint-disable-line react-hooks/exhaustive-deps + }, [map]) // eslint-disable-line react-hooks/exhaustive-deps - // ── cleanup on unmount ───────────────────────────────────────────────────── - - useEffect(() => { - return () => { - if (viewer.isDestroyed()) return - sphereMapRef.current.forEach(e => viewer.entities.remove(e)) - sphereMapRef.current.clear() - pathEntities.current.forEach(e => viewer.entities.remove(e)) - pathEntities.current = [] - if (startLabelRef.current) { - viewer.entities.remove(startLabelRef.current) - startLabelRef.current = null - } - } - }, [viewer]) - - return { anchors, pathPts, selectedId, mode, setMode, updateAnchorHeight, removeAnchor, loadAnchors, undoLast, clearAll } + return { anchors, pathPts, selectedId, mode, setMode, + updateAnchorHeight, removeAnchor, loadAnchors, undoLast, clearAll } } diff --git a/web/src/coaster/useTerrainCapture.ts b/web/src/coaster/useTerrainCapture.ts index 86ea1fa..260d8d1 100644 --- a/web/src/coaster/useTerrainCapture.ts +++ b/web/src/coaster/useTerrainCapture.ts @@ -1,7 +1,8 @@ import { useState, useEffect, useRef } from 'react' -import * as Cesium from 'cesium' +import type { Map } from 'maplibre-gl' import * as THREE from 'three' import type { CoasterSimulationResult } from '../types/api' +import { lngLatAltToECEF, ecefToEnu } from '../maplibre/geoUtils' // ── Public types ─────────────────────────────────────────────────────────────── @@ -10,7 +11,7 @@ export interface TerrainCaptureData { tileBbox: [number, number, number, number] /** Stitched satellite imagery */ imageBitmap: ImageBitmap - /** 64×64 grid of ENU positions (X=East, Y=Up, Z=−North) with terrain heights */ + /** 64×64 grid of ENU positions (X=East, Y=North, Z=Up → remapped to Three.js) */ terrainVertices: THREE.Vector3[] gridSize: 64 /** Geodetic origin used for ENU conversions (shared across all patches) */ @@ -22,34 +23,126 @@ export interface TerrainCaptureData { export type CaptureStatus = 'idle' | 'loading' | 'ready' | 'error' // ── Patch sizing ─────────────────────────────────────────────────────────────── -// Each patch covers PATCH_RADIUS_M metres in each direction from the track centre. -// Patches are spaced PATCH_INTERVAL_M apart along the track arc-length. -// At z19 a 1 km × 1 km bbox fits in 4×4 tiles → ~0.85 m/px (vs ~9 m/px for an -// 8 km bbox at the z15 forced by the old single-capture approach). -// Patches overlap by ~150 m at the midpoint between centres so swaps are seamless. -const PATCH_RADIUS_M = 500 // ±500 m → 1 km × 1 km per patch -const PATCH_INTERVAL_M = 700 // one patch centre every 700 m of track +const PATCH_RADIUS_M = 500 +const PATCH_INTERVAL_M = 700 + +// ── Tile math ───────────────────────────────────────────────────────────────── + +function lngLatToTile(lon: number, lat: number, z: number): { x: number; y: number } { + const n = Math.pow(2, z) + const x = Math.floor((lon + 180) / 360 * n) + const latR = lat * Math.PI / 180 + const y = Math.floor((1 - Math.log(Math.tan(latR) + 1 / Math.cos(latR)) / Math.PI) / 2 * n) + return { x, y } +} + +function tileToLng(x: number, z: number): number { + return x / Math.pow(2, z) * 360 - 180 +} + +function tileToLat(y: number, z: number): number { + const n = Math.PI - 2 * Math.PI * y / Math.pow(2, z) + return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))) +} // ── Geo → Three.js ENU (X=East, Y=Up, Z=−North) ─────────────────────────────── +// Note: RideRenderer expects Y=Up, Z=−North (Three.js-friendly ENU). export function geoToEnu( lon: number, lat: number, alt: number, origin: [number, number, number], ): THREE.Vector3 { - const pt = Cesium.Cartesian3.fromDegrees(lon, lat, alt) - const org = Cesium.Cartesian3.fromDegrees(origin[0], origin[1], origin[2]) - const enuFrame = Cesium.Transforms.eastNorthUpToFixedFrame(org) - const inv = Cesium.Matrix4.inverseTransformation(enuFrame, new Cesium.Matrix4()) - const enu = Cesium.Matrix4.multiplyByPoint(inv, pt, new Cesium.Cartesian3()) - // ENU: x=East, y=North, z=Up → Three.js: x=East, y=Up, z=−North + const pt = lngLatAltToECEF(lon, lat, alt) + const enu = ecefToEnu(pt, origin) // X=East, Y=North, Z=Up + // Remap to Three.js convention used by RideRenderer: X=East, Y=Up, Z=−North return new THREE.Vector3(enu.x, enu.z, -enu.y) } +// ── Terrarium tile elevation decoding ───────────────────────────────────────── + +async function fetchTerrariumTile(z: number, x: number, y: number): Promise { + const url = `https://s3.amazonaws.com/elevation-tiles-prod/terrarium/${z}/${x}/${y}.png` + try { + const resp = await fetch(url) + if (!resp.ok) return null + return createImageBitmap(await resp.blob()) + } catch { + return null + } +} + +function decodeTerrarium(r: number, g: number, b: number): number { + return r * 256 + g + b / 256 - 32768 +} + +async function sampleElevationGrid( + tileBbox: [number, number, number, number], + gridSize: number, +): Promise { + const [west, south, east, north] = tileBbox + + // Use zoom 14 for terrain sampling — 4× better resolution than z13 (~5 m/px), + // much closer to MapLibre's z15 queryTerrainElevation samples, so track altitudes + // and mesh heights agree within 1-3 m instead of 10+ m on varied terrain. + const ELEV_ZOOM = 14 + + const swTile = lngLatToTile(west, south, ELEV_ZOOM) + const neTile = lngLatToTile(east, north, ELEV_ZOOM) + const tileXMin = swTile.x + const tileXMax = neTile.x + const tileYMin = neTile.y + const tileYMax = swTile.y + const nx = tileXMax - tileXMin + 1 + const ny = tileYMax - tileYMin + 1 + + // Fetch and stitch terrain tiles + const tileImages = await Promise.all( + Array.from({ length: ny }, (_, tj) => + Array.from({ length: nx }, async (_, ti) => { + const img = await fetchTerrariumTile(ELEV_ZOOM, tileXMin + ti, tileYMin + tj) + return { ti, tj, img } + }), + ).flat(), + ) + + const TILE_PX = 256 + const canvas = new OffscreenCanvas(nx * TILE_PX, ny * TILE_PX) + const ctx = canvas.getContext('2d') as OffscreenCanvasRenderingContext2D + for (const { ti, tj, img } of tileImages) { + if (img) ctx.drawImage(img as CanvasImageSource, ti * TILE_PX, tj * TILE_PX) + } + + // Actual geographic extent covered by stitched tiles + const tileWest = tileToLng(tileXMin, ELEV_ZOOM) + const tileEast = tileToLng(tileXMax + 1, ELEV_ZOOM) + const tileNorth = tileToLat(tileYMin, ELEV_ZOOM) + const tileSouth = tileToLat(tileYMax + 1, ELEV_ZOOM) + const totalW = nx * TILE_PX + const totalH = ny * TILE_PX + + const elevations: number[] = [] + for (let j = 0; j < gridSize; j++) { + for (let i = 0; i < gridSize; i++) { + const lon = west + (east - west) * i / (gridSize - 1) + const lat = south + (north - south) * j / (gridSize - 1) + const px = Math.max(0, Math.min(totalW - 1, Math.round( + (lon - tileWest) / (tileEast - tileWest) * totalW, + ))) + // Lat is top-to-bottom (north=0, south=height) + const py = Math.max(0, Math.min(totalH - 1, Math.round( + (tileNorth - lat) / (tileNorth - tileSouth) * totalH, + ))) + const [r, g, b] = ctx.getImageData(px, py, 1, 1).data + elevations.push(decodeTerrarium(r, g, b)) + } + } + return elevations +} + // ── Single-patch capture ─────────────────────────────────────────────────────── async function captureTerrainPatch( - viewer: Cesium.Viewer, origin: [number, number, number], minLon: number, maxLon: number, @@ -57,91 +150,72 @@ async function captureTerrainPatch( maxLat: number, trackFrac: number, ): Promise { - // ── Use Cesium's imagery provider (same tiles the viewer already shows) ─── - const provider = viewer.imageryLayers.get(0).imageryProvider - const tilingScheme = provider.tilingScheme - const maxLevel = Math.min((provider as { maximumLevel?: number }).maximumLevel ?? 19, 21) - - // ── Find the highest zoom where tile count stays ≤ 25 ──────────────────── - let level = maxLevel + // ── Satellite imagery (ESRI World Imagery, {z}/{y}/{x} order) ───────────── + // Use z19 max; find highest zoom where tile count ≤ 25 + let level = 19 while (level > 5) { - const sw = tilingScheme.positionToTileXY( - Cesium.Cartographic.fromDegrees(minLon, minLat), level, new Cesium.Cartesian2(), - ) - const ne = tilingScheme.positionToTileXY( - Cesium.Cartographic.fromDegrees(maxLon, maxLat), level, new Cesium.Cartesian2(), - ) - if (!sw || !ne) { level--; continue } + const sw = lngLatToTile(minLon, minLat, level) + const ne = lngLatToTile(maxLon, maxLat, level) const nx = ne.x - sw.x + 1 const ny = sw.y - ne.y + 1 if (nx >= 1 && ny >= 1 && nx * ny <= 25) break level-- } - const swTile = tilingScheme.positionToTileXY( - Cesium.Cartographic.fromDegrees(minLon, minLat), level, new Cesium.Cartesian2(), - )! - const neTile = tilingScheme.positionToTileXY( - Cesium.Cartographic.fromDegrees(maxLon, maxLat), level, new Cesium.Cartesian2(), - )! - + const swTile = lngLatToTile(minLon, minLat, level) + const neTile = lngLatToTile(maxLon, maxLat, level) const tileXMin = swTile.x const tileXMax = neTile.x - const tileYMin = neTile.y + const tileYMin = neTile.y // north tiles have smaller y const tileYMax = swTile.y const nx = tileXMax - tileXMin + 1 const ny = tileYMax - tileYMin + 1 - const TILE_PX = provider.tileWidth + const TILE_PX = 256 - // ── Fetch tiles via the provider ───────────────────────────────────────── - const tileImages = await Promise.all( + // ESRI uses {z}/{y}/{x} + const satImages = await Promise.all( Array.from({ length: ny }, (_, tj) => - Array.from({ length: nx }, (_, ti) => { + Array.from({ length: nx }, async (_, ti) => { const x = tileXMin + ti const y = tileYMin + tj - const result = provider.requestImage(x, y, level) - const p: Promise = - result instanceof Promise ? result : Promise.resolve(result ?? undefined) - return p.then(img => ({ ti, tj, img: img ?? null })) + const url = `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/${level}/${y}/${x}` + try { + const resp = await fetch(url) + if (!resp.ok) return { ti, tj, img: null } + const img = await createImageBitmap(await resp.blob()) + return { ti, tj, img } + } catch { + return { ti, tj, img: null } + } }), ).flat(), ) - // ── Stitch into a single OffscreenCanvas ───────────────────────────────── const canvas = new OffscreenCanvas(nx * TILE_PX, ny * TILE_PX) - const ctx = canvas.getContext('2d') as OffscreenCanvasRenderingContext2D - for (const { ti, tj, img } of tileImages) { + const ctx = canvas.getContext('2d') as OffscreenCanvasRenderingContext2D + for (const { ti, tj, img } of satImages) { if (img) ctx.drawImage(img as CanvasImageSource, ti * TILE_PX, tj * TILE_PX) } - const imageBitmap = await createImageBitmap(canvas) + // Three.js ignores `flipY` for ImageBitmap; flip at creation time so UV v=0 (South row) + // maps to the bottom of the satellite image (South), not top (North). + const imageBitmap = await createImageBitmap(canvas, { imageOrientation: 'flipY' }) - // ── Derive geographic bbox from actual tile edges ───────────────────────── - const nwRect = tilingScheme.tileXYToRectangle(tileXMin, tileYMin, level, new Cesium.Rectangle()) - const seRect = tilingScheme.tileXYToRectangle(tileXMax, tileYMax, level, new Cesium.Rectangle()) + // Geographic bbox from actual tile edges const tileBbox: [number, number, number, number] = [ - Cesium.Math.toDegrees(nwRect.west), - Cesium.Math.toDegrees(seRect.south), - Cesium.Math.toDegrees(seRect.east), - Cesium.Math.toDegrees(nwRect.north), + tileToLng(tileXMin, level), + tileToLat(tileYMax + 1, level), + tileToLng(tileXMax + 1, level), + tileToLat(tileYMin, level), ] - // ── Sample terrain heights on a 64×64 grid ─────────────────────────────── - const GRID = 64 - const cartographics: Cesium.Cartographic[] = [] - for (let j = 0; j < GRID; j++) { - for (let i = 0; i < GRID; i++) { - const lon = tileBbox[0] + (tileBbox[2] - tileBbox[0]) * i / (GRID - 1) - const lat = tileBbox[1] + (tileBbox[3] - tileBbox[1]) * j / (GRID - 1) - cartographics.push(Cesium.Cartographic.fromDegrees(lon, lat)) - } - } - const sampled = await Cesium.sampleTerrainMostDetailed(viewer.terrainProvider, cartographics) + // ── Terrain elevation grid ──────────────────────────────────────────────── + const GRID = 64 as const + const elevations = await sampleElevationGrid(tileBbox, GRID) - // ── Convert to ENU Three.js vectors ────────────────────────────────────── - const terrainVertices: THREE.Vector3[] = sampled.map((c, idx) => { + const terrainVertices: THREE.Vector3[] = elevations.map((elev, idx) => { const lon = tileBbox[0] + (tileBbox[2] - tileBbox[0]) * (idx % GRID) / (GRID - 1) const lat = tileBbox[1] + (tileBbox[3] - tileBbox[1]) * Math.floor(idx / GRID) / (GRID - 1) - return geoToEnu(lon, lat, c.height ?? 0, origin) + return geoToEnu(lon, lat, elev, origin) }) return { tileBbox, imageBitmap, terrainVertices, gridSize: 64, origin, trackFrac } @@ -150,7 +224,6 @@ async function captureTerrainPatch( // ── Multi-patch prefetch ─────────────────────────────────────────────────────── async function captureAllPatches( - viewer: Cesium.Viewer, simResult: CoasterSimulationResult, ): Promise { const origin = simResult.origin @@ -159,13 +232,11 @@ async function captureAllPatches( const r1 = simResult.rail_1 as [number, number, number][] const r2 = simResult.rail_2 as [number, number, number][] - // ── Sample N patch centres evenly along arc-length ──────────────────────── const N = Math.max(1, Math.round(totalLength / PATCH_INTERVAL_M) + 1) const patchFracs = Array.from({ length: N }, (_, i) => N === 1 ? 0.5 : i / (N - 1), ) - // Geographic midpoint between both rails at a given s_frac function geoAt(frac: number): [number, number] { let idx = sFracs.length - 2 for (let j = 0; j < sFracs.length - 1; j++) { @@ -175,14 +246,13 @@ async function captureAllPatches( return [(r1[idx][0] + r2[idx][0]) / 2, (r1[idx][1] + r2[idx][1]) / 2] } - // ── Fetch all patches in parallel ───────────────────────────────────────── return Promise.all( patchFracs.map(frac => { const [lon, lat] = geoAt(frac) const rLat = PATCH_RADIUS_M / 111320 const rLon = PATCH_RADIUS_M / (111320 * Math.cos(lat * Math.PI / 180)) return captureTerrainPatch( - viewer, origin, + origin, lon - rLon, lon + rLon, lat - rLat, lat + rLat, frac, @@ -191,10 +261,10 @@ async function captureAllPatches( ) } -// ── Hook ───────────────────────────────────────────────────────────────────── +// ── Hook ────────────────────────────────────────────────────────────────────── export function useTerrainCapture( - viewer: Cesium.Viewer, + _map: Map, // kept for API compatibility; tile fetching is now direct simResult: CoasterSimulationResult | null, ) { const [status, setStatus] = useState('idle') @@ -213,7 +283,7 @@ export function useTerrainCapture( abortRef.current = false setStatus('loading') - captureAllPatches(viewer, simResult) + captureAllPatches(simResult) .then(patches => { if (abortRef.current) return setCaptureData(patches) @@ -225,10 +295,8 @@ export function useTerrainCapture( setStatus('error') }) - return () => { - abortRef.current = true - } - }, [simResult, viewer]) + return () => { abortRef.current = true } + }, [simResult]) // eslint-disable-line react-hooks/exhaustive-deps return { status, captureData } } diff --git a/web/src/maplibre/MapLibreViewer.tsx b/web/src/maplibre/MapLibreViewer.tsx new file mode 100644 index 0000000..1404a4c --- /dev/null +++ b/web/src/maplibre/MapLibreViewer.tsx @@ -0,0 +1,116 @@ +import { useEffect, useRef, useState } from 'react' +import maplibregl from 'maplibre-gl' +import 'maplibre-gl/dist/maplibre-gl.css' +import { MapLibreContext } from './maplibreContext' + +interface Props { + children?: React.ReactNode +} + +/** + * Satellite imagery: ESRI World Imagery (free, no key needed). + * 3-D terrain DEM: AWS Open Terrain in Terrarium format (public domain). + * OSM labels/roads overlay: OpenStreetMap raster at reduced opacity. + */ +function buildMapStyle(): maplibregl.StyleSpecification { + return { + version: 8, + glyphs: 'https://fonts.openmaptiles.org/{fontstack}/{range}.pbf', + sources: { + satellite: { + type: 'raster', + // ESRI uses {z}/{y}/{x} tile order + tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'], + tileSize: 256, + maxzoom: 19, + attribution: '© Esri, Maxar, Earthstar Geographics', + }, + 'terrain-dem': { + type: 'raster-dem', + encoding: 'terrarium', + tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'], + tileSize: 256, + maxzoom: 15, + attribution: '© Mapzen, USGS, SRTM', + }, + osm: { + type: 'raster', + tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], + tileSize: 256, + maxzoom: 19, + attribution: '© OpenStreetMap contributors', + }, + }, + layers: [ + { + id: 'satellite-layer', + type: 'raster', + source: 'satellite', + }, + { + id: 'osm-overlay', + type: 'raster', + source: 'osm', + paint: { 'raster-opacity': 0.25 }, + }, + ], + // sky is set programmatically after load + } +} + +export function MapLibreViewer({ children }: Props) { + const containerRef = useRef(null) + const [map, setMap] = useState(null) + + useEffect(() => { + if (!containerRef.current || map) return + + const m = new maplibregl.Map({ + container: containerRef.current, + style: buildMapStyle(), + center: [10.0, 51.0], + zoom: 7, + pitch: 50, + bearing: 0, + antialias: true, + maxPitch: 80, + }) + + m.on('load', () => { + // Enable 3D terrain + m.setTerrain({ source: 'terrain-dem', exaggeration: 1.5 }) + + // Sky atmosphere + m.setSky({ + 'sky-color': '#199EF3', + 'sky-horizon-blend': 0.5, + 'horizon-color': '#ffffff', + 'horizon-fog-blend': 0.5, + 'fog-color': '#0000ff', + 'fog-ground-blend': 0.9, + 'atmosphere-blend': ['interpolate', ['linear'], ['zoom'], 0, 1, 10, 0] as maplibregl.ExpressionSpecification, + }) + + // Navigation control (zoom + compass) + m.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), 'top-right') + + setMap(m) + }) + + return () => { + m.remove() + setMap(null) + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + return ( + <> + + {map && ( + + {children} + + )} + > + ) +} diff --git a/web/src/maplibre/custom3DLayer.ts b/web/src/maplibre/custom3DLayer.ts new file mode 100644 index 0000000..3ae0ee7 --- /dev/null +++ b/web/src/maplibre/custom3DLayer.ts @@ -0,0 +1,271 @@ +/** + * Custom MapLibre layer that renders 3D tube geometry above terrain. + * + * MapLibre 4.x drapes all built-in layer types (line, circle, fill) on the terrain + * surface regardless of their GeoJSON Z coordinate. The only escape hatch is a + * `type: 'custom'` layer whose `render()` callback receives the Mercator projection + * matrix and can draw arbitrary WebGL. We use Three.js for the geometry, reusing + * MapLibre's WebGL context so the output lands in the same frame. + * + * Altitude alignment: MapLibre passes the `mercatorMatrix` to custom layers, which + * does NOT include the center-elevation offset baked into the standard tile matrix. + * To align with the visual terrain surface (rendered at DEM × exaggeration), we must: + * 1. Multiply stored raw-MSL altitudes by TERRAIN_EXAGGERATION when building geometry. + * 2. Subtract `transform.elevation` (the exaggerated center elevation) from the + * origin Z each frame so objects track the camera-relative terrain position. + */ + +import * as THREE from 'three' +import { MercatorCoordinate } from 'maplibre-gl' +import type { CustomLayerInterface, Map } from 'maplibre-gl' + +// Must match the exaggeration value in MapLibreViewer.tsx. +const TERRAIN_EXAGGERATION = 1.5 + +// ── Public types ─────────────────────────────────────────────────────────────── + +export interface Line3D { + /** Geographic [lon, lat, altMSL] triples, in order. */ + pts: [number, number, number][] + /** Three.js colour hex (e.g. 0xfbbf24). */ + color: number + /** Physical tube radius in metres. */ + radiusMeters: number +} + +export interface Point3D { + /** Stable ID returned by hitTestPoints(). */ + id: string + /** Geographic [lon, lat, altMSL] position. */ + pt: [number, number, number] + /** Three.js colour hex. */ + color: number + /** Physical sphere radius in metres. */ + radiusMeters: number +} + +/** Handle returned by createCustom3DLayer. */ +export interface Layer3DHandle { + /** Replace all rendered tube lines and trigger a repaint. */ + update: (lines: Line3D[]) => void + /** Replace all rendered sphere points and trigger a repaint. */ + updatePoints: (points: Point3D[]) => void + /** + * Screen-space hit test against rendered spheres. + * @param x CSS-pixel x coordinate + * @param y CSS-pixel y coordinate + * @param w Canvas CSS-pixel width + * @param h Canvas CSS-pixel height + * @returns The `id` of the closest intersected sphere, or null. + */ + hitTestPoints: (x: number, y: number, w: number, h: number) => string | null + /** Remove the layer from the map and dispose GPU resources. */ + destroy: () => void +} + +// ── Factory ─────────────────────────────────────────────────────────────────── + +export function createCustom3DLayer(id: string, map: Map): Layer3DHandle { + const threeCamera = new THREE.Camera() + const scene = new THREE.Scene() + let renderer: THREE.WebGLRenderer | null = null + + let origin: MercatorCoordinate | null = null + let dirty = true + let currentLines: Line3D[] = [] + let currentPoints: Point3D[] = [] + + // GPU resource tracking (lines) + const geos: THREE.BufferGeometry[] = [] + const mats: THREE.Material[] = [] + const meshes: THREE.Mesh[] = [] + + // Sphere meshes for hit testing (parallel arrays) + const sphereMeshes: THREE.Mesh[] = [] + const sphereIds: string[] = [] + + // ── Geometry helpers ──────────────────────────────────────────────────────── + + function disposeGeometry() { + meshes.forEach(m => scene.remove(m)) + geos.forEach(g => g.dispose()) + mats.forEach(m => m.dispose()) + geos.length = 0 + mats.length = 0 + meshes.length = 0 + sphereMeshes.length = 0 + sphereIds.length = 0 + origin = null + } + + function rebuildGeometry() { + disposeGeometry() + dirty = false + + // Determine origin from the first available point (line or sphere). + const firstLinePt = currentLines.find(l => l.pts.length >= 2)?.pts[0] + const firstSphPt = currentPoints[0]?.pt + const firstPt = firstLinePt ?? firstSphPt + if (!firstPt) return + + const [lon0, lat0, alt0] = firstPt + // Use exaggerated altitude so geometry aligns with visual terrain. + origin = MercatorCoordinate.fromLngLat([lon0, lat0], alt0 * TERRAIN_EXAGGERATION) + const mpu = origin.meterInMercatorCoordinateUnits() + + // ── Tube geometry for lines ───────────────────────────────────────────── + + for (const { pts, color, radiusMeters } of currentLines) { + if (pts.length < 2) continue + + const step = Math.max(1, Math.floor(pts.length / 400)) + const sampled = pts.filter((_, i) => i % step === 0 || i === pts.length - 1) + if (sampled.length < 2) continue + + const threePts = sampled.map(([lon, lat, alt]) => { + const mc = MercatorCoordinate.fromLngLat([lon, lat], alt * TERRAIN_EXAGGERATION) + return new THREE.Vector3(mc.x - origin!.x, mc.y - origin!.y, mc.z - origin!.z) + }) + + const curve = new THREE.CatmullRomCurve3(threePts) + const segments = Math.min(threePts.length * 4, 800) + const geo = new THREE.TubeGeometry(curve, segments, radiusMeters * mpu, 8, false) + const mat = new THREE.MeshBasicMaterial({ color, depthTest: false, depthWrite: false }) + const mesh = new THREE.Mesh(geo, mat) + mesh.frustumCulled = false + + scene.add(mesh) + meshes.push(mesh) + geos.push(geo) + mats.push(mat) + } + + // ── Sphere geometry for points ────────────────────────────────────────── + + for (const { id, pt, color, radiusMeters } of currentPoints) { + const [lon, lat, alt] = pt + const mc = MercatorCoordinate.fromLngLat([lon, lat], alt * TERRAIN_EXAGGERATION) + const pos = new THREE.Vector3(mc.x - origin!.x, mc.y - origin!.y, mc.z - origin!.z) + + const geo = new THREE.SphereGeometry(radiusMeters * mpu, 14, 10) + const mat = new THREE.MeshBasicMaterial({ color, depthTest: false, depthWrite: false }) + const mesh = new THREE.Mesh(geo, mat) + mesh.position.copy(pos) + mesh.frustumCulled = false + + scene.add(mesh) + meshes.push(mesh) + geos.push(geo) + mats.push(mat) + sphereMeshes.push(mesh) + sphereIds.push(id) + } + } + + // ── MapLibre custom layer ─────────────────────────────────────────────────── + + const layer: CustomLayerInterface = { + id, + type: 'custom', + renderingMode: '3d', + + onAdd(_m: Map, gl: WebGLRenderingContext) { + renderer = new THREE.WebGLRenderer({ + canvas: map.getCanvas(), + context: gl as unknown as WebGL2RenderingContext, + antialias: false, + }) + renderer.autoClear = false + }, + + onRemove() { + disposeGeometry() + renderer?.dispose() + renderer = null + }, + + render(_gl: WebGLRenderingContext, matrix: number[]) { + if (!renderer) return + + if (dirty) rebuildGeometry() + + if (!origin || meshes.length === 0) { + renderer.resetState() + return + } + + // Compensate for the center-elevation offset that is present in + // modelViewProjectionMatrix (used by terrain tiles) but absent from + // mercatorMatrix (passed to custom layers). Subtracting it here makes + // our exaggerated-altitude objects align with the visual terrain surface. + const mpu = origin.meterInMercatorCoordinateUnits() + const centerElevM = (map as unknown as { transform: { elevation: number } }).transform?.elevation ?? 0 + const centerElevMerc = centerElevM * mpu + + const m = new THREE.Matrix4().fromArray(matrix) + const t = new THREE.Matrix4().makeTranslation( + origin.x, + origin.y, + origin.z - centerElevMerc, + ) + threeCamera.projectionMatrix = m.multiply(t) + // Keep inverse in sync so Raycaster can unproject correctly. + threeCamera.projectionMatrixInverse.copy(threeCamera.projectionMatrix).invert() + + renderer.resetState() + renderer.render(scene, threeCamera) + }, + } + + map.addLayer(layer) + + // ── Public handle ─────────────────────────────────────────────────────────── + + return { + update(lines: Line3D[]) { + currentLines = lines + dirty = true + map.triggerRepaint() + }, + + updatePoints(points: Point3D[]) { + currentPoints = points + dirty = true + map.triggerRepaint() + }, + + hitTestPoints(x: number, y: number, w: number, h: number): string | null { + if (sphereMeshes.length === 0) return null + + const ndcX = (2 * x) / w - 1 + const ndcY = 1 - (2 * y) / h + + // Unproject screen point into local scene space. + const projInv = threeCamera.projectionMatrix.clone().invert() + const near = new THREE.Vector3(ndcX, ndcY, -1).applyMatrix4(projInv) + const far = new THREE.Vector3(ndcX, ndcY, 1).applyMatrix4(projInv) + const dir = far.clone().sub(near).normalize() + + const raycaster = new THREE.Raycaster(near, dir) + + let closestDist = Infinity + let closestIdx = -1 + + for (let i = 0; i < sphereMeshes.length; i++) { + const hits = raycaster.intersectObject(sphereMeshes[i]) + if (hits.length > 0 && hits[0].distance < closestDist) { + closestDist = hits[0].distance + closestIdx = i + } + } + + return closestIdx >= 0 ? sphereIds[closestIdx] : null + }, + + destroy() { + try { + if (map.getLayer(id)) map.removeLayer(id) + } catch { /* map may already be destroyed */ } + }, + } +} diff --git a/web/src/maplibre/geoUtils.ts b/web/src/maplibre/geoUtils.ts new file mode 100644 index 0000000..cc4d96e --- /dev/null +++ b/web/src/maplibre/geoUtils.ts @@ -0,0 +1,162 @@ +import * as THREE from 'three' + +// WGS-84 ellipsoid constants +const A = 6378137.0 // semi-major axis (m) +const E2 = 0.00669437999014 // first eccentricity squared + +/** + * Convert geographic coordinates to ECEF (Earth-Centred Earth-Fixed) metres. + */ +export function lngLatAltToECEF(lon: number, lat: number, alt: number): THREE.Vector3 { + const lonR = lon * Math.PI / 180 + const latR = lat * Math.PI / 180 + const sinLat = Math.sin(latR) + const cosLat = Math.cos(latR) + const N = A / Math.sqrt(1 - E2 * sinLat * sinLat) + return new THREE.Vector3( + (N + alt) * cosLat * Math.cos(lonR), + (N + alt) * cosLat * Math.sin(lonR), + (N * (1 - E2) + alt) * sinLat, + ) +} + +/** + * Convert ECEF metres back to [longitude, latitude, altitude]. + * Uses Bowring's iterative method (5 iterations → sub-mm accuracy). + */ +export function ecefToLngLatAlt(v: THREE.Vector3): [number, number, number] { + const p = Math.sqrt(v.x * v.x + v.y * v.y) + const lon = Math.atan2(v.y, v.x) * 180 / Math.PI + let lat = Math.atan2(v.z, p * (1 - E2)) + for (let i = 0; i < 5; i++) { + const sinLat = Math.sin(lat) + const N = A / Math.sqrt(1 - E2 * sinLat * sinLat) + lat = Math.atan2(v.z + E2 * N * sinLat, p) + } + const sinLat = Math.sin(lat) + const N = A / Math.sqrt(1 - E2 * sinLat * sinLat) + const alt = p / Math.cos(lat) - N + return [lon, lat * 180 / Math.PI, alt] +} + +/** + * Convert a point from ECEF to ENU coordinates relative to an origin. + * ENU: X=East, Y=North, Z=Up + */ +export function ecefToEnu( + point: THREE.Vector3, + origin: [number, number, number], +): THREE.Vector3 { + const [oLon, oLat, oAlt] = origin + const org = lngLatAltToECEF(oLon, oLat, oAlt) + const diff = point.clone().sub(org) + + const oLonR = oLon * Math.PI / 180 + const oLatR = oLat * Math.PI / 180 + // ENU basis vectors in ECEF + const east = new THREE.Vector3(-Math.sin(oLonR), Math.cos(oLonR), 0) + const north = new THREE.Vector3( + -Math.sin(oLatR) * Math.cos(oLonR), + -Math.sin(oLatR) * Math.sin(oLonR), + Math.cos(oLatR), + ) + const up = new THREE.Vector3( + Math.cos(oLatR) * Math.cos(oLonR), + Math.cos(oLatR) * Math.sin(oLonR), + Math.sin(oLatR), + ) + + return new THREE.Vector3(diff.dot(east), diff.dot(north), diff.dot(up)) +} + +/** + * Build a Three.js Matrix4 that positions and orients a local scene + * at the given geographic coordinate (ENU → ECEF transform + heading rotation). + * + * @param lon Longitude in degrees + * @param lat Latitude in degrees + * @param alt Altitude in metres above WGS-84 ellipsoid + * @param headingDeg Clockwise heading in degrees (0 = North) + */ +export function buildSplatWorldMatrix( + lon: number, + lat: number, + alt: number, + headingDeg: number, +): THREE.Matrix4 { + const lonR = lon * Math.PI / 180 + const latR = lat * Math.PI / 180 + + // ENU basis vectors in ECEF + const east = new THREE.Vector3(-Math.sin(lonR), Math.cos(lonR), 0) + const north = new THREE.Vector3( + -Math.sin(latR) * Math.cos(lonR), + -Math.sin(latR) * Math.sin(lonR), + Math.cos(latR), + ) + const up = new THREE.Vector3( + Math.cos(latR) * Math.cos(lonR), + Math.cos(latR) * Math.sin(lonR), + Math.sin(latR), + ) + + // Apply heading rotation (clockwise from North = negative rotation around Up/Z-ENU) + const hRad = -headingDeg * Math.PI / 180 + const cosH = Math.cos(hRad) + const sinH = Math.sin(hRad) + + // Rotated ENU: X' = cosH*East + sinH*North, Y' = -sinH*East + cosH*North, Z' = Up + const xAxis = east.clone().multiplyScalar(cosH).addScaledVector(north, sinH) + const yAxis = east.clone().multiplyScalar(-sinH).addScaledVector(north, cosH) + const zAxis = up.clone() + + const pos = lngLatAltToECEF(lon, lat, alt) + + // Column-major Matrix4 (Three.js): each column is [x, y, z, 0], last col is translation + return new THREE.Matrix4().set( + xAxis.x, yAxis.x, zAxis.x, pos.x, + xAxis.y, yAxis.y, zAxis.y, pos.y, + xAxis.z, yAxis.z, zAxis.z, pos.z, + 0, 0, 0, 1, + ) +} + +/** + * Estimate MapLibre camera altitude above terrain in metres. + * Falls back to zoom-based approximation when transform internals are unavailable. + */ +export function getMapCameraAltitude(map: { getZoom(): number; getCenter(): { lat: number }; transform?: unknown }): number { + const t = (map as { transform?: { cameraToCenterDistance?: number; pixelsPerMeter?: number; altitude?: number } }).transform + if (t) { + if (typeof t.altitude === 'number') return t.altitude + if (t.cameraToCenterDistance && t.pixelsPerMeter) { + return t.cameraToCenterDistance / t.pixelsPerMeter + } + } + // Fallback: approximate from zoom (rough but covers our visibility thresholds) + const zoom = map.getZoom() + const lat = map.getCenter().lat + return (40075016.7 * Math.cos(lat * Math.PI / 180)) / Math.pow(2, zoom + 8) * 600 +} + +/** + * Remove MapLibre layers and sources without throwing if the map was already + * destroyed (map.remove() sets this.style to undefined internally). + */ +export function safeRemoveLayers( + map: { + getLayer: (id: string) => unknown + removeLayer: (id: string) => void + getSource: (id: string) => unknown + removeSource:(id: string) => void + }, + layerIds: string[], + sourceIds: string[], +) { + try { + for (const id of layerIds) { if (map.getLayer(id)) map.removeLayer(id) } + for (const id of sourceIds) { if (map.getSource(id)) map.removeSource(id) } + } catch { + // Map was already destroyed before this cleanup ran — safe to ignore. + } +} diff --git a/web/src/maplibre/maplibreContext.ts b/web/src/maplibre/maplibreContext.ts new file mode 100644 index 0000000..9049dbb --- /dev/null +++ b/web/src/maplibre/maplibreContext.ts @@ -0,0 +1,12 @@ +import { createContext, useContext } from 'react' +import type { Map } from 'maplibre-gl' + +export const MapLibreContext = createContext(null) + +export function useMapLibreMap(): Map { + const map = useContext(MapLibreContext) + if (!map) { + throw new Error('useMapLibreMap must be used inside ') + } + return map +} diff --git a/web/src/maplibre/useMapLibreCamera.ts b/web/src/maplibre/useMapLibreCamera.ts new file mode 100644 index 0000000..2278b80 --- /dev/null +++ b/web/src/maplibre/useMapLibreCamera.ts @@ -0,0 +1,36 @@ +import { useEffect } from 'react' +import { useMapLibreMap } from './maplibreContext' +import { useMapStore } from '../store/mapStore' +import { getMapCameraAltitude } from './geoUtils' + +/** + * Tracks MapLibre camera state (altitude + view bbox) and writes it to mapStore. + * Throttled to at most once per 200 ms so expensive API queries are not spammed. + */ +export function useMapLibreCamera() { + const map = useMapLibreMap() + const setCameraState = useMapStore((s) => s.setCameraState) + + useEffect(() => { + let lastFired = 0 + const THROTTLE_MS = 200 + + function onMove() { + const now = Date.now() + if (now - lastFired < THROTTLE_MS) return + lastFired = now + + const b = map.getBounds() + const bbox: [number, number, number, number] = [ + b.getWest(), b.getSouth(), b.getEast(), b.getNorth(), + ] + const altitude = getMapCameraAltitude(map) + setCameraState(altitude, bbox) + } + + map.on('move', onMove) + onMove() // fire once immediately so store is populated before first render + + return () => { map.off('move', onMove) } + }, [map, setCameraState]) +} diff --git a/web/src/splat/SplatLayer.tsx b/web/src/splat/SplatLayer.tsx index c18d9a5..984835c 100644 --- a/web/src/splat/SplatLayer.tsx +++ b/web/src/splat/SplatLayer.tsx @@ -1,41 +1,40 @@ import { useEffect, useRef } from 'react' -import * as Cesium from 'cesium' -import { useCesiumViewer } from '../cesium/cesiumContext' -import { useCesiumCamera } from '../cesium/useCesiumCamera' +import maplibregl from 'maplibre-gl' +import { useMapLibreMap } from '../maplibre/maplibreContext' +import { useMapLibreCamera } from '../maplibre/useMapLibreCamera' import { useMapStore } from '../store/mapStore' import { fetchSplats } from '../api/splats' import type { BBox } from '../types/geo' import type { SplatMapProperties } from '../types/api' -// Show splat pins when camera is below this altitude (metres) const SPLAT_VISIBLE_HEIGHT = 50_000 +const MAX_BBOX_DEGREES = 1.0 export function SplatLayer() { - const viewer = useCesiumViewer() - useCesiumCamera() + const map = useMapLibreMap() + useMapLibreCamera() const { bbox, cameraHeight, setLoadedSplats, setActiveSplatId } = useMapStore() - const entityMapRef = useRef>(new Map()) + const markersRef = useRef>(new Map()) const lastBboxRef = useRef(null) - // Fetch and sync splat entities whenever the bbox changes meaningfully + // Fetch and sync splat markers whenever the bbox changes meaningfully useEffect(() => { - if (!bbox || cameraHeight > SPLAT_VISIBLE_HEIGHT) { - // Clear all splat pins when zoomed out - entityMapRef.current.forEach((e) => viewer.entities.remove(e)) - entityMapRef.current.clear() + const bboxTooLarge = bbox && ( + (bbox[2] - bbox[0]) > MAX_BBOX_DEGREES || (bbox[3] - bbox[1]) > MAX_BBOX_DEGREES + ) + if (!bbox || cameraHeight > SPLAT_VISIBLE_HEIGHT || bboxTooLarge) { + markersRef.current.forEach(m => m.remove()) + markersRef.current.clear() setLoadedSplats([]) return } - // Avoid re-fetching if bbox moved less than 0.01° (noise reduction) const last = lastBboxRef.current if (last) { const delta = Math.max( - Math.abs(bbox[0] - last[0]), - Math.abs(bbox[1] - last[1]), - Math.abs(bbox[2] - last[2]), - Math.abs(bbox[3] - last[3]), + Math.abs(bbox[0] - last[0]), Math.abs(bbox[1] - last[1]), + Math.abs(bbox[2] - last[2]), Math.abs(bbox[3] - last[3]), ) if (delta < 0.01) return } @@ -44,75 +43,48 @@ export function SplatLayer() { fetchSplats(bbox).then((fc) => { const incoming = new Set(fc.features.map((f: GeoJSON.Feature) => f.properties.id)) - // Remove entities that are no longer in view - entityMapRef.current.forEach((entity, id) => { - if (!incoming.has(id)) { - viewer.entities.remove(entity) - entityMapRef.current.delete(id) - } + markersRef.current.forEach((marker, id) => { + if (!incoming.has(id)) { marker.remove(); markersRef.current.delete(id) } }) - // Add new entities fc.features.forEach((feature: GeoJSON.Feature) => { const id = feature.properties.id - if (entityMapRef.current.has(id)) return + if (markersRef.current.has(id)) return const [lon, lat] = feature.geometry.coordinates - const alt = feature.properties.altitude ?? 0 + const el = createSplatPinElement() + el.addEventListener('click', () => setActiveSplatId(id)) - const entity = viewer.entities.add({ - id: `splat-${id}`, - position: Cesium.Cartesian3.fromDegrees(lon, lat, alt), - billboard: { - image: createSplatPinSvg(), - width: 32, - height: 32, - verticalOrigin: Cesium.VerticalOrigin.BOTTOM, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - properties: { splatId: id }, - }) - entityMapRef.current.set(id, entity) + const marker = new maplibregl.Marker({ element: el, anchor: 'bottom' }) + .setLngLat([lon, lat]) + .addTo(map) + markersRef.current.set(id, marker) }) setLoadedSplats(fc.features) }).catch(console.error) }, [bbox, cameraHeight]) // eslint-disable-line react-hooks/exhaustive-deps - // Wire up entity selection → activeSplatId in store - useEffect(() => { - const remove = viewer.selectedEntityChanged.addEventListener((entity) => { - if (!entity) { - setActiveSplatId(null) - return - } - const splatId = entity.properties?.splatId?.getValue() - if (splatId) { - setActiveSplatId(splatId) - } - }) - return () => remove() - }, [viewer, setActiveSplatId]) - - // Clean up all entities on unmount + // Clean up on unmount useEffect(() => { return () => { - if (viewer.isDestroyed()) return - entityMapRef.current.forEach((e) => viewer.entities.remove(e)) - entityMapRef.current.clear() + markersRef.current.forEach(m => m.remove()) + markersRef.current.clear() } - }, [viewer]) + }, []) - return null // no DOM — everything is imperative Cesium entities + return null } -function createSplatPinSvg(): string { - const svg = ` +function createSplatPinElement(): HTMLElement { + const el = document.createElement('div') + el.style.cssText = 'width:32px;height:32px;cursor:pointer' + el.innerHTML = ` 3D ` - return `data:image/svg+xml;base64,${btoa(svg)}` + return el } diff --git a/web/src/splat/SplatRenderer.tsx b/web/src/splat/SplatRenderer.tsx index 07b7450..2a2f9ce 100644 --- a/web/src/splat/SplatRenderer.tsx +++ b/web/src/splat/SplatRenderer.tsx @@ -1,44 +1,44 @@ import { useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import * as THREE from 'three' -import { useCesiumViewer } from '../cesium/cesiumContext' +import { useMapLibreMap } from '../maplibre/maplibreContext' import { useMapStore } from '../store/mapStore' import { useSplatStore } from '../store/splatStore' import { syncSplatCamera } from './useSplatCamera' import { getSplatDownloadUrl } from './splatLoader' -import { buildSplatWorldMatrix } from '../cesium/geoUtils' +import { buildSplatWorldMatrix } from '../maplibre/geoUtils' import { fetchSplatDetail } from '../api/splats' -// Only render the splat when the camera is below this altitude +// Only render the splat when the camera is below this altitude (metres) const RENDER_HEIGHT = 500 export function SplatRenderer() { - const viewer = useCesiumViewer() + const map = useMapLibreMap() const canvasRef = useRef(null) const splatViewerRef = useRef(null) - const camerRef = useRef(new THREE.PerspectiveCamera()) + const cameraRef = useRef(new THREE.PerspectiveCamera()) const activeSplatId = useMapStore((s) => s.activeSplatId) - const cameraHeight = useMapStore((s) => s.cameraHeight) + const cameraHeight = useMapStore((s) => s.cameraHeight) const { setSplatDetail, splatCache } = useSplatStore() - // Keep canvas dimensions in sync with Cesium canvas + // Keep overlay canvas dimensions in sync with the MapLibre canvas useEffect(() => { - const cesiumCanvas = viewer.canvas + const mapCanvas = map.getCanvas() const overlayCanvas = canvasRef.current if (!overlayCanvas) return function syncSize() { - overlayCanvas!.width = cesiumCanvas.width - overlayCanvas!.height = cesiumCanvas.height - camerRef.current.aspect = cesiumCanvas.width / cesiumCanvas.height - camerRef.current.updateProjectionMatrix() + overlayCanvas!.width = mapCanvas.width + overlayCanvas!.height = mapCanvas.height + cameraRef.current.aspect = mapCanvas.width / mapCanvas.height + cameraRef.current.updateProjectionMatrix() } syncSize() const observer = new ResizeObserver(syncSize) - observer.observe(cesiumCanvas) + observer.observe(mapCanvas) return () => observer.disconnect() - }, [viewer]) + }, [map]) // Load / unload splat when activeSplatId changes useEffect(() => { @@ -52,7 +52,6 @@ export function SplatRenderer() { async function loadSplat() { if (!activeSplatId) return - // Fetch detail if not cached let detail = splatCache.get(activeSplatId) if (!detail) { detail = await fetchSplatDetail(activeSplatId) @@ -65,7 +64,6 @@ export function SplatRenderer() { const url = await getSplatDownloadUrl(activeSplatId) if (cancelled) return - // Dynamically import the library to keep initial bundle lean const { Viewer: GaussianViewer } = await import('@mkkellogg/gaussian-splats-3d') if (cancelled) return @@ -78,26 +76,21 @@ export function SplatRenderer() { selfDrivenMode: false, useBuiltInControls: false, renderer: new THREE.WebGLRenderer({ canvas, alpha: true }), - camera: camerRef.current, + camera: cameraRef.current, }) - // Geo-anchor the splat const [lon, lat] = detail.location.coordinates - const alt = detail.altitude ?? 0 + const alt = detail.altitude ?? 0 const heading = detail.heading ?? 0 const worldMatrix = buildSplatWorldMatrix(lon, lat, alt, heading) await gViewer.addSplatScene(url, { progressiveLoad: true, - onProgress: () => { /* optional: update progress UI */ }, + onProgress: () => { /* optional progress UI */ }, }) - if (cancelled) { - gViewer.dispose() - return - } + if (cancelled) { gViewer.dispose(); return } - // Apply geo-anchor transform to the loaded scene const scene = gViewer.splatMesh if (scene) { scene.matrixAutoUpdate = false @@ -109,34 +102,32 @@ export function SplatRenderer() { } loadSplat().catch(console.error) - - return () => { - cancelled = true - } + return () => { cancelled = true } }, [activeSplatId]) // eslint-disable-line react-hooks/exhaustive-deps - // Drive the splat render loop from Cesium's postRender event + // Drive the splat render loop from MapLibre's 'render' event useEffect(() => { - const remove = viewer.scene.postRender.addEventListener(() => { + function onRender() { const gViewer = splatViewerRef.current as import('@mkkellogg/gaussian-splats-3d').Viewer | null if (!gViewer || cameraHeight > RENDER_HEIGHT) return const canvas = canvasRef.current if (!canvas) return - syncSplatCamera(viewer, camerRef.current, canvas) + syncSplatCamera(map, cameraRef.current, canvas) gViewer.update() gViewer.render() - }) - return () => remove() - }, [viewer, cameraHeight]) + } + + map.on('render', onRender) + return () => { map.off('render', onRender) } + }, [map, cameraHeight]) function disposeSplatViewer() { const gViewer = splatViewerRef.current as { dispose?: () => void } | null if (gViewer?.dispose) gViewer.dispose() splatViewerRef.current = null - // Clear the overlay canvas const canvas = canvasRef.current if (canvas) { const ctx = canvas.getContext('2d') @@ -144,7 +135,6 @@ export function SplatRenderer() { } } - // Overlay canvas portal — sits above the Cesium canvas, no pointer events const overlayCanvas = ( { + const fov = (map as { transform?: { fov?: number } }).transform?.fov + return fov ? fov * 180 / Math.PI : 45 + })() + threeCamera.fov = fovDeg threeCamera.aspect = canvas.width / canvas.height threeCamera.updateProjectionMatrix() } diff --git a/web/src/ui/OverlayControls.tsx b/web/src/ui/OverlayControls.tsx index 17d3406..1220f04 100644 --- a/web/src/ui/OverlayControls.tsx +++ b/web/src/ui/OverlayControls.tsx @@ -1,78 +1,85 @@ import { useEffect, useRef, useState } from 'react' -import * as Cesium from 'cesium' -import { useCesiumViewer } from '../cesium/cesiumContext' +import type { Map } from 'maplibre-gl' +import { useMapLibreMap } from '../maplibre/maplibreContext' +import { safeRemoveLayers } from '../maplibre/geoUtils' import styles from './OverlayControls.module.css' interface OverlayDef { id: string label: string - // UrlTemplateImageryProvider url — uses {z}/{x}/{y} OR {z}/{y}/{x} for ESRI - url: string - alpha: number + tiles: string[] + tileSize: 256 | 512 + opacity: number } const OVERLAYS: OverlayDef[] = [ { id: 'streets', label: 'Streets', - // ESRI World Transportation — roads, highways, rail; no labels - url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}', - alpha: 0.75, + tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}'], + tileSize: 256, + opacity: 0.75, }, { id: 'labels', label: 'City names', - // CartoDB Voyager labels-only — place/city/country labels - url: 'https://a.basemaps.cartocdn.com/rastertiles/voyager_only_labels/{z}/{x}/{y}.png', - alpha: 1.0, + tiles: ['https://a.basemaps.cartocdn.com/rastertiles/voyager_only_labels/{z}/{x}/{y}.png'], + tileSize: 256, + opacity: 1.0, }, { id: 'borders', label: 'Borders', - // ESRI World Boundaries & Places — country + admin borders + labels - url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}', - alpha: 0.85, + tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}'], + tileSize: 256, + opacity: 0.85, }, ] +function addOverlay(map: Map, overlay: OverlayDef) { + const srcId = `overlay-src-${overlay.id}` + const lyrId = `overlay-lyr-${overlay.id}` + map.addSource(srcId, { type: 'raster', tiles: overlay.tiles, tileSize: overlay.tileSize }) + map.addLayer({ id: lyrId, type: 'raster', source: srcId, + paint: { 'raster-opacity': overlay.opacity } }) +} + +function removeOverlay(map: Map, overlay: OverlayDef) { + safeRemoveLayers(map, + [`overlay-lyr-${overlay.id}`], + [`overlay-src-${overlay.id}`], + ) +} + export function OverlayControls() { - const viewer = useCesiumViewer() + const map = useMapLibreMap() const [active, setActive] = useState>(new Set()) - // Keep a ref map from overlay id → the live ImageryLayer so we can remove it - const layerRefs = useRef>(new Map()) + const activeRef = useRef(active) + activeRef.current = active function toggle(overlay: OverlayDef) { setActive((prev) => { const next = new Set(prev) if (next.has(overlay.id)) { - // Remove layer - const layer = layerRefs.current.get(overlay.id) - if (layer && !viewer.isDestroyed()) { - viewer.imageryLayers.remove(layer, true) - } - layerRefs.current.delete(overlay.id) + removeOverlay(map, overlay) next.delete(overlay.id) } else { - // Add layer - const provider = new Cesium.UrlTemplateImageryProvider({ url: overlay.url }) - const layer = viewer.imageryLayers.addImageryProvider(provider) - layer.alpha = overlay.alpha - layerRefs.current.set(overlay.id, layer) + addOverlay(map, overlay) next.add(overlay.id) } return next }) } - // Clean up all layers if the component unmounts + // Clean up all active overlays on unmount useEffect(() => { - const refs = layerRefs.current return () => { - refs.forEach((layer) => { - if (!viewer.isDestroyed()) viewer.imageryLayers.remove(layer, true) - }) + for (const id of activeRef.current) { + const overlay = OVERLAYS.find(o => o.id === id) + if (overlay) removeOverlay(map, overlay) + } } - }, [viewer]) + }, [map]) return ( diff --git a/web/src/ui/SearchBar.tsx b/web/src/ui/SearchBar.tsx index b492cb5..53263ad 100644 --- a/web/src/ui/SearchBar.tsx +++ b/web/src/ui/SearchBar.tsx @@ -1,6 +1,5 @@ import { useState, useRef, useEffect } from 'react' -import * as Cesium from 'cesium' -import { useCesiumViewer } from '../cesium/cesiumContext' +import { useMapLibreMap } from '../maplibre/maplibreContext' import styles from './SearchBar.module.css' interface NominatimResult { @@ -12,7 +11,7 @@ interface NominatimResult { } export function SearchBar() { - const viewer = useCesiumViewer() + const map = useMapLibreMap() const [query, setQuery] = useState('') const [results, setResults] = useState([]) const [open, setOpen] = useState(false) @@ -57,11 +56,7 @@ export function SearchBar() { function flyTo(result: NominatimResult) { const [minLat, maxLat, minLon, maxLon] = result.boundingbox.map(Number) - const rect = Cesium.Rectangle.fromDegrees(minLon, minLat, maxLon, maxLat) - viewer.camera.flyTo({ - destination: rect, - duration: 1.5, - }) + map.fitBounds([[minLon, minLat], [maxLon, maxLat]], { duration: 1500, padding: 40 }) setQuery(result.display_name.split(',')[0]) setOpen(false) setResults([]) diff --git a/web/vite.config.ts b/web/vite.config.ts index 52c3209..2cb3281 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,21 +1,10 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' -import cesium from 'vite-plugin-cesium' export default defineConfig({ - plugins: [react(), cesium()], + plugins: [react()], optimizeDeps: { - // Prevent Vite from pre-bundling Cesium — it ships its own worker scripts - // that must be loaded from a specific public path, not inlined. - exclude: ['cesium'], - // Force pre-bundling of @mkkellogg/gaussian-splats-3d and all its CJS - // transitive dependencies. The 'pkg > dep' syntax tells Vite's esbuild - // step to convert each dep to ESM when reached through that package. - // All CJS transitive dependencies of cesium and @mkkellogg/gaussian-splats-3d. - // Because cesium is in exclude, Vite won't crawl its deps automatically — - // every CJS package it touches must be listed here for ESM conversion. - // List generated via: node -e "..." (see vite.config.ts comments above) include: [ '@mkkellogg/gaussian-splats-3d', '@mkkellogg/gaussian-splats-3d > mersenne-twister', @@ -41,8 +30,7 @@ export default defineConfig({ rollupOptions: { output: { manualChunks: { - // cesium is handled by vite-plugin-cesium (served from /Cesium/ public path) - // and must NOT appear here. + maplibre: ['maplibre-gl'], splat: ['@mkkellogg/gaussian-splats-3d', 'three'], }, }, @@ -52,8 +40,6 @@ export default defineConfig({ server: { port: 5173, proxy: { - // Proxy API calls to Django — avoids CORS in development. - // Django dev server runs on the host at :8000. '/api': { target: 'http://localhost:8000', changeOrigin: true,
No coasters yet.