diff --git a/.gitignore b/.gitignore index 6128ed5..f09a999 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ next-env.d.ts # local documentation docs.txt ROADMAP.local.md + +# heap analysis +scripts/ \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index df2568c..b78dabf 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,25 @@ import type { NextConfig } from "next"; +const isDev = process.env.NODE_ENV === "development"; + +// Content Security Policy — allows only the external resources Aeris actually uses. +// https://nextjs.org/docs/app/guides/content-security-policy +const cspHeader = ` + default-src 'self'; + script-src 'self' 'unsafe-inline' https://www.googletagmanager.com${isDev ? " 'unsafe-eval'" : ""}; + style-src 'self' 'unsafe-inline'; + img-src 'self' blob: data: https: ; + font-src 'self'; + connect-src 'self' data: https://opensky-network.org https://*.basemaps.cartocdn.com https://basemaps.cartocdn.com https://server.arcgisonline.com https://s3.amazonaws.com https://tile.opentopomap.org https://www.google-analytics.com https://www.googletagmanager.com https://api.github.com https://hexdb.io; + worker-src 'self' blob:; + child-src blob:; + object-src 'none'; + base-uri 'self'; + form-action 'self'; + frame-ancestors 'none'; + upgrade-insecure-requests; +`; + const nextConfig: NextConfig = { transpilePackages: [ "@deck.gl/core", @@ -25,6 +45,10 @@ const nextConfig: NextConfig = { { source: "/(.*)", headers: [ + { + key: "Content-Security-Policy", + value: cspHeader.replace(/\s{2,}/g, " ").trim(), + }, { key: "X-Content-Type-Options", value: "nosniff" }, { key: "X-Frame-Options", value: "DENY" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, diff --git a/package.json b/package.json index aa4fba4..b2e637d 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,11 @@ "lint": "eslint" }, "dependencies": { - "@deck.gl/core": "^9.2.7", + "@deck.gl/core": "^9.2.11", "@deck.gl/geo-layers": "^9.2.7", - "@deck.gl/layers": "^9.2.7", - "@deck.gl/mapbox": "^9.2.7", - "@deck.gl/mesh-layers": "^9.2.7", + "@deck.gl/layers": "^9.2.11", + "@deck.gl/mapbox": "^9.2.11", + "@deck.gl/mesh-layers": "^9.2.11", "@deck.gl/react": "^9.2.7", "@loaders.gl/core": "^4.3.4", "@loaders.gl/gltf": "^4.3.4", @@ -24,6 +24,7 @@ "@luma.gl/webgl": "^9.2.6", "@radix-ui/react-slider": "^1.3.6", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "^0.564.0", "maplibre-gl": "^5.18.0", "motion": "^12.34.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e3fdbd..e4cebd0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,23 +9,23 @@ importers: .: dependencies: '@deck.gl/core': - specifier: ^9.2.7 - version: 9.2.7 + specifier: ^9.2.11 + version: 9.2.11 '@deck.gl/geo-layers': specifier: ^9.2.7 - version: 9.2.7(@deck.gl/core@9.2.7)(@deck.gl/extensions@9.2.7(@deck.gl/core@9.2.7)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))))(@deck.gl/layers@9.2.7(@deck.gl/core@9.2.7)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))))(@deck.gl/mesh-layers@9.2.7(@deck.gl/core@9.2.7)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/gltf@9.2.6(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@loaders.gl/core@4.3.4)(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))) + version: 9.2.7(@deck.gl/core@9.2.11)(@deck.gl/extensions@9.2.7(@deck.gl/core@9.2.11)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))))(@deck.gl/layers@9.2.11(@deck.gl/core@9.2.11)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))))(@deck.gl/mesh-layers@9.2.11(@deck.gl/core@9.2.11)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/gltf@9.2.6(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@loaders.gl/core@4.3.4)(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))) '@deck.gl/layers': - specifier: ^9.2.7 - version: 9.2.7(@deck.gl/core@9.2.7)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))) + specifier: ^9.2.11 + version: 9.2.11(@deck.gl/core@9.2.11)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))) '@deck.gl/mapbox': - specifier: ^9.2.7 - version: 9.2.7(@deck.gl/core@9.2.7)(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@math.gl/web-mercator@4.1.0) + specifier: ^9.2.11 + version: 9.2.11(@deck.gl/core@9.2.11)(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@math.gl/web-mercator@4.1.0) '@deck.gl/mesh-layers': - specifier: ^9.2.7 - version: 9.2.7(@deck.gl/core@9.2.7)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/gltf@9.2.6(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)) + specifier: ^9.2.11 + version: 9.2.11(@deck.gl/core@9.2.11)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/gltf@9.2.6(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)) '@deck.gl/react': specifier: ^9.2.7 - version: 9.2.7(@deck.gl/core@9.2.7)(@deck.gl/widgets@9.2.7(@deck.gl/core@9.2.7)(@luma.gl/core@9.2.6))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 9.2.7(@deck.gl/core@9.2.11)(@deck.gl/widgets@9.2.7(@deck.gl/core@9.2.11)(@luma.gl/core@9.2.6))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@loaders.gl/core': specifier: ^4.3.4 version: 4.3.4 @@ -44,6 +44,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) lucide-react: specifier: ^0.564.0 version: 0.564.0(react@19.2.3) @@ -167,8 +170,8 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@deck.gl/core@9.2.7': - resolution: {integrity: sha512-mltYFDC2dMtAZPkAaVIadccbM3iy9jjnhLa5obFnWzPtXyc1UBr7OW50Cjy0IlQmAsgDI2BATzcw5a/p4zU8zw==} + '@deck.gl/core@9.2.11': + resolution: {integrity: sha512-lpdxXQuFSkd6ET7M6QxPI8QMhsLRY6vzLyk83sPGFb7JSb4OhrNHYt9sfIhcA/hxJW7bdBSMWWphf2GvQetVuA==} '@deck.gl/extensions@9.2.7': resolution: {integrity: sha512-jIsep2NByEimWlScqc/NLjpqWknLk5rd+uP8UAl7qI8CTInXV4KdzaYgujL+bE4lSV4Zlg0oMOAkbcviMKDLNw==} @@ -188,24 +191,24 @@ packages: '@luma.gl/core': ~9.2.6 '@luma.gl/engine': ~9.2.6 - '@deck.gl/layers@9.2.7': - resolution: {integrity: sha512-oGRv3s+i+Rq4qQFTfdCBx2S650K4p0gGS/bPYpURCXW0a0LQBHqN8AkKbhdos7b7Lawp1iMwkIggBZSZaNkyXg==} + '@deck.gl/layers@9.2.11': + resolution: {integrity: sha512-2FSb0Qa6YR+Rg6GWhYOGTUug3vtZ4uKcFdnrdiJoVXGyibKJMScKZIsivY0r/yQQZsaBjYqty5QuVJvdtEHxSA==} peerDependencies: '@deck.gl/core': ~9.2.0 - '@loaders.gl/core': ^4.3.4 + '@loaders.gl/core': ~4.3.4 '@luma.gl/core': ~9.2.6 '@luma.gl/engine': ~9.2.6 - '@deck.gl/mapbox@9.2.7': - resolution: {integrity: sha512-kcTMavoM9RqGbDXg78U/DGlR3dCQMR5+9ctc83qy0aNP57zQ62okomnq9DVCfxvcQjYb1uMqAt3HaBespInRcA==} + '@deck.gl/mapbox@9.2.11': + resolution: {integrity: sha512-5OaFZgjyA4Vq6WjHUdcEdl0Phi8dwj8hSCErej0NetW90mctdbxwMt0gSbqcvWBowwhyj2QAhH0P2FcITjKG/A==} peerDependencies: '@deck.gl/core': ~9.2.0 '@luma.gl/constants': ~9.2.6 '@luma.gl/core': ~9.2.6 '@math.gl/web-mercator': ^4.1.0 - '@deck.gl/mesh-layers@9.2.7': - resolution: {integrity: sha512-EpWHJ3GaCXELCsYRlabvkXxtgLQwOZYU8YPOmlKUYf+/410B2D89oNGtJinRcfM1/T9TBelBS9CHMYsL1tv9cA==} + '@deck.gl/mesh-layers@9.2.11': + resolution: {integrity: sha512-zPB7TtnPXB3tOEoOfcOkNZo7coIq/ukIQa8HIUQLLiOE8AVSQfz3kbMmMK6rUabXlQbgSw/I/j3kFSYRHg3NGg==} peerDependencies: '@deck.gl/core': ~9.2.0 '@luma.gl/core': ~9.2.6 @@ -729,12 +732,21 @@ packages: '@probe.gl/env@4.1.0': resolution: {integrity: sha512-5ac2Jm2K72VCs4eSMsM7ykVRrV47w32xOGMvcgqn8vQdEMF9PRXyBGYEV9YbqRKWNKpNKmQJVi4AHM/fkCxs9w==} + '@probe.gl/env@4.1.1': + resolution: {integrity: sha512-+68seNDMVsEegRB47pFA/Ws1Fjy8agcFYXxzorKToyPcD6zd+gZ5uhwoLd7TzsSw6Ydns//2KEszWn+EnNHTbA==} + '@probe.gl/log@4.1.0': resolution: {integrity: sha512-r4gRReNY6f+OZEMgfWEXrAE2qJEt8rX0HsDJQXUBMoc+5H47bdB7f/5HBHAmapK8UydwPKL9wCDoS22rJ0yq7Q==} + '@probe.gl/log@4.1.1': + resolution: {integrity: sha512-kcZs9BT44pL7hS1OkRGKYRXI/SN9KejUlPD+BY40DguRLzdC5tLG/28WGMyfKdn/51GT4a0p+0P8xvDn1Ez+Kg==} + '@probe.gl/stats@4.1.0': resolution: {integrity: sha512-EI413MkWKBDVNIfLdqbeNSJTs7ToBz/KVGkwi3D+dQrSIkRI2IYbWGAU3xX+D6+CI4ls8ehxMhNpUVMaZggDvQ==} + '@probe.gl/stats@4.1.1': + resolution: {integrity: sha512-4VpAyMHOqydSvPlEyHwXaE+AkIdR03nX+Qhlxsk2D/IW4OVmDZgIsvJB1cDzyEEtcfKcnaEbfXeiPgejBceT6g==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -772,6 +784,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-direction@1.1.1': resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} peerDependencies: @@ -781,6 +806,76 @@ packages: '@types/react': optional: true + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.1.3': resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} peerDependencies: @@ -816,6 +911,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-controllable-state@1.2.2': resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} peerDependencies: @@ -834,6 +938,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-layout-effect@1.1.1': resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} peerDependencies: @@ -1206,6 +1319,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -1326,6 +1443,12 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1407,6 +1530,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -1681,6 +1807,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -2296,8 +2426,8 @@ packages: potpack@2.1.0: resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==} - preact@10.28.3: - resolution: {integrity: sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==} + preact@10.28.4: + resolution: {integrity: sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==} prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} @@ -2330,6 +2460,36 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react@19.2.3: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} @@ -2610,6 +2770,26 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2764,7 +2944,7 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@deck.gl/core@9.2.7': + '@deck.gl/core@9.2.11': dependencies: '@loaders.gl/core': 4.3.4 '@loaders.gl/images': 4.3.4(@loaders.gl/core@4.3.4) @@ -2777,28 +2957,28 @@ snapshots: '@math.gl/sun': 4.1.0 '@math.gl/types': 4.1.0 '@math.gl/web-mercator': 4.1.0 - '@probe.gl/env': 4.1.0 - '@probe.gl/log': 4.1.0 - '@probe.gl/stats': 4.1.0 + '@probe.gl/env': 4.1.1 + '@probe.gl/log': 4.1.1 + '@probe.gl/stats': 4.1.1 '@types/offscreencanvas': 2019.7.3 gl-matrix: 3.4.4 mjolnir.js: 3.0.0 - '@deck.gl/extensions@9.2.7(@deck.gl/core@9.2.7)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))': + '@deck.gl/extensions@9.2.7(@deck.gl/core@9.2.11)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))': dependencies: - '@deck.gl/core': 9.2.7 + '@deck.gl/core': 9.2.11 '@luma.gl/constants': 9.2.6 '@luma.gl/core': 9.2.6 '@luma.gl/engine': 9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)) '@luma.gl/shadertools': 9.2.6(@luma.gl/core@9.2.6) '@math.gl/core': 4.1.0 - '@deck.gl/geo-layers@9.2.7(@deck.gl/core@9.2.7)(@deck.gl/extensions@9.2.7(@deck.gl/core@9.2.7)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))))(@deck.gl/layers@9.2.7(@deck.gl/core@9.2.7)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))))(@deck.gl/mesh-layers@9.2.7(@deck.gl/core@9.2.7)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/gltf@9.2.6(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@loaders.gl/core@4.3.4)(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))': + '@deck.gl/geo-layers@9.2.7(@deck.gl/core@9.2.11)(@deck.gl/extensions@9.2.7(@deck.gl/core@9.2.11)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))))(@deck.gl/layers@9.2.11(@deck.gl/core@9.2.11)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))))(@deck.gl/mesh-layers@9.2.11(@deck.gl/core@9.2.11)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/gltf@9.2.6(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@loaders.gl/core@4.3.4)(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))': dependencies: - '@deck.gl/core': 9.2.7 - '@deck.gl/extensions': 9.2.7(@deck.gl/core@9.2.7)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))) - '@deck.gl/layers': 9.2.7(@deck.gl/core@9.2.7)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))) - '@deck.gl/mesh-layers': 9.2.7(@deck.gl/core@9.2.7)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/gltf@9.2.6(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)) + '@deck.gl/core': 9.2.11 + '@deck.gl/extensions': 9.2.7(@deck.gl/core@9.2.11)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))) + '@deck.gl/layers': 9.2.11(@deck.gl/core@9.2.11)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))) + '@deck.gl/mesh-layers': 9.2.11(@deck.gl/core@9.2.11)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/gltf@9.2.6(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)) '@loaders.gl/3d-tiles': 4.3.4(@loaders.gl/core@4.3.4) '@loaders.gl/core': 4.3.4 '@loaders.gl/gis': 4.3.4(@loaders.gl/core@4.3.4) @@ -2822,9 +3002,9 @@ snapshots: transitivePeerDependencies: - '@luma.gl/constants' - '@deck.gl/layers@9.2.7(@deck.gl/core@9.2.7)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))': + '@deck.gl/layers@9.2.11(@deck.gl/core@9.2.11)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))': dependencies: - '@deck.gl/core': 9.2.7 + '@deck.gl/core': 9.2.11 '@loaders.gl/core': 4.3.4 '@loaders.gl/images': 4.3.4(@loaders.gl/core@4.3.4) '@loaders.gl/schema': 4.3.4(@loaders.gl/core@4.3.4) @@ -2837,16 +3017,16 @@ snapshots: '@math.gl/web-mercator': 4.1.0 earcut: 2.2.4 - '@deck.gl/mapbox@9.2.7(@deck.gl/core@9.2.7)(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@math.gl/web-mercator@4.1.0)': + '@deck.gl/mapbox@9.2.11(@deck.gl/core@9.2.11)(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@math.gl/web-mercator@4.1.0)': dependencies: - '@deck.gl/core': 9.2.7 + '@deck.gl/core': 9.2.11 '@luma.gl/constants': 9.2.6 '@luma.gl/core': 9.2.6 '@math.gl/web-mercator': 4.1.0 - '@deck.gl/mesh-layers@9.2.7(@deck.gl/core@9.2.7)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/gltf@9.2.6(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))': + '@deck.gl/mesh-layers@9.2.11(@deck.gl/core@9.2.11)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/gltf@9.2.6(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))': dependencies: - '@deck.gl/core': 9.2.7 + '@deck.gl/core': 9.2.11 '@loaders.gl/gltf': 4.3.4(@loaders.gl/core@4.3.4) '@loaders.gl/schema': 4.3.4(@loaders.gl/core@4.3.4) '@luma.gl/core': 9.2.6 @@ -2856,18 +3036,18 @@ snapshots: transitivePeerDependencies: - '@loaders.gl/core' - '@deck.gl/react@9.2.7(@deck.gl/core@9.2.7)(@deck.gl/widgets@9.2.7(@deck.gl/core@9.2.7)(@luma.gl/core@9.2.6))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@deck.gl/react@9.2.7(@deck.gl/core@9.2.11)(@deck.gl/widgets@9.2.7(@deck.gl/core@9.2.11)(@luma.gl/core@9.2.6))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@deck.gl/core': 9.2.7 - '@deck.gl/widgets': 9.2.7(@deck.gl/core@9.2.7)(@luma.gl/core@9.2.6) + '@deck.gl/core': 9.2.11 + '@deck.gl/widgets': 9.2.7(@deck.gl/core@9.2.11)(@luma.gl/core@9.2.6) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@deck.gl/widgets@9.2.7(@deck.gl/core@9.2.7)(@luma.gl/core@9.2.6)': + '@deck.gl/widgets@9.2.7(@deck.gl/core@9.2.11)(@luma.gl/core@9.2.6)': dependencies: - '@deck.gl/core': 9.2.7 + '@deck.gl/core': 9.2.11 '@luma.gl/core': 9.2.6 - preact: 10.28.3 + preact: 10.28.4 '@emnapi/core@1.8.1': dependencies: @@ -3245,8 +3425,8 @@ snapshots: '@luma.gl/shadertools': 9.2.6(@luma.gl/core@9.2.6) '@math.gl/core': 4.1.0 '@math.gl/types': 4.1.0 - '@probe.gl/log': 4.1.0 - '@probe.gl/stats': 4.1.0 + '@probe.gl/log': 4.1.1 + '@probe.gl/stats': 4.1.1 '@luma.gl/gltf@9.2.6(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))': dependencies: @@ -3407,12 +3587,20 @@ snapshots: '@probe.gl/env@4.1.0': {} + '@probe.gl/env@4.1.1': {} + '@probe.gl/log@4.1.0': dependencies: '@probe.gl/env': 4.1.0 + '@probe.gl/log@4.1.1': + dependencies: + '@probe.gl/env': 4.1.1 + '@probe.gl/stats@4.1.0': {} + '@probe.gl/stats@4.1.1': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -3441,12 +3629,91 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + aria-hidden: 1.2.6 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.3)': dependencies: react: 19.2.3 optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3) @@ -3482,6 +3749,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.3)': dependencies: '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.3) @@ -3497,6 +3770,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.3)': dependencies: react: 19.2.3 @@ -3834,6 +4114,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -3982,6 +4266,18 @@ snapshots: clsx@2.1.1: {} + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4057,6 +4353,8 @@ snapshots: detect-libc@2.1.2: {} + detect-node-es@1.1.0: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -4481,6 +4779,8 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -5063,7 +5363,7 @@ snapshots: potpack@2.1.0: {} - preact@10.28.3: {} + preact@10.28.4: {} prelude-ls@1.2.1: {} @@ -5090,6 +5390,33 @@ snapshots: react-is@16.13.1: {} + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.3): + dependencies: + react: 19.2.3 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.3) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.3): + dependencies: + react: 19.2.3 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.3) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.3) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.3) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.3): + dependencies: + get-nonce: 1.0.1 + react: 19.2.3 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + react@19.2.3: {} readable-stream@2.3.8: @@ -5485,6 +5812,21 @@ snapshots: dependencies: punycode: 2.3.1 + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.3): + dependencies: + react: 19.2.3 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.3): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.3 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + util-deprecate@1.0.2: {} wgsl_reflect@1.2.3: {} diff --git a/src/app/api/aircraft-photos/route.ts b/src/app/api/aircraft-photos/route.ts new file mode 100644 index 0000000..8bf611e --- /dev/null +++ b/src/app/api/aircraft-photos/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; + +const JETAPI_BASE = "https://www.jetapi.dev/api"; +const FETCH_TIMEOUT_MS = 12_000; +const REG_REGEX = /^[A-Z0-9-]{2,10}$/i; + +export async function GET(request: NextRequest): Promise { + const reg = request.nextUrl.searchParams.get("reg")?.trim(); + + if (!reg || !REG_REGEX.test(reg)) { + return NextResponse.json( + { error: "Missing or invalid 'reg' parameter" }, + { status: 400, headers: { "Cache-Control": "no-store" } }, + ); + } + + const params = new URLSearchParams({ + reg, + photos: "10", + flights: "0", + }); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const upstream = await fetch(`${JETAPI_BASE}?${params.toString()}`, { + signal: controller.signal, + headers: { Accept: "application/json" }, + }); + + clearTimeout(timer); + + if (!upstream.ok) { + return NextResponse.json( + { error: "Upstream error" }, + { + status: upstream.status >= 500 ? 502 : upstream.status, + headers: { "Cache-Control": "no-store" }, + }, + ); + } + + const data: unknown = await upstream.json(); + + return NextResponse.json(data, { + status: 200, + headers: { + "Cache-Control": + "public, max-age=1800, s-maxage=1800, stale-while-revalidate=3600", + }, + }); + } catch (err) { + clearTimeout(timer); + + if (err instanceof DOMException && err.name === "AbortError") { + return NextResponse.json( + { error: "Upstream timeout" }, + { status: 504, headers: { "Cache-Control": "no-store" } }, + ); + } + + return NextResponse.json( + { error: "Proxy error" }, + { status: 502, headers: { "Cache-Control": "no-store" } }, + ); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 3e9184e..41da85b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -81,3 +81,65 @@ body { scroll-behavior: auto !important; } } + +/* ── cmdk styles ─────────────────────────────────────────────────────── */ + +.aeris-cmdk [cmdk-input] { + border: none; + outline: none; + background: transparent; +} + +.aeris-cmdk [cmdk-group-heading] { + padding: 6px 12px 4px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgb(255 255 255 / 0.15); + user-select: none; +} + +.aeris-cmdk [cmdk-list] { + overscroll-behavior: contain; +} + +.aeris-cmdk [cmdk-group] + [cmdk-group] { + padding-top: 4px; +} + +.aeris-cmdk [cmdk-empty] { + display: flex; +} + +.aeris-cmdk .search-item { + position: relative; + display: flex; + align-items: center; + gap: 10px; + border-radius: 12px; + padding: 8px 12px; + cursor: pointer; + transition: background-color 0.12s ease; + outline: none; + user-select: none; +} + +.aeris-cmdk .search-item[data-selected="true"] { + background: rgb(255 255 255 / 0.05); +} + +.aeris-cmdk + [cmdk-item][data-selected="true"] + .group-data-\[selected\=true\]\/item\:opacity-100 { + opacity: 1; +} + +.aeris-cmdk .search-item[data-disabled="true"] { + opacity: 0.5; + cursor: not-allowed; +} + +.aeris-cmdk .search-item:active:not([data-disabled="true"]) { + background: rgb(255 255 255 / 0.07); +} diff --git a/src/components/flight-tracker-random.ts b/src/components/flight-tracker-random.ts new file mode 100644 index 0000000..7884531 --- /dev/null +++ b/src/components/flight-tracker-random.ts @@ -0,0 +1,72 @@ +import { AIRPORTS } from "@/lib/airports"; +import { airportToCity } from "@/lib/airports"; +import type { City } from "@/lib/cities"; +import type { FlightState } from "@/lib/opensky"; +import { DEFAULT_CITY } from "@/components/flight-tracker-utils"; + +const HIGH_TRAFFIC_IATA = [ + "ATL", + "DXB", + "LHR", + "HND", + "DFW", + "DEN", + "IST", + "LAX", + "CDG", + "AMS", + "FRA", + "MAD", + "JFK", + "SIN", + "ORD", + "SFO", + "MIA", + "LAS", + "MUC", + "CLT", +] as const; + +const HUB_PICK_PROBABILITY = 0.75; +const HIGH_TRAFFIC_IATA_SET = new Set(HIGH_TRAFFIC_IATA); +const HIGH_TRAFFIC_AIRPORTS = AIRPORTS.filter((airport) => + HIGH_TRAFFIC_IATA_SET.has(airport.iata.toUpperCase()), +); + +function chooseRandom(items: readonly T[]): T | null { + if (items.length === 0) return null; + return items[Math.floor(Math.random() * items.length)] ?? null; +} + +export function pickRandomAirportCity(excludeIata?: string): City { + const exclude = excludeIata?.toUpperCase(); + const filteredHubs = exclude + ? HIGH_TRAFFIC_AIRPORTS.filter( + (airport) => airport.iata.toUpperCase() !== exclude, + ) + : HIGH_TRAFFIC_AIRPORTS; + + const filteredAirports = exclude + ? AIRPORTS.filter((airport) => airport.iata.toUpperCase() !== exclude) + : AIRPORTS; + + const useHubs = + filteredHubs.length > 0 && Math.random() < HUB_PICK_PROBABILITY; + const source = useHubs ? filteredHubs : filteredAirports; + const randomAirport = chooseRandom(source); + if (!randomAirport) return DEFAULT_CITY; + return airportToCity(randomAirport); +} + +export function cityFromFlight(flight: FlightState): City | null { + if (flight.longitude == null || flight.latitude == null) return null; + const code = flight.icao24.toUpperCase(); + return { + id: `trk-${flight.icao24}`, + name: `Flight ${code}`, + country: flight.originCountry || "Unknown", + iata: code.slice(0, 3), + coordinates: [flight.longitude, flight.latitude], + radius: 2, + }; +} diff --git a/src/components/flight-tracker-utils.ts b/src/components/flight-tracker-utils.ts new file mode 100644 index 0000000..b1ec7d3 --- /dev/null +++ b/src/components/flight-tracker-utils.ts @@ -0,0 +1,119 @@ +import { CITIES, type City } from "@/lib/cities"; +import { findByIata, airportToCity } from "@/lib/airports"; +import { MAP_STYLES, DEFAULT_STYLE, type MapStyle } from "@/lib/map-styles"; + +export { DEFAULT_STYLE }; + +export const DEFAULT_CITY_ID = "sfo"; +export const STYLE_STORAGE_KEY = "aeris:mapStyle"; +export const DEFAULT_CITY = + CITIES.find((c) => c.id === DEFAULT_CITY_ID) ?? CITIES[0]; +export const GITHUB_REPO_URL = "https://github.com/kewonit/aeris"; +export const GITHUB_REPO_API = "https://api.github.com/repos/kewonit/aeris"; +export const ICAO24_REGEX = /^[0-9a-f]{6}$/i; + +export const subscribeNoop = () => () => {}; + +let _cachedInitialCity: City | null = null; + +export function resolveInitialCity(): City { + if (_cachedInitialCity) return _cachedInitialCity; + try { + const params = new URLSearchParams(window.location.search); + const code = params.get("city")?.trim().toUpperCase(); + if (!code) { + _cachedInitialCity = DEFAULT_CITY; + return DEFAULT_CITY; + } + + const preset = CITIES.find( + (c) => c.iata.toUpperCase() === code || c.id === code.toLowerCase(), + ); + if (preset) { + _cachedInitialCity = preset; + return preset; + } + + const airport = findByIata(code); + if (airport) { + _cachedInitialCity = airportToCity(airport); + return _cachedInitialCity; + } + + _cachedInitialCity = DEFAULT_CITY; + return DEFAULT_CITY; + } catch { + _cachedInitialCity = DEFAULT_CITY; + return DEFAULT_CITY; + } +} + +export function syncCityToUrl(city: City): void { + if (typeof window === "undefined") return; + try { + const url = new URL(window.location.href); + url.searchParams.set("city", city.iata); + url.searchParams.delete("from"); + url.searchParams.delete("to"); + url.searchParams.delete("fpv"); + window.history.replaceState(null, "", url.toString()); + } catch { + /* ignore */ + } +} + +export function syncFpvToUrl(icao24: string | null, activeCity?: City): void { + if (typeof window === "undefined") return; + try { + const url = new URL(window.location.href); + if (icao24) { + url.searchParams.set("fpv", icao24); + url.searchParams.delete("city"); + url.searchParams.delete("from"); + url.searchParams.delete("to"); + } else { + url.searchParams.delete("fpv"); + if (activeCity) { + url.searchParams.set("city", activeCity.iata); + } + } + window.history.replaceState(null, "", url.toString()); + } catch { + /* ignore */ + } +} + +export function resolveInitialFpv(): string | null { + try { + const params = new URLSearchParams(window.location.search); + const raw = params.get("fpv")?.trim().toLowerCase(); + return raw && /^[0-9a-f]{6}$/.test(raw) ? raw : null; + } catch { + return null; + } +} + +export function loadMapStyle(): MapStyle { + try { + const id = localStorage.getItem(STYLE_STORAGE_KEY); + if (!id) return DEFAULT_STYLE; + return MAP_STYLES.find((s) => s.id === id) ?? DEFAULT_STYLE; + } catch { + return DEFAULT_STYLE; + } +} + +export function saveMapStyle(style: MapStyle): void { + if (typeof window === "undefined") return; + try { + localStorage.setItem(STYLE_STORAGE_KEY, style.id); + } catch { + /* blocked */ + } +} + +export function formatStarCount(value: number): string { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}m`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`; + return `${value}`; +} diff --git a/src/components/flight-tracker.tsx b/src/components/flight-tracker.tsx index 0f7001e..235be6d 100644 --- a/src/components/flight-tracker.tsx +++ b/src/components/flight-tracker.tsx @@ -8,7 +8,7 @@ import { useRef, useSyncExternalStore, } from "react"; -import { motion, AnimatePresence } from "motion/react"; +import { AnimatePresence } from "motion/react"; import { ErrorBoundary } from "@/components/error-boundary"; import { Map as MapView } from "@/components/map/map"; import { CameraController } from "@/components/map/camera-controller"; @@ -16,7 +16,6 @@ import { AirportLayer } from "@/components/map/airport-layer"; import { FlightLayers } from "@/components/map/flight-layers"; import { FlightCard } from "@/components/ui/flight-card"; import { FpvHud } from "@/components/ui/fpv-hud"; -import { KeyboardShortcutsHelp } from "@/components/ui/keyboard-shortcuts-help"; import { ControlPanel } from "@/components/ui/control-panel"; import { AltitudeLegend } from "@/components/ui/altitude-legend"; import { CameraControls } from "@/components/ui/camera-controls"; @@ -27,191 +26,36 @@ import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useFlights } from "@/hooks/use-flights"; import { useTrailHistory } from "@/hooks/use-trail-history"; import { useFlightTrack } from "@/hooks/use-flight-track"; -import { MAP_STYLES, DEFAULT_STYLE, type MapStyle } from "@/lib/map-styles"; -import { CITIES, type City } from "@/lib/cities"; -import { AIRPORTS, findByIata, airportToCity } from "@/lib/airports"; +import { useMergedTrails } from "@/hooks/use-merged-trails"; +import { useFlightMonitors } from "@/hooks/use-flight-monitors"; +import type { MapStyle } from "@/lib/map-styles"; +import type { City } from "@/lib/cities"; import { fetchFlightByIcao24, fetchFlightByCallsign, type FlightState, } from "@/lib/opensky"; -import { snapLngToReference, unwrapLngPath } from "@/lib/geo"; import { formatCallsign } from "@/lib/flight-utils"; import type { PickingInfo } from "@deck.gl/core"; -import { Github, Star, Keyboard } from "lucide-react"; - -const DEFAULT_CITY_ID = "sfo"; -const STYLE_STORAGE_KEY = "aeris:mapStyle"; - -const DEFAULT_CITY = CITIES.find((c) => c.id === DEFAULT_CITY_ID) ?? CITIES[0]; -const GITHUB_REPO_URL = "https://github.com/kewonit/aeris"; -const GITHUB_REPO_API = "https://api.github.com/repos/kewonit/aeris"; -const HIGH_TRAFFIC_IATA = [ - "ATL", - "DXB", - "LHR", - "HND", - "DFW", - "DEN", - "IST", - "LAX", - "CDG", - "AMS", - "FRA", - "MAD", - "JFK", - "SIN", - "ORD", - "SFO", - "MIA", - "LAS", - "MUC", - "CLT", -] as const; -const HUB_PICK_PROBABILITY = 0.75; -const HIGH_TRAFFIC_IATA_SET = new Set(HIGH_TRAFFIC_IATA); -const HIGH_TRAFFIC_AIRPORTS = AIRPORTS.filter((airport) => - HIGH_TRAFFIC_IATA_SET.has(airport.iata.toUpperCase()), -); -const ICAO24_REGEX = /^[0-9a-f]{6}$/i; - -const subscribeNoop = () => () => {}; - -let _cachedInitialCity: City | null = null; - -function resolveInitialCity(): City { - if (_cachedInitialCity) return _cachedInitialCity; - try { - const params = new URLSearchParams(window.location.search); - const code = params.get("city")?.trim().toUpperCase(); - if (!code) { - _cachedInitialCity = DEFAULT_CITY; - return DEFAULT_CITY; - } - - const preset = CITIES.find( - (c) => c.iata.toUpperCase() === code || c.id === code.toLowerCase(), - ); - if (preset) { - _cachedInitialCity = preset; - return preset; - } - - const airport = findByIata(code); - if (airport) { - _cachedInitialCity = airportToCity(airport); - return _cachedInitialCity; - } - - _cachedInitialCity = DEFAULT_CITY; - return DEFAULT_CITY; - } catch { - _cachedInitialCity = DEFAULT_CITY; - return DEFAULT_CITY; - } -} - -function syncCityToUrl(city: City): void { - if (typeof window === "undefined") return; - try { - const url = new URL(window.location.href); - url.searchParams.set("city", city.iata); - url.searchParams.delete("from"); - url.searchParams.delete("to"); - url.searchParams.delete("fpv"); - window.history.replaceState(null, "", url.toString()); - } catch { - /* ignore */ - } -} - -function syncFpvToUrl(icao24: string | null, activeCity?: City): void { - if (typeof window === "undefined") return; - try { - const url = new URL(window.location.href); - if (icao24) { - url.searchParams.set("fpv", icao24); - url.searchParams.delete("city"); - url.searchParams.delete("from"); - url.searchParams.delete("to"); - } else { - url.searchParams.delete("fpv"); - if (activeCity) { - url.searchParams.set("city", activeCity.iata); - } - } - window.history.replaceState(null, "", url.toString()); - } catch { - /* ignore */ - } -} - -function resolveInitialFpv(): string | null { - try { - const params = new URLSearchParams(window.location.search); - const raw = params.get("fpv")?.trim().toLowerCase(); - return raw && /^[0-9a-f]{6}$/.test(raw) ? raw : null; - } catch { - return null; - } -} - -function loadMapStyle(): MapStyle { - try { - const id = localStorage.getItem(STYLE_STORAGE_KEY); - if (!id) return DEFAULT_STYLE; - return MAP_STYLES.find((s) => s.id === id) ?? DEFAULT_STYLE; - } catch { - return DEFAULT_STYLE; - } -} - -function saveMapStyle(style: MapStyle): void { - if (typeof window === "undefined") return; - try { - localStorage.setItem(STYLE_STORAGE_KEY, style.id); - } catch { - /* blocked */ - } -} - -function chooseRandom(items: readonly T[]): T | null { - if (items.length === 0) return null; - return items[Math.floor(Math.random() * items.length)] ?? null; -} - -function pickRandomAirportCity(excludeIata?: string): City { - const exclude = excludeIata?.toUpperCase(); - const filteredHubs = exclude - ? HIGH_TRAFFIC_AIRPORTS.filter( - (airport) => airport.iata.toUpperCase() !== exclude, - ) - : HIGH_TRAFFIC_AIRPORTS; - - const filteredAirports = exclude - ? AIRPORTS.filter((airport) => airport.iata.toUpperCase() !== exclude) - : AIRPORTS; - - const useHubs = - filteredHubs.length > 0 && Math.random() < HUB_PICK_PROBABILITY; - const source = useHubs ? filteredHubs : filteredAirports; - const randomAirport = chooseRandom(source); - if (!randomAirport) return DEFAULT_CITY; - return airportToCity(randomAirport); -} - -function cityFromFlight(flight: FlightState): City | null { - if (flight.longitude == null || flight.latitude == null) return null; - const code = flight.icao24.toUpperCase(); - return { - id: `trk-${flight.icao24}`, - name: `Flight ${code}`, - country: flight.originCountry || "Unknown", - iata: code.slice(0, 3), - coordinates: [flight.longitude, flight.latitude], - radius: 2, - }; -} +import { Github, Star } from "lucide-react"; +import { + DEFAULT_CITY, + DEFAULT_STYLE, + GITHUB_REPO_URL, + ICAO24_REGEX, + subscribeNoop, + resolveInitialCity, + syncCityToUrl, + syncFpvToUrl, + resolveInitialFpv, + loadMapStyle, + saveMapStyle, + formatStarCount, +} from "@/components/flight-tracker-utils"; +import { + pickRandomAirportCity, + cityFromFlight, +} from "@/components/flight-tracker-random"; function FlightTrackerInner() { const hydratedCity = useSyncExternalStore( @@ -228,8 +72,6 @@ function FlightTrackerInner() { const [cityOverride, setCityOverride] = useState(); const [styleOverride, setStyleOverride] = useState(); const [selectedIcao24, setSelectedIcao24] = useState(null); - const [showHelp, setShowHelp] = useState(false); - const [repoStars, setRepoStars] = useState(null); const [followIcao24, setFollowIcao24] = useState(null); const [fpvIcao24, setFpvIcao24] = useState(null); @@ -263,6 +105,7 @@ function FlightTrackerInner() { setStyleOverride(style); saveMapStyle(style); }, []); + const { flights, loading, rateLimited, retryIn } = useFlights( activeCity, fpvIcao24, @@ -272,7 +115,6 @@ function FlightTrackerInner() { const displayFlights = flights; const displayTrails = useTrailHistory(displayFlights); - // Fetch /tracks only for explicit click-selection (never FPV). const selectedFlightForTrack = useMemo(() => { if (!selectedIcao24) return null; return displayFlights.find((f) => f.icao24 === selectedIcao24) ?? null; @@ -288,250 +130,13 @@ function FlightTrackerInner() { enabled: shouldFetchSelectedTrack, }); - const mergedTrails = useMemo(() => { - if (!selectedIcao24 || !selectedTrack) return displayTrails; - - const flight = - displayFlights.find((f) => f.icao24 === selectedIcao24) ?? null; - - const livePos: [number, number] | null = - flight && flight.longitude != null && flight.latitude != null - ? [flight.longitude, flight.latitude] - : null; - - const trackPositions: [number, number][] = []; - const trackAltitudes: Array = []; - - for (const p of selectedTrack.path) { - if (p.longitude == null || p.latitude == null) continue; - trackPositions.push([p.longitude, p.latitude]); - trackAltitudes.push(p.baroAltitude ?? null); - } - - // Unwrap longitudes to avoid dateline/world-wrap glitches. - if (trackPositions.length >= 2) { - const unwrapped = unwrapLngPath(trackPositions); - trackPositions.splice(0, trackPositions.length, ...unwrapped); - } - - const livePosAdjusted: [number, number] | null = - livePos && trackPositions.length > 0 - ? [ - snapLngToReference( - livePos[0], - trackPositions[trackPositions.length - 1][0], - ), - livePos[1], - ] - : livePos; - - const lastWaypointTime = - selectedTrack.path[selectedTrack.path.length - 1]?.time; - const nowSec = - selectedTrackFetchedAtMs > 0 - ? Math.floor(selectedTrackFetchedAtMs / 1000) - : 0; - const lastWaypointAgeSec = - typeof lastWaypointTime === "number" && Number.isFinite(lastWaypointTime) - ? Math.max(0, nowSec - lastWaypointTime) - : 0; - const speedMps = - flight && Number.isFinite(flight.velocity) && flight.velocity! > 30 - ? Math.max(0, flight.velocity!) - : 140; - const expectedDeg = (speedMps * lastWaypointAgeSec) / 111_320; - - // Guard against wrong tracks (tolerate sparse/laggy waypoints). - if (livePosAdjusted && trackPositions.length >= 2) { - const searchWindow = 70; - const start = Math.max(0, trackPositions.length - searchWindow); - let bestDistSq = Number.POSITIVE_INFINITY; - for (let i = start; i < trackPositions.length; i++) { - const p = trackPositions[i]; - const dx = p[0] - livePosAdjusted[0]; - const dy = p[1] - livePosAdjusted[1]; - const d2 = dx * dx + dy * dy; - if (d2 < bestDistSq) bestDistSq = d2; - } - - // Tracks can be sparse; scale tolerance by speed and waypoint age. - const lowAltitude = - flight && Number.isFinite(flight.baroAltitude) - ? flight.baroAltitude! < 6_000 - : false; - const maxAllowedDeg = Math.min( - lowAltitude ? 2.8 : 6, - Math.max(lowAltitude ? 0.75 : 0.9, expectedDeg * 1.35 + 0.22), - ); - - if (bestDistSq > maxAllowedDeg * maxAllowedDeg) { - return displayTrails; - } - } - - // Merge the high-frequency live tail for recent turns. - const existingTrail = - displayTrails.find((t) => t.icao24 === selectedIcao24) ?? null; - if (existingTrail && existingTrail.path.length >= 2) { - const tailCount = 18; - const start = Math.max(0, existingTrail.path.length - tailCount); - const rawTailPath = existingTrail.path.slice(start); - const tailAlt = existingTrail.altitudes.slice(start); - - // Unwrap tail points to be continuous with the historical track. - const tailPath: [number, number][] = []; - let refLng = - trackPositions.length > 0 - ? trackPositions[trackPositions.length - 1][0] - : rawTailPath[0][0]; - for (const [lng, lat] of rawTailPath) { - const nextLng = snapLngToReference(lng, refLng); - tailPath.push([nextLng, lat]); - refLng = nextLng; - } - - // Merge where the two data sources overlap near the end. - const MERGE_SNAP_DEG = 0.06; - const CONNECT_BRIDGE_DEG = 0.07; - const MAX_CONNECT_GAP_DEG = - flight && - Number.isFinite(flight.baroAltitude) && - flight.baroAltitude! < 6_000 - ? 1.25 - : 3.5; - - const firstTail = tailPath[0]; - const searchWindow = 70; - const searchStart = Math.max(0, trackPositions.length - searchWindow); - let bestIndex = -1; - let bestDistSq = Number.POSITIVE_INFINITY; - - for (let i = searchStart; i < trackPositions.length; i++) { - const p = trackPositions[i]; - const dx = p[0] - firstTail[0]; - const dy = p[1] - firstTail[1]; - const d2 = dx * dx + dy * dy; - if (d2 < bestDistSq) { - bestDistSq = d2; - bestIndex = i; - } - } - - if (bestIndex >= 0 && bestDistSq <= MERGE_SNAP_DEG * MERGE_SNAP_DEG) { - // Snap to overlap, then append the live tail. - trackPositions.splice(bestIndex + 1); - trackAltitudes.splice(bestIndex + 1); - - const join = trackPositions[trackPositions.length - 1]; - if (join) { - tailPath[0] = join; - const joinAlt = trackAltitudes[trackAltitudes.length - 1] ?? null; - tailAlt[0] = joinAlt ?? tailAlt[0] ?? null; - } - } else { - // No overlap: disconnect stale history or insert a short bridge when close. - const last = trackPositions[trackPositions.length - 1]; - const lastAlt = trackAltitudes[trackAltitudes.length - 1] ?? null; - if (last) { - const dx = last[0] - firstTail[0]; - const dy = last[1] - firstTail[1]; - const gap = Math.sqrt(dx * dx + dy * dy); - const shouldDisconnect = - gap > 0.25 || - (lastWaypointAgeSec > 900 && gap > 0.06) || - (lastWaypointAgeSec > 300 && gap > 0.1); - - if (shouldDisconnect) { - trackPositions.splice(0, trackPositions.length, ...tailPath); - trackAltitudes.splice(0, trackAltitudes.length, ...tailAlt); - tailPath.length = 0; - tailAlt.length = 0; - } else { - if (gap > MAX_CONNECT_GAP_DEG) { - tailPath.length = 0; - } else if (gap > CONNECT_BRIDGE_DEG) { - const steps = Math.max(6, Math.min(24, Math.ceil(gap / 0.15))); - const firstTailAlt = tailAlt[0] ?? null; - for (let s = 1; s < steps; s++) { - const t = s / steps; - trackPositions.push([ - last[0] + (firstTail[0] - last[0]) * t, - last[1] + (firstTail[1] - last[1]) * t, - ]); - if (lastAlt == null && firstTailAlt == null) { - trackAltitudes.push(null); - } else { - const a0 = lastAlt ?? firstTailAlt ?? 0; - const a1 = firstTailAlt ?? lastAlt ?? a0; - trackAltitudes.push(a0 + (a1 - a0) * t); - } - } - } else { - tailPath[0] = last; - tailAlt[0] = lastAlt ?? tailAlt[0] ?? null; - } - } - } - } - - // Append tail points, skipping consecutive duplicates. - for (let i = 0; i < tailPath.length; i++) { - const pos = tailPath[i]; - const alt = tailAlt[i] ?? null; - const last = trackPositions[trackPositions.length - 1]; - if (last && last[0] === pos[0] && last[1] === pos[1]) continue; - trackPositions.push(pos); - trackAltitudes.push(alt); - } - } - - // Ensure the trail reaches the aircraft. - if (livePosAdjusted) { - const last = trackPositions[trackPositions.length - 1]; - if ( - !last || - last[0] !== livePosAdjusted[0] || - last[1] !== livePosAdjusted[1] - ) { - trackPositions.push(livePosAdjusted); - trackAltitudes.push(flight?.baroAltitude ?? null); - } - } - - if (trackPositions.length < 2) return displayTrails; - - const out = displayTrails.map((t) => { - if (t.icao24 !== selectedIcao24) return t; - const baroAltitude = - trackAltitudes[trackAltitudes.length - 1] ?? t.baroAltitude ?? null; - return { - ...t, - path: trackPositions, - altitudes: trackAltitudes, - baroAltitude, - fullHistory: true, - }; - }); - - // If the selected aircraft didn't have an in-memory trail yet, add one. - if (!out.some((t) => t.icao24 === selectedIcao24)) { - out.push({ - icao24: selectedIcao24, - path: trackPositions, - altitudes: trackAltitudes, - baroAltitude: trackAltitudes[trackAltitudes.length - 1] ?? null, - fullHistory: true, - }); - } - - return out; - }, [ + const mergedTrails = useMergedTrails( selectedIcao24, selectedTrack, selectedTrackFetchedAtMs, displayTrails, displayFlights, - ]); + ); const selectedFlight = useMemo(() => { if (!selectedIcao24) return null; @@ -560,166 +165,27 @@ function FlightTrackerInner() { syncFpvToUrl(fpvIcao24, activeCity); }, [fpvIcao24, activeCity]); - const fpvLookupDoneRef = useRef(false); - useEffect(() => { - const pending = pendingFpvRef.current; - if (!pending || fpvIcao24) return; - - const match = displayFlights.find( - (f) => f.icao24.toLowerCase() === pending, - ); - if (match && match.longitude != null && match.latitude != null) { - if (match.onGround) { - pendingFpvRef.current = null; - syncFpvToUrl(null, activeCity); - setSelectedIcao24(match.icao24); - return; - } - pendingFpvRef.current = null; - fpvLookupDoneRef.current = false; - setFpvSeedCenter({ lng: match.longitude, lat: match.latitude }); - setFpvIcao24(pending); - setFollowIcao24(null); - return; - } - - if (!fpvLookupDoneRef.current && displayFlights.length > 0) { - fpvLookupDoneRef.current = true; - const controller = new AbortController(); - fetchFlightByIcao24(pending, controller.signal) - .then((result) => { - if ( - result.flight && - result.flight.longitude != null && - result.flight.latitude != null && - !result.flight.onGround && - pendingFpvRef.current === pending - ) { - const focusCity = cityFromFlight(result.flight); - if (focusCity) { - setCityOverride(focusCity); - } - setFpvSeedCenter({ - lng: result.flight.longitude, - lat: result.flight.latitude, - }); - pendingFpvRef.current = null; - setFpvIcao24(pending); - setFollowIcao24(null); - } else if (pendingFpvRef.current === pending) { - pendingFpvRef.current = null; - syncFpvToUrl(null, activeCity); - if (result.flight) { - setSelectedIcao24(result.flight.icao24); - } - } - }) - .catch(() => { - if (pendingFpvRef.current === pending) { - pendingFpvRef.current = null; - } - }); - return () => controller.abort(); - } - }, [displayFlights, fpvIcao24, activeCity]); + const { repoStars } = useFlightMonitors({ + pendingFpvRef, + fpvIcao24, + fpvFlight, + followIcao24, + followFlight, + selectedIcao24, + selectedFlight, + displayFlights, + activeCity, + rateLimited, + setSelectedIcao24, + setFpvIcao24, + setFollowIcao24, + setCityOverride, + setFpvSeedCenter, + }); const fpvFlightOrCached = fpvFlight; - - const fpvMissCountRef = useRef(0); - useEffect(() => { - if (!fpvIcao24) { - fpvMissCountRef.current = 0; - return; - } - - if (fpvFlight) { - fpvMissCountRef.current = 0; - if (fpvFlight.onGround) { - const exitIcao = fpvIcao24; - const timer = setTimeout(() => { - setSelectedIcao24(exitIcao); - setFpvIcao24(null); - }, 0); - return () => clearTimeout(timer); - } - } else { - if (!rateLimited) { - fpvMissCountRef.current += 1; - } - if (fpvMissCountRef.current >= 3) { - const exitIcao = fpvIcao24; - const timer = setTimeout(() => { - setSelectedIcao24(exitIcao); - setFpvIcao24(null); - }, 0); - return () => clearTimeout(timer); - } - } - }, [fpvIcao24, fpvFlight, rateLimited]); - - const followMissCountRef = useRef(0); - useEffect(() => { - if (!followIcao24) { - followMissCountRef.current = 0; - return; - } - if (followFlight) { - followMissCountRef.current = 0; - } else { - followMissCountRef.current += 1; - if (followMissCountRef.current >= 3) { - const timer = setTimeout(() => setFollowIcao24(null), 0); - return () => clearTimeout(timer); - } - } - }, [followIcao24, followFlight]); - const displayFlight = selectedFlight; - const missingSinceRef = useRef(null); - useEffect(() => { - if (!selectedIcao24) { - missingSinceRef.current = null; - return; - } - if (selectedFlight) { - missingSinceRef.current = null; - return; - } - const now = Date.now(); - if (missingSinceRef.current == null) { - missingSinceRef.current = now; - return; - } - if (now - missingSinceRef.current >= 60_000) { - const timer = setTimeout(() => setSelectedIcao24(null), 0); - missingSinceRef.current = null; - return () => clearTimeout(timer); - } - }, [selectedIcao24, selectedFlight, displayFlights]); - - useEffect(() => { - let mounted = true; - - async function loadRepoStars() { - try { - const res = await fetch(GITHUB_REPO_API, { cache: "no-store" }); - if (!res.ok) return; - const data = (await res.json()) as { stargazers_count?: number }; - if (mounted && typeof data.stargazers_count === "number") { - setRepoStars(data.stargazers_count); - } - } catch { - /* silent fallback */ - } - } - - loadRepoStars(); - return () => { - mounted = false; - }; - }, []); - const handleClick = useCallback( (info: PickingInfo | null) => { if (fpvIcao24) return; @@ -796,7 +262,7 @@ function FlightTrackerInner() { }, []); const handleToggleHelp = useCallback(() => { - setShowHelp((prev) => !prev); + window.dispatchEvent(new CustomEvent("aeris:open-shortcuts")); }, []); const handleToggleFpvKey = useCallback(() => { @@ -885,7 +351,12 @@ function FlightTrackerInner() { return (
- + @@ -937,22 +409,6 @@ function FlightTrackerInner() { {!fpvIcao24 && ( - {!fpvIcao24 && ( - setShowHelp(false)} - /> - )} - {fpvIcao24 && fpvFlightOrCached && ( @@ -1061,9 +516,3 @@ function Brand({ isDark }: { isDark: boolean }) { ); } - -function formatStarCount(value: number): string { - if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}m`; - if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`; - return `${value}`; -} diff --git a/src/components/map/aircraft-appearance.ts b/src/components/map/aircraft-appearance.ts new file mode 100644 index 0000000..d3682c5 --- /dev/null +++ b/src/components/map/aircraft-appearance.ts @@ -0,0 +1,234 @@ +// ── Category Styling ─────────────────────────────────────────────────── + +export const CATEGORY_TINT: Record = { + 2: [100, 235, 180], + 3: [120, 225, 235], + 4: [255, 210, 120], + 5: [255, 185, 110], + 6: [255, 160, 120], + 7: [255, 120, 200], + 8: [140, 220, 160], + 9: [170, 210, 255], + 10: [220, 170, 255], + 11: [255, 150, 180], + 12: [180, 230, 160], + 14: [195, 165, 255], +}; + +export function categorySizeMultiplier(category: number | null): number { + switch (category) { + case 2: + return 0.88; + case 3: + return 0.96; + case 4: + return 1.08; + case 5: + return 1.18; + case 6: + return 1.28; + case 7: + return 1.04; + case 8: + return 0.86; + case 9: + case 12: + return 0.8; + case 10: + return 1.15; + case 14: + return 0.72; + default: + return 1; + } +} + +export function tintAircraftColor( + base: [number, number, number, number], + category: number | null, +): [number, number, number, number] { + const tint = category !== null ? CATEGORY_TINT[category] : undefined; + if (!tint) return base; + + return [ + Math.round(base[0] * 0.58 + tint[0] * 0.42), + Math.round(base[1] * 0.58 + tint[1] * 0.42), + Math.round(base[2] * 0.58 + tint[2] * 0.42), + base[3], + ]; +} + +// ── Selection pulse timing ───────────────────────────────────────────── + +export const PULSE_PERIOD_MS = 7000; +export const RING_PERIOD_MS = 5500; + +// ── Canvas Atlas Generators ──────────────────────────────────────────── + +export function createHaloAtlas(): HTMLCanvasElement { + const size = 256; + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d")!; + ctx.clearRect(0, 0, size, size); + const c = size / 2; + for (let r = 0; r < c; r++) { + const norm = r / c; + let alpha = 0; + if (norm < 0.18) { + alpha = 0; + } else if (norm < 0.35) { + const t = (norm - 0.18) / 0.17; + alpha = t * t * 0.7; + } else if (norm < 0.55) { + alpha = 0.7 - ((norm - 0.35) / 0.2) * 0.3; + } else { + const t = (norm - 0.55) / 0.45; + alpha = 0.4 * (1 - t) * (1 - t); + } + if (alpha < 0.003) continue; + ctx.strokeStyle = `rgba(255,255,255,${alpha})`; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(c, c, r, 0, Math.PI * 2); + ctx.stroke(); + } + return canvas; +} + +export function createSoftRingAtlas(): HTMLCanvasElement { + const size = 256; + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d")!; + ctx.clearRect(0, 0, size, size); + const c = size / 2; + const ringCenter = c * 0.75; + const ringWidth = c * 0.18; + for (let r = 0; r < c; r++) { + const dist = Math.abs(r - ringCenter); + const falloff = Math.max(0, 1 - (dist / ringWidth) ** 2); + const alpha = falloff * 0.85; + if (alpha < 0.005) continue; + ctx.strokeStyle = `rgba(255,255,255,${alpha})`; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(c, c, r, 0, Math.PI * 2); + ctx.stroke(); + } + return canvas; +} + +export function createAircraftAtlas(): HTMLCanvasElement { + const size = 128; + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d")!; + + ctx.clearRect(0, 0, size, size); + ctx.fillStyle = "#ffffff"; + + ctx.beginPath(); + ctx.moveTo(64, 6); + ctx.lineTo(71, 19); + ctx.lineTo(71, 33); + ctx.lineTo(100, 44); + ctx.lineTo(106, 52); + ctx.lineTo(80, 53); + ctx.lineTo(72, 56); + ctx.lineTo(72, 88); + ctx.lineTo(90, 101); + ctx.lineTo(88, 108); + ctx.lineTo(69, 99); + ctx.lineTo(69, 121); + ctx.lineTo(64, 126); + ctx.lineTo(59, 121); + ctx.lineTo(59, 99); + ctx.lineTo(40, 108); + ctx.lineTo(38, 101); + ctx.lineTo(56, 88); + ctx.lineTo(56, 56); + ctx.lineTo(48, 53); + ctx.lineTo(22, 52); + ctx.lineTo(28, 44); + ctx.lineTo(57, 33); + ctx.lineTo(57, 19); + ctx.closePath(); + ctx.fill(); + + ctx.globalCompositeOperation = "destination-out"; + ctx.beginPath(); + ctx.moveTo(64, 13); + ctx.lineTo(67, 19); + ctx.lineTo(64, 24); + ctx.lineTo(61, 19); + ctx.closePath(); + ctx.fill(); + ctx.globalCompositeOperation = "source-over"; + + return canvas; +} + +// ── Icon Mappings ────────────────────────────────────────────────────── + +export const HALO_MAPPING = { + halo: { + x: 0, + y: 0, + width: 256, + height: 256, + anchorX: 128, + anchorY: 128, + mask: true, + }, +}; + +export const RING_MAPPING = { + ring: { + x: 0, + y: 0, + width: 256, + height: 256, + anchorX: 128, + anchorY: 128, + mask: true, + }, +}; + +export const AIRCRAFT_ICON_MAPPING = { + aircraft: { + x: 0, + y: 0, + width: 128, + height: 128, + anchorX: 64, + anchorY: 64, + mask: true, + }, +}; + +// ── Cached Atlas Data URLs ───────────────────────────────────────────── + +let _haloCache: string | undefined; +export function getHaloUrl(): string { + if (typeof document === "undefined") return ""; + if (!_haloCache) _haloCache = createHaloAtlas().toDataURL(); + return _haloCache; +} + +let _ringCache: string | undefined; +export function getRingUrl(): string { + if (typeof document === "undefined") return ""; + if (!_ringCache) _ringCache = createSoftRingAtlas().toDataURL(); + return _ringCache; +} + +let _atlasCache: string | undefined; +export function getAircraftAtlasUrl(): string { + if (typeof document === "undefined") return ""; + if (!_atlasCache) _atlasCache = createAircraftAtlas().toDataURL(); + return _atlasCache; +} diff --git a/src/components/map/camera-controller-utils.ts b/src/components/map/camera-controller-utils.ts index eb2dcf2..5328638 100644 --- a/src/components/map/camera-controller-utils.ts +++ b/src/components/map/camera-controller-utils.ts @@ -1,5 +1,4 @@ -import type maplibregl from "maplibre-gl"; -import { MercatorCoordinate } from "maplibre-gl"; +import maplibregl from "maplibre-gl"; export const FPV_DISTANCE_ZOOM_OFFSET = 1.1; @@ -32,42 +31,76 @@ export function fpvZoomForAltitude(altMeters: number): number { return Math.max(10.1, Math.min(16.2, zoom)); } +/** + * Project a geographic position at a given elevation to a screen‐space + * pixel offset from the map's visual centre. + * + * Uses MapLibre's internal transform.locationToScreenPoint with a synthetic + * terrain provider so the correct projection (Globe, Mercator, or the + * automatic transition between them) handles elevation natively. + * + * There is no public MapLibre API for elevation-aware screen projection + * (map.project() is 2D only). This internal access is tested against + * MapLibre GL JS v5.18.x. A public-API fallback (without elevation) is + * provided for resilience against future internal refactors. + */ export function projectLngLatElevationPixelDelta( map: maplibregl.Map, lng: number, lat: number, elevationMeters: number, ): { dx: number; dy: number } | null { - type Transform3DLike = { - _pixelMatrix3D?: unknown; - centerPoint?: { x: number; y: number }; - coordinatePoint: ( - coord: MercatorCoordinate, - elevation: number, - pixelMatrix3D: unknown, - ) => { x: number; y: number } | null; + // MapLibre's transform has separate Globe and Mercator implementations of + // locationToScreenPoint(lnglat, terrain). Both support elevation when a + // terrain-like provider is supplied: + // Mercator: coordinatePoint(coord, elevation, _pixelMatrix3D) + // Globe: scales surface point by (1 + elevation/earthRadius), then projects + // By providing a duck-typed provider that returns our altitude, we get + // elevation-aware projection in every mode without touching internals. + type TransformLike = { + locationToScreenPoint: ( + lnglat: maplibregl.LngLat, + terrain: unknown, + ) => { x: number; y: number }; }; - const tr = (map as unknown as { transform?: Transform3DLike }).transform; - if (!tr || typeof tr.coordinatePoint !== "function") return null; + const tr = (map as unknown as { transform?: TransformLike }).transform; - const pixelMatrix3D = tr._pixelMatrix3D; - const centerPoint = tr.centerPoint; - if (!pixelMatrix3D || !centerPoint) return null; + const canvas = map.getCanvas(); + const cx = canvas.clientWidth / 2; + const cy = canvas.clientHeight / 2; - let p: { x: number; y: number } | null = null; - try { - p = tr.coordinatePoint( - MercatorCoordinate.fromLngLat({ lng, lat }), - elevationMeters, - pixelMatrix3D, - ); - } catch { - return null; + // Try elevation-aware internal API first + if (tr && typeof tr.locationToScreenPoint === "function") { + const fakeTerrain = { + getElevationForLngLat: () => elevationMeters, + getElevationForLngLatZoom: () => elevationMeters, + }; + + try { + const lnglat = new maplibregl.LngLat(lng, lat); + const screenPt = tr.locationToScreenPoint(lnglat, fakeTerrain); + + if (Number.isFinite(screenPt.x) && Number.isFinite(screenPt.y)) { + return { dx: screenPt.x - cx, dy: screenPt.y - cy }; + } + } catch { + // Point may be behind the globe horizon — fall through to public API + } } - if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y)) return null; - return { dx: p.x - centerPoint.x, dy: p.y - centerPoint.y }; + // Fallback: public map.project() without elevation awareness. + // This gives correct 2D placement but ignores altitude offset. + try { + const projected = map.project(new maplibregl.LngLat(lng, lat)); + if (Number.isFinite(projected.x) && Number.isFinite(projected.y)) { + return { dx: projected.x - cx, dy: projected.y - cy }; + } + } catch { + // Point may be behind the globe horizon + } + + return null; } export function setMapInteractionsEnabled( diff --git a/src/components/map/camera-controller.tsx b/src/components/map/camera-controller.tsx index 2900120..785291d 100644 --- a/src/components/map/camera-controller.tsx +++ b/src/components/map/camera-controller.tsx @@ -2,22 +2,14 @@ import { useEffect, useRef, type MutableRefObject } from "react"; import { useMap } from "./map"; -import { - FPV_DISTANCE_ZOOM_OFFSET, - fpvZoomForAltitude, - lerp, - lerpLng, - normalizeLng, - projectLngLatElevationPixelDelta, - setMapInteractionsEnabled, - smoothstep, -} from "./camera-controller-utils"; +import { smoothstep } from "./camera-controller-utils"; import { useSettings } from "@/hooks/use-settings"; import type { City } from "@/lib/cities"; import type { FlightState } from "@/lib/opensky"; +import { useFpvCamera } from "./use-fpv-camera"; +import { useKeyboardCamera } from "./use-keyboard-camera"; +import { useOrbitCamera } from "./use-orbit-camera"; -const IDLE_TIMEOUT_MS = 5_000; -const ORBIT_EASE_IN_MS = 2000; const DEFAULT_ZOOM = 9.2; const DEFAULT_PITCH = 49; const DEFAULT_BEARING = 27.4; @@ -25,29 +17,6 @@ const FOLLOW_ZOOM = 10.5; const FOLLOW_PITCH = 55; const FOLLOW_EASE_MS = 1200; -const FPV_FLY_DURATION = 1600; -const FPV_PITCH = 65; -const FPV_CENTER_ALPHA = 0.16; -const FPV_BEARING_ALPHA = 0.1; -const FPV_ZOOM_ALPHA = 0.06; -const FPV_IDLE_RECENTER_MS = 1200; -const FPV_EASE_IN_MS = 600; - -const CAMERA_ACCEL = 2.5; -const CAMERA_DECEL = 4.0; -const ZOOM_SPEED = 1.2; -const PITCH_SPEED = 28; -const BEARING_SPEED = 55; -const MINIMUM_IMPULSE_DURATION_MS = 180; - -type CameraActionType = "zoom" | "pitch" | "bearing"; -type ActionState = { - direction: number; - velocity: number; - held: boolean; - impulseEnd: number; -}; - type FpvPosition = { lng: number; lat: number; alt: number; track: number }; export function CameraController({ @@ -82,6 +51,7 @@ export function CameraController({ fpvFlightRef.current = fpvFlight; }, [fpvFlight]); + // City flyTo useEffect(() => { if (!map || !isLoaded || !city) return; if (city.id === prevCityRef.current) return; @@ -97,6 +67,7 @@ export function CameraController({ }); }, [map, isLoaded, city]); + // Follow flight init useEffect(() => { if (!map || !isLoaded) return; @@ -128,6 +99,7 @@ export function CameraController({ }); }, [map, isLoaded, followFlight]); + // Follow flight continuous update useEffect(() => { if (!map || !isLoaded || !followFlight) return; if (followFlight.longitude == null || followFlight.latitude == null) return; @@ -151,251 +123,46 @@ export function CameraController({ followFlight?.trueTrack, ]); - useEffect(() => { - if (!map || !isLoaded) { - if (isFpvActiveRef.current) { - isFpvActiveRef.current = false; - } - return; - } - - const fpv = fpvFlightRef.current; - const fpvKey = fpv?.icao24 ?? null; - if (fpvKey === prevFpvRef.current) return; - - const wasFpv = prevFpvRef.current !== null; - prevFpvRef.current = fpvKey; - - if (!fpv || fpv.longitude == null || fpv.latitude == null) { - isFpvActiveRef.current = false; - if (wasFpv) { - setMapInteractionsEnabled(map, true); - } - if (wasFpv) { - map.flyTo({ - center: city.coordinates, - zoom: DEFAULT_ZOOM, - pitch: DEFAULT_PITCH, - bearing: DEFAULT_BEARING, - duration: 1800, - essential: true, - }); - } - return; - } - - isFpvActiveRef.current = true; - setMapInteractionsEnabled(map, true); - - const bearing = Number.isFinite(fpv.trueTrack) - ? fpv.trueTrack! - : map.getBearing(); - const safeAltitude = Number.isFinite(fpv.baroAltitude) - ? fpv.baroAltitude! - : 5000; - const zoom = fpvZoomForAltitude(safeAltitude) - FPV_DISTANCE_ZOOM_OFFSET; - - let fpvOffsetX = 0; - let fpvOffsetY = 0; - - map.flyTo({ - center: [normalizeLng(fpv.longitude), fpv.latitude], - zoom, - pitch: FPV_PITCH, - bearing, - duration: FPV_FLY_DURATION, - essential: true, - }); - - let frameId: number | null = null; - let startupTimer: ReturnType | null = null; - let prevBearing = bearing; - - let lastInteractionTime = 0; // 0 = no interaction yet → track immediately - let recenterStartTime = 0; - let programmaticMove = false; - - function onUserInteraction() { - if (programmaticMove) return; - lastInteractionTime = performance.now(); - recenterStartTime = 0; - } - - const onMapInteraction = (e: unknown) => { - if (programmaticMove) return; - const evt = e as { originalEvent?: Event }; - if (!evt?.originalEvent) return; - onUserInteraction(); - }; - - const interactionEventTypes = [ - "movestart", - "move", - "zoomstart", - "zoom", - "rotatestart", - "rotate", - "pitchstart", - "pitch", - ] as const; - - for (const t of interactionEventTypes) { - map.on(t, onMapInteraction); - } - - function keepInFrame() { - if (!isFpvActiveRef.current || !map) { - frameId = null; - return; - } - - const interpPos = fpvPosRef.current?.current ?? null; - const live = fpvFlightRef.current; - - const posLng = interpPos?.lng ?? live?.longitude ?? null; - const posLat = interpPos?.lat ?? live?.latitude ?? null; - const posAlt = interpPos?.alt ?? live?.baroAltitude ?? 5000; - const posTrack = interpPos?.track ?? live?.trueTrack ?? null; - - if (posLng == null || posLat == null) { - frameId = requestAnimationFrame(keepInFrame); - return; - } - - if ( - !Number.isFinite(posLng) || - !Number.isFinite(posLat) || - Math.abs(posLat) > 90 - ) { - frameId = requestAnimationFrame(keepInFrame); - return; - } - - const now = performance.now(); - const idleMs = - lastInteractionTime === 0 - ? FPV_IDLE_RECENTER_MS + 1 - : now - lastInteractionTime; - const isIdle = idleMs > FPV_IDLE_RECENTER_MS; - - let trackingStrength = 0; - if (isIdle) { - if (recenterStartTime === 0) { - recenterStartTime = now; - } - const easeElapsed = now - recenterStartTime; - const t = Math.min(easeElapsed / FPV_EASE_IN_MS, 1); - trackingStrength = smoothstep(t); - } - - const liveBearing = - posTrack !== null && Number.isFinite(posTrack) ? posTrack : prevBearing; - const bearingDelta = ((liveBearing - prevBearing + 540) % 360) - 180; - prevBearing = prevBearing + bearingDelta * FPV_BEARING_ALPHA; - - if (trackingStrength > 0.001) { - const safeAlt = Number.isFinite(posAlt) ? posAlt : 5000; - const targetZoom = - fpvZoomForAltitude(safeAlt) - FPV_DISTANCE_ZOOM_OFFSET; - const currentZoom = map.getZoom(); - const zoomAlpha = FPV_ZOOM_ALPHA * trackingStrength; - const smoothZoom = lerp(currentZoom, targetZoom, zoomAlpha); - - const currentPitch = map.getPitch(); - const targetLng = normalizeLng(posLng); - const targetLat = posLat; - const center = map.getCenter(); - const centerAlpha = FPV_CENTER_ALPHA * trackingStrength; - - const canvas = map.getCanvas(); - const canvasW = Math.max(1, canvas.clientWidth); - const canvasH = Math.max(1, canvas.clientHeight); - - const elevationMeters = Math.max(safeAlt * 5, 200); - const deltaPx = projectLngLatElevationPixelDelta( - map, - targetLng, - targetLat, - elevationMeters, - ); - if (deltaPx) { - const desiredX = fpvOffsetX - deltaPx.dx; - const desiredY = fpvOffsetY - deltaPx.dy; - const offsetAlpha = 0.08 * trackingStrength; - fpvOffsetX = lerp(fpvOffsetX, desiredX, offsetAlpha); - fpvOffsetY = lerp(fpvOffsetY, desiredY, offsetAlpha); - } else { - const decayAlpha = 0.1 * trackingStrength; - fpvOffsetX = lerp(fpvOffsetX, 0, decayAlpha); - fpvOffsetY = lerp(fpvOffsetY, 0, decayAlpha); - } - - const maxScale = Math.min(1.5, Math.max(1, elevationMeters / 15_000)); - const maxOffset = 0.45 * maxScale * Math.min(canvasW, canvasH); - fpvOffsetX = Math.max(-maxOffset, Math.min(maxOffset, fpvOffsetX)); - fpvOffsetY = Math.max(-maxOffset, Math.min(maxOffset, fpvOffsetY)); - - const currentBearing = map.getBearing(); - const bearingToCurrent = - ((prevBearing - currentBearing + 540) % 360) - 180; - const newMapBearing = - currentBearing + - bearingToCurrent * FPV_BEARING_ALPHA * trackingStrength; - - const pitchAlpha = 0.05 * trackingStrength; - const newPitch = lerp(currentPitch, FPV_PITCH, pitchAlpha); - - programmaticMove = true; - try { - map.easeTo({ - center: [ - lerpLng(center.lng, targetLng, centerAlpha), - lerp(center.lat, targetLat, centerAlpha), - ], - bearing: newMapBearing, - zoom: smoothZoom, - pitch: newPitch, - offset: [fpvOffsetX, fpvOffsetY], - duration: 0, - animate: false, - essential: true, - }); - } finally { - programmaticMove = false; - } - } - - frameId = requestAnimationFrame(keepInFrame); - } - - startupTimer = setTimeout(() => { - startupTimer = null; - frameId = requestAnimationFrame(keepInFrame); - }, FPV_FLY_DURATION + 300); - - return () => { - if (startupTimer) clearTimeout(startupTimer); - if (frameId != null) cancelAnimationFrame(frameId); - for (const t of interactionEventTypes) { - map.off(t, onMapInteraction); - } - if (map && isFpvActiveRef.current) { - setMapInteractionsEnabled(map, true); - isFpvActiveRef.current = false; - } - }; - }, [map, isLoaded, fpvFlight?.icao24, city]); + // FPV camera hook + useFpvCamera( + map, + isLoaded, + fpvFlight, + city, + fpvFlightRef, + fpvPosRef, + isFpvActiveRef, + prevFpvRef, + ); + // North-up & reset-view useEffect(() => { if (!map || !isLoaded || !city) return; + let northUpRafId: number | undefined; + const onNorthUp = () => { if (isFpvActiveRef.current) return; - map.easeTo({ - bearing: 0, - duration: 650, - essential: true, - }); + if (northUpRafId != null) cancelAnimationFrame(northUpRafId); + const startBearing = map.getBearing(); + const delta = ((0 - startBearing + 540) % 360) - 180; + if (Math.abs(delta) < 0.5) { + map.setBearing(0); + return; + } + const duration = 650; + const start = performance.now(); + function animateBearing() { + const t = Math.min((performance.now() - start) / duration, 1); + const eased = smoothstep(t); + map!.setBearing(startBearing + delta * eased); + if (t < 1) { + northUpRafId = requestAnimationFrame(animateBearing); + } else { + northUpRafId = undefined; + } + } + northUpRafId = requestAnimationFrame(animateBearing); }; const onResetView = (event: Event) => { @@ -416,233 +183,33 @@ export function CameraController({ window.addEventListener("aeris:reset-view", onResetView); return () => { + if (northUpRafId != null) cancelAnimationFrame(northUpRafId); window.removeEventListener("aeris:north-up", onNorthUp); window.removeEventListener("aeris:reset-view", onResetView); }; }, [map, isLoaded, city]); - useEffect(() => { - if (!map || !isLoaded) return; + // Keyboard camera hook + useKeyboardCamera( + map, + isLoaded, + isFpvActiveRef, + isInteractingRef, + idleTimerRef, + ); - const actions = new Map(); - let frameId: number | null = null; - let lastTime = 0; - - function getOrCreate( - type: CameraActionType, - direction: number, - ): ActionState { - let s = actions.get(type); - if (!s) { - s = { direction, velocity: 0, held: false, impulseEnd: 0 }; - actions.set(type, s); - } - return s; - } - - function maxSpeed(type: CameraActionType): number { - if (type === "zoom") return ZOOM_SPEED; - if (type === "pitch") return PITCH_SPEED; - return BEARING_SPEED; - } - - function applyDelta(type: CameraActionType, delta: number) { - if (type === "zoom") { - const z = map!.getZoom() + delta; - map!.setZoom( - Math.min(Math.max(z, map!.getMinZoom()), map!.getMaxZoom()), - ); - } else if (type === "pitch") { - const p = map!.getPitch() + delta; - map!.setPitch(Math.min(Math.max(p, 0), map!.getMaxPitch())); - } else { - map!.setBearing(map!.getBearing() + delta); - } - } - - function tick(now: number) { - const dt = lastTime ? Math.min((now - lastTime) / 1000, 0.1) : 0.016; - lastTime = now; - - let anyActive = false; - - for (const [type, state] of actions) { - const wantSpeed = state.held || now < state.impulseEnd; - - if (wantSpeed) { - state.velocity = Math.min( - state.velocity + CAMERA_ACCEL * dt * maxSpeed(type), - maxSpeed(type), - ); - } else { - state.velocity = Math.max( - state.velocity - CAMERA_DECEL * dt * maxSpeed(type), - 0, - ); - } - - if (state.velocity > 0.001) { - applyDelta(type, state.direction * state.velocity * dt); - anyActive = true; - } else { - state.velocity = 0; - if (!state.held) { - actions.delete(type); - if (type === "bearing") { - isInteractingRef.current = false; - } - } - } - } - - frameId = anyActive ? requestAnimationFrame(tick) : null; - } - - function ensureLoop() { - if (frameId == null) { - lastTime = 0; - frameId = requestAnimationFrame(tick); - } - } - - const onStart = (e: Event) => { - if (isFpvActiveRef.current) return; - const { type, direction } = (e as CustomEvent).detail as { - type: CameraActionType; - direction: number; - }; - const state = getOrCreate(type, direction); - state.direction = direction; - state.held = true; - state.impulseEnd = performance.now() + MINIMUM_IMPULSE_DURATION_MS; - - if (type === "bearing") { - isInteractingRef.current = true; - if (idleTimerRef.current) clearTimeout(idleTimerRef.current); - } - - ensureLoop(); - }; - - const onStop = (e: Event) => { - const { type } = (e as CustomEvent).detail as { type: CameraActionType }; - const state = actions.get(type); - if (state) state.held = false; - }; - - window.addEventListener("aeris:camera-start", onStart); - window.addEventListener("aeris:camera-stop", onStop); - - return () => { - window.removeEventListener("aeris:camera-start", onStart); - window.removeEventListener("aeris:camera-stop", onStop); - if (frameId != null) cancelAnimationFrame(frameId); - }; - }, [map, isLoaded]); - - useEffect(() => { - if ( - !map || - !isLoaded || - !city || - !settings.autoOrbit || - followFlight || - fpvFlight - ) { - if (orbitFrameRef.current) cancelAnimationFrame(orbitFrameRef.current); - if (idleTimerRef.current) clearTimeout(idleTimerRef.current); - return; - } - - const prefersReducedMotion = - window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; - if (prefersReducedMotion) return; - - const directionMultiplier = - settings.orbitDirection === "clockwise" ? 1 : -1; - const speed = settings.orbitSpeed * directionMultiplier; - - function startOrbit() { - if (!map || isInteractingRef.current) return; - - const resumeStart = performance.now(); - - function tick() { - if (!map || isInteractingRef.current) return; - const resumeElapsed = performance.now() - resumeStart; - const t = Math.min(resumeElapsed / ORBIT_EASE_IN_MS, 1); - const easeFactor = smoothstep(t); - const bearing = map.getBearing() + speed * easeFactor; - map.setBearing(bearing % 360); - orbitFrameRef.current = requestAnimationFrame(tick); - } - - orbitFrameRef.current = requestAnimationFrame(tick); - } - - function stopOrbit() { - if (orbitFrameRef.current) { - cancelAnimationFrame(orbitFrameRef.current); - orbitFrameRef.current = null; - } - } - - function resetIdleTimer() { - isInteractingRef.current = true; - stopOrbit(); - - if (idleTimerRef.current) clearTimeout(idleTimerRef.current); - idleTimerRef.current = setTimeout(() => { - isInteractingRef.current = false; - startOrbit(); - }, IDLE_TIMEOUT_MS); - } - - const events = ["mousedown", "wheel", "touchstart"] as const; - const container = map.getContainer(); - events.forEach((e) => - container.addEventListener(e, resetIdleTimer, { passive: true }), - ); - - const onMoveStart = () => { - if (isInteractingRef.current) stopOrbit(); - }; - map.on("movestart", onMoveStart); - - const onCameraStop = (e: Event) => { - const { type } = (e as CustomEvent).detail ?? {}; - if (type === "bearing") { - if (idleTimerRef.current) clearTimeout(idleTimerRef.current); - idleTimerRef.current = setTimeout(() => { - isInteractingRef.current = false; - startOrbit(); - }, IDLE_TIMEOUT_MS); - } - }; - window.addEventListener("aeris:camera-stop", onCameraStop); - - idleTimerRef.current = setTimeout(() => { - isInteractingRef.current = false; - startOrbit(); - }, IDLE_TIMEOUT_MS); - - return () => { - stopOrbit(); - if (idleTimerRef.current) clearTimeout(idleTimerRef.current); - events.forEach((e) => container.removeEventListener(e, resetIdleTimer)); - map.off("movestart", onMoveStart); - window.removeEventListener("aeris:camera-stop", onCameraStop); - }; - }, [ + // Auto-orbit hook + useOrbitCamera( map, isLoaded, city, followFlight, fpvFlight, - settings.autoOrbit, - settings.orbitSpeed, - settings.orbitDirection, - ]); + settings, + isInteractingRef, + orbitFrameRef, + idleTimerRef, + ); return null; } diff --git a/src/components/map/flight-animation-helpers.ts b/src/components/map/flight-animation-helpers.ts new file mode 100644 index 0000000..3ff453b --- /dev/null +++ b/src/components/map/flight-animation-helpers.ts @@ -0,0 +1,570 @@ +import type { FlightState } from "@/lib/opensky"; +import type { TrailEntry } from "@/hooks/use-trail-history"; +import { + snapLngToReference, + unwrapLngPath, + greatCircleIntermediate, + gcDistanceDeg, +} from "@/lib/geo"; +import { roundSharpCorners2D } from "@/lib/trail-smoothing"; +import type { ElevatedPoint, Snapshot } from "./flight-layer-constants"; +import { + STARTUP_TRAIL_POLLS, + STARTUP_TRAIL_STEP_SEC, + TELEPORT_THRESHOLD, + TRAIL_SMOOTHING_ITERATIONS, +} from "./flight-layer-constants"; + +// ── Startup Trail ────────────────────────────────────────────────────── + +export function buildStartupFallbackTrail(f: FlightState): [number, number][] { + if (f.longitude == null || f.latitude == null) return []; + + const heading = + ((Number.isFinite(f.trueTrack) ? f.trueTrack! : 0) * Math.PI) / 180; + const speed = Number.isFinite(f.velocity) ? f.velocity! : 200; + const degPerSecond = speed / 111_320; + + const path: [number, number][] = []; + for (let i = STARTUP_TRAIL_POLLS; i >= 1; i--) { + const distDeg = Math.min(degPerSecond * STARTUP_TRAIL_STEP_SEC * i, 0.08); + path.push([ + f.longitude - Math.sin(heading) * distDeg, + f.latitude - Math.cos(heading) * distDeg, + ]); + } + path.push([f.longitude, f.latitude]); + return path; +} + +// ── Interpolation Math ───────────────────────────────────────────────── + +export function lerpAngle(a: number, b: number, t: number): number { + const delta = ((b - a + 540) % 360) - 180; + return a + delta * t; +} + +export function trackFromDelta( + dx: number, + dy: number, + fallback: number, +): number { + if (dx * dx + dy * dy < 1e-10) return fallback; + return ((Math.atan2(dx, dy) * 180) / Math.PI + 360) % 360; +} + +export function smoothStep(t: number): number { + return t * t * (3 - 2 * t); +} + +// ── Distance Helpers ─────────────────────────────────────────────────── + +export function horizontalDistanceFromLngLat( + aLng: number, + aLat: number, + bLng: number, + bLat: number, +): number { + const avgLatRad = ((aLat + bLat) * 0.5 * Math.PI) / 180; + const metersPerDegLon = 111_320 * Math.max(0.2, Math.cos(avgLatRad)); + const dx = (bLng - aLng) * metersPerDegLon; + const dy = (bLat - aLat) * 111_320; + return Math.hypot(dx, dy); +} + +export function horizontalDistanceMeters(a: Snapshot, b: Snapshot): number { + return horizontalDistanceFromLngLat(a.lng, a.lat, b.lng, b.lat); +} + +// ── Path Trimming ────────────────────────────────────────────────────── + +export function trimAfterLargeJump( + path: [number, number][], + altitudes: Array, + maxJumpDeg: number, +): { path: [number, number][]; altitudes: Array } { + if (path.length < 2) return { path, altitudes }; + + const maxJumpSq = maxJumpDeg * maxJumpDeg; + let start = 0; + for (let i = path.length - 2; i >= 0; i--) { + const a = path[i]; + const b = path[i + 1]; + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + if (dx * dx + dy * dy > maxJumpSq) { + start = i + 1; + break; + } + } + + if (start > 0) { + start = Math.min(start, path.length - 2); + return { + path: path.slice(start), + altitudes: altitudes.slice(start), + }; + } + + return { path, altitudes }; +} + +// ── Elevated Path Smoothing ──────────────────────────────────────────── + +export function smoothElevatedPath( + points: ElevatedPoint[], + iterations: number = TRAIL_SMOOTHING_ITERATIONS, +): ElevatedPoint[] { + if (points.length < 3 || iterations <= 0) return points; + + let current = points; + for (let iter = 0; iter < iterations; iter++) { + if (current.length < 3) break; + + const next: ElevatedPoint[] = [current[0]]; + for (let i = 0; i < current.length - 1; i++) { + const a = current[i]; + const b = current[i + 1]; + next.push([ + a[0] * 0.75 + b[0] * 0.25, + a[1] * 0.75 + b[1] * 0.25, + a[2] * 0.75 + b[2] * 0.25, + ]); + next.push([ + a[0] * 0.25 + b[0] * 0.75, + a[1] * 0.25 + b[1] * 0.75, + a[2] * 0.25 + b[2] * 0.75, + ]); + } + next.push(current[current.length - 1]); + current = next; + } + + return current; +} + +export function densifyElevatedPath( + points: ElevatedPoint[], + subdivisions: number = 2, +): ElevatedPoint[] { + if (points.length < 2 || subdivisions <= 1) return points; + + // Threshold in degrees above which we use great-circle interpolation + // instead of linear. ~0.5° ≈ 55 km at the equator. + const GC_THRESHOLD_DEG = 0.4; + + const out: ElevatedPoint[] = []; + for (let i = 0; i < points.length - 1; i++) { + const a = points[i]; + const b = points[i + 1]; + out.push(a); + + const dist = gcDistanceDeg(a[0], a[1], b[0], b[1]); + const useGC = dist > GC_THRESHOLD_DEG; + + // For longer segments, add extra subdivisions proportional to distance + const effectiveSubs = useGC + ? Math.max(subdivisions, Math.min(16, Math.ceil(dist / 0.3))) + : subdivisions; + + for (let j = 1; j < effectiveSubs; j++) { + const t = j / effectiveSubs; + if (useGC) { + const [lng, lat] = greatCircleIntermediate(a[0], a[1], b[0], b[1], t); + const alt = a[2] + (b[2] - a[2]) * t; + out.push([lng, lat, alt]); + } else { + out.push([ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, + ]); + } + } + } + out.push(points[points.length - 1]); + return out; +} + +// ── Numeric & Planar Smoothing ───────────────────────────────────────── + +export function smoothNumericSeries(values: number[]): number[] { + if (values.length < 3) return values; + const out = [...values]; + for (let i = 1; i < values.length - 1; i++) { + out[i] = values[i - 1] * 0.2 + values[i] * 0.6 + values[i + 1] * 0.2; + } + return out; +} + +/** + * Multi-pass altitude smoothing with a wider kernel to prevent + * near-vertical "wall" artifacts on climb/descent trails. + * The wider kernel (0.3/0.4/0.3) and multiple passes spread steep + * altitude transitions over more trail points, producing a gradual + * climb/descent gradient that looks natural with elevation exaggeration. + */ +export function smoothAnimationAltitudes( + values: number[], + passes: number = 3, +): number[] { + if (values.length < 3 || passes <= 0) return values; + + let result = values; + for (let p = 0; p < passes; p++) { + const next = [...result]; + for (let i = 1; i < result.length - 1; i++) { + next[i] = result[i - 1] * 0.3 + result[i] * 0.4 + result[i + 1] * 0.3; + } + result = next; + } + return result; +} + +/** Remove points that create sharp reversals (V-spikes) in a 2D path. */ +export function removePlanarSpikes( + points: [number, number][], +): [number, number][] { + if (points.length < 3) return points; + + const keep: boolean[] = new Array(points.length).fill(true); + const COS_THRESHOLD = -0.5; // reject turns sharper than 120° + + for (let pass = 0; pass < 2; pass++) { + let changed = false; + for (let i = 1; i < points.length - 1; i++) { + if (!keep[i]) continue; + let prevIdx = i - 1; + while (prevIdx >= 0 && !keep[prevIdx]) prevIdx--; + if (prevIdx < 0) continue; + let nextIdx = i + 1; + while (nextIdx < points.length && !keep[nextIdx]) nextIdx++; + if (nextIdx >= points.length) continue; + + const dx1 = points[i][0] - points[prevIdx][0]; + const dy1 = points[i][1] - points[prevIdx][1]; + const dx2 = points[nextIdx][0] - points[i][0]; + const dy2 = points[nextIdx][1] - points[i][1]; + const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1); + const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2); + if (len1 < 1e-10 || len2 < 1e-10) continue; + + const cos = (dx1 * dx2 + dy1 * dy2) / (len1 * len2); + if (cos < COS_THRESHOLD) { + keep[i] = false; + changed = true; + } + } + if (!changed) break; + } + + if (keep.every(Boolean)) return points; + return points.filter((_, i) => keep[i]); +} + +export function smoothPlanarPath( + points: [number, number][], +): [number, number][] { + if (points.length < 3) return points; + + let current: [number, number][] = removePlanarSpikes(points); + current = roundSharpCorners2D(current, 15); + + for (let pass = 0; pass < 6; pass++) { + const next = [...current]; + for (let i = 1; i < current.length - 1; i++) { + next[i] = [ + current[i - 1][0] * 0.2 + current[i][0] * 0.6 + current[i + 1][0] * 0.2, + current[i - 1][1] * 0.2 + current[i][1] * 0.6 + current[i + 1][1] * 0.2, + ]; + } + current = next; + } + + return current; +} + +// ── Trail Ahead Trimming ─────────────────────────────────────────────── + +export function trimPathAheadOfAircraft( + points: ElevatedPoint[], + aircraft: ElevatedPoint, +): ElevatedPoint[] { + if (points.length < 2) return [aircraft]; + + const px = aircraft[0]; + const py = aircraft[1]; + + let bestIndex = points.length - 2; + let bestDistanceSq = Number.POSITIVE_INFINITY; + const searchStart = Math.max(0, points.length - 40); + + for (let i = searchStart; i < points.length - 1; i++) { + const a = points[i]; + const b = points[i + 1]; + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const denom = dx * dx + dy * dy; + const t = + denom > 1e-12 + ? Math.max( + 0, + Math.min(1, ((px - a[0]) * dx + (py - a[1]) * dy) / denom), + ) + : 0; + const qx = a[0] + dx * t; + const qy = a[1] + dy * t; + const distSq = (px - qx) * (px - qx) + (py - qy) * (py - qy); + + if (distSq < bestDistanceSq) { + bestDistanceSq = distSq; + bestIndex = i; + } + } + + const trimmed = points.slice(0, bestIndex + 1); + trimmed.push([px, py, aircraft[2]]); + + return trimmed; +} + +// ── Visible Trail Point Builder (extracted from component) ───────────── + +export function buildVisibleTrailPoints( + trail: TrailEntry, + animFlight: FlightState | undefined, + trailDistance: number, + smoothingIterations: number, + denseSubdivisions: number, +): ElevatedPoint[] { + const isFullHistory = trail.fullHistory === true; + const historyPoints = isFullHistory + ? trail.path.length + : Math.max(2, Math.round(trailDistance)); + + let pathSlice = + isFullHistory || trail.path.length <= historyPoints + ? trail.path + : trail.path.slice(trail.path.length - historyPoints); + let altitudeSlice = + isFullHistory || trail.altitudes.length <= historyPoints + ? trail.altitudes + : trail.altitudes.slice(trail.altitudes.length - historyPoints); + + if (isFullHistory) { + const MAX_FULL_HISTORY_POINTS = 2000; + if (pathSlice.length > MAX_FULL_HISTORY_POINTS) { + const stride = pathSlice.length / MAX_FULL_HISTORY_POINTS; + const nextPath: [number, number][] = []; + const nextAlt: Array = []; + for (let i = 0; i < MAX_FULL_HISTORY_POINTS - 1; i++) { + const idx = Math.floor(i * stride); + nextPath.push(pathSlice[idx]); + nextAlt.push(altitudeSlice[idx] ?? null); + } + nextPath.push(pathSlice[pathSlice.length - 1]); + nextAlt.push(altitudeSlice[altitudeSlice.length - 1] ?? null); + pathSlice = nextPath; + altitudeSlice = nextAlt; + } + } + + if (altitudeSlice.length !== pathSlice.length) { + const last = altitudeSlice[altitudeSlice.length - 1] ?? null; + if (altitudeSlice.length < pathSlice.length) { + altitudeSlice = [...altitudeSlice]; + while (altitudeSlice.length < pathSlice.length) { + altitudeSlice.push(last); + } + } else { + altitudeSlice = altitudeSlice.slice( + altitudeSlice.length - pathSlice.length, + ); + } + } + + const unwrappedPath = unwrapLngPath(pathSlice); + const maxJumpDeg = isFullHistory ? 3.0 : TELEPORT_THRESHOLD; + const trimmed = trimAfterLargeJump(unwrappedPath, altitudeSlice, maxJumpDeg); + pathSlice = trimmed.path; + altitudeSlice = trimmed.altitudes; + + const smoothPathSlice = isFullHistory + ? pathSlice + : smoothPlanarPath(pathSlice); + + const rawAltitudes = altitudeSlice.map( + (a) => a ?? trail.baroAltitude ?? animFlight?.baroAltitude ?? 0, + ); + const altitudeMeters = isFullHistory + ? rawAltitudes + : smoothAnimationAltitudes(rawAltitudes, 3); + + const basePath = smoothPathSlice.map((p, i) => [ + p[0], + p[1], + Math.max(0, altitudeMeters[i] ?? trail.baroAltitude ?? 0), + ]) as ElevatedPoint[]; + const denseBasePath = densifyElevatedPath( + basePath, + isFullHistory ? 1 : denseSubdivisions, + ); + + if ( + animFlight && + animFlight.longitude != null && + animFlight.latitude != null && + denseBasePath.length > 1 + ) { + const refLng = denseBasePath[denseBasePath.length - 1][0]; + const snappedLng = snapLngToReference(animFlight.longitude, refLng); + const clipped = trimPathAheadOfAircraft(denseBasePath, [ + snappedLng, + animFlight.latitude, + Math.max(0, animFlight.baroAltitude ?? 0), + ]); + + const smoothed = + clipped.length < 4 + ? clipped + : smoothElevatedPath(clipped, isFullHistory ? 1 : smoothingIterations); + + return smoothed.map((p) => [p[0], p[1], Math.max(0, p[2])]); + } + + const smoothed = + denseBasePath.length < 4 + ? denseBasePath + : smoothElevatedPath( + denseBasePath, + isFullHistory ? 1 : smoothingIterations, + ); + + return smoothed.map((p) => [p[0], p[1], Math.max(0, p[2])]); +} + +// ── Pitch Calculation (extracted from component) ─────────────────────── + +export function computePitchByIcao( + interpolated: FlightState[], + trailByIcao: Map, + currSnapshots: Map, + prevSnapshots: Map, +): Map { + const pitchByIcao = new Map(); + + for (const f of interpolated) { + const curr = currSnapshots.get(f.icao24); + const prev = prevSnapshots.get(f.icao24); + + const trendTrail = trailByIcao.get(f.icao24); + const trendPitch = + trendTrail && trendTrail.path.length >= 2 + ? (() => { + const end = trendTrail.path.length - 1; + const start = Math.max(0, end - 7); + const startAlt = + trendTrail.altitudes[start] ?? + trendTrail.altitudes[end] ?? + f.baroAltitude ?? + 0; + const endAlt = + trendTrail.altitudes[end] ?? f.baroAltitude ?? startAlt; + const [sLng, sLat] = trendTrail.path[start]; + const [eLng, eLat] = trendTrail.path[end]; + const hMeters = horizontalDistanceFromLngLat( + sLng, + sLat, + eLng, + eLat, + ); + if (hMeters < 1) return 0; + return (-Math.atan2(endAlt - startAlt, hMeters) * 180) / Math.PI; + })() + : 0; + + const risePitch = + curr && prev + ? (() => { + const hMeters = horizontalDistanceMeters(prev, curr); + if (hMeters < 1) return 0; + const deltaAltitudeMeters = curr.alt - prev.alt; + return (-Math.atan2(deltaAltitudeMeters, hMeters) * 180) / Math.PI; + })() + : 0; + + const speed = Number.isFinite(f.velocity) ? f.velocity! : 0; + const verticalRate = Number.isFinite(f.verticalRate) ? f.verticalRate! : 0; + const kinematicPitch = + speed > 0 ? (-Math.atan2(verticalRate, speed) * 180) / Math.PI : 0; + + const blendedPitch = + trendPitch * 0.5 + risePitch * 0.38 + kinematicPitch * 0.12; + const amplifiedPitch = blendedPitch * 1.55; + const clampedPitch = Math.max(-40, Math.min(40, amplifiedPitch)); + pitchByIcao.set(f.icao24, clampedPitch); + } + + return pitchByIcao; +} + +// ── Flight Interpolation (extracted from RAF loop) ───────────────────── + +export function computeInterpolatedFlights( + currentFlights: FlightState[], + prevSnapshots: Map, + currSnapshots: Map, + tPos: number, + tAngle: number, + rawT: number, + animDuration: number, +): FlightState[] { + return currentFlights.map((f) => { + if (f.longitude == null || f.latitude == null) return f; + + const curr = currSnapshots.get(f.icao24); + if (!curr) return f; + + const prev = prevSnapshots.get(f.icao24); + if (!prev) { + return { + ...f, + longitude: curr.lng, + latitude: curr.lat, + baroAltitude: curr.alt, + trueTrack: Number.isFinite(f.trueTrack) ? f.trueTrack! : curr.track, + }; + } + + const dx = curr.lng - prev.lng; + const dy = curr.lat - prev.lat; + if (dx * dx + dy * dy > TELEPORT_THRESHOLD * TELEPORT_THRESHOLD) { + return f; + } + + if (rawT <= 1) { + const blendedTrack = lerpAngle(prev.track, curr.track, tAngle); + return { + ...f, + longitude: prev.lng + dx * tPos, + latitude: prev.lat + dy * tPos, + baroAltitude: prev.alt + (curr.alt - prev.alt) * tPos, + trueTrack: trackFromDelta(dx, dy, blendedTrack), + }; + } + + const heading = (curr.track * Math.PI) / 180; + const speed = Number.isFinite(f.velocity) ? f.velocity! : 200; + const extraSec = ((rawT - 1) * animDuration) / 1000; + const extraDeg = Math.min((speed * extraSec) / 111_320, 0.03); + const moveDx = Math.sin(heading) * extraDeg; + const moveDy = Math.cos(heading) * extraDeg; + return { + ...f, + longitude: curr.lng + moveDx, + latitude: curr.lat + moveDy, + baroAltitude: curr.alt, + trueTrack: trackFromDelta(moveDx, moveDy, curr.track), + }; + }); +} diff --git a/src/components/map/flight-layer-builders.ts b/src/components/map/flight-layer-builders.ts new file mode 100644 index 0000000..aee5294 --- /dev/null +++ b/src/components/map/flight-layer-builders.ts @@ -0,0 +1,355 @@ +import { IconLayer, PathLayer } from "@deck.gl/layers"; +import { altitudeToColor, altitudeToElevation } from "@/lib/flight-utils"; +import type { FlightState } from "@/lib/opensky"; +import type { TrailEntry } from "@/hooks/use-trail-history"; +import type { ElevatedPoint } from "./flight-layer-constants"; +import { + TRAIL_BELOW_AIRCRAFT_METERS, + TRAIL_SMOOTHING_ITERATIONS, + SELECTION_FADE_MS, +} from "./flight-layer-constants"; +import { + PULSE_PERIOD_MS, + RING_PERIOD_MS, + HALO_MAPPING, + RING_MAPPING, +} from "./aircraft-appearance"; +import { + buildStartupFallbackTrail, + buildVisibleTrailPoints, + smoothStep, +} from "./flight-animation-helpers"; + +// ── Slope limiter (post-elevation-exaggeration) ──────────────────────── + +/** + * Maximum elevation-change-per-degree ratio for rendered trail paths. + * One degree of latitude ≈ 111 km. A ratio of 80 000 means + * max visual slope ≈ 80 km rise per 111 km horizontal ≈ ~36°. + */ +const MAX_ELEV_GRADIENT = 80_000; + +/** + * Caps the vertical gradient of an already-elevation-exaggerated trail + * so that steep climbs/descents don't look like near-vertical walls. + * Forward-backward averaging preserves the trail endpoints while + * preventing any single segment from exceeding MAX_ELEV_GRADIENT. + */ +function limitTrailSlope( + pts: [number, number, number][], +): [number, number, number][] { + if (pts.length < 2) return pts; + + const n = pts.length; + + const fwd = pts.map((p) => p[2]); + for (let i = 1; i < n; i++) { + const dx = pts[i][0] - pts[i - 1][0]; + const dy = pts[i][1] - pts[i - 1][1]; + const dH = Math.sqrt(dx * dx + dy * dy); + const maxDz = Math.max(dH * MAX_ELEV_GRADIENT, 30); + const dz = fwd[i] - fwd[i - 1]; + if (Math.abs(dz) > maxDz) { + fwd[i] = fwd[i - 1] + Math.sign(dz) * maxDz; + } + } + + const bwd = pts.map((p) => p[2]); + for (let i = n - 2; i >= 0; i--) { + const dx = pts[i + 1][0] - pts[i][0]; + const dy = pts[i + 1][1] - pts[i][1]; + const dH = Math.sqrt(dx * dx + dy * dy); + const maxDz = Math.max(dH * MAX_ELEV_GRADIENT, 30); + const dz = bwd[i] - bwd[i + 1]; + if (Math.abs(dz) > maxDz) { + bwd[i] = bwd[i + 1] + Math.sign(dz) * maxDz; + } + } + + return pts.map((p, i) => { + // Preserve endpoints so trail connects to aircraft and origin + if (i === 0 || i === n - 1) return p; + return [p[0], p[1], Math.max(0, (fwd[i] + bwd[i]) / 2)]; + }); +} + +// ── Trail layer builder ──────────────────────────────────────────────── + +export interface TrailLayerParams { + interpolated: FlightState[]; + interpolatedMap: Map; + currentTrails: TrailEntry[]; + trailDistance: number; + trailThickness: number; + altColors: boolean; + defaultColor: [number, number, number, number]; + elapsed: number; + globeFade: number; + currentZoom: number; + visible?: boolean; +} + +export function buildTrailLayers(params: TrailLayerParams) { + const { + interpolated, + interpolatedMap, + currentTrails, + trailDistance, + trailThickness, + altColors, + defaultColor, + elapsed, + globeFade, + currentZoom, + visible = true, + } = params; + + const trailMap = new Map(currentTrails.map((t) => [t.icao24, t])); + const handledIds = new Set(); + const trailData: TrailEntry[] = []; + const denseSubdivisions = 2; + const smoothingIters = + interpolated.length > 220 ? 2 : TRAIL_SMOOTHING_ITERATIONS; + + const visibleTrailCache = new Map(); + const getVisibleTrailPoints = ( + trail: TrailEntry, + animFlight: FlightState | undefined, + ): ElevatedPoint[] => { + const cached = visibleTrailCache.get(trail.icao24); + if (cached) return cached; + const computed = buildVisibleTrailPoints( + trail, + animFlight, + trailDistance, + smoothingIters, + denseSubdivisions, + ); + visibleTrailCache.set(trail.icao24, computed); + return computed; + }; + + for (const f of interpolated) { + if (f.longitude == null || f.latitude == null) continue; + const existing = trailMap.get(f.icao24); + handledIds.add(f.icao24); + if (existing && existing.path.length >= 2) { + trailData.push(existing); + continue; + } + const startupPath = buildStartupFallbackTrail(f); + trailData.push({ + icao24: f.icao24, + path: startupPath, + altitudes: startupPath.map( + () => existing?.baroAltitude ?? f.baroAltitude, + ), + baroAltitude: existing?.baroAltitude ?? f.baroAltitude, + }); + } + + for (const d of currentTrails) { + if (!handledIds.has(d.icao24)) trailData.push(d); + } + + return new PathLayer({ + id: "flight-trails", + visible, + data: trailData, + opacity: globeFade, + updateTriggers: { + getPath: [elapsed, trailDistance], + getColor: [elapsed, altColors, trailDistance], + }, + getPath: (d) => { + const animFlight = interpolatedMap.get(d.icao24); + // Scale elevation exaggeration by zoom: + // At globe zoom (<5) altitude spikes look absurd, so reduce. + // At city zoom (>8) full exaggeration is needed for visual depth. + const elevScale = + currentZoom < 5 + ? 0.15 + (currentZoom / 5) * 0.35 + : currentZoom < 8 + ? 0.5 + ((currentZoom - 5) / 3) * 0.5 + : 1.0; + const raw = getVisibleTrailPoints(d, animFlight).map( + (p) => + [ + p[0], + p[1], + Math.max( + 0, + (altitudeToElevation(p[2]) - TRAIL_BELOW_AIRCRAFT_METERS) * + elevScale, + ), + ] as [number, number, number], + ); + return limitTrailSlope(raw); + }, + getColor: (d) => { + const animFlight = interpolatedMap.get(d.icao24); + const visiblePoints = getVisibleTrailPoints(d, animFlight); + const len = visiblePoints.length; + const isFullHist = d.fullHistory === true; + + return visiblePoints.map((point, i) => { + const tVal = len > 1 ? i / (len - 1) : 1; + const fade = isFullHist + ? 0.35 + 0.65 * Math.pow(tVal, 1.1) + : 0.15 + 0.85 * Math.pow(tVal, 1.4); + const base = altColors ? altitudeToColor(point[2]) : defaultColor; + const alpha = isFullHist + ? Math.round(55 + fade * 165) + : Math.round(60 + fade * 160); + return [base[0], base[1], base[2], alpha]; + }) as [number, number, number, number][]; + }, + getWidth: trailThickness, + widthUnits: "pixels", + widthMinPixels: Math.max(1, trailThickness * 0.6), + widthMaxPixels: Math.max(2, trailThickness * 1.8), + wrapLongitude: true, + billboard: true, + capRounded: true, + jointRounded: true, + }); +} + +// ── Selection pulse layer builder ────────────────────────────────────── + +export interface SelectionPulseParams { + selectionChangeTime: number; + selectedId: string | null; + prevId: string | null; + interpolated: FlightState[]; + elapsed: number; + globeFade: number; + currentZoom: number; + haloUrl: string; + ringUrl: string; + layersVisible?: boolean; +} + +export interface SelectionPulseResult { + layers: IconLayer[]; + shouldClearPrev: boolean; +} + +// Dummy position used for invisible layers to keep deck.gl layer state alive +const EMPTY_PULSE_DATA: { position: [number, number, number] }[] = []; + +export function buildSelectionPulseLayers( + params: SelectionPulseParams, +): SelectionPulseResult { + const { + selectionChangeTime, + selectedId, + prevId, + interpolated, + elapsed, + globeFade, + currentZoom, + haloUrl, + ringUrl, + layersVisible = true, + } = params; + + // Zoom-dependent elevation scale (matches trail/aircraft scaling) + const elevScale = + currentZoom < 5 + ? 0.15 + (currentZoom / 5) * 0.35 + : currentZoom < 8 + ? 0.5 + ((currentZoom - 5) / 3) * 0.5 + : 1.0; + + const layers: IconLayer[] = []; + const fadeElapsed = performance.now() - selectionChangeTime; + const fadeT = Math.min(fadeElapsed / SELECTION_FADE_MS, 1); + const fadeIn = smoothStep(fadeT); + const fadeOut = 1 - fadeIn; + + let shouldClearPrev = false; + if (!prevId || prevId === selectedId || fadeOut <= 0.01) { + if (fadeT >= 1) shouldClearPrev = true; + } + + // Build stable layers for both "sel" and "prev" prefixes. + // Always emit all 8 IDs; use `visible` to toggle rather than omitting layers. + const prefixes = ["sel", "prev"] as const; + for (const prefix of prefixes) { + const isSelected = prefix === "sel"; + const targetId = isSelected ? selectedId : prevId; + const op = isSelected ? fadeIn : fadeOut; + + const flight = targetId + ? interpolated.find((f) => f.icao24 === targetId) + : undefined; + const hasPosition = + flight && flight.longitude != null && flight.latitude != null; + + const active = layersVisible && !!targetId && hasPosition && op > 0.01; + const pos: [number, number, number] = hasPosition + ? [ + flight!.longitude!, + flight!.latitude!, + altitudeToElevation(flight!.baroAltitude) * elevScale, + ] + : [0, 0, 0]; + const data = active ? [{ position: pos }] : EMPTY_PULSE_DATA; + + const breathT = (elapsed % PULSE_PERIOD_MS) / PULSE_PERIOD_MS; + const breath = Math.sin(breathT * Math.PI * 2); + const softBreath = smoothStep(smoothStep((breath + 1) / 2)) * 2 - 1; + + const haloSize = 75 + 8 * softBreath; + const haloAlpha = Math.round((18 + 8 * softBreath) * op); + + layers.push( + new IconLayer({ + id: `${prefix}-halo`, + visible: active && haloAlpha > 0, + data, + opacity: globeFade, + getPosition: (d: { position: [number, number, number] }) => d.position, + getIcon: () => "halo", + getSize: haloSize, + getColor: [70, 160, 240, haloAlpha], + iconAtlas: haloUrl, + iconMapping: HALO_MAPPING, + billboard: true, + sizeUnits: "pixels", + sizeScale: 1, + }), + ); + + const ringOffsets = [0, RING_PERIOD_MS / 3, (RING_PERIOD_MS * 2) / 3]; + ringOffsets.forEach((offset, i) => { + const t = ((elapsed + offset) % RING_PERIOD_MS) / RING_PERIOD_MS; + const eased = 1 - (1 - t) ** 5; + const ringSize = 30 + 60 * eased; + const fade = 1 - t; + const ringAlpha = Math.round(70 * fade * fade * fade * fade * op); + + layers.push( + new IconLayer({ + id: `${prefix}-ring-${i}`, + visible: active && ringAlpha >= 2, + data, + opacity: globeFade, + getPosition: (d: { position: [number, number, number] }) => + d.position, + getIcon: () => "ring", + getSize: ringSize, + getColor: [70, 165, 235, ringAlpha], + iconAtlas: ringUrl, + iconMapping: RING_MAPPING, + billboard: true, + sizeUnits: "pixels", + sizeScale: 1, + }), + ); + }); + } + + return { layers, shouldClearPrev }; +} diff --git a/src/components/map/flight-layer-constants.ts b/src/components/map/flight-layer-constants.ts new file mode 100644 index 0000000..c6f14af --- /dev/null +++ b/src/components/map/flight-layer-constants.ts @@ -0,0 +1,73 @@ +import { type MapboxOverlay } from "@deck.gl/mapbox"; +import { type PickingInfo } from "@deck.gl/core"; +import type { FlightState } from "@/lib/opensky"; +import type { TrailEntry } from "@/hooks/use-trail-history"; +import type { MutableRefObject } from "react"; + +// ── Overlay type augmentation ────────────────────────────────────────── + +export type DeckGLOverlay = MapboxOverlay & { + pickObject?(opts: { + x: number; + y: number; + radius: number; + }): PickingInfo | null; +}; + +// ── Animation & rendering constants ──────────────────────────────────── + +export const DEFAULT_ANIM_DURATION_MS = 30_000; +export const MIN_ANIM_DURATION_MS = 8_000; +export const MAX_ANIM_DURATION_MS = 45_000; +export const TELEPORT_THRESHOLD = 0.3; +export const TRAIL_BELOW_AIRCRAFT_METERS = 40; +export const STARTUP_TRAIL_POLLS = 3; +export const STARTUP_TRAIL_STEP_SEC = 12; +export const TRACK_DAMPING = 0.18; +export const TRAIL_SMOOTHING_ITERATIONS = 3; +export const AIRCRAFT_SCENEGRAPH_URL = "/models/airplane.glb"; +export const AIRCRAFT_PX_PER_UNIT = 0.3; +export const BASE_AIRCRAFT_SIZE = 25; +export const AIRCRAFT_PICK_RADIUS_PX = 14; +export const SELECTION_FADE_MS = 600; + +// Globe/Mercator hard-switch: dots below this zoom, flights above. +export const GLOBE_SWITCH_ZOOM = 5.8; +export const GLOBE_FADE_ZOOM_FLOOR = GLOBE_SWITCH_ZOOM - 0.05; +export const GLOBE_FADE_ZOOM_CEIL = GLOBE_SWITCH_ZOOM + 0.05; +export const GLOBE_NATIVE_ZOOM_CEIL = GLOBE_SWITCH_ZOOM; + +// GeoJSON globe dot layer timing +export const GEOJSON_THROTTLE_MS = 1500; +export const GEOJSON_DEBOUNCE_MS = 200; + +// ── Shared types ─────────────────────────────────────────────────────── + +export type Snapshot = { + lng: number; + lat: number; + alt: number; + track: number; +}; + +export type ElevatedPoint = [number, number, number]; + +export type FlightLayerProps = { + flights: FlightState[]; + trails: TrailEntry[]; + onClick: (info: PickingInfo | null) => void; + selectedIcao24: string | null; + showTrails: boolean; + trailThickness: number; + trailDistance: number; + showShadows: boolean; + showAltitudeColors: boolean; + globeMode?: boolean; + fpvIcao24?: string | null; + fpvPositionRef?: MutableRefObject<{ + lng: number; + lat: number; + alt: number; + track: number; + } | null>; +}; diff --git a/src/components/map/flight-layers.tsx b/src/components/map/flight-layers.tsx index 38c5429..d08db48 100644 --- a/src/components/map/flight-layers.tsx +++ b/src/components/map/flight-layers.tsx @@ -1,493 +1,50 @@ "use client"; -import { useEffect, useRef, useCallback, type MutableRefObject } from "react"; +import { useEffect, useRef, useCallback } from "react"; import maplibregl from "maplibre-gl"; import { MapboxOverlay } from "@deck.gl/mapbox"; -import { IconLayer, PathLayer } from "@deck.gl/layers"; +import { IconLayer } from "@deck.gl/layers"; import { ScenegraphLayer } from "@deck.gl/mesh-layers"; import { useMap } from "./map"; import { altitudeToColor, altitudeToElevation } from "@/lib/flight-utils"; import type { FlightState } from "@/lib/opensky"; -import { snapLngToReference, unwrapLngPath } from "@/lib/geo"; -import { type TrailEntry } from "@/hooks/use-trail-history"; -import type { PickingInfo } from "@deck.gl/core"; +import { type PickingInfo, MapView } from "@deck.gl/core"; -type DeckGLOverlay = MapboxOverlay & { - pickObject?(opts: { - x: number; - y: number; - radius: number; - }): PickingInfo | null; -}; +import type { DeckGLOverlay, Snapshot } from "./flight-layer-constants"; +import { + DEFAULT_ANIM_DURATION_MS, + MIN_ANIM_DURATION_MS, + MAX_ANIM_DURATION_MS, + TELEPORT_THRESHOLD, + TRACK_DAMPING, + AIRCRAFT_SCENEGRAPH_URL, + AIRCRAFT_PX_PER_UNIT, + BASE_AIRCRAFT_SIZE, + AIRCRAFT_PICK_RADIUS_PX, + GLOBE_FADE_ZOOM_FLOOR, + GLOBE_FADE_ZOOM_CEIL, + type FlightLayerProps, +} from "./flight-layer-constants"; -const DEFAULT_ANIM_DURATION_MS = 30_000; -const MIN_ANIM_DURATION_MS = 8_000; -const MAX_ANIM_DURATION_MS = 45_000; -const TELEPORT_THRESHOLD = 0.3; -const TRAIL_BELOW_AIRCRAFT_METERS = 40; -const STARTUP_TRAIL_POLLS = 3; -const STARTUP_TRAIL_STEP_SEC = 12; -const TRACK_DAMPING = 0.18; -const TRAIL_SMOOTHING_ITERATIONS = 3; -const AIRCRAFT_SCENEGRAPH_URL = "/models/airplane.glb"; -const AIRCRAFT_PX_PER_UNIT = 0.3; -const BASE_AIRCRAFT_SIZE = 25; -const AIRCRAFT_PICK_RADIUS_PX = 14; +import { + categorySizeMultiplier, + tintAircraftColor, + AIRCRAFT_ICON_MAPPING, + getHaloUrl, + getRingUrl, + getAircraftAtlasUrl, +} from "./aircraft-appearance"; -const CATEGORY_TINT: Record = { - 2: [100, 235, 180], - 3: [120, 225, 235], - 4: [255, 210, 120], - 5: [255, 185, 110], - 6: [255, 160, 120], - 7: [255, 120, 200], - 8: [140, 220, 160], - 9: [170, 210, 255], - 10: [220, 170, 255], - 11: [255, 150, 180], - 12: [180, 230, 160], - 14: [195, 165, 255], -}; +import { + lerpAngle, + smoothStep, + computePitchByIcao, + computeInterpolatedFlights, +} from "./flight-animation-helpers"; -function categorySizeMultiplier(category: number | null): number { - switch (category) { - case 2: - return 0.88; - case 3: - return 0.96; - case 4: - return 1.08; - case 5: - return 1.18; - case 6: - return 1.28; - case 7: - return 1.04; - case 8: - return 0.86; - case 9: - case 12: - return 0.8; - case 10: - return 1.15; - case 14: - return 0.72; - default: - return 1; - } -} - -function tintAircraftColor( - base: [number, number, number, number], - category: number | null, -): [number, number, number, number] { - const tint = category !== null ? CATEGORY_TINT[category] : undefined; - if (!tint) return base; - - return [ - Math.round(base[0] * 0.58 + tint[0] * 0.42), - Math.round(base[1] * 0.58 + tint[1] * 0.42), - Math.round(base[2] * 0.58 + tint[2] * 0.42), - base[3], - ]; -} - -const PULSE_PERIOD_MS = 7000; -const RING_PERIOD_MS = 5500; - -function createHaloAtlas(): HTMLCanvasElement { - const size = 256; - const canvas = document.createElement("canvas"); - canvas.width = size; - canvas.height = size; - const ctx = canvas.getContext("2d")!; - ctx.clearRect(0, 0, size, size); - const c = size / 2; - for (let r = 0; r < c; r++) { - const norm = r / c; - let alpha = 0; - if (norm < 0.18) { - alpha = 0; - } else if (norm < 0.35) { - const t = (norm - 0.18) / 0.17; - alpha = t * t * 0.7; - } else if (norm < 0.55) { - alpha = 0.7 - ((norm - 0.35) / 0.2) * 0.3; - } else { - const t = (norm - 0.55) / 0.45; - alpha = 0.4 * (1 - t) * (1 - t); - } - if (alpha < 0.003) continue; - ctx.strokeStyle = `rgba(255,255,255,${alpha})`; - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.arc(c, c, r, 0, Math.PI * 2); - ctx.stroke(); - } - return canvas; -} - -function createSoftRingAtlas(): HTMLCanvasElement { - const size = 256; - const canvas = document.createElement("canvas"); - canvas.width = size; - canvas.height = size; - const ctx = canvas.getContext("2d")!; - ctx.clearRect(0, 0, size, size); - const c = size / 2; - const ringCenter = c * 0.75; - const ringWidth = c * 0.18; - for (let r = 0; r < c; r++) { - const dist = Math.abs(r - ringCenter); - const falloff = Math.max(0, 1 - (dist / ringWidth) ** 2); - const alpha = falloff * 0.85; - if (alpha < 0.005) continue; - ctx.strokeStyle = `rgba(255,255,255,${alpha})`; - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.arc(c, c, r, 0, Math.PI * 2); - ctx.stroke(); - } - return canvas; -} - -const HALO_MAPPING = { - halo: { - x: 0, - y: 0, - width: 256, - height: 256, - anchorX: 128, - anchorY: 128, - mask: true, - }, -}; - -const RING_MAPPING = { - ring: { - x: 0, - y: 0, - width: 256, - height: 256, - anchorX: 128, - anchorY: 128, - mask: true, - }, -}; - -let _haloCache: string | undefined; -function getHaloUrl(): string { - if (typeof document === "undefined") return ""; - if (!_haloCache) _haloCache = createHaloAtlas().toDataURL(); - return _haloCache; -} - -let _ringCache: string | undefined; -function getRingUrl(): string { - if (typeof document === "undefined") return ""; - if (!_ringCache) _ringCache = createSoftRingAtlas().toDataURL(); - return _ringCache; -} - -function buildStartupFallbackTrail(f: FlightState): [number, number][] { - if (f.longitude == null || f.latitude == null) return []; - - const heading = - ((Number.isFinite(f.trueTrack) ? f.trueTrack! : 0) * Math.PI) / 180; - const speed = Number.isFinite(f.velocity) ? f.velocity! : 200; - const degPerSecond = speed / 111_320; - - const path: [number, number][] = []; - for (let i = STARTUP_TRAIL_POLLS; i >= 1; i--) { - const distDeg = Math.min(degPerSecond * STARTUP_TRAIL_STEP_SEC * i, 0.08); - path.push([ - f.longitude - Math.sin(heading) * distDeg, - f.latitude - Math.cos(heading) * distDeg, - ]); - } - path.push([f.longitude, f.latitude]); - return path; -} - -type Snapshot = { lng: number; lat: number; alt: number; track: number }; - -function lerpAngle(a: number, b: number, t: number): number { - const delta = ((b - a + 540) % 360) - 180; - return a + delta * t; -} - -function trackFromDelta(dx: number, dy: number, fallback: number): number { - if (dx * dx + dy * dy < 1e-10) return fallback; - return ((Math.atan2(dx, dy) * 180) / Math.PI + 360) % 360; -} - -function smoothStep(t: number): number { - return t * t * (3 - 2 * t); -} - -function horizontalDistanceFromLngLat( - aLng: number, - aLat: number, - bLng: number, - bLat: number, -): number { - const avgLatRad = ((aLat + bLat) * 0.5 * Math.PI) / 180; - const metersPerDegLon = 111_320 * Math.max(0.2, Math.cos(avgLatRad)); - const dx = (bLng - aLng) * metersPerDegLon; - const dy = (bLat - aLat) * 111_320; - return Math.hypot(dx, dy); -} - -function horizontalDistanceMeters(a: Snapshot, b: Snapshot): number { - return horizontalDistanceFromLngLat(a.lng, a.lat, b.lng, b.lat); -} - -function trimAfterLargeJump( - path: [number, number][], - altitudes: Array, - maxJumpDeg: number, -): { path: [number, number][]; altitudes: Array } { - if (path.length < 2) return { path, altitudes }; - - const maxJumpSq = maxJumpDeg * maxJumpDeg; - let start = 0; - for (let i = path.length - 2; i >= 0; i--) { - const a = path[i]; - const b = path[i + 1]; - const dx = b[0] - a[0]; - const dy = b[1] - a[1]; - if (dx * dx + dy * dy > maxJumpSq) { - start = i + 1; - break; - } - } - - if (start > 0) { - start = Math.min(start, path.length - 2); - return { - path: path.slice(start), - altitudes: altitudes.slice(start), - }; - } - - return { path, altitudes }; -} - -type ElevatedPoint = [number, number, number]; - -function smoothElevatedPath( - points: ElevatedPoint[], - iterations: number = TRAIL_SMOOTHING_ITERATIONS, -): ElevatedPoint[] { - if (points.length < 3 || iterations <= 0) return points; - - let current = points; - for (let iter = 0; iter < iterations; iter++) { - if (current.length < 3) break; - - const next: ElevatedPoint[] = [current[0]]; - for (let i = 0; i < current.length - 1; i++) { - const a = current[i]; - const b = current[i + 1]; - next.push([ - a[0] * 0.75 + b[0] * 0.25, - a[1] * 0.75 + b[1] * 0.25, - a[2] * 0.75 + b[2] * 0.25, - ]); - next.push([ - a[0] * 0.25 + b[0] * 0.75, - a[1] * 0.25 + b[1] * 0.75, - a[2] * 0.25 + b[2] * 0.75, - ]); - } - next.push(current[current.length - 1]); - current = next; - } - - return current; -} - -function densifyElevatedPath( - points: ElevatedPoint[], - subdivisions: number = 2, -): ElevatedPoint[] { - if (points.length < 2 || subdivisions <= 1) return points; - - const out: ElevatedPoint[] = []; - for (let i = 0; i < points.length - 1; i++) { - const a = points[i]; - const b = points[i + 1]; - out.push(a); - for (let j = 1; j < subdivisions; j++) { - const t = j / subdivisions; - out.push([ - a[0] + (b[0] - a[0]) * t, - a[1] + (b[1] - a[1]) * t, - a[2] + (b[2] - a[2]) * t, - ]); - } - } - out.push(points[points.length - 1]); - return out; -} - -function smoothNumericSeries(values: number[]): number[] { - if (values.length < 3) return values; - const out = [...values]; - for (let i = 1; i < values.length - 1; i++) { - out[i] = values[i - 1] * 0.2 + values[i] * 0.6 + values[i + 1] * 0.2; - } - return out; -} - -function smoothPlanarPath(points: [number, number][]): [number, number][] { - if (points.length < 3) return points; - - let current = points; - for (let pass = 0; pass < 2; pass++) { - const next = [...current]; - for (let i = 1; i < current.length - 1; i++) { - next[i] = [ - current[i - 1][0] * 0.2 + current[i][0] * 0.6 + current[i + 1][0] * 0.2, - current[i - 1][1] * 0.2 + current[i][1] * 0.6 + current[i + 1][1] * 0.2, - ]; - } - current = next; - } - - return current; -} - -function trimPathAheadOfAircraft( - points: ElevatedPoint[], - aircraft: ElevatedPoint, -): ElevatedPoint[] { - if (points.length < 2) return [aircraft]; - - const px = aircraft[0]; - const py = aircraft[1]; - - let bestIndex = points.length - 2; - let bestDistanceSq = Number.POSITIVE_INFINITY; - const searchStart = Math.max(0, points.length - 10); - - for (let i = searchStart; i < points.length - 1; i++) { - const a = points[i]; - const b = points[i + 1]; - const dx = b[0] - a[0]; - const dy = b[1] - a[1]; - const denom = dx * dx + dy * dy; - const t = - denom > 1e-12 - ? Math.max( - 0, - Math.min(1, ((px - a[0]) * dx + (py - a[1]) * dy) / denom), - ) - : 0; - const qx = a[0] + dx * t; - const qy = a[1] + dy * t; - const distSq = (px - qx) * (px - qx) + (py - qy) * (py - qy); - - if (distSq < bestDistanceSq) { - bestDistanceSq = distSq; - bestIndex = i; - } - } - - const trimmed = points.slice(0, bestIndex + 1); - trimmed.push([px, py, aircraft[2]]); - - return trimmed; -} - -function createAircraftAtlas(): HTMLCanvasElement { - const size = 128; - const canvas = document.createElement("canvas"); - canvas.width = size; - canvas.height = size; - const ctx = canvas.getContext("2d")!; - - ctx.clearRect(0, 0, size, size); - ctx.fillStyle = "#ffffff"; - - ctx.beginPath(); - ctx.moveTo(64, 6); - ctx.lineTo(71, 19); - ctx.lineTo(71, 33); - ctx.lineTo(100, 44); - ctx.lineTo(106, 52); - ctx.lineTo(80, 53); - ctx.lineTo(72, 56); - ctx.lineTo(72, 88); - ctx.lineTo(90, 101); - ctx.lineTo(88, 108); - ctx.lineTo(69, 99); - ctx.lineTo(69, 121); - ctx.lineTo(64, 126); - ctx.lineTo(59, 121); - ctx.lineTo(59, 99); - ctx.lineTo(40, 108); - ctx.lineTo(38, 101); - ctx.lineTo(56, 88); - ctx.lineTo(56, 56); - ctx.lineTo(48, 53); - ctx.lineTo(22, 52); - ctx.lineTo(28, 44); - ctx.lineTo(57, 33); - ctx.lineTo(57, 19); - ctx.closePath(); - ctx.fill(); - - ctx.globalCompositeOperation = "destination-out"; - ctx.beginPath(); - ctx.moveTo(64, 13); - ctx.lineTo(67, 19); - ctx.lineTo(64, 24); - ctx.lineTo(61, 19); - ctx.closePath(); - ctx.fill(); - ctx.globalCompositeOperation = "source-over"; - - return canvas; -} - -const AIRCRAFT_ICON_MAPPING = { - aircraft: { - x: 0, - y: 0, - width: 128, - height: 128, - anchorX: 64, - anchorY: 64, - mask: true, - }, -}; - -let _atlasCache: string | undefined; -function getAircraftAtlasUrl(): string { - if (typeof document === "undefined") return ""; - if (!_atlasCache) _atlasCache = createAircraftAtlas().toDataURL(); - return _atlasCache; -} - -type FlightLayerProps = { - flights: FlightState[]; - trails: TrailEntry[]; - onClick: (info: PickingInfo | null) => void; - selectedIcao24: string | null; - showTrails: boolean; - trailThickness: number; - trailDistance: number; - showShadows: boolean; - showAltitudeColors: boolean; - fpvIcao24?: string | null; - fpvPositionRef?: MutableRefObject<{ - lng: number; - lat: number; - alt: number; - track: number; - } | null>; -}; +import { buildTrailLayers } from "./flight-layer-builders"; +import { buildSelectionPulseLayers } from "./flight-layer-builders"; +import { useGlobeDots } from "./use-globe-dots"; export function FlightLayers({ flights, @@ -499,6 +56,7 @@ export function FlightLayers({ trailDistance, showShadows, showAltitudeColors, + globeMode = false, fpvIcao24 = null, fpvPositionRef, }: FlightLayerProps) { @@ -516,19 +74,36 @@ export function FlightLayers({ const flightsRef = useRef(flights); const trailsRef = useRef(trails); + const onClickRef = useRef(onClick); const showTrailsRef = useRef(showTrails); const trailThicknessRef = useRef(trailThickness); const trailDistanceRef = useRef(trailDistance); const showShadowsRef = useRef(showShadows); const showAltColorsRef = useRef(showAltitudeColors); + const globeModeRef = useRef(globeMode); const selectedIcao24Ref = useRef(selectedIcao24); const fpvIcao24Ref = useRef(fpvIcao24); const fpvPosRef = useRef(fpvPositionRef); const prevSelectedRef = useRef(null); const selectionChangeTimeRef = useRef(0); - const SELECTION_FADE_MS = 600; + + const { updateGlobeDots } = useGlobeDots( + map, + isLoaded, + flightsRef, + trailsRef, + dataTimestampRef, + onClickRef, + showTrailsRef, + ); + + // Stabilize updateGlobeDots via ref so the animation loop doesn't restart on every render + const updateGlobeDotsRef = useRef(updateGlobeDots); + + // ── Sync props into refs ─────────────────────────────────────────── useEffect(() => { + updateGlobeDotsRef.current = updateGlobeDots; flightsRef.current = flights; trailsRef.current = trails; showTrailsRef.current = showTrails; @@ -538,24 +113,31 @@ export function FlightLayers({ showAltColorsRef.current = showAltitudeColors; fpvIcao24Ref.current = fpvIcao24; fpvPosRef.current = fpvPositionRef; + onClickRef.current = onClick; + globeModeRef.current = globeMode; if (selectedIcao24 !== selectedIcao24Ref.current) { prevSelectedRef.current = selectedIcao24Ref.current; selectionChangeTimeRef.current = performance.now(); } selectedIcao24Ref.current = selectedIcao24; }, [ + updateGlobeDots, flights, trails, + onClick, showTrails, trailThickness, trailDistance, showShadows, showAltitudeColors, + globeMode, selectedIcao24, fpvIcao24, fpvPositionRef, ]); + // ── Snapshot interpolation on new data ───────────────────────────── + useEffect(() => { const elapsed = performance.now() - dataTimestampRef.current; const oldLinearT = Math.min(elapsed / animDurationRef.current, 1); @@ -616,6 +198,8 @@ export function FlightLayers({ dataTimestampRef.current = now; }, [flights]); + // ── Cursor management ────────────────────────────────────────────── + const handleHover = useCallback( (info: PickingInfo) => { const canvas = map?.getCanvas(); @@ -638,6 +222,8 @@ export function FlightLayers({ [onClick], ); + // ── Map click pass-through ───────────────────────────────────────── + useEffect(() => { if (!map || !isLoaded) return; @@ -663,12 +249,15 @@ export function FlightLayers({ }; }, [map, isLoaded, onClick]); + // ── Overlay lifecycle ────────────────────────────────────────────── + useEffect(() => { if (!map || !isLoaded) return; if (!overlayRef.current) { overlayRef.current = new MapboxOverlay({ interleaved: false, + views: new MapView({ id: "mapbox" }) as never, pickingRadius: AIRCRAFT_PICK_RADIUS_PX, layers: [], }); @@ -678,6 +267,7 @@ export function FlightLayers({ return () => { if (overlayRef.current) { try { + overlayRef.current.finalize(); map.removeControl( overlayRef.current as unknown as maplibregl.IControl, ); @@ -689,6 +279,8 @@ export function FlightLayers({ }; }, [map, isLoaded]); + // ── Main animation loop ──────────────────────────────────────────── + useEffect(() => { if (!atlasUrl) return; @@ -698,6 +290,26 @@ export function FlightLayers({ const overlay = overlayRef.current; if (!overlay) return; + const currentZoom = map?.getZoom() ?? 10; + const now = performance.now(); + const isGlobe = globeModeRef.current; + + updateGlobeDotsRef.current(isGlobe, currentZoom, now); + + let globeFade = 1; + let layersVisible = true; + if (isGlobe) { + if (currentZoom < GLOBE_FADE_ZOOM_FLOOR) { + layersVisible = false; + globeFade = 0; + } else if (currentZoom < GLOBE_FADE_ZOOM_CEIL) { + const t = + (currentZoom - GLOBE_FADE_ZOOM_FLOOR) / + (GLOBE_FADE_ZOOM_CEIL - GLOBE_FADE_ZOOM_FLOOR); + globeFade = t * t * t; + } + } + try { const elapsed = performance.now() - dataTimestampRef.current; const rawT = elapsed / animDurationRef.current; @@ -706,74 +318,29 @@ export function FlightLayers({ const currentFlights = flightsRef.current; const currentTrails = trailsRef.current; - const trailByIcao = new Map(currentTrails.map((t) => [t.icao24, t])); const altColors = showAltColorsRef.current; const defaultColor: [number, number, number, number] = [ 180, 220, 255, 200, ]; - const interpolated: FlightState[] = currentFlights.map((f) => { - if (f.longitude == null || f.latitude == null) return f; - - const curr = currSnapshotsRef.current.get(f.icao24); - if (!curr) return f; - - const prev = prevSnapshotsRef.current.get(f.icao24); - // For newly-loaded aircraft we may not have a real previous snapshot yet. - // Avoid synthesizing a fake motion vector from heading/velocity because it - // can briefly animate aircraft in the wrong direction until the next poll. - if (!prev) { - return { - ...f, - longitude: curr.lng, - latitude: curr.lat, - baroAltitude: curr.alt, - trueTrack: Number.isFinite(f.trueTrack) - ? f.trueTrack! - : curr.track, - }; - } - - const dx = curr.lng - prev.lng; - const dy = curr.lat - prev.lat; - if (dx * dx + dy * dy > TELEPORT_THRESHOLD * TELEPORT_THRESHOLD) { - return f; - } - - if (rawT <= 1) { - const blendedTrack = lerpAngle(prev.track, curr.track, tAngle); - return { - ...f, - longitude: prev.lng + dx * tPos, - latitude: prev.lat + dy * tPos, - baroAltitude: prev.alt + (curr.alt - prev.alt) * tPos, - trueTrack: trackFromDelta(dx, dy, blendedTrack), - }; - } - - const heading = (curr.track * Math.PI) / 180; - const speed = Number.isFinite(f.velocity) ? f.velocity! : 200; - const extraSec = ((rawT - 1) * animDurationRef.current) / 1000; - const extraDeg = Math.min((speed * extraSec) / 111_320, 0.03); - const moveDx = Math.sin(heading) * extraDeg; - const moveDy = Math.cos(heading) * extraDeg; - return { - ...f, - longitude: curr.lng + moveDx, - latitude: curr.lat + moveDy, - baroAltitude: curr.alt, - trueTrack: trackFromDelta(moveDx, moveDy, curr.track), - }; - }); + const interpolated = computeInterpolatedFlights( + currentFlights, + prevSnapshotsRef.current, + currSnapshotsRef.current, + tPos, + tAngle, + rawT, + animDurationRef.current, + ); const interpolatedMap = new Map(); for (const f of interpolated) { interpolatedMap.set(f.icao24, f); } + // FPV position output const fpvId = fpvIcao24Ref.current?.toLowerCase() ?? null; const visibleFlights = interpolated; - const fpvPosOut = fpvPosRef.current; if (fpvPosOut && fpvId) { const fpvF = @@ -798,410 +365,92 @@ export function FlightLayers({ fpvPosOut.current = null; } - const pitchByIcao = new Map(); - for (const f of interpolated) { - const curr = currSnapshotsRef.current.get(f.icao24); - const prev = prevSnapshotsRef.current.get(f.icao24); - - const trendTrail = trailByIcao.get(f.icao24); - const trendPitch = - trendTrail && trendTrail.path.length >= 2 - ? (() => { - const end = trendTrail.path.length - 1; - const start = Math.max(0, end - 7); - const startAlt = - trendTrail.altitudes[start] ?? - trendTrail.altitudes[end] ?? - f.baroAltitude ?? - 0; - const endAlt = - trendTrail.altitudes[end] ?? f.baroAltitude ?? startAlt; - const [sLng, sLat] = trendTrail.path[start]; - const [eLng, eLat] = trendTrail.path[end]; - const horizontalMeters = horizontalDistanceFromLngLat( - sLng, - sLat, - eLng, - eLat, - ); - if (horizontalMeters < 1) return 0; - return ( - (-Math.atan2(endAlt - startAlt, horizontalMeters) * 180) / - Math.PI - ); - })() - : 0; - - const risePitch = - curr && prev - ? (() => { - const horizontalMeters = horizontalDistanceMeters(prev, curr); - if (horizontalMeters < 1) return 0; - const deltaAltitudeMeters = curr.alt - prev.alt; - return ( - (-Math.atan2(deltaAltitudeMeters, horizontalMeters) * 180) / - Math.PI - ); - })() - : 0; - - const speed = Number.isFinite(f.velocity) ? f.velocity! : 0; - const verticalRate = Number.isFinite(f.verticalRate) - ? f.verticalRate! - : 0; - const kinematicPitch = - speed > 0 ? (-Math.atan2(verticalRate, speed) * 180) / Math.PI : 0; - - const blendedPitch = - trendPitch * 0.5 + risePitch * 0.38 + kinematicPitch * 0.12; - const amplifiedPitch = blendedPitch * 1.55; - const clampedPitch = Math.max(-40, Math.min(40, amplifiedPitch)); - pitchByIcao.set(f.icao24, clampedPitch); - } + const pitchByIcao = computePitchByIcao( + interpolated, + new Map(currentTrails.map((t) => [t.icao24, t])), + currSnapshotsRef.current, + prevSnapshotsRef.current, + ); const layers = []; - if (showShadowsRef.current) { - layers.push( - new IconLayer({ - id: "flight-shadows", - data: visibleFlights, - getPosition: (d) => [d.longitude!, d.latitude!, 0], - getIcon: () => "aircraft", - getSize: (d) => 20 * categorySizeMultiplier(d.category), - getColor: () => [0, 0, 0, 60], - getAngle: (d) => - 360 - (Number.isFinite(d.trueTrack) ? d.trueTrack! : 0), - iconAtlas: atlasUrl, - iconMapping: AIRCRAFT_ICON_MAPPING, - billboard: false, - sizeUnits: "pixels", - sizeScale: 1, - }), - ); - } + // Shadow layer — always included, toggled via `visible` to retain WebGL state + layers.push( + new IconLayer({ + id: "flight-shadows", + visible: layersVisible && showShadowsRef.current, + data: visibleFlights, + opacity: globeFade, + getPosition: (d) => [d.longitude!, d.latitude!, 0], + getIcon: () => "aircraft", + getSize: (d) => 20 * categorySizeMultiplier(d.category), + getColor: () => [0, 0, 0, 60], + getAngle: (d) => + 360 - (Number.isFinite(d.trueTrack) ? d.trueTrack! : 0), + iconAtlas: atlasUrl, + iconMapping: AIRCRAFT_ICON_MAPPING, + billboard: false, + sizeUnits: "pixels", + sizeScale: 1, + }), + ); - if (showTrailsRef.current) { - const trailMap = new Map(currentTrails.map((t) => [t.icao24, t])); - const handledIds = new Set(); - const trailData: TrailEntry[] = []; - const denseSubdivisions = interpolated.length > 140 ? 1 : 2; - const smoothingIterations = - interpolated.length > 220 ? 1 : TRAIL_SMOOTHING_ITERATIONS; + // Trail layer — always included, toggled via `visible` to retain WebGL state + layers.push( + buildTrailLayers({ + interpolated, + interpolatedMap, + currentTrails, + trailDistance: trailDistanceRef.current, + trailThickness: trailThicknessRef.current, + altColors, + defaultColor, + elapsed, + globeFade, + currentZoom, + visible: layersVisible && showTrailsRef.current, + }), + ); - const buildVisibleTrailPoints = ( - trail: TrailEntry, - animFlight: FlightState | undefined, - ): ElevatedPoint[] => { - const isFullHistory = trail.fullHistory === true; - const historyPoints = isFullHistory - ? trail.path.length - : Math.max(2, Math.round(trailDistanceRef.current)); - - let pathSlice = - isFullHistory || trail.path.length <= historyPoints - ? trail.path - : trail.path.slice(trail.path.length - historyPoints); - let altitudeSlice = - isFullHistory || trail.altitudes.length <= historyPoints - ? trail.altitudes - : trail.altitudes.slice(trail.altitudes.length - historyPoints); - - // Keep full-history rendering performant by limiting point count. - if (isFullHistory) { - const MAX_FULL_HISTORY_POINTS = 1200; - if (pathSlice.length > MAX_FULL_HISTORY_POINTS) { - const stride = pathSlice.length / MAX_FULL_HISTORY_POINTS; - const nextPath: [number, number][] = []; - const nextAlt: Array = []; - for (let i = 0; i < MAX_FULL_HISTORY_POINTS - 1; i++) { - const idx = Math.floor(i * stride); - nextPath.push(pathSlice[idx]); - nextAlt.push(altitudeSlice[idx] ?? null); - } - nextPath.push(pathSlice[pathSlice.length - 1]); - nextAlt.push(altitudeSlice[altitudeSlice.length - 1] ?? null); - pathSlice = nextPath; - altitudeSlice = nextAlt; - } - } - - if (altitudeSlice.length !== pathSlice.length) { - const last = altitudeSlice[altitudeSlice.length - 1] ?? null; - if (altitudeSlice.length < pathSlice.length) { - altitudeSlice = [...altitudeSlice]; - while (altitudeSlice.length < pathSlice.length) { - altitudeSlice.push(last); - } - } else { - altitudeSlice = altitudeSlice.slice( - altitudeSlice.length - pathSlice.length, - ); - } - } - - const unwrappedPath = unwrapLngPath(pathSlice); - const maxJumpDeg = isFullHistory ? 3.0 : TELEPORT_THRESHOLD; - const trimmed = trimAfterLargeJump( - unwrappedPath, - altitudeSlice, - maxJumpDeg, - ); - pathSlice = trimmed.path; - altitudeSlice = trimmed.altitudes; - - // The OpenSky track endpoint can be extremely sparse (waypoints ~ every 15min). - // Applying planar smoothing to sparse points can create visible kinks/loops. - // For full-history tracks, keep the raw geometry. - const smoothPathSlice = isFullHistory - ? pathSlice - : smoothPlanarPath(pathSlice); - - const altitudeMeters = smoothNumericSeries( - altitudeSlice.map( - (a) => a ?? trail.baroAltitude ?? animFlight?.baroAltitude ?? 0, - ), - ); - - const basePath = smoothPathSlice.map((p, i) => [ - p[0], - p[1], - Math.max(0, altitudeMeters[i] ?? trail.baroAltitude ?? 0), - ]) as ElevatedPoint[]; - const denseBasePath = densifyElevatedPath( - basePath, - isFullHistory ? 1 : denseSubdivisions, - ); - - if ( - animFlight && - animFlight.longitude != null && - animFlight.latitude != null && - denseBasePath.length > 1 - ) { - const refLng = denseBasePath[denseBasePath.length - 1][0]; - const snappedLng = snapLngToReference( - animFlight.longitude, - refLng, - ); - const clipped = trimPathAheadOfAircraft(denseBasePath, [ - snappedLng, - animFlight.latitude, - Math.max(0, animFlight.baroAltitude ?? 0), - ]); - - const smoothed = - clipped.length < 4 - ? clipped - : smoothElevatedPath( - clipped, - isFullHistory ? 0 : smoothingIterations, - ); - - return smoothed.map((p) => [p[0], p[1], Math.max(0, p[2])]); - } - - const smoothed = - denseBasePath.length < 4 - ? denseBasePath - : smoothElevatedPath( - denseBasePath, - isFullHistory ? 0 : smoothingIterations, - ); - - return smoothed.map((p) => [p[0], p[1], Math.max(0, p[2])]); - }; - - const visibleTrailCache = new Map(); - const getVisibleTrailPoints = ( - trail: TrailEntry, - animFlight: FlightState | undefined, - ): ElevatedPoint[] => { - const cached = visibleTrailCache.get(trail.icao24); - if (cached) return cached; - const computed = buildVisibleTrailPoints(trail, animFlight); - visibleTrailCache.set(trail.icao24, computed); - return computed; - }; - - for (const f of interpolated) { - if (f.longitude == null || f.latitude == null) continue; - - const existing = trailMap.get(f.icao24); - handledIds.add(f.icao24); - - if (existing && existing.path.length >= 2) { - trailData.push(existing); - continue; - } - - const startupPath = buildStartupFallbackTrail(f); - - trailData.push({ - icao24: f.icao24, - path: startupPath, - altitudes: startupPath.map( - () => existing?.baroAltitude ?? f.baroAltitude, - ), - baroAltitude: existing?.baroAltitude ?? f.baroAltitude, - }); - } - - for (const d of currentTrails) { - if (!handledIds.has(d.icao24)) { - trailData.push(d); - } - } - - layers.push( - new PathLayer({ - id: "flight-trails", - data: trailData, - updateTriggers: { - getPath: [elapsed, trailDistanceRef.current], - getColor: [elapsed, altColors, trailDistanceRef.current], - }, - getPath: (d) => { - const animFlight = interpolatedMap.get(d.icao24); - const visiblePoints = getVisibleTrailPoints(d, animFlight); - return visiblePoints.map( - (p) => - [ - p[0], - p[1], - Math.max( - 0, - altitudeToElevation(p[2]) - TRAIL_BELOW_AIRCRAFT_METERS, - ), - ] as [number, number, number], - ); - }, - getColor: (d) => { - const animFlight = interpolatedMap.get(d.icao24); - const visiblePoints = getVisibleTrailPoints(d, animFlight); - const len = visiblePoints.length; - - return visiblePoints.map((point, i) => { - const tVal = len > 1 ? i / (len - 1) : 1; - const fade = Math.pow(tVal, 1.65); - const base = altColors - ? altitudeToColor(point[2]) - : defaultColor; - return [ - base[0], - base[1], - base[2], - Math.round(70 + fade * 150), - ]; - }) as [number, number, number, number][]; - }, - getWidth: trailThicknessRef.current, - widthUnits: "pixels", - widthMinPixels: Math.max(1, trailThicknessRef.current * 0.6), - widthMaxPixels: Math.max(2, trailThicknessRef.current * 1.8), - billboard: true, - capRounded: true, - jointRounded: true, - }), - ); - } - - const smoothstep = (t: number) => t * t * (3 - 2 * t); - const easeOutQuint = (t: number) => 1 - (1 - t) ** 5; - - const fadeElapsed = performance.now() - selectionChangeTimeRef.current; - const fadeT = Math.min(fadeElapsed / SELECTION_FADE_MS, 1); - const fadeIn = smoothstep(fadeT); - const fadeOut = 1 - fadeIn; - - const selectedId = selectedIcao24Ref.current; - const prevId = prevSelectedRef.current; - - const pulseTargets: { id: string; opacity: number; prefix: string }[] = - []; - if (selectedId) - pulseTargets.push({ id: selectedId, opacity: fadeIn, prefix: "sel" }); - if (prevId && prevId !== selectedId && fadeOut > 0.01) { - pulseTargets.push({ id: prevId, opacity: fadeOut, prefix: "prev" }); - } else if (fadeT >= 1) { + // Selection pulse layers (halo + rings) + const pulseResult = buildSelectionPulseLayers({ + selectionChangeTime: selectionChangeTimeRef.current, + selectedId: selectedIcao24Ref.current, + prevId: prevSelectedRef.current, + interpolated, + elapsed, + globeFade, + currentZoom, + haloUrl, + ringUrl, + layersVisible, + }); + layers.push(...pulseResult.layers); + if (pulseResult.shouldClearPrev) { prevSelectedRef.current = null; } - for (const target of pulseTargets) { - const flight = interpolated.find((f) => f.icao24 === target.id); - if (!flight || flight.longitude == null || flight.latitude == null) - continue; - - const pos: [number, number, number] = [ - flight.longitude, - flight.latitude, - altitudeToElevation(flight.baroAltitude), - ]; - const op = target.opacity; - - const breathT = (elapsed % PULSE_PERIOD_MS) / PULSE_PERIOD_MS; - const breath = Math.sin(breathT * Math.PI * 2); - const softBreath = smoothstep(smoothstep((breath + 1) / 2)) * 2 - 1; - - const haloSize = 75 + 8 * softBreath; - const haloAlpha = Math.round((18 + 8 * softBreath) * op); - - if (haloAlpha > 0) { - layers.push( - new IconLayer({ - id: `${target.prefix}-halo`, - data: [{ position: pos }], - getPosition: (d: { position: [number, number, number] }) => - d.position, - getIcon: () => "halo", - getSize: haloSize, - getColor: [70, 160, 240, haloAlpha], - iconAtlas: haloUrl, - iconMapping: HALO_MAPPING, - billboard: true, - sizeUnits: "pixels", - sizeScale: 1, - }), - ); - } - - const ringOffsets = [0, RING_PERIOD_MS / 3, (RING_PERIOD_MS * 2) / 3]; - ringOffsets.forEach((offset, i) => { - const t = ((elapsed + offset) % RING_PERIOD_MS) / RING_PERIOD_MS; - const eased = easeOutQuint(t); - const ringSize = 30 + 60 * eased; - const fade = 1 - t; - const ringAlpha = Math.round(70 * fade * fade * fade * fade * op); - - if (ringAlpha < 2) return; - - layers.push( - new IconLayer({ - id: `${target.prefix}-ring-${i}`, - data: [{ position: pos }], - getPosition: (d: { position: [number, number, number] }) => - d.position, - getIcon: () => "ring", - getSize: ringSize, - getColor: [70, 165, 235, ringAlpha], - iconAtlas: ringUrl, - iconMapping: RING_MAPPING, - billboard: true, - sizeUnits: "pixels", - sizeScale: 1, - }), - ); - }); - } + // Zoom-dependent elevation scale to prevent absurd altitude spikes + // at globe zoom levels. Full exaggeration at city zoom (>8). + const elevScale = + currentZoom < 5 + ? 0.15 + (currentZoom / 5) * 0.35 + : currentZoom < 8 + ? 0.5 + ((currentZoom - 5) / 3) * 0.5 + : 1.0; + // Aircraft 3D model layer — always included with `visible` to avoid + // re-fetching the .glb model on every zoom in/out cycle layers.push( new ScenegraphLayer({ id: "flight-aircraft", + visible: layersVisible, data: visibleFlights, + opacity: globeFade, getPosition: (d) => [ d.longitude!, d.latitude!, - altitudeToElevation(d.baroAltitude), + altitudeToElevation(d.baroAltitude) * elevScale, ], getOrientation: (d) => { const pitch = pitchByIcao.get(d.icao24) ?? 0; diff --git a/src/components/map/map.tsx b/src/components/map/map.tsx index a9c56cb..eb33e64 100644 --- a/src/components/map/map.tsx +++ b/src/components/map/map.tsx @@ -1,6 +1,6 @@ "use client"; -import maplibregl from "maplibre-gl"; +import maplibregl, { setMaxParallelImageRequests } from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import { createContext, @@ -14,7 +14,23 @@ import { type ReactNode, } from "react"; import { cn } from "@/lib/utils"; -import { DEFAULT_STYLE, type MapStyleSpec } from "@/lib/map-styles"; +import { + createTerrainDemSource, + DEFAULT_STYLE, + DARK_TERRAIN_HILLSHADE_LAYER, + DARK_TERRAIN_SKY, + DARK_TERRAIN_SPEC, + TERRAIN_DEM_SOURCE_ID, + TERRAIN_HILLSHADE_LAYER_ID, + type MapStyleSpec, + type TerrainProfile, +} from "@/lib/map-styles"; + +// Increase parallel tile requests for faster DEM + base tile loading. +// Default is 6; 16 allows terrain tiles to saturate HTTP/2 connections. +setMaxParallelImageRequests(16); + +const GLOBE_MAX_PITCH = 80; type MapContextValue = { map: maplibregl.Map | null; @@ -34,7 +50,9 @@ type MapProps = { children?: ReactNode; className?: string; mapStyle?: MapStyleSpec; + terrainProfile?: TerrainProfile; isDark?: boolean; + globeMode?: boolean; center?: [number, number]; zoom?: number; pitch?: number; @@ -50,7 +68,9 @@ export const Map = forwardRef(function Map( children, className, mapStyle = DEFAULT_STYLE.style, + terrainProfile = "none", isDark = true, + globeMode = false, center = [0, 20], zoom = 2.5, pitch = 49, @@ -66,20 +86,32 @@ export const Map = forwardRef(function Map( useImperativeHandle(ref, () => mapInstance as maplibregl.Map, [mapInstance]); + // Ref that allows style-load callbacks to see the latest value without re-running effects + const isDarkRef = useRef(isDark); + isDarkRef.current = isDark; + + const globeModeRef = useRef(globeMode); + globeModeRef.current = globeMode; + + // ── Map creation ────────────────────────────────────────────────── useEffect(() => { if (!containerRef.current) return; + const safePitch = Math.min(pitch, GLOBE_MAX_PITCH); + const map = new maplibregl.Map({ container: containerRef.current, style: DEFAULT_STYLE.style as maplibregl.StyleSpecification | string, center, zoom, - pitch, + pitch: safePitch, bearing, minZoom, maxZoom, - maxPitch: 85, + maxPitch: GLOBE_MAX_PITCH, attributionControl: false, + cancelPendingTileRequestsWhileZooming: true, + maxTileCacheZoomLevels: 3, // fewer cached zoom levels = less memory for DEM tiles renderWorldCopies: false, }); @@ -94,39 +126,54 @@ export const Map = forwardRef(function Map( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const isDarkRef = useRef(isDark); - isDarkRef.current = isDark; - + // Inject globe projection into every style change when globe mode is on. + // In Mercator mode, skip projection injection entirely. useEffect(() => { if (!mapInstance || !isLoaded) return; - mapInstance.setStyle(mapStyle as maplibregl.StyleSpecification | string); - const onStyleLoad = () => { - if (typeof mapStyle === "object" && "terrain" in mapStyle) { - const spec = mapStyle as Record; - try { - mapInstance.setTerrain( - spec.terrain as maplibregl.TerrainSpecification, - ); - } catch { - /* terrain source not yet loaded */ - } - } else { - try { - mapInstance.setTerrain(null); - } catch { - /* no terrain to remove */ - } - } + mapInstance.setStyle( + mapStyle as maplibregl.StyleSpecification | string, + { + transformStyle: (_prev, next) => { + const style = next as MutableStyleSpecification; + if (globeMode) { + style.projection = { type: "globe" }; + if (!style.sky) { + style.sky = { + "atmosphere-blend": [ + "interpolate", + ["linear"], + ["zoom"], + 0, + 1, + 5, + 0, + ], + }; + } + } + + if (terrainProfile === "dark" && !globeMode) { + applyDarkTerrainStyle(style); + style.sky = DARK_TERRAIN_SKY as Record; + } + + return style; + }, + } as maplibregl.StyleSwapOptions & { transformStyle: unknown }, + ); + + // Set projection imperatively so it takes effect immediately. + mapInstance.once("style.load", () => { + mapInstance.setProjection({ type: globeMode ? "globe" : "mercator" }); addAerowayLayers(mapInstance, isDarkRef.current); - }; - mapInstance.once("style.load", onStyleLoad); + }); return () => { - mapInstance.off("style.load", onStyleLoad); + mapInstance.off("style.load", () => {}); }; - }, [mapInstance, isLoaded, mapStyle]); + }, [mapInstance, isLoaded, mapStyle, terrainProfile, globeMode]); const ctx = useMemo( () => ({ map: mapInstance, isLoaded }), @@ -147,6 +194,42 @@ export const Map = forwardRef(function Map( Map.displayName = "Map"; +type MutableStyleSpecification = maplibregl.StyleSpecification & { + projection?: maplibregl.ProjectionSpecification; + sky?: Record; + sources?: Record; + layers?: maplibregl.LayerSpecification[]; + terrain?: maplibregl.TerrainSpecification; +}; + +function applyDarkTerrainStyle(style: MutableStyleSpecification): void { + const sources = (style.sources ??= + {}) as maplibregl.StyleSpecification["sources"]; + + // Single DEM source shared by both terrain mesh and hillshade layer. + // This halves tile downloads vs. having two separate sources. + if (!sources[TERRAIN_DEM_SOURCE_ID]) { + sources[TERRAIN_DEM_SOURCE_ID] = + createTerrainDemSource() as maplibregl.SourceSpecification; + } + + style.terrain = DARK_TERRAIN_SPEC as maplibregl.TerrainSpecification; + + const layers = (style.layers ??= []); + if (!layers.some((layer) => layer.id === TERRAIN_HILLSHADE_LAYER_ID)) { + const firstSymbolIndex = layers.findIndex( + (layer) => layer.type === "symbol", + ); + const insertIndex = + firstSymbolIndex === -1 ? layers.length : firstSymbolIndex; + layers.splice( + insertIndex, + 0, + DARK_TERRAIN_HILLSHADE_LAYER as maplibregl.LayerSpecification, + ); + } +} + function findVectorSource(map: maplibregl.Map): string | null { const style = map.getStyle(); if (!style?.sources) return null; diff --git a/src/components/map/use-fpv-camera.ts b/src/components/map/use-fpv-camera.ts new file mode 100644 index 0000000..2074d05 --- /dev/null +++ b/src/components/map/use-fpv-camera.ts @@ -0,0 +1,275 @@ +"use client"; + +import { useEffect, type MutableRefObject } from "react"; +import type maplibregl from "maplibre-gl"; +import { + FPV_DISTANCE_ZOOM_OFFSET, + fpvZoomForAltitude, + lerp, + lerpLng, + normalizeLng, + projectLngLatElevationPixelDelta, + setMapInteractionsEnabled, + smoothstep, +} from "./camera-controller-utils"; +import type { City } from "@/lib/cities"; +import type { FlightState } from "@/lib/opensky"; + +const DEFAULT_ZOOM = 9.2; +const DEFAULT_PITCH = 49; +const DEFAULT_BEARING = 27.4; +const FPV_FLY_DURATION = 1600; +const FPV_PITCH = 65; +const FPV_CENTER_ALPHA = 0.16; +const FPV_BEARING_ALPHA = 0.1; +const FPV_ZOOM_ALPHA = 0.06; +const FPV_IDLE_RECENTER_MS = 1200; +const FPV_EASE_IN_MS = 600; + +type FpvPosition = { lng: number; lat: number; alt: number; track: number }; + +export function useFpvCamera( + map: maplibregl.Map | null, + isLoaded: boolean, + fpvFlight: FlightState | null, + city: City, + fpvFlightRef: MutableRefObject, + fpvPosRef: MutableRefObject | undefined>, + isFpvActiveRef: MutableRefObject, + prevFpvRef: MutableRefObject, +) { + useEffect(() => { + if (!map || !isLoaded) { + if (isFpvActiveRef.current) { + isFpvActiveRef.current = false; + } + return; + } + + const fpv = fpvFlightRef.current; + const fpvKey = fpv?.icao24 ?? null; + if (fpvKey === prevFpvRef.current) return; + + const wasFpv = prevFpvRef.current !== null; + prevFpvRef.current = fpvKey; + + if (!fpv || fpv.longitude == null || fpv.latitude == null) { + isFpvActiveRef.current = false; + if (wasFpv) { + setMapInteractionsEnabled(map, true); + } + if (wasFpv) { + map.flyTo({ + center: city.coordinates, + zoom: DEFAULT_ZOOM, + pitch: DEFAULT_PITCH, + bearing: DEFAULT_BEARING, + duration: 1800, + essential: true, + }); + } + return; + } + + isFpvActiveRef.current = true; + setMapInteractionsEnabled(map, true); + + const bearing = Number.isFinite(fpv.trueTrack) + ? fpv.trueTrack! + : map.getBearing(); + const safeAltitude = Number.isFinite(fpv.baroAltitude) + ? fpv.baroAltitude! + : 5000; + const zoom = fpvZoomForAltitude(safeAltitude) - FPV_DISTANCE_ZOOM_OFFSET; + + let fpvOffsetX = 0; + let fpvOffsetY = 0; + + map.flyTo({ + center: [normalizeLng(fpv.longitude), fpv.latitude], + zoom, + pitch: FPV_PITCH, + bearing, + duration: FPV_FLY_DURATION, + essential: true, + }); + + let frameId: number | null = null; + let startupTimer: ReturnType | null = null; + let prevBearing = bearing; + + let lastInteractionTime = 0; + let recenterStartTime = 0; + let programmaticMove = false; + + function onUserInteraction() { + if (programmaticMove) return; + lastInteractionTime = performance.now(); + recenterStartTime = 0; + } + + const onMapInteraction = (e: unknown) => { + if (programmaticMove) return; + const evt = e as { originalEvent?: Event }; + if (!evt?.originalEvent) return; + onUserInteraction(); + }; + + const interactionEventTypes = [ + "movestart", + "move", + "zoomstart", + "zoom", + "rotatestart", + "rotate", + "pitchstart", + "pitch", + ] as const; + + for (const t of interactionEventTypes) { + map.on(t, onMapInteraction); + } + + function keepInFrame() { + if (!isFpvActiveRef.current || !map) { + frameId = null; + return; + } + + const interpPos = fpvPosRef.current?.current ?? null; + const live = fpvFlightRef.current; + + const posLng = interpPos?.lng ?? live?.longitude ?? null; + const posLat = interpPos?.lat ?? live?.latitude ?? null; + const posAlt = interpPos?.alt ?? live?.baroAltitude ?? 5000; + const posTrack = interpPos?.track ?? live?.trueTrack ?? null; + + if (posLng == null || posLat == null) { + frameId = requestAnimationFrame(keepInFrame); + return; + } + + if ( + !Number.isFinite(posLng) || + !Number.isFinite(posLat) || + Math.abs(posLat) > 90 + ) { + frameId = requestAnimationFrame(keepInFrame); + return; + } + + const now = performance.now(); + const idleMs = + lastInteractionTime === 0 + ? FPV_IDLE_RECENTER_MS + 1 + : now - lastInteractionTime; + const isIdle = idleMs > FPV_IDLE_RECENTER_MS; + + let trackingStrength = 0; + if (isIdle) { + if (recenterStartTime === 0) { + recenterStartTime = now; + } + const easeElapsed = now - recenterStartTime; + const t = Math.min(easeElapsed / FPV_EASE_IN_MS, 1); + trackingStrength = smoothstep(t); + } + + const liveBearing = + posTrack !== null && Number.isFinite(posTrack) ? posTrack : prevBearing; + const bearingDelta = ((liveBearing - prevBearing + 540) % 360) - 180; + prevBearing = prevBearing + bearingDelta * FPV_BEARING_ALPHA; + + if (trackingStrength > 0.001) { + const safeAlt = Number.isFinite(posAlt) ? posAlt : 5000; + const targetZoom = + fpvZoomForAltitude(safeAlt) - FPV_DISTANCE_ZOOM_OFFSET; + const currentZoom = map.getZoom(); + const zoomAlpha = FPV_ZOOM_ALPHA * trackingStrength; + const smoothZoom = lerp(currentZoom, targetZoom, zoomAlpha); + + const currentPitch = map.getPitch(); + const targetLng = normalizeLng(posLng); + const targetLat = posLat; + const center = map.getCenter(); + const centerAlpha = FPV_CENTER_ALPHA * trackingStrength; + + const canvas = map.getCanvas(); + const canvasW = Math.max(1, canvas.clientWidth); + const canvasH = Math.max(1, canvas.clientHeight); + + const elevationMeters = Math.max(safeAlt * 5, 200); + const deltaPx = projectLngLatElevationPixelDelta( + map, + targetLng, + targetLat, + elevationMeters, + ); + if (deltaPx) { + const desiredX = fpvOffsetX - deltaPx.dx; + const desiredY = fpvOffsetY - deltaPx.dy; + const offsetAlpha = 0.08 * trackingStrength; + fpvOffsetX = lerp(fpvOffsetX, desiredX, offsetAlpha); + fpvOffsetY = lerp(fpvOffsetY, desiredY, offsetAlpha); + } else { + const decayAlpha = 0.1 * trackingStrength; + fpvOffsetX = lerp(fpvOffsetX, 0, decayAlpha); + fpvOffsetY = lerp(fpvOffsetY, 0, decayAlpha); + } + + const maxScale = Math.min(1.5, Math.max(1, elevationMeters / 15_000)); + const maxOffset = 0.45 * maxScale * Math.min(canvasW, canvasH); + fpvOffsetX = Math.max(-maxOffset, Math.min(maxOffset, fpvOffsetX)); + fpvOffsetY = Math.max(-maxOffset, Math.min(maxOffset, fpvOffsetY)); + + const currentBearing = map.getBearing(); + const bearingToCurrent = + ((prevBearing - currentBearing + 540) % 360) - 180; + const newMapBearing = + currentBearing + + bearingToCurrent * FPV_BEARING_ALPHA * trackingStrength; + + const pitchAlpha = 0.05 * trackingStrength; + const newPitch = lerp(currentPitch, FPV_PITCH, pitchAlpha); + + programmaticMove = true; + try { + map.easeTo({ + center: [ + lerpLng(center.lng, targetLng, centerAlpha), + lerp(center.lat, targetLat, centerAlpha), + ], + bearing: newMapBearing, + zoom: smoothZoom, + pitch: newPitch, + offset: [fpvOffsetX, fpvOffsetY], + duration: 0, + animate: false, + essential: true, + }); + } finally { + programmaticMove = false; + } + } + + frameId = requestAnimationFrame(keepInFrame); + } + + startupTimer = setTimeout(() => { + startupTimer = null; + frameId = requestAnimationFrame(keepInFrame); + }, FPV_FLY_DURATION + 300); + + return () => { + if (startupTimer) clearTimeout(startupTimer); + if (frameId != null) cancelAnimationFrame(frameId); + for (const t of interactionEventTypes) { + map.off(t, onMapInteraction); + } + if (map && isFpvActiveRef.current) { + setMapInteractionsEnabled(map, true); + isFpvActiveRef.current = false; + } + }; + }, [map, isLoaded, fpvFlight?.icao24, city]); +} diff --git a/src/components/map/use-globe-dots.ts b/src/components/map/use-globe-dots.ts new file mode 100644 index 0000000..d6486a6 --- /dev/null +++ b/src/components/map/use-globe-dots.ts @@ -0,0 +1,377 @@ +"use client"; + +import { useEffect, useRef, type MutableRefObject } from "react"; +import maplibregl from "maplibre-gl"; +import type { FlightState } from "@/lib/opensky"; +import { altitudeToColor } from "@/lib/flight-utils"; +import { type PickingInfo } from "@deck.gl/core"; +import type { TrailEntry } from "@/hooks/use-trail-history"; +import { + densifyGreatCircle2D, + splitAtAntimeridian, + unwrapLngPath, +} from "@/lib/geo"; +import { + GLOBE_NATIVE_ZOOM_CEIL, + GLOBE_SWITCH_ZOOM, + GEOJSON_THROTTLE_MS, + GEOJSON_DEBOUNCE_MS, +} from "./flight-layer-constants"; + +const SOURCE_ID = "globe-aircraft-source"; +const LAYER_ID = "globe-aircraft-dots"; +const TRAIL_SOURCE_ID = "globe-trail-source"; +const TRAIL_LAYER_ID = "globe-trail-lines"; + +/** + * Custom hook that manages native MapLibre GeoJSON circle + line layers for + * rendering aircraft dots AND trail lines at low globe zoom levels where + * deck.gl accuracy degrades. Native MapLibre layers follow the globe + * curvature perfectly and handle antimeridian crossings automatically. + */ +export function useGlobeDots( + map: maplibregl.Map | null, + isLoaded: boolean, + flightsRef: MutableRefObject, + trailsRef: MutableRefObject, + dataTimestampRef: MutableRefObject, + onClickRef: MutableRefObject<(info: PickingInfo | null) => void>, + showTrailsRef: MutableRefObject, +) { + const lastGeoJsonUpdateRef = useRef(0); + const lastGeoJsonTimestampRef = useRef(0); + const geoJsonClearedRef = useRef(false); + const globeZoomEnteredAtRef = useRef(0); + + // Set up MapLibre source, layer, and event handlers + useEffect(() => { + if (!map || !isLoaded) return; + + const ensureGlobeLayers = () => { + // ── Aircraft dots ── + if (!map.getSource(SOURCE_ID)) { + map.addSource(SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + } + + if (!map.getLayer(LAYER_ID)) { + map.addLayer({ + id: LAYER_ID, + type: "circle", + source: SOURCE_ID, + paint: { + "circle-radius": [ + "interpolate", + ["exponential", 1.5], + ["zoom"], + 0, + ["interpolate", ["linear"], ["get", "alt_norm"], 0, 1.2, 1, 2.0], + 2, + ["interpolate", ["linear"], ["get", "alt_norm"], 0, 1.8, 1, 2.8], + GLOBE_NATIVE_ZOOM_CEIL, + ["interpolate", ["linear"], ["get", "alt_norm"], 0, 3.0, 1, 5.0], + ], + "circle-color": ["get", "color"], + "circle-opacity": [ + "interpolate", + ["linear"], + ["zoom"], + GLOBE_SWITCH_ZOOM - 0.05, + 0.9, + GLOBE_SWITCH_ZOOM, + 0, + ], + "circle-stroke-color": "rgba(255, 255, 255, 0.5)", + "circle-stroke-width": [ + "interpolate", + ["linear"], + ["zoom"], + 0, + 0.3, + GLOBE_SWITCH_ZOOM, + 0.8, + ], + "circle-blur": 0.1, + }, + }); + } + + // ── Trail lines ── + if (!map.getSource(TRAIL_SOURCE_ID)) { + map.addSource(TRAIL_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + } + + if (!map.getLayer(TRAIL_LAYER_ID)) { + map.addLayer( + { + id: TRAIL_LAYER_ID, + type: "line", + source: TRAIL_SOURCE_ID, + paint: { + "line-color": ["get", "color"], + "line-width": [ + "interpolate", + ["linear"], + ["zoom"], + 0, + 0.8, + 2, + 1.2, + GLOBE_NATIVE_ZOOM_CEIL, + 1.8, + ], + "line-opacity": [ + "interpolate", + ["linear"], + ["zoom"], + GLOBE_SWITCH_ZOOM - 0.05, + 0.65, + GLOBE_SWITCH_ZOOM, + 0, + ], + }, + layout: { + "line-cap": "round", + "line-join": "round", + }, + }, + LAYER_ID, // render trails below dots + ); + } + }; + + ensureGlobeLayers(); + map.on("style.load", ensureGlobeLayers); + + const onDotClick = ( + e: maplibregl.MapMouseEvent & { features?: maplibregl.GeoJSONFeature[] }, + ) => { + const icao24 = e.features?.[0]?.properties?.icao24; + if (!icao24) return; + const flight = flightsRef.current.find((f) => f.icao24 === icao24); + if (flight) { + onClickRef.current({ object: flight } as PickingInfo); + } + }; + map.on("click", LAYER_ID, onDotClick); + + const onDotEnter = () => { + map.getCanvas().style.cursor = "pointer"; + }; + const onDotLeave = () => { + map.getCanvas().style.cursor = ""; + }; + map.on("mouseenter", LAYER_ID, onDotEnter); + map.on("mouseleave", LAYER_ID, onDotLeave); + + return () => { + map.off("style.load", ensureGlobeLayers); + map.off("click", LAYER_ID, onDotClick); + map.off("mouseenter", LAYER_ID, onDotEnter); + map.off("mouseleave", LAYER_ID, onDotLeave); + try { + if (map.getLayer(TRAIL_LAYER_ID)) map.removeLayer(TRAIL_LAYER_ID); + if (map.getSource(TRAIL_SOURCE_ID)) map.removeSource(TRAIL_SOURCE_ID); + if (map.getLayer(LAYER_ID)) map.removeLayer(LAYER_ID); + if (map.getSource(SOURCE_ID)) map.removeSource(SOURCE_ID); + } catch { + /* map already removed */ + } + }; + }, [map, isLoaded, flightsRef, onClickRef]); + + /** + * Called from the RAF animation loop. Updates (or clears) both the dot + * GeoJSON source and the trail line GeoJSON source based on current + * zoom level and globe mode. + */ + function updateGlobeDots(isGlobe: boolean, currentZoom: number, now: number) { + if (!map) return; + + const MAX_ALTITUDE_METERS = 13000; + + // Hide layers unless globe mode AND below switch zoom + const dotsVisible = isGlobe && currentZoom < GLOBE_NATIVE_ZOOM_CEIL; + try { + if (map.getLayer(LAYER_ID)) { + map.setLayoutProperty( + LAYER_ID, + "visibility", + dotsVisible ? "visible" : "none", + ); + } + if (map.getLayer(TRAIL_LAYER_ID)) { + map.setLayoutProperty( + TRAIL_LAYER_ID, + "visibility", + dotsVisible ? "visible" : "none", + ); + } + } catch { + /* layer may not exist yet */ + } + + if (isGlobe) { + if (currentZoom < GLOBE_NATIVE_ZOOM_CEIL) { + if (globeZoomEnteredAtRef.current === 0) { + globeZoomEnteredAtRef.current = now; + } + const stableMs = now - globeZoomEnteredAtRef.current; + + if (stableMs >= GEOJSON_DEBOUNCE_MS) { + const dataChanged = + dataTimestampRef.current !== lastGeoJsonTimestampRef.current; + const throttleExpired = + now - lastGeoJsonUpdateRef.current > GEOJSON_THROTTLE_MS; + + if (dataChanged || throttleExpired) { + // ── Update aircraft dots ── + const dotSrc = map.getSource(SOURCE_ID) as + | maplibregl.GeoJSONSource + | undefined; + if (dotSrc) { + const flights = flightsRef.current; + const features = []; + for (const f of flights) { + if ( + f.longitude == null || + f.latitude == null || + !Number.isFinite(f.longitude) || + !Number.isFinite(f.latitude) + ) + continue; + const c = altitudeToColor(f.baroAltitude); + const altNorm = Math.min( + 1, + Math.max(0, (f.baroAltitude ?? 0) / MAX_ALTITUDE_METERS), + ); + features.push({ + type: "Feature" as const, + geometry: { + type: "Point" as const, + coordinates: [f.longitude, f.latitude], + }, + properties: { + icao24: f.icao24, + color: `rgb(${c[0]},${c[1]},${c[2]})`, + alt_norm: altNorm, + }, + }); + } + dotSrc.setData({ type: "FeatureCollection", features }); + } + + // ── Update trail lines ── + const trailSrc = map.getSource(TRAIL_SOURCE_ID) as + | maplibregl.GeoJSONSource + | undefined; + if (trailSrc) { + // Respect the showTrails user setting + if (!showTrailsRef.current) { + trailSrc.setData({ type: "FeatureCollection", features: [] }); + } else { + const trails = trailsRef.current; + const trailFeatures: GeoJSON.Feature[] = []; + + for (const trail of trails) { + if (trail.path.length < 2) continue; + + // Get the trail color from the most recent altitude + const lastAlt = + trail.baroAltitude ?? + trail.altitudes[trail.altitudes.length - 1] ?? + 0; + const c = altitudeToColor(lastAlt); + const color = `rgba(${c[0]},${c[1]},${c[2]},0.7)`; + + // Limit to last N points for performance at globe zoom + const maxPts = 60; + const rawPath = + trail.path.length > maxPts + ? trail.path.slice(trail.path.length - maxPts) + : trail.path; + + // Unwrap longitudes for continuity + const unwrapped = unwrapLngPath(rawPath); + + // Densify along great-circle arcs so trails curve + // properly on the globe (segments > 0.3° get subdivided) + const densified = densifyGreatCircle2D(unwrapped, 0.3, 16); + + // Normalize longitudes back to [-180, 180] range + const normalized: [number, number][] = densified.map( + ([lng, lat]) => { + let normLng = lng; + while (normLng > 180) normLng -= 360; + while (normLng < -180) normLng += 360; + return [normLng, lat]; + }, + ); + + // Split at antimeridian crossings for MapLibre + const segments = splitAtAntimeridian(normalized); + + for (const seg of segments) { + if (seg.length < 2) continue; + trailFeatures.push({ + type: "Feature", + geometry: { + type: "LineString", + coordinates: seg, + }, + properties: { color, icao24: trail.icao24 }, + }); + } + } + + trailSrc.setData({ + type: "FeatureCollection", + features: trailFeatures, + }); + } // end showTrails check + } + + lastGeoJsonUpdateRef.current = now; + lastGeoJsonTimestampRef.current = dataTimestampRef.current; + geoJsonClearedRef.current = false; + } + } + } else { + globeZoomEnteredAtRef.current = 0; + if (!geoJsonClearedRef.current) { + clearNativeSources(); + } + } + } else if (!geoJsonClearedRef.current) { + clearNativeSources(); + } + } + + function clearNativeSources() { + if (!map) return; + try { + const dotSrc = map.getSource(SOURCE_ID) as + | maplibregl.GeoJSONSource + | undefined; + if (dotSrc) { + dotSrc.setData({ type: "FeatureCollection", features: [] }); + } + const trailSrc = map.getSource(TRAIL_SOURCE_ID) as + | maplibregl.GeoJSONSource + | undefined; + if (trailSrc) { + trailSrc.setData({ type: "FeatureCollection", features: [] }); + } + geoJsonClearedRef.current = true; + } catch { + /* source may be removed */ + } + } + + return { updateGlobeDots }; +} diff --git a/src/components/map/use-keyboard-camera.ts b/src/components/map/use-keyboard-camera.ts new file mode 100644 index 0000000..ab5aecb --- /dev/null +++ b/src/components/map/use-keyboard-camera.ts @@ -0,0 +1,146 @@ +"use client"; + +import { useEffect, type MutableRefObject } from "react"; +import type maplibregl from "maplibre-gl"; + +const CAMERA_ACCEL = 2.5; +const CAMERA_DECEL = 4.0; +const ZOOM_SPEED = 1.2; +const PITCH_SPEED = 28; +const BEARING_SPEED = 55; +const MINIMUM_IMPULSE_DURATION_MS = 180; + +type CameraActionType = "zoom" | "pitch" | "bearing"; +type ActionState = { + direction: number; + velocity: number; + held: boolean; + impulseEnd: number; +}; + +export function useKeyboardCamera( + map: maplibregl.Map | null, + isLoaded: boolean, + isFpvActiveRef: MutableRefObject, + isInteractingRef: MutableRefObject, + idleTimerRef: MutableRefObject | null>, +) { + useEffect(() => { + if (!map || !isLoaded) return; + + const actions = new Map(); + let frameId: number | null = null; + let lastTime = 0; + + function getOrCreate( + type: CameraActionType, + direction: number, + ): ActionState { + let s = actions.get(type); + if (!s) { + s = { direction, velocity: 0, held: false, impulseEnd: 0 }; + actions.set(type, s); + } + return s; + } + + function maxSpeed(type: CameraActionType): number { + if (type === "zoom") return ZOOM_SPEED; + if (type === "pitch") return PITCH_SPEED; + return BEARING_SPEED; + } + + function applyDelta(type: CameraActionType, delta: number) { + if (type === "zoom") { + const z = map!.getZoom() + delta; + map!.setZoom( + Math.min(Math.max(z, map!.getMinZoom()), map!.getMaxZoom()), + ); + } else if (type === "pitch") { + const p = map!.getPitch() + delta; + map!.setPitch(Math.min(Math.max(p, 0), map!.getMaxPitch())); + } else { + map!.setBearing(map!.getBearing() + delta); + } + } + + function tick(now: number) { + const dt = lastTime ? Math.min((now - lastTime) / 1000, 0.1) : 0.016; + lastTime = now; + + let anyActive = false; + + for (const [type, state] of actions) { + const wantSpeed = state.held || now < state.impulseEnd; + + if (wantSpeed) { + state.velocity = Math.min( + state.velocity + CAMERA_ACCEL * dt * maxSpeed(type), + maxSpeed(type), + ); + } else { + state.velocity = Math.max( + state.velocity - CAMERA_DECEL * dt * maxSpeed(type), + 0, + ); + } + + if (state.velocity > 0.001) { + applyDelta(type, state.direction * state.velocity * dt); + anyActive = true; + } else { + state.velocity = 0; + if (!state.held) { + actions.delete(type); + if (type === "bearing") { + isInteractingRef.current = false; + } + } + } + } + + frameId = anyActive ? requestAnimationFrame(tick) : null; + } + + function ensureLoop() { + if (frameId == null) { + lastTime = 0; + frameId = requestAnimationFrame(tick); + } + } + + const onStart = (e: Event) => { + if (isFpvActiveRef.current) return; + const { type, direction } = (e as CustomEvent).detail as { + type: CameraActionType; + direction: number; + }; + const state = getOrCreate(type, direction); + state.direction = direction; + state.held = true; + state.impulseEnd = performance.now() + MINIMUM_IMPULSE_DURATION_MS; + + if (type === "bearing") { + isInteractingRef.current = true; + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + } + + ensureLoop(); + }; + + const onStop = (e: Event) => { + const { type } = (e as CustomEvent).detail as { type: CameraActionType }; + const state = actions.get(type); + if (state) state.held = false; + }; + + window.addEventListener("aeris:camera-start", onStart); + window.addEventListener("aeris:camera-stop", onStop); + + return () => { + window.removeEventListener("aeris:camera-start", onStart); + window.removeEventListener("aeris:camera-stop", onStop); + if (frameId != null) cancelAnimationFrame(frameId); + }; + }, [map, isLoaded]); +} diff --git a/src/components/map/use-orbit-camera.ts b/src/components/map/use-orbit-camera.ts new file mode 100644 index 0000000..89377c0 --- /dev/null +++ b/src/components/map/use-orbit-camera.ts @@ -0,0 +1,121 @@ +"use client"; + +import { useEffect, useRef, type MutableRefObject } from "react"; +import type maplibregl from "maplibre-gl"; +import { smoothstep } from "./camera-controller-utils"; +import type { City } from "@/lib/cities"; +import type { FlightState } from "@/lib/opensky"; +import type { Settings } from "@/hooks/use-settings"; + +const IDLE_TIMEOUT_MS = 5_000; +const ORBIT_EASE_IN_MS = 2000; + +export function useOrbitCamera( + map: maplibregl.Map | null, + isLoaded: boolean, + city: City, + followFlight: FlightState | null | undefined, + fpvFlight: FlightState | null | undefined, + settings: Settings, + isInteractingRef: MutableRefObject, + orbitFrameRef: MutableRefObject, + idleTimerRef: MutableRefObject | null>, +) { + // Store speed in a ref so tick() reads the latest value without effect re-runs + const speedRef = useRef(0); + useEffect(() => { + speedRef.current = + settings.orbitSpeed * (settings.orbitDirection === "clockwise" ? 1 : -1); + }, [settings.orbitSpeed, settings.orbitDirection]); + + useEffect(() => { + if ( + !map || + !isLoaded || + !city || + !settings.autoOrbit || + followFlight || + fpvFlight + ) { + if (orbitFrameRef.current) cancelAnimationFrame(orbitFrameRef.current); + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + return; + } + + const prefersReducedMotion = + window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; + if (prefersReducedMotion) return; + + function startOrbit() { + if (!map || isInteractingRef.current) return; + + const resumeStart = performance.now(); + + function tick() { + if (!map || isInteractingRef.current) return; + const resumeElapsed = performance.now() - resumeStart; + const t = Math.min(resumeElapsed / ORBIT_EASE_IN_MS, 1); + const easeFactor = smoothstep(t); + const bearing = map.getBearing() + speedRef.current * easeFactor; + map.setBearing(bearing % 360); + orbitFrameRef.current = requestAnimationFrame(tick); + } + + orbitFrameRef.current = requestAnimationFrame(tick); + } + + function stopOrbit() { + if (orbitFrameRef.current) { + cancelAnimationFrame(orbitFrameRef.current); + orbitFrameRef.current = null; + } + } + + function resetIdleTimer() { + isInteractingRef.current = true; + stopOrbit(); + + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + idleTimerRef.current = setTimeout(() => { + isInteractingRef.current = false; + startOrbit(); + }, IDLE_TIMEOUT_MS); + } + + const events = ["mousedown", "wheel", "touchstart"] as const; + const container = map.getContainer(); + events.forEach((e) => + container.addEventListener(e, resetIdleTimer, { passive: true }), + ); + + const onMoveStart = () => { + if (isInteractingRef.current) stopOrbit(); + }; + map.on("movestart", onMoveStart); + + const onCameraStop = (e: Event) => { + const { type } = (e as CustomEvent).detail ?? {}; + if (type === "bearing") { + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + idleTimerRef.current = setTimeout(() => { + isInteractingRef.current = false; + startOrbit(); + }, IDLE_TIMEOUT_MS); + } + }; + window.addEventListener("aeris:camera-stop", onCameraStop); + + idleTimerRef.current = setTimeout(() => { + isInteractingRef.current = false; + startOrbit(); + }, IDLE_TIMEOUT_MS); + + return () => { + stopOrbit(); + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + events.forEach((e) => container.removeEventListener(e, resetIdleTimer)); + map.off("movestart", onMoveStart); + window.removeEventListener("aeris:camera-stop", onCameraStop); + }; + }, [map, isLoaded, city, followFlight, fpvFlight, settings.autoOrbit]); +} diff --git a/src/components/ui/aircraft-photos.tsx b/src/components/ui/aircraft-photos.tsx new file mode 100644 index 0000000..ec081b9 --- /dev/null +++ b/src/components/ui/aircraft-photos.tsx @@ -0,0 +1,406 @@ +"use client"; + +import { useState, useCallback, useEffect, useRef, memo } from "react"; +import { createPortal } from "react-dom"; +import { motion, AnimatePresence } from "motion/react"; +import { + Camera, + ChevronLeft, + ChevronRight, + X, + Plane, + ImageOff, +} from "lucide-react"; +import type { + NormalizedPhoto, + AircraftDetails, +} from "@/hooks/use-aircraft-photos"; + +const Thumbnail = memo(function Thumbnail({ + photo, + index, + onClick, +}: { + photo: NormalizedPhoto; + index: number; + onClick: (index: number) => void; +}) { + const ref = useRef(null); + const [loaded, setLoaded] = useState(false); + const [failed, setFailed] = useState(false); + const [visible, setVisible] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting) { + setVisible(true); + observer.disconnect(); + } + }, + { rootMargin: "100px" }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + if (failed) return null; + + return ( + + ); +}); + +export function Lightbox({ + photos, + index, + onClose, + onNavigate, +}: { + photos: NormalizedPhoto[]; + index: number; + onClose: () => void; + onNavigate: (index: number) => void; +}) { + const photo = photos[index]; + const [loaded, setLoaded] = useState(false); + const [imgError, setImgError] = useState(false); + + useEffect(() => { + setLoaded(false); + setImgError(false); + }, [index]); + + const goPrev = useCallback(() => { + onNavigate(index > 0 ? index - 1 : photos.length - 1); + }, [index, photos.length, onNavigate]); + + const goNext = useCallback(() => { + onNavigate(index < photos.length - 1 ? index + 1 : 0); + }, [index, photos.length, onNavigate]); + + useEffect(() => { + function handleKey(e: globalThis.KeyboardEvent) { + if (e.key === "Escape") onClose(); + else if (e.key === "ArrowLeft") goPrev(); + else if (e.key === "ArrowRight") goNext(); + } + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [goPrev, goNext, onClose]); + + if (!photo) return null; + + return ( + + + + + {index + 1} / {photos.length} + + + e.stopPropagation()} + > + {!loaded && !imgError && ( +
+
+
+ )} + + {imgError ? ( +
+ +

Failed to load image

+
+ ) : ( + {`Aircraft setLoaded(true)} + onError={() => setImgError(true)} + className={`max-h-[85vh] max-w-[94vw] rounded-xl object-contain shadow-2xl transition-opacity duration-300 sm:max-w-[90vw] ${loaded ? "opacity-100" : "opacity-0"}`} + draggable={false} + /> + )} + + + {photos.length > 1 && ( + <> + + + + )} + + {(photo.photographer || photo.location || photo.dateTaken) && ( + + + {photo.photographer && ( + + {photo.photographer} + + )} + {photo.photographer && photo.location && ( + | + )} + {photo.location && ( + {photo.location} + )} + {(photo.photographer || photo.location) && photo.dateTaken && ( + | + )} + {photo.dateTaken && ( + {photo.dateTaken} + )} + + + )} + + ); +} + +type AircraftPhotosProps = { + photos: NormalizedPhoto[]; + loading: boolean; + aircraft: AircraftDetails | null; + error: boolean; + onPhotoClick?: (index: number) => void; + defaultExpanded?: boolean; + hideEmptyState?: boolean; +}; + +export function AircraftPhotos({ + photos, + loading, + aircraft, + error, + onPhotoClick, + defaultExpanded = false, + hideEmptyState = false, +}: AircraftPhotosProps) { + const [expanded, setExpanded] = useState(defaultExpanded); + const [lightboxIndex, setLightboxIndex] = useState(null); + const scrollRef = useRef(null); + + const handlePhotoClick = useCallback( + (index: number) => { + if (onPhotoClick) { + onPhotoClick(index); + } else { + setLightboxIndex(index); + } + }, + [onPhotoClick], + ); + + const closeLightbox = useCallback(() => { + setLightboxIndex(null); + }, []); + + const hasPhotos = photos.length > 0; + const hasAircraft = aircraft !== null; + const showSection = hideEmptyState + ? loading || hasPhotos + : loading || hasPhotos || hasAircraft; + + if (!showSection) return null; + + const detailParts: string[] = []; + if (aircraft?.manufacturer) detailParts.push(aircraft.manufacturer); + if (aircraft?.type) detailParts.push(aircraft.type); + if (aircraft?.airline && !detailParts.includes(aircraft.airline)) { + detailParts.push(aircraft.airline); + } + const detailLine = detailParts.join(" · "); + + return ( + <> +
+
+ + + + + {expanded && ( + + {loading && ( +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ )} + + {!loading && hasPhotos && ( +
+ {photos.map((photo, i) => ( + + ))} +
+ )} + + {!loading && !hasPhotos && hasAircraft && ( +
+ +
+ {detailLine && ( +

+ {detailLine} +

+ )} +

+ + No photos available +

+
+
+ )} + + {!loading && !hasPhotos && !hasAircraft && error && ( +
+ +

+ Could not load aircraft data +

+
+ )} + + )} + +
+ + {!onPhotoClick && + typeof document !== "undefined" && + createPortal( + + {lightboxIndex !== null && ( + + )} + , + document.body, + )} + + ); +} diff --git a/src/components/ui/control-panel-search.tsx b/src/components/ui/control-panel-search.tsx new file mode 100644 index 0000000..6bec7c6 --- /dev/null +++ b/src/components/ui/control-panel-search.tsx @@ -0,0 +1,768 @@ +"use client"; + +import { useState, useMemo, useCallback, useRef, useEffect } from "react"; +import { Command } from "cmdk"; +import { + Search, + X, + MapPin, + Plane, + Eye, + Loader2, + Clock, + Trash2, + Gauge, + ArrowUpRight, + Globe2, +} from "lucide-react"; +import { CITIES, type City } from "@/lib/cities"; +import { searchAirports, airportToCity } from "@/lib/airports"; +import type { FlightState } from "@/lib/opensky"; +import { + formatCallsign, + altitudeToColor, + metersToFeet, + msToKnots, + headingToCardinal, +} from "@/lib/flight-utils"; + +// ── Recent searches (localStorage) ───────────────────────────────────── + +const RECENT_KEY = "aeris:recent-searches"; +const RECENT_MAX = 4; +const RECENT_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +type RecentEntry = { q: string; ts: number }; + +function getRecents(): string[] { + try { + const raw = localStorage.getItem(RECENT_KEY); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + const now = Date.now(); + const valid = parsed + .filter( + (e): e is RecentEntry => + typeof e === "object" && + e !== null && + typeof e.q === "string" && + typeof e.ts === "number" && + now - e.ts < RECENT_EXPIRY_MS, + ) + .slice(0, RECENT_MAX); + if (valid.length !== parsed.length) { + localStorage.setItem(RECENT_KEY, JSON.stringify(valid)); + } + return valid.map((e) => e.q); + } catch { + return []; + } +} + +function addRecent(query: string) { + const q = query.trim(); + if (!q || q.length > 100) return; + try { + const raw = localStorage.getItem(RECENT_KEY); + const prev: RecentEntry[] = raw ? (JSON.parse(raw) ?? []) : []; + const filtered = (Array.isArray(prev) ? prev : []).filter( + (e): e is RecentEntry => + typeof e === "object" && + e !== null && + typeof e.q === "string" && + e.q.toLowerCase() !== q.toLowerCase(), + ); + const next = [{ q, ts: Date.now() }, ...filtered].slice(0, RECENT_MAX); + localStorage.setItem(RECENT_KEY, JSON.stringify(next)); + } catch { + /* quota exceeded — ignore */ + } +} + +function removeRecent(query: string) { + try { + const raw = localStorage.getItem(RECENT_KEY); + const prev: RecentEntry[] = raw ? (JSON.parse(raw) ?? []) : []; + const next = (Array.isArray(prev) ? prev : []).filter( + (e): e is RecentEntry => + typeof e === "object" && + e !== null && + typeof e.q === "string" && + e.q.toLowerCase() !== query.toLowerCase(), + ); + localStorage.setItem(RECENT_KEY, JSON.stringify(next)); + } catch { + /* ignore */ + } +} + +function clearRecents() { + try { + localStorage.removeItem(RECENT_KEY); + } catch { + /* ignore */ + } +} + +// ── Highlight matched text safely ────────────────────────────────────── + +function HighlightMatch({ text, query }: { text: string; query: string }) { + if (!query) return <>{text}; + const q = query.trim().toLowerCase(); + if (!q) return <>{text}; + + const idx = text.toLowerCase().indexOf(q); + if (idx === -1) return <>{text}; + + return ( + <> + {text.slice(0, idx)} + + {text.slice(idx, idx + q.length)} + + {text.slice(idx + q.length)} + + ); +} + +// ── Altitude color dot ───────────────────────────────────────────────── + +function AltitudeDot({ altitude }: { altitude: number | null }) { + const [r, g, b] = altitudeToColor(altitude); + return ( + + ); +} + +// ── Country code to flag emoji ───────────────────────────────────────── + +function countryFlag(countryName: string): string { + const COUNTRY_ISO: Record = { + "united states": "US", + usa: "US", + us: "US", + "united kingdom": "GB", + uk: "GB", + gb: "GB", + germany: "DE", + france: "FR", + spain: "ES", + italy: "IT", + canada: "CA", + australia: "AU", + japan: "JP", + china: "CN", + india: "IN", + brazil: "BR", + russia: "RU", + mexico: "MX", + "south korea": "KR", + netherlands: "NL", + switzerland: "CH", + sweden: "SE", + norway: "NO", + denmark: "DK", + ireland: "IE", + portugal: "PT", + austria: "AT", + belgium: "BE", + turkey: "TR", + thailand: "TH", + singapore: "SG", + malaysia: "MY", + indonesia: "ID", + philippines: "PH", + "united arab emirates": "AE", + "saudi arabia": "SA", + qatar: "QA", + israel: "IL", + "south africa": "ZA", + egypt: "EG", + "new zealand": "NZ", + argentina: "AR", + chile: "CL", + colombia: "CO", + peru: "PE", + poland: "PL", + czechia: "CZ", + "czech republic": "CZ", + romania: "RO", + greece: "GR", + finland: "FI", + vietnam: "VN", + taiwan: "TW", + "hong kong": "HK", + pakistan: "PK", + bangladesh: "BD", + ukraine: "UA", + hungary: "HU", + morocco: "MA", + nigeria: "NG", + kenya: "KE", + iceland: "IS", + luxembourg: "LU", + croatia: "HR", + serbia: "RS", + bulgaria: "BG", + slovakia: "SK", + slovenia: "SI", + estonia: "EE", + latvia: "LV", + lithuania: "LT", + malta: "MT", + cyprus: "CY", + }; + + const key = countryName.trim().toLowerCase(); + const iso = COUNTRY_ISO[key]; + if (!iso) return ""; + + // Convert ISO code to flag emoji using regional indicator symbols + return String.fromCodePoint( + ...iso.split("").map((c) => 0x1f1e6 + c.charCodeAt(0) - 65), + ); +} + +// ── Main SearchContent ───────────────────────────────────────────────── + +export function SearchContent({ + activeCity, + onSelect, + flights, + activeFlightIcao24, + onLookupFlight, +}: { + activeCity: City; + onSelect: (city: City) => void; + flights: FlightState[]; + activeFlightIcao24: string | null; + onLookupFlight: (query: string, enterFpv?: boolean) => Promise; +}) { + const [query, setQuery] = useState(""); + const [lookupBusy, setLookupBusy] = useState(false); + const [lookupError, setLookupError] = useState(null); + const [recents, setRecents] = useState([]); + const inputRef = useRef(null); + const listRef = useRef(null); + + // Load recents on mount + useEffect(() => { + setRecents(getRecents()); + }, []); + + // Auto-focus with a frame delay for dialog mounting + useEffect(() => { + requestAnimationFrame(() => inputRef.current?.focus()); + }, []); + + // Live search results + const { featured, airports } = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) + return { + featured: CITIES, + airports: [] as ReturnType, + }; + + const featured = CITIES.filter( + (c) => + c.name.toLowerCase().includes(q) || + c.iata.toLowerCase().includes(q) || + c.country.toLowerCase().includes(q), + ); + + const featuredIatas = new Set(CITIES.map((c) => c.iata)); + const airports = searchAirports(q).filter( + (a) => !featuredIatas.has(a.iata), + ); + return { featured, airports }; + }, [query]); + + const compactQuery = query.trim().toLowerCase().replace(/\s+/g, ""); + const isIcao24Query = /^[0-9a-f]{6}$/.test(compactQuery); + + const flightMatches = useMemo(() => { + if (!compactQuery) return [] as FlightState[]; + return flights + .filter((flight) => { + const icao = flight.icao24.toLowerCase(); + const callsign = (flight.callsign ?? "") + .trim() + .toLowerCase() + .replace(/\s+/g, ""); + return icao.includes(compactQuery) || callsign.includes(compactQuery); + }) + .slice(0, 15); + }, [flights, compactQuery]); + + const hasResults = + featured.length > 0 || airports.length > 0 || flightMatches.length > 0; + const showRecents = !query && recents.length > 0; + + // Total result count for screen reader + const totalResults = flightMatches.length + featured.length + airports.length; + + // ── Actions ──────────────────────────────────────────────────────── + + const runLookup = useCallback( + async (enterFpv = false) => { + if (!query.trim() || lookupBusy) return; + setLookupBusy(true); + setLookupError(null); + addRecent(query.trim()); + setRecents(getRecents()); + try { + const found = await onLookupFlight(query, enterFpv); + if (!found) { + setLookupError( + isIcao24Query + ? "Flight not found for this ICAO24 right now" + : 'No live flight match found — try a callsign like "UAL123" or ICAO24 hex', + ); + } + } finally { + setLookupBusy(false); + } + }, + [query, lookupBusy, onLookupFlight, isIcao24Query], + ); + + const openFlight = useCallback( + async (icao24: string, enterFpv = false) => { + if (lookupBusy) return; + setLookupBusy(true); + setLookupError(null); + addRecent(icao24.toUpperCase()); + setRecents(getRecents()); + try { + const found = await onLookupFlight(icao24, enterFpv); + if (!found) setLookupError("Unable to open the selected flight"); + } finally { + setLookupBusy(false); + } + }, + [lookupBusy, onLookupFlight], + ); + + const handleRemoveRecent = useCallback((q: string) => { + removeRecent(q); + setRecents(getRecents()); + }, []); + + const handleClearRecents = useCallback(() => { + clearRecents(); + setRecents([]); + }, []); + + // ── Custom cmdk filter ───────────────────────────────────────────── + + const cmdkFilter = useCallback( + (value: string, search: string, keywords?: string[]) => { + if (!search) return 1; + const s = search.toLowerCase().replace(/\s+/g, ""); + const v = value.toLowerCase(); + const kw = keywords ? keywords.join(" ").toLowerCase() : ""; + const combined = `${v} ${kw}`; + + if (v === s) return 1; + if (v.startsWith(s)) return 0.95; + if (kw && kw.startsWith(s)) return 0.9; + const words = combined.split(/[\s·,]+/); + for (const w of words) { + if (w.startsWith(s)) return 0.8; + } + if (combined.includes(s)) return 0.6; + return 0; + }, + [], + ); + + return ( + + {/* ── Search input ──────────────────────────────────────────── */} +
+ + { + setQuery(v); + setLookupError(null); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + void runLookup(true); + } + }} + placeholder="Search airports, flights, ICAO24…" + aria-label="Search airports, flights, and cities" + className="flex-1 bg-transparent text-[14px] font-medium text-white/90 placeholder:text-white/20 outline-none" + /> + {query && ( + + )} +
+ + {/* ── Error banner ──────────────────────────────────────────── */} + {lookupError && ( +
+ + {lookupError} + +
+ )} + + {/* ── Result list ───────────────────────────────────────────── */} + + +
+ +
+
+

+ No results found +

+

+ Try an airport code like "JFK", a city name, or a flight + callsign like "UAL123" +

+
+
+ + {/* ── Recent searches ───────────────────────────────────── */} + {showRecents && ( + + Recent + +
+ } + > + {recents.map((r) => ( + { + setQuery(r); + inputRef.current?.focus(); + }} + className="search-item" + > +
+ +
+ + {r} + + +
+ ))} + + )} + + {/* ── Worldwide lookup action ───────────────────────────── */} + {compactQuery && ( + + void runLookup(false)} + disabled={lookupBusy} + className="search-item" + > +
+ {lookupBusy ? ( + + ) : ( + + )} +
+
+

+ Search worldwide for "{query.trim()}" +

+

+ {isIcao24Query + ? "ICAO24 hex lookup" + : "Callsign / flight number lookup"} +

+
+ + ↵ + +
+ void runLookup(true)} + disabled={lookupBusy} + className="search-item" + > +
+ {lookupBusy ? ( + + ) : ( + + )} +
+
+

+ Open in FPV mode +

+

+ Follow camera view +

+
+ + ↵ + +
+
+ )} + + {/* ── Live flights ──────────────────────────────────────── */} + {flightMatches.length > 0 && ( + + {flightMatches.map((flight) => { + const cs = formatCallsign(flight.callsign); + const flag = countryFlag(flight.originCountry); + return ( + void openFlight(flight.icao24, false)} + className="search-item" + > +
+ +
+
+
+

+ +

+ {activeFlightIcao24 === flight.icao24 && ( + + Active + + )} +
+
+ + + + · + {flag && {flag}} + {flight.originCountry} +
+
+ + {/* Flight info chips */} +
+ {flight.baroAltitude != null && ( + + + {metersToFeet(flight.baroAltitude)} + + )} + {flight.velocity != null && ( + + + {msToKnots(flight.velocity)} + + )} + {flight.trueTrack != null && ( + + + {headingToCardinal(flight.trueTrack)} + + )} +
+ + {/* FPV button — visible on hover/keyboard-select */} + {!flight.onGround && ( + + )} +
+ ); + })} +
+ )} + + {/* ── Featured cities ───────────────────────────────────── */} + {featured.length > 0 && ( + + {featured.map((city) => ( + onSelect(city)} + className="search-item" + > +
+ +
+
+

+ +

+

+ + · + {city.country} +

+
+ {activeCity?.id === city.id && ( + + Current + + )} +
+ ))} +
+ )} + + {/* ── Airport results ───────────────────────────────────── */} + {airports.length > 0 && ( + + {airports.map((airport) => ( + onSelect(airportToCity(airport))} + className="search-item" + > +
+ +
+
+

+ +

+

+ + · + ,{" "} + {airport.country} +

+
+ {activeCity?.iata === airport.iata && ( + + Current + + )} +
+ ))} +
+ )} + + {/* ── SR-only result count ──────────────────────────────── */} +
+ {query + ? `${totalResults} result${totalResults !== 1 ? "s" : ""} found` + : `${CITIES.length} featured airports`} +
+ + {/* ── Footer hint ───────────────────────────────────────── */} + {!query && !showRecents && ( +
+

+ Search 9,000+ airports worldwide +

+
+ )} + + + ); +} diff --git a/src/components/ui/control-panel-settings.tsx b/src/components/ui/control-panel-settings.tsx new file mode 100644 index 0000000..86e6b94 --- /dev/null +++ b/src/components/ui/control-panel-settings.tsx @@ -0,0 +1,538 @@ +"use client"; + +import type { ReactNode } from "react"; +import { motion } from "motion/react"; +import { + RotateCw, + Route, + Layers, + Palette, + Globe, + ArrowLeftRight, +} from "lucide-react"; +import { useSettings, type OrbitDirection } from "@/hooks/use-settings"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Slider } from "@/components/ui/slider"; +import { SHORTCUTS } from "@/components/ui/keyboard-shortcuts-help"; + +const ORBIT_SPEED_PRESETS = [ + { label: "Slow", value: 0.06 }, + { label: "Normal", value: 0.15 }, + { label: "Fast", value: 0.35 }, +]; + +const ORBIT_SPEED_MIN = 0.02; +const ORBIT_SPEED_MAX = 0.5; +const ORBIT_SNAP_THRESHOLD = 0.025; +const TRAIL_THICKNESS_MIN = 0.5; +const TRAIL_THICKNESS_MAX = 8; +const TRAIL_DISTANCE_MIN = 12; +const TRAIL_DISTANCE_MAX = 100; + +const ORBIT_DIRECTIONS: { label: string; value: OrbitDirection }[] = [ + { label: "Clockwise", value: "clockwise" }, + { label: "Counter", value: "counter-clockwise" }, +]; + +export function SettingsContent() { + const { settings, update, reset } = useSettings(); + + return ( + +
+ } + title="Auto-orbit" + description="Camera slowly rotates around the airport" + checked={settings.autoOrbit} + onChange={(v) => update("autoOrbit", v)} + /> + + {settings.autoOrbit && ( + <> + update("orbitSpeed", v)} + /> + } + title="Direction" + options={ORBIT_DIRECTIONS} + value={settings.orbitDirection} + onChange={(v) => update("orbitDirection", v)} + /> + + )} + +
+ + } + title="Flight trails" + description="Altitude-colored trails behind aircraft" + checked={settings.showTrails} + onChange={(v) => update("showTrails", v)} + /> + {settings.showTrails && ( + <> + update("trailThickness", v)} + /> + update("trailDistance", v)} + /> + + )} + } + title="Ground shadows" + description="Shadow projections on the map surface" + checked={settings.showShadows} + onChange={(v) => update("showShadows", v)} + /> + } + title="Altitude colors" + description="Color aircraft and trails by altitude" + checked={settings.showAltitudeColors} + onChange={(v) => update("showAltitudeColors", v)} + /> + +
+ + } + title="Globe mode" + description="Display earth as a 3D sphere when zoomed out" + checked={settings.globeMode} + onChange={(v) => update("globeMode", v)} + badge="BETA" + /> + +
+ +
+ +
+ +
+
+ + ); +} + +export function ShortcutsContent() { + return ( + +
+
+ {SHORTCUTS.map(({ key, description }) => ( +
+ + {description} + + + {key} + +
+ ))} +
+
+
+ ); +} + +function OrbitSpeedSlider({ + value, + onChange, +}: { + value: number; + onChange: (v: number) => void; +}) { + const activeLabel = + ORBIT_SPEED_PRESETS.find( + (p) => Math.abs(p.value - value) < ORBIT_SNAP_THRESHOLD, + )?.label ?? `${value.toFixed(2)}×`; + + function handleChange(vals: number[]) { + let raw = vals[0]; + for (const preset of ORBIT_SPEED_PRESETS) { + if (Math.abs(raw - preset.value) < ORBIT_SNAP_THRESHOLD) { + raw = preset.value; + break; + } + } + onChange(raw); + } + + return ( +
+
+ +
+
+
+

Orbit speed

+ + {activeLabel} + +
+
+ +
+ {ORBIT_SPEED_PRESETS.map((preset) => { + const pct = + ((preset.value - ORBIT_SPEED_MIN) / + (ORBIT_SPEED_MAX - ORBIT_SPEED_MIN)) * + 100; + const isActive = + Math.abs(preset.value - value) < ORBIT_SNAP_THRESHOLD; + return ( + + ); + })} +
+
+
+
+ ); +} + +function TrailThicknessSlider({ + value, + onChange, +}: { + value: number; + onChange: (v: number) => void; +}) { + return ( +
+
+ +
+
+
+

+ Trail thickness +

+ + {value.toFixed(1)} px + +
+ onChange(vals[0])} + aria-label="Trail thickness" + /> +
+
+ ); +} + +function TrailDistanceSlider({ + value, + onChange, +}: { + value: number; + onChange: (v: number) => void; +}) { + return ( +
+
+ +
+
+
+

+ Trail distance +

+ + {value} pts + +
+ onChange(vals[0])} + aria-label="Trail distance" + /> +
+
+ ); +} + +function SettingRow({ + icon, + title, + description, + checked, + onChange, + badge, +}: { + icon: ReactNode; + title: string; + description: string; + checked: boolean; + onChange: (v: boolean) => void; + badge?: string; +}) { + return ( + + ); +} + +function SegmentRow({ + icon, + title, + options, + value, + onChange, +}: { + icon: ReactNode; + title: string; + options: { label: string; value: T }[]; + value: T; + onChange: (v: T) => void; +}) { + return ( +
+
+ {icon} +
+

+ {title} +

+
+ {options.map((opt) => { + const isActive = opt.value === value; + return ( + + ); + })} +
+
+ ); +} + +function Toggle({ checked }: { checked: boolean }) { + return ( +
+ +
+ ); +} + +const CHANGELOG = [ + { + date: "Mar 11", + title: "Globe mode & aircraft photos", + description: + "Zoom out to see the entire earth as a 3D sphere with altitude-colored dots for every flight. Trails are now interpolated with centripetal Catmull\u2013Rom splines — a C\u00B9-continuous piecewise cubic that passes through every waypoint without overshooting, using \u03B1\u2009=\u20090.5 parameterization for natural curvature. Dark terrain, aircraft photo banners in flight cards, and a hard dot-to-flight cutover with zero overlap. Globe mode is in beta — find it in Settings.", + }, + { + date: "Feb 22", + title: "Flight history tracking", + description: + "Full trail rendering for every tracked flight. Airline logo caching so they actually load.", + }, + { + date: "Feb 21", + title: "First person view", + description: + "FPV mode — pick any plane and ride along with a HUD. Also added flight search by callsign.", + }, + { + date: "Feb 17", + title: "Airline logos & attribution", + description: + "Proper logos for airlines, and attribution for OSM, OpenSky, CARTO, Esri, and everyone whose data makes this work.", + }, + { + date: "Feb 15", + title: "9,000+ airports", + description: + "Went from a handful of cities to every airport we could find. Copilot helped build the dataset. Added keyboard shortcuts and click-to-select.", + }, + { + date: "Feb 14", + title: "Day one", + description: + "Basic map, flight cards, trail rendering, orbit camera. Spent most of the day fighting Vercel timeouts and OpenSky IP blocks before realizing the API just supports CORS.", + }, +]; + +export function AboutContent() { + return ( + +
+

+ Aeris +

+ +
+

+ Live flight tracking in 3D. The planes you see are real — position + data comes from the OpenSky Network, updated every few seconds via + ADS-B receivers people run on their roofs worldwide. +

+

+ You can search through 9,000+ airports, jump into first-person view + to ride along with any plane, or just leave it on a screen and watch + things move. Trails change color with altitude so you can tell + who's cruising at 35,000ft and who's on approach. +

+
+ +
+ + ); +} + +export function ChangelogContent() { + return ( + +
+ {CHANGELOG.map((entry) => ( +
+ + {entry.date} + +
+

+ {entry.title} +

+

+ {entry.description} +

+
+
+ ))} +
+
+ ); +} diff --git a/src/components/ui/control-panel-styles.tsx b/src/components/ui/control-panel-styles.tsx new file mode 100644 index 0000000..95c1fe9 --- /dev/null +++ b/src/components/ui/control-panel-styles.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +import { motion, AnimatePresence } from "motion/react"; +import { Check } from "lucide-react"; +import { MAP_STYLES, type MapStyle } from "@/lib/map-styles"; +import { ScrollArea } from "@/components/ui/scroll-area"; + +export function StyleContent({ + activeStyle, + onSelect, +}: { + activeStyle: MapStyle; + onSelect: (style: MapStyle) => void; +}) { + return ( + +
+ {MAP_STYLES.map((style, i) => ( + onSelect(style)} + /> + ))} +
+
+

+ Satellite © Esri · Terrain © OpenTopoMap / Terrain Tiles · Base maps © + CARTO +

+
+
+ ); +} + +function StyleTile({ + style, + isActive, + index, + onSelect, +}: { + style: MapStyle; + isActive: boolean; + index: number; + onSelect: () => void; +}) { + const [imgLoaded, setImgLoaded] = useState(false); + + return ( + +
+
+ {`${style.name} setImgLoaded(true)} + onError={() => setImgLoaded(true)} + className={`object-cover transition-all duration-500 group-hover:scale-105 ${ + imgLoaded ? "opacity-100" : "opacity-0" + }`} + draggable={false} + /> +
+ + + {isActive && ( + + + + )} + +
+ +
+ + {style.name} + + {style.dark && ( + + )} +
+ + ); +} diff --git a/src/components/ui/control-panel.tsx b/src/components/ui/control-panel.tsx index 623a3ac..25aa261 100644 --- a/src/components/ui/control-panel.tsx +++ b/src/components/ui/control-panel.tsx @@ -1,36 +1,36 @@ "use client"; -import { useState, useMemo, useRef, useEffect, type ReactNode } from "react"; -import Image from "next/image"; +import { useState, useEffect, useRef, type ReactNode } from "react"; import { motion, AnimatePresence } from "motion/react"; import { Search, Map as MapIcon, Settings, + Keyboard, X, - Check, - MapPin, - ChevronRight, - RotateCw, - Route, - Layers, - Palette, - ArrowLeftRight, Github, - Plane, - Eye, - Loader2, + Info, + Clock, } from "lucide-react"; -import { CITIES, type City } from "@/lib/cities"; -import { searchAirports, airportToCity } from "@/lib/airports"; -import { MAP_STYLES, type MapStyle } from "@/lib/map-styles"; -import { useSettings, type OrbitDirection } from "@/hooks/use-settings"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Slider } from "@/components/ui/slider"; +import type { City } from "@/lib/cities"; +import type { MapStyle } from "@/lib/map-styles"; import type { FlightState } from "@/lib/opensky"; -import { formatCallsign } from "@/lib/flight-utils"; +import { SearchContent } from "@/components/ui/control-panel-search"; +import { StyleContent } from "@/components/ui/control-panel-styles"; +import { + SettingsContent, + ShortcutsContent, + AboutContent, + ChangelogContent, +} from "@/components/ui/control-panel-settings"; -type TabId = "search" | "style" | "settings"; +type TabId = + | "search" + | "style" + | "settings" + | "shortcuts" + | "changelog" + | "about"; const MAIN_TABS: { id: TabId; @@ -39,10 +39,15 @@ const MAIN_TABS: { }[] = [ { id: "search", icon: Search, label: "Search" }, { id: "style", icon: MapIcon, label: "Map Style" }, - { id: "settings", icon: Settings, label: "Settings" }, ]; -const PANEL_TABS = MAIN_TABS; +const PANEL_TABS = [ + ...MAIN_TABS, + { id: "settings" as TabId, icon: Settings, label: "Settings" }, + { id: "shortcuts" as TabId, icon: Keyboard, label: "Shortcuts" }, + { id: "changelog" as TabId, icon: Clock, label: "Changelog" }, + { id: "about" as TabId, icon: Info, label: "About" }, +]; type ControlPanelProps = { activeCity: City; @@ -69,9 +74,15 @@ export function ControlPanel({ function handleOpenSearch() { setOpenTab("search"); } + function handleOpenShortcuts() { + setOpenTab("shortcuts"); + } window.addEventListener("aeris:open-search", handleOpenSearch); - return () => + window.addEventListener("aeris:open-shortcuts", handleOpenShortcuts); + return () => { window.removeEventListener("aeris:open-search", handleOpenSearch); + window.removeEventListener("aeris:open-shortcuts", handleOpenShortcuts); + }; }, []); const open = (tab: TabId) => setOpenTab(tab); @@ -98,6 +109,22 @@ export function ControlPanel({ ))} + open("settings")} + className="flex h-9 w-9 items-center justify-center rounded-xl backdrop-blur-2xl transition-colors" + style={{ + borderWidth: 1, + borderColor: "rgb(var(--ui-fg) / 0.06)", + backgroundColor: "rgb(var(--ui-fg) / 0.03)", + color: "rgb(var(--ui-fg) / 0.5)", + }} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + aria-label="Settings" + > + + + {openTab && ( @@ -217,7 +244,7 @@ function PanelDialog({ aria-modal="true" aria-labelledby="panel-dialog-title" > -
+
{/* Desktop sidebar (hidden on mobile) */}

@@ -272,7 +299,7 @@ function PanelDialog({

- v0.1 · OpenSky Network + Powered by OpenSky Network

@@ -337,24 +364,40 @@ function PanelDialog({ )} + {activeTab === "shortcuts" && ( + + + + )} + {activeTab === "changelog" && ( + + + + )} + {activeTab === "about" && ( + + + + )}
{/* Mobile tab bar */} -
-