diff --git a/next.config.ts b/next.config.ts index 7c57f80..783e2f4 100644 --- a/next.config.ts +++ b/next.config.ts @@ -14,7 +14,7 @@ const cspHeader = ` 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://api.airplanes.live https://api.adsb.lol https://res.cloudinary.com; + 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://api.airplanes.live https://api.adsb.lol https://res.cloudinary.com https://api.rainviewer.com; worker-src 'self' blob:; child-src blob:; object-src 'none'; diff --git a/package.json b/package.json index a94d4bd..008deda 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "lucide-react": "^0.564.0", "maplibre-gl": "^5.18.0", "motion": "^12.34.0", - "next": "16.2.0", + "next": "16.2.1", "next-themes": "^0.4.6", "react": "19.2.4", "react-dom": "19.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7320ccc..6572c5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,8 +57,8 @@ importers: specifier: ^12.34.0 version: 12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: - specifier: 16.2.0 - version: 16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 16.2.1 + version: 16.2.1(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -658,60 +658,60 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - '@next/env@16.2.0': - resolution: {integrity: sha512-OZIbODWWAi0epQRCRjNe1VO45LOFBzgiyqmTLzIqWq6u1wrxKnAyz1HH6tgY/Mc81YzIjRPoYsPAEr4QV4l9TA==} + '@next/env@16.2.1': + resolution: {integrity: sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==} '@next/eslint-plugin-next@16.2.0': resolution: {integrity: sha512-3D3pEMcGKfENC9Pzlkr67GOm+205+5hRdYPZvHuNIy5sr9k0ybSU8g+sxOO/R/RLEh/gWZ3UlY+5LmEyZ1xgXQ==} - '@next/swc-darwin-arm64@16.2.0': - resolution: {integrity: sha512-/JZsqKzKt01IFoiLLAzlNqys7qk2F3JkcUhj50zuRhKDQkZNOz9E5N6wAQWprXdsvjRP4lTFj+/+36NSv5AwhQ==} + '@next/swc-darwin-arm64@16.2.1': + resolution: {integrity: sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.0': - resolution: {integrity: sha512-/hV8erWq4SNlVgglUiW5UmQ5Hwy5EW/AbbXlJCn6zkfKxTy/E/U3V8U1Ocm2YCTUoFgQdoMxRyRMOW5jYy4ygg==} + '@next/swc-darwin-x64@16.2.1': + resolution: {integrity: sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.0': - resolution: {integrity: sha512-GkjL/Q7MWOwqWR9zoxu1TIHzkOI2l2BHCf7FzeQG87zPgs+6WDh+oC9Sw9ARuuL/FUk6JNCgKRkA6rEQYadUaw==} + '@next/swc-linux-arm64-gnu@16.2.1': + resolution: {integrity: sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.2.0': - resolution: {integrity: sha512-1ffhC6KY5qWLg5miMlKJp3dZbXelEfjuXt1qcp5WzSCQy36CV3y+JT7OC1WSFKizGQCDOcQbfkH/IjZP3cdRNA==} + '@next/swc-linux-arm64-musl@16.2.1': + resolution: {integrity: sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.2.0': - resolution: {integrity: sha512-FmbDcZQ8yJRq93EJSL6xaE0KK/Rslraf8fj1uViGxg7K4CKBCRYSubILJPEhjSgZurpcPQq12QNOJQ0DRJl6Hg==} + '@next/swc-linux-x64-gnu@16.2.1': + resolution: {integrity: sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.2.0': - resolution: {integrity: sha512-HzjIHVkmGAwRbh/vzvoBWWEbb8BBZPxBvVbDQDvzHSf3D8RP/4vjw7MNLDXFF9Q1WEzeQyEj2zdxBtVAHu5Oyw==} + '@next/swc-linux-x64-musl@16.2.1': + resolution: {integrity: sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.2.0': - resolution: {integrity: sha512-UMiFNQf5H7+1ZsZPxEsA064WEuFbRNq/kEXyepbCnSErp4f5iut75dBA8UeerFIG3vDaQNOfCpevnERPp2V+nA==} + '@next/swc-win32-arm64-msvc@16.2.1': + resolution: {integrity: sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.0': - resolution: {integrity: sha512-DRrNJKW+/eimrZgdhVN1uvkN1OI4j6Lpefwr44jKQ0YQzztlmOBUUzHuV5GxOMPK3nmodAYElUVCY8ZXo/IWeA==} + '@next/swc-win32-x64-msvc@16.2.1': + resolution: {integrity: sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2305,8 +2305,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.2.0: - resolution: {integrity: sha512-NLBVrJy1pbV1Yn00L5sU4vFyAHt5XuSjzrNyFnxo6Com0M0KrL6hHM5B99dbqXb2bE9pm4Ow3Zl1xp6HVY9edQ==} + next@16.2.1: + resolution: {integrity: sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -3550,34 +3550,34 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@next/env@16.2.0': {} + '@next/env@16.2.1': {} '@next/eslint-plugin-next@16.2.0': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@16.2.0': + '@next/swc-darwin-arm64@16.2.1': optional: true - '@next/swc-darwin-x64@16.2.0': + '@next/swc-darwin-x64@16.2.1': optional: true - '@next/swc-linux-arm64-gnu@16.2.0': + '@next/swc-linux-arm64-gnu@16.2.1': optional: true - '@next/swc-linux-arm64-musl@16.2.0': + '@next/swc-linux-arm64-musl@16.2.1': optional: true - '@next/swc-linux-x64-gnu@16.2.0': + '@next/swc-linux-x64-gnu@16.2.1': optional: true - '@next/swc-linux-x64-musl@16.2.0': + '@next/swc-linux-x64-musl@16.2.1': optional: true - '@next/swc-win32-arm64-msvc@16.2.0': + '@next/swc-win32-arm64-msvc@16.2.1': optional: true - '@next/swc-win32-x64-msvc@16.2.0': + '@next/swc-win32-x64-msvc@16.2.1': optional: true '@nodelib/fs.scandir@2.1.5': @@ -5238,9 +5238,9 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - next@16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.1(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@next/env': 16.2.0 + '@next/env': 16.2.1 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.9.19 caniuse-lite: 1.0.30001769 @@ -5249,14 +5249,14 @@ snapshots: react-dom: 19.2.4(react@19.2.4) styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.0 - '@next/swc-darwin-x64': 16.2.0 - '@next/swc-linux-arm64-gnu': 16.2.0 - '@next/swc-linux-arm64-musl': 16.2.0 - '@next/swc-linux-x64-gnu': 16.2.0 - '@next/swc-linux-x64-musl': 16.2.0 - '@next/swc-win32-arm64-msvc': 16.2.0 - '@next/swc-win32-x64-msvc': 16.2.0 + '@next/swc-darwin-arm64': 16.2.1 + '@next/swc-darwin-x64': 16.2.1 + '@next/swc-linux-arm64-gnu': 16.2.1 + '@next/swc-linux-arm64-musl': 16.2.1 + '@next/swc-linux-x64-gnu': 16.2.1 + '@next/swc-linux-x64-musl': 16.2.1 + '@next/swc-win32-arm64-msvc': 16.2.1 + '@next/swc-win32-x64-msvc': 16.2.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' diff --git a/public/airline-logos/air-busan.png b/public/airline-logos/air-busan.png new file mode 100644 index 0000000..cc8215f Binary files /dev/null and b/public/airline-logos/air-busan.png differ diff --git a/public/airline-logos/air-corsica.png b/public/airline-logos/air-corsica.png new file mode 100644 index 0000000..b04392a Binary files /dev/null and b/public/airline-logos/air-corsica.png differ diff --git a/public/airline-logos/air-do.png b/public/airline-logos/air-do.png new file mode 100644 index 0000000..28cac95 Binary files /dev/null and b/public/airline-logos/air-do.png differ diff --git a/public/airline-logos/air-greenland.png b/public/airline-logos/air-greenland.png new file mode 100644 index 0000000..7b3890c Binary files /dev/null and b/public/airline-logos/air-greenland.png differ diff --git a/public/airline-logos/air-koryo.png b/public/airline-logos/air-koryo.png new file mode 100644 index 0000000..0f4cb3f Binary files /dev/null and b/public/airline-logos/air-koryo.png differ diff --git a/public/airline-logos/air-madagascar.png b/public/airline-logos/air-madagascar.png new file mode 100644 index 0000000..8d9d0c9 Binary files /dev/null and b/public/airline-logos/air-madagascar.png differ diff --git a/public/airline-logos/air-malta.png b/public/airline-logos/air-malta.png new file mode 100644 index 0000000..b75a6cd Binary files /dev/null and b/public/airline-logos/air-malta.png differ diff --git a/public/airline-logos/air-niugini.png b/public/airline-logos/air-niugini.png new file mode 100644 index 0000000..e4d5aed Binary files /dev/null and b/public/airline-logos/air-niugini.png differ diff --git a/public/airline-logos/air-peace.png b/public/airline-logos/air-peace.png new file mode 100644 index 0000000..d9bdb48 Binary files /dev/null and b/public/airline-logos/air-peace.png differ diff --git a/public/airline-logos/air-senegal.png b/public/airline-logos/air-senegal.png new file mode 100644 index 0000000..4135289 Binary files /dev/null and b/public/airline-logos/air-senegal.png differ diff --git a/public/airline-logos/air-seychelles.png b/public/airline-logos/air-seychelles.png new file mode 100644 index 0000000..b628bbd Binary files /dev/null and b/public/airline-logos/air-seychelles.png differ diff --git a/public/airline-logos/air-tanzania.png b/public/airline-logos/air-tanzania.png new file mode 100644 index 0000000..fd1d32e Binary files /dev/null and b/public/airline-logos/air-tanzania.png differ diff --git a/public/airline-logos/airbridgecargo-airlines.png b/public/airline-logos/airbridgecargo-airlines.png new file mode 100644 index 0000000..07bedc2 Binary files /dev/null and b/public/airline-logos/airbridgecargo-airlines.png differ diff --git a/public/airline-logos/akasa-air.png b/public/airline-logos/akasa-air.png new file mode 100644 index 0000000..ff542e4 Binary files /dev/null and b/public/airline-logos/akasa-air.png differ diff --git a/public/airline-logos/ameriflight.png b/public/airline-logos/ameriflight.png new file mode 100644 index 0000000..5f2ff74 Binary files /dev/null and b/public/airline-logos/ameriflight.png differ diff --git a/public/airline-logos/ariana-afghan-airlines.png b/public/airline-logos/ariana-afghan-airlines.png new file mode 100644 index 0000000..3d6af36 Binary files /dev/null and b/public/airline-logos/ariana-afghan-airlines.png differ diff --git a/public/airline-logos/arkia-israeli-airlines.png b/public/airline-logos/arkia-israeli-airlines.png new file mode 100644 index 0000000..8e64066 Binary files /dev/null and b/public/airline-logos/arkia-israeli-airlines.png differ diff --git a/public/airline-logos/asl-airlines-belgium.png b/public/airline-logos/asl-airlines-belgium.png new file mode 100644 index 0000000..7302df2 Binary files /dev/null and b/public/airline-logos/asl-airlines-belgium.png differ diff --git a/public/airline-logos/asl-airlines-france.png b/public/airline-logos/asl-airlines-france.png new file mode 100644 index 0000000..10cdfda Binary files /dev/null and b/public/airline-logos/asl-airlines-france.png differ diff --git a/public/airline-logos/aurigny-air-services.png b/public/airline-logos/aurigny-air-services.png new file mode 100644 index 0000000..5345797 Binary files /dev/null and b/public/airline-logos/aurigny-air-services.png differ diff --git a/public/airline-logos/aurora.png b/public/airline-logos/aurora.png new file mode 100644 index 0000000..64e0491 Binary files /dev/null and b/public/airline-logos/aurora.png differ diff --git a/public/airline-logos/avion-express.png b/public/airline-logos/avion-express.png new file mode 100644 index 0000000..66fc09a Binary files /dev/null and b/public/airline-logos/avion-express.png differ diff --git a/public/airline-logos/azerbaijan-airlines.png b/public/airline-logos/azerbaijan-airlines.png new file mode 100644 index 0000000..4670fa2 Binary files /dev/null and b/public/airline-logos/azerbaijan-airlines.png differ diff --git a/public/airline-logos/bamboo-airways.png b/public/airline-logos/bamboo-airways.png new file mode 100644 index 0000000..00b1faf Binary files /dev/null and b/public/airline-logos/bamboo-airways.png differ diff --git a/public/airline-logos/bangkok-airways.png b/public/airline-logos/bangkok-airways.png new file mode 100644 index 0000000..d228a18 Binary files /dev/null and b/public/airline-logos/bangkok-airways.png differ diff --git a/public/airline-logos/batik-air.png b/public/airline-logos/batik-air.png new file mode 100644 index 0000000..139560c Binary files /dev/null and b/public/airline-logos/batik-air.png differ diff --git a/public/airline-logos/beijing-capital-airlines.png b/public/airline-logos/beijing-capital-airlines.png new file mode 100644 index 0000000..8c5b1dc Binary files /dev/null and b/public/airline-logos/beijing-capital-airlines.png differ diff --git a/public/airline-logos/belavia.png b/public/airline-logos/belavia.png new file mode 100644 index 0000000..067b62c Binary files /dev/null and b/public/airline-logos/belavia.png differ diff --git a/public/airline-logos/biman-bangladesh-airlines.png b/public/airline-logos/biman-bangladesh-airlines.png new file mode 100644 index 0000000..7de1f95 Binary files /dev/null and b/public/airline-logos/biman-bangladesh-airlines.png differ diff --git a/public/airline-logos/blue-air.png b/public/airline-logos/blue-air.png new file mode 100644 index 0000000..21b76ee Binary files /dev/null and b/public/airline-logos/blue-air.png differ diff --git a/public/airline-logos/boliviana-de-aviacion.png b/public/airline-logos/boliviana-de-aviacion.png new file mode 100644 index 0000000..fd2bd6c Binary files /dev/null and b/public/airline-logos/boliviana-de-aviacion.png differ diff --git a/public/airline-logos/breeze-airways.png b/public/airline-logos/breeze-airways.png new file mode 100644 index 0000000..6e91786 Binary files /dev/null and b/public/airline-logos/breeze-airways.png differ diff --git a/public/airline-logos/bulgaria-air.png b/public/airline-logos/bulgaria-air.png new file mode 100644 index 0000000..aacc801 Binary files /dev/null and b/public/airline-logos/bulgaria-air.png differ diff --git a/public/airline-logos/cargojet-airways.png b/public/airline-logos/cargojet-airways.png new file mode 100644 index 0000000..5f5d679 Binary files /dev/null and b/public/airline-logos/cargojet-airways.png differ diff --git a/public/airline-logos/cargolux.png b/public/airline-logos/cargolux.png new file mode 100644 index 0000000..128c543 Binary files /dev/null and b/public/airline-logos/cargolux.png differ diff --git a/public/airline-logos/caribbean-airlines.png b/public/airline-logos/caribbean-airlines.png new file mode 100644 index 0000000..78158e1 Binary files /dev/null and b/public/airline-logos/caribbean-airlines.png differ diff --git a/public/airline-logos/china-cargo-airlines.png b/public/airline-logos/china-cargo-airlines.png new file mode 100644 index 0000000..c7968d9 Binary files /dev/null and b/public/airline-logos/china-cargo-airlines.png differ diff --git a/public/airline-logos/china-express-airlines.png b/public/airline-logos/china-express-airlines.png new file mode 100644 index 0000000..1595f9b Binary files /dev/null and b/public/airline-logos/china-express-airlines.png differ diff --git a/public/airline-logos/citilink.png b/public/airline-logos/citilink.png new file mode 100644 index 0000000..f18ba59 Binary files /dev/null and b/public/airline-logos/citilink.png differ diff --git a/public/airline-logos/corendon-airlines.png b/public/airline-logos/corendon-airlines.png new file mode 100644 index 0000000..f82ccb0 Binary files /dev/null and b/public/airline-logos/corendon-airlines.png differ diff --git a/public/airline-logos/cyprus-airways.png b/public/airline-logos/cyprus-airways.png new file mode 100644 index 0000000..b3fc4e4 Binary files /dev/null and b/public/airline-logos/cyprus-airways.png differ diff --git a/public/airline-logos/dhl.png b/public/airline-logos/dhl.png new file mode 100644 index 0000000..80aa86a Binary files /dev/null and b/public/airline-logos/dhl.png differ diff --git a/public/airline-logos/discover-airlines.png b/public/airline-logos/discover-airlines.png new file mode 100644 index 0000000..50ca5ca Binary files /dev/null and b/public/airline-logos/discover-airlines.png differ diff --git a/public/airline-logos/druk-air.png b/public/airline-logos/druk-air.png new file mode 100644 index 0000000..886ad37 Binary files /dev/null and b/public/airline-logos/druk-air.png differ diff --git a/public/airline-logos/eastar-jet.png b/public/airline-logos/eastar-jet.png new file mode 100644 index 0000000..47af5eb Binary files /dev/null and b/public/airline-logos/eastar-jet.png differ diff --git a/public/airline-logos/eastern-airways.png b/public/airline-logos/eastern-airways.png new file mode 100644 index 0000000..ecdb430 Binary files /dev/null and b/public/airline-logos/eastern-airways.png differ diff --git a/public/airline-logos/edelweiss-air.png b/public/airline-logos/edelweiss-air.png new file mode 100644 index 0000000..16f4b33 Binary files /dev/null and b/public/airline-logos/edelweiss-air.png differ diff --git a/public/airline-logos/endeavor-air.png b/public/airline-logos/endeavor-air.png new file mode 100644 index 0000000..7289a90 Binary files /dev/null and b/public/airline-logos/endeavor-air.png differ diff --git a/public/airline-logos/enter-air.png b/public/airline-logos/enter-air.png new file mode 100644 index 0000000..ca0ffd3 Binary files /dev/null and b/public/airline-logos/enter-air.png differ diff --git a/public/airline-logos/flair-airlines.png b/public/airline-logos/flair-airlines.png new file mode 100644 index 0000000..a19d42a Binary files /dev/null and b/public/airline-logos/flair-airlines.png differ diff --git a/public/airline-logos/flybe.png b/public/airline-logos/flybe.png new file mode 100644 index 0000000..500a532 Binary files /dev/null and b/public/airline-logos/flybe.png differ diff --git a/public/airline-logos/flynas.png b/public/airline-logos/flynas.png new file mode 100644 index 0000000..9d9c7be Binary files /dev/null and b/public/airline-logos/flynas.png differ diff --git a/public/airline-logos/flyone.png b/public/airline-logos/flyone.png new file mode 100644 index 0000000..f8f1ce2 Binary files /dev/null and b/public/airline-logos/flyone.png differ diff --git a/public/airline-logos/freebird-airlines.png b/public/airline-logos/freebird-airlines.png new file mode 100644 index 0000000..7ae0510 Binary files /dev/null and b/public/airline-logos/freebird-airlines.png differ diff --git a/public/airline-logos/french-bee.png b/public/airline-logos/french-bee.png new file mode 100644 index 0000000..6cfd551 Binary files /dev/null and b/public/airline-logos/french-bee.png differ diff --git a/public/airline-logos/fuji-dream-airlines.png b/public/airline-logos/fuji-dream-airlines.png new file mode 100644 index 0000000..d492092 Binary files /dev/null and b/public/airline-logos/fuji-dream-airlines.png differ diff --git a/public/airline-logos/helvetic-airways.png b/public/airline-logos/helvetic-airways.png new file mode 100644 index 0000000..901013e Binary files /dev/null and b/public/airline-logos/helvetic-airways.png differ diff --git a/public/airline-logos/hi-fly.png b/public/airline-logos/hi-fly.png new file mode 100644 index 0000000..f0f58c2 Binary files /dev/null and b/public/airline-logos/hi-fly.png differ diff --git a/public/airline-logos/iran-air.png b/public/airline-logos/iran-air.png new file mode 100644 index 0000000..9288cb7 Binary files /dev/null and b/public/airline-logos/iran-air.png differ diff --git a/public/airline-logos/iraqi-airways.png b/public/airline-logos/iraqi-airways.png new file mode 100644 index 0000000..8539725 Binary files /dev/null and b/public/airline-logos/iraqi-airways.png differ diff --git a/public/airline-logos/j-air.png b/public/airline-logos/j-air.png new file mode 100644 index 0000000..66bf9e1 Binary files /dev/null and b/public/airline-logos/j-air.png differ diff --git a/public/airline-logos/jin-air.png b/public/airline-logos/jin-air.png new file mode 100644 index 0000000..0c53b50 Binary files /dev/null and b/public/airline-logos/jin-air.png differ diff --git a/public/airline-logos/kalitta-air.png b/public/airline-logos/kalitta-air.png new file mode 100644 index 0000000..0f77946 Binary files /dev/null and b/public/airline-logos/kalitta-air.png differ diff --git a/public/airline-logos/kalitta-charters.png b/public/airline-logos/kalitta-charters.png new file mode 100644 index 0000000..df784d6 Binary files /dev/null and b/public/airline-logos/kalitta-charters.png differ diff --git a/public/airline-logos/kenya-airways.png b/public/airline-logos/kenya-airways.png new file mode 100644 index 0000000..01ec3eb Binary files /dev/null and b/public/airline-logos/kenya-airways.png differ diff --git a/public/airline-logos/kuwait-airways.png b/public/airline-logos/kuwait-airways.png new file mode 100644 index 0000000..410b3b3 Binary files /dev/null and b/public/airline-logos/kuwait-airways.png differ diff --git a/public/airline-logos/lam-mozambique.png b/public/airline-logos/lam-mozambique.png new file mode 100644 index 0000000..fd2cde1 Binary files /dev/null and b/public/airline-logos/lam-mozambique.png differ diff --git a/public/airline-logos/lion-air.png b/public/airline-logos/lion-air.png new file mode 100644 index 0000000..5855185 Binary files /dev/null and b/public/airline-logos/lion-air.png differ diff --git a/public/airline-logos/loganair.png b/public/airline-logos/loganair.png new file mode 100644 index 0000000..5c82785 Binary files /dev/null and b/public/airline-logos/loganair.png differ diff --git a/public/airline-logos/lucky-air.png b/public/airline-logos/lucky-air.png new file mode 100644 index 0000000..6e1422d Binary files /dev/null and b/public/airline-logos/lucky-air.png differ diff --git a/public/airline-logos/mahan-air.png b/public/airline-logos/mahan-air.png new file mode 100644 index 0000000..c85df47 Binary files /dev/null and b/public/airline-logos/mahan-air.png differ diff --git a/public/airline-logos/mandarin-airlines.png b/public/airline-logos/mandarin-airlines.png new file mode 100644 index 0000000..30930ac Binary files /dev/null and b/public/airline-logos/mandarin-airlines.png differ diff --git a/public/airline-logos/mesa-airlines.png b/public/airline-logos/mesa-airlines.png new file mode 100644 index 0000000..71d65fc Binary files /dev/null and b/public/airline-logos/mesa-airlines.png differ diff --git a/public/airline-logos/miat-mongolian-airlines.png b/public/airline-logos/miat-mongolian-airlines.png new file mode 100644 index 0000000..e5c40e4 Binary files /dev/null and b/public/airline-logos/miat-mongolian-airlines.png differ diff --git a/public/airline-logos/middle-east-airlines.png b/public/airline-logos/middle-east-airlines.png new file mode 100644 index 0000000..c1297be Binary files /dev/null and b/public/airline-logos/middle-east-airlines.png differ diff --git a/public/airline-logos/mng-airlines.png b/public/airline-logos/mng-airlines.png new file mode 100644 index 0000000..ec5ebea Binary files /dev/null and b/public/airline-logos/mng-airlines.png differ diff --git a/public/airline-logos/myanmar-airways-international.png b/public/airline-logos/myanmar-airways-international.png new file mode 100644 index 0000000..25ea3bd Binary files /dev/null and b/public/airline-logos/myanmar-airways-international.png differ diff --git a/public/airline-logos/myanmar-national-airlines.png b/public/airline-logos/myanmar-national-airlines.png new file mode 100644 index 0000000..47f5a3b Binary files /dev/null and b/public/airline-logos/myanmar-national-airlines.png differ diff --git a/public/airline-logos/neos.png b/public/airline-logos/neos.png new file mode 100644 index 0000000..b2596b1 Binary files /dev/null and b/public/airline-logos/neos.png differ diff --git a/public/airline-logos/nippon-cargo-airlines.png b/public/airline-logos/nippon-cargo-airlines.png new file mode 100644 index 0000000..bff21ca Binary files /dev/null and b/public/airline-logos/nippon-cargo-airlines.png differ diff --git a/public/airline-logos/nok-air.png b/public/airline-logos/nok-air.png new file mode 100644 index 0000000..568ee34 Binary files /dev/null and b/public/airline-logos/nok-air.png differ diff --git a/public/airline-logos/nordwind-airlines.png b/public/airline-logos/nordwind-airlines.png new file mode 100644 index 0000000..511ed4e Binary files /dev/null and b/public/airline-logos/nordwind-airlines.png differ diff --git a/public/airline-logos/nouvelair.png b/public/airline-logos/nouvelair.png new file mode 100644 index 0000000..c908380 Binary files /dev/null and b/public/airline-logos/nouvelair.png differ diff --git a/public/airline-logos/okay-airways.png b/public/airline-logos/okay-airways.png new file mode 100644 index 0000000..698d452 Binary files /dev/null and b/public/airline-logos/okay-airways.png differ diff --git a/public/airline-logos/omni-air-international.png b/public/airline-logos/omni-air-international.png new file mode 100644 index 0000000..eb46de0 Binary files /dev/null and b/public/airline-logos/omni-air-international.png differ diff --git a/public/airline-logos/play.png b/public/airline-logos/play.png new file mode 100644 index 0000000..034113b Binary files /dev/null and b/public/airline-logos/play.png differ diff --git a/public/airline-logos/polar-air-cargo.png b/public/airline-logos/polar-air-cargo.png new file mode 100644 index 0000000..215a5de Binary files /dev/null and b/public/airline-logos/polar-air-cargo.png differ diff --git a/public/airline-logos/precision-air.png b/public/airline-logos/precision-air.png new file mode 100644 index 0000000..9789a1b Binary files /dev/null and b/public/airline-logos/precision-air.png differ diff --git a/public/airline-logos/rex-airlines.png b/public/airline-logos/rex-airlines.png new file mode 100644 index 0000000..ece1ff0 Binary files /dev/null and b/public/airline-logos/rex-airlines.png differ diff --git a/public/airline-logos/royal-brunei-airlines.png b/public/airline-logos/royal-brunei-airlines.png new file mode 100644 index 0000000..8d07f36 Binary files /dev/null and b/public/airline-logos/royal-brunei-airlines.png differ diff --git a/public/airline-logos/rwandair.png b/public/airline-logos/rwandair.png new file mode 100644 index 0000000..8a73040 Binary files /dev/null and b/public/airline-logos/rwandair.png differ diff --git a/public/airline-logos/s7-airlines.png b/public/airline-logos/s7-airlines.png new file mode 100644 index 0000000..3133586 Binary files /dev/null and b/public/airline-logos/s7-airlines.png differ diff --git a/public/airline-logos/scat-airlines.png b/public/airline-logos/scat-airlines.png new file mode 100644 index 0000000..b22d09d Binary files /dev/null and b/public/airline-logos/scat-airlines.png differ diff --git a/public/airline-logos/shandong-airlines.png b/public/airline-logos/shandong-airlines.png new file mode 100644 index 0000000..3a919ca Binary files /dev/null and b/public/airline-logos/shandong-airlines.png differ diff --git a/public/airline-logos/silk-way-west-airlines.png b/public/airline-logos/silk-way-west-airlines.png new file mode 100644 index 0000000..48f0ef8 Binary files /dev/null and b/public/airline-logos/silk-way-west-airlines.png differ diff --git a/public/airline-logos/sky-airline.png b/public/airline-logos/sky-airline.png new file mode 100644 index 0000000..b0633c8 Binary files /dev/null and b/public/airline-logos/sky-airline.png differ diff --git a/public/airline-logos/skyup-airlines.png b/public/airline-logos/skyup-airlines.png new file mode 100644 index 0000000..892822f Binary files /dev/null and b/public/airline-logos/skyup-airlines.png differ diff --git a/public/airline-logos/smartlynx-airlines.png b/public/airline-logos/smartlynx-airlines.png new file mode 100644 index 0000000..5c64d13 Binary files /dev/null and b/public/airline-logos/smartlynx-airlines.png differ diff --git a/public/airline-logos/smartwings.png b/public/airline-logos/smartwings.png new file mode 100644 index 0000000..9380b6f Binary files /dev/null and b/public/airline-logos/smartwings.png differ diff --git a/public/airline-logos/solaseed-air.png b/public/airline-logos/solaseed-air.png new file mode 100644 index 0000000..5741852 Binary files /dev/null and b/public/airline-logos/solaseed-air.png differ diff --git a/public/airline-logos/solomon-airlines.png b/public/airline-logos/solomon-airlines.png new file mode 100644 index 0000000..c78341c Binary files /dev/null and b/public/airline-logos/solomon-airlines.png differ diff --git a/public/airline-logos/spring-airlines-japan.png b/public/airline-logos/spring-airlines-japan.png new file mode 100644 index 0000000..749b5a4 Binary files /dev/null and b/public/airline-logos/spring-airlines-japan.png differ diff --git a/public/airline-logos/star-air.png b/public/airline-logos/star-air.png new file mode 100644 index 0000000..6566ad0 Binary files /dev/null and b/public/airline-logos/star-air.png differ diff --git a/public/airline-logos/starflyer.png b/public/airline-logos/starflyer.png new file mode 100644 index 0000000..cba1e78 Binary files /dev/null and b/public/airline-logos/starflyer.png differ diff --git a/public/airline-logos/sunclass-airlines.png b/public/airline-logos/sunclass-airlines.png new file mode 100644 index 0000000..f6aca16 Binary files /dev/null and b/public/airline-logos/sunclass-airlines.png differ diff --git a/public/airline-logos/sunexpress.png b/public/airline-logos/sunexpress.png new file mode 100644 index 0000000..21bfd62 Binary files /dev/null and b/public/airline-logos/sunexpress.png differ diff --git a/public/airline-logos/sunwing-airlines.png b/public/airline-logos/sunwing-airlines.png new file mode 100644 index 0000000..3794f03 Binary files /dev/null and b/public/airline-logos/sunwing-airlines.png differ diff --git a/public/airline-logos/swiftair.png b/public/airline-logos/swiftair.png new file mode 100644 index 0000000..c6cdd4f Binary files /dev/null and b/public/airline-logos/swiftair.png differ diff --git a/public/airline-logos/taag-angola-airlines.png b/public/airline-logos/taag-angola-airlines.png new file mode 100644 index 0000000..b6afa61 Binary files /dev/null and b/public/airline-logos/taag-angola-airlines.png differ diff --git a/public/airline-logos/tianjin-airlines.png b/public/airline-logos/tianjin-airlines.png new file mode 100644 index 0000000..014c181 Binary files /dev/null and b/public/airline-logos/tianjin-airlines.png differ diff --git a/public/airline-logos/tui-fly-netherlands.png b/public/airline-logos/tui-fly-netherlands.png new file mode 100644 index 0000000..edeb187 Binary files /dev/null and b/public/airline-logos/tui-fly-netherlands.png differ diff --git a/public/airline-logos/tunisair.png b/public/airline-logos/tunisair.png new file mode 100644 index 0000000..a1364d2 Binary files /dev/null and b/public/airline-logos/tunisair.png differ diff --git a/public/airline-logos/turkmenistan-airlines.png b/public/airline-logos/turkmenistan-airlines.png new file mode 100644 index 0000000..a617e0f Binary files /dev/null and b/public/airline-logos/turkmenistan-airlines.png differ diff --git a/public/airline-logos/ukraine-international-airlines.png b/public/airline-logos/ukraine-international-airlines.png new file mode 100644 index 0000000..722854f Binary files /dev/null and b/public/airline-logos/ukraine-international-airlines.png differ diff --git a/public/airline-logos/ural-airlines.png b/public/airline-logos/ural-airlines.png new file mode 100644 index 0000000..cdd94b6 Binary files /dev/null and b/public/airline-logos/ural-airlines.png differ diff --git a/public/airline-logos/urumqi-air.png b/public/airline-logos/urumqi-air.png new file mode 100644 index 0000000..967ab40 Binary files /dev/null and b/public/airline-logos/urumqi-air.png differ diff --git a/public/airline-logos/us-bangla-airlines.png b/public/airline-logos/us-bangla-airlines.png new file mode 100644 index 0000000..b3d4374 Binary files /dev/null and b/public/airline-logos/us-bangla-airlines.png differ diff --git a/public/airline-logos/uzbekistan-airways.png b/public/airline-logos/uzbekistan-airways.png new file mode 100644 index 0000000..399e8b7 Binary files /dev/null and b/public/airline-logos/uzbekistan-airways.png differ diff --git a/public/airline-logos/viva-aerobus.png b/public/airline-logos/viva-aerobus.png new file mode 100644 index 0000000..2c764e5 Binary files /dev/null and b/public/airline-logos/viva-aerobus.png differ diff --git a/public/airline-logos/volga-dnepr-airlines.png b/public/airline-logos/volga-dnepr-airlines.png new file mode 100644 index 0000000..cc249ba Binary files /dev/null and b/public/airline-logos/volga-dnepr-airlines.png differ diff --git a/src/app/api/flights/route.ts b/src/app/api/flights/route.ts index 2fd75c4..2fb9d62 100644 --- a/src/app/api/flights/route.ts +++ b/src/app/api/flights/route.ts @@ -19,16 +19,6 @@ const ADSB_LOL_BASE = "https://api.adsb.lol/v2"; const VALID_PATH = /^\/(?:point\/-?\d+(?:\.\d+)?\/-?\d+(?:\.\d+)?\/\d{1,3}|hex\/[0-9a-f]{6}|callsign\/[A-Z0-9-]{1,8})$/i; -// ── Rate limiter (in-memory) ─────────────────────────────────────────── -// NOTE: This is per-instance, per-cold-start. In serverless/edge -// deployments each instance has its own counter, so the effective global -// rate can exceed RATE_MS when multiple instances serve concurrent -// traffic. For strict rate limiting, use a shared store (e.g., Upstash -// Redis or Vercel KV). - -let lastRequestTime = 0; -const RATE_MS = 500; // self-imposed: 2 req/s for adsb.lol - // ── Handler ──────────────────────────────────────────────────────────── export async function GET(request: NextRequest): Promise { @@ -50,19 +40,6 @@ export async function GET(request: NextRequest): Promise { ); } - const now = Date.now(); - const elapsed = now - lastRequestTime; - lastRequestTime = now; - if (elapsed < RATE_MS) { - return NextResponse.json( - { error: "Rate limited" }, - { - status: 429, - headers: { "Cache-Control": "no-store", "Retry-After": "1" }, - }, - ); - } - const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), READSB_FETCH_TIMEOUT_MS); diff --git a/src/app/api/flights/trace/route.ts b/src/app/api/flights/trace/route.ts index 44fe506..7772cb2 100644 --- a/src/app/api/flights/trace/route.ts +++ b/src/app/api/flights/trace/route.ts @@ -8,8 +8,8 @@ const FT_TO_M = 0.3048; const TRACE_TIMEOUT_MS = 10_000; const OPENSKY_TIMEOUT_MS = 8_000; -const TARGET_WAYPOINTS = 60; -const MAX_AGE_SECONDS = 90 * 60; +const TARGET_WAYPOINTS = 120; +const MAX_AGE_SECONDS = 120 * 60; const GLOBE_TRACE_SOURCES = [ { @@ -36,11 +36,48 @@ const OPENSKY_API = "https://opensky-network.org/api"; const APP_UA = "Aeris/1.0 (flight-tracker; +https://github.com/kewonit/aeris)"; -let lastRequestTime = 0; -const RATE_MS = 800; - // trace[i] = [offset_sec, lat, lng, alt_ft|"ground"|null, gs, track, flags, vrate, ...] // flags bit 0 = stale +/** + * Trim waypoints to only the last flight leg. + * + * Finds the last ground→airborne transition (requiring at least + * `MIN_GROUND_FOR_SPLIT` consecutive ground points to avoid false + * triggers from GPS noise). Includes one ground waypoint before + * takeoff as a departure airport anchor so the trail visually + * starts at the runway. + * + * If no multi-point ground segment is found (single-leg flight or + * all-airborne trace), returns the input unchanged. + */ +const MIN_GROUND_FOR_SPLIT = 2; + +function trimToLastFlight(waypoints: TrackWaypoint[]): TrackWaypoint[] { + if (waypoints.length < 3) return waypoints; + + let lastTakeoffIdx = -1; + + for (let i = 1; i < waypoints.length; i++) { + if (!waypoints[i].onGround && waypoints[i - 1].onGround) { + // Count consecutive ground points before this transition + let groundCount = 0; + for (let j = i - 1; j >= 0; j--) { + if (waypoints[j].onGround) groundCount++; + else break; + } + if (groundCount >= MIN_GROUND_FOR_SPLIT) { + lastTakeoffIdx = i; + } + } + } + + if (lastTakeoffIdx <= 0) return waypoints; + + // Include one ground point before takeoff as departure anchor + const startIdx = Math.max(0, lastTakeoffIdx - 1); + return waypoints.slice(startIdx); +} + function parseReadsbTrace(hex: string, data: unknown): FlightTrack | null { if (typeof data !== "object" || data === null) return null; @@ -63,6 +100,33 @@ function parseReadsbTrace(hex: string, data: unknown): FlightTrack | null { } const cutoffOffset = latestOffset - MAX_AGE_SECONDS; + // ── Pre-scan: find the last new-leg marker (flags & 2) ───────── + // readsb sets this flag at the start of a new flight leg, which is + // the most reliable signal for detecting the last departure. + // See: https://github.com/wiedehopf/readsb/blob/dev/README-json.md + let lastNewLegOffset = -Infinity; + let hasNewLegFlag = false; + + for (const entry of rawTrace) { + if (!Array.isArray(entry) || entry.length < 7) continue; + const offset = typeof entry[0] === "number" ? entry[0] : null; + if (offset === null || !Number.isFinite(offset)) continue; + if (offset < cutoffOffset) continue; + const flags = typeof entry[6] === "number" ? entry[6] : 0; + if (flags & 1) continue; // skip stale + if (flags & 2) { + lastNewLegOffset = offset; + hasNewLegFlag = true; + } + } + + // Allow up to 90 seconds before the new-leg marker so that the + // departure airport position is included as an anchor point. + const NEW_LEG_ANCHOR_SEC = 90; + const legCutoff = hasNewLegFlag + ? lastNewLegOffset - NEW_LEG_ANCHOR_SEC + : -Infinity; + const waypoints: TrackWaypoint[] = []; for (const entry of rawTrace) { @@ -73,6 +137,9 @@ function parseReadsbTrace(hex: string, data: unknown): FlightTrack | null { if (offset < cutoffOffset) continue; + // Skip entries before the last flight leg + if (offset < legCutoff) continue; + const lat = typeof entry[1] === "number" ? entry[1] : null; const lng = typeof entry[2] === "number" ? entry[2] : null; if (lat === null || lng === null) continue; @@ -117,11 +184,22 @@ function parseReadsbTrace(hex: string, data: unknown): FlightTrack | null { waypoints.sort((a, b) => a.time - b.time); - const deduped: TrackWaypoint[] = [waypoints[0]]; - for (let i = 1; i < waypoints.length; i++) { + // If no new-leg flag was found, fall back to onGround detection + // to trim to the last flight leg. + const legTrimmed = hasNewLegFlag ? waypoints : trimToLastFlight(waypoints); + + const deduped: TrackWaypoint[] = [legTrimmed[0]]; + for (let i = 1; i < legTrimmed.length; i++) { const prev = deduped[deduped.length - 1]; - const curr = waypoints[i]; - if (prev.latitude === curr.latitude && prev.longitude === curr.longitude) { + const curr = legTrimmed[i]; + // Skip exact duplicates and near-duplicates (< ~30m apart) from GPS jitter. + const dlat = (curr.latitude ?? 0) - (prev.latitude ?? 0); + const dlng = (curr.longitude ?? 0) - (prev.longitude ?? 0); + if (dlat * dlat + dlng * dlng < 0.0003 * 0.0003) { + // Keep the later point if it has better altitude data. + if (curr.baroAltitude != null && prev.baroAltitude == null) { + deduped[deduped.length - 1] = curr; + } continue; } deduped.push(curr); @@ -213,12 +291,18 @@ function parseOpenSkyTrack(hex: string, data: unknown): FlightTrack | null { waypoints.sort((a, b) => a.time - b.time); + // Trim to the last flight leg using onGround detection + const legTrimmed = trimToLastFlight(waypoints); + const deduped: TrackWaypoint[] = []; let lastLng: number | null = null; let lastLat: number | null = null; - for (const p of waypoints) { + for (const p of legTrimmed) { if (lastLng !== null && lastLat !== null) { - if (p.longitude === lastLng && p.latitude === lastLat) continue; + // Skip exact duplicates and near-duplicates (< ~30m). + const dlat = (p.latitude ?? 0) - lastLat; + const dlng = (p.longitude ?? 0) - lastLng; + if (dlat * dlat + dlng * dlng < 0.0003 * 0.0003) continue; } deduped.push(p); lastLng = p.longitude; @@ -248,19 +332,6 @@ export async function GET(request: NextRequest): Promise { ); } - const now = Date.now(); - const elapsed = now - lastRequestTime; - lastRequestTime = now; - if (elapsed < RATE_MS) { - return NextResponse.json( - { error: "Rate limited" }, - { - status: 429, - headers: { "Cache-Control": "no-store", "Retry-After": "1" }, - }, - ); - } - const lastTwo = hex.slice(-2); const traceHeaders = (source: (typeof GLOBE_TRACE_SOURCES)[number]) => ({ @@ -271,33 +342,41 @@ export async function GET(request: NextRequest): Promise { }); for (const source of GLOBE_TRACE_SOURCES) { - try { - const fullUrl = `${source.baseUrl}/${lastTwo}/trace_full_${hex}.json`; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), TRACE_TIMEOUT_MS); + // Try trace_full first (complete flight history), then trace_recent + // as fallback (last ~few minutes, still useful for active flights). + const urlsToTry = [ + `${source.baseUrl}/${lastTwo}/trace_full_${hex}.json`, + `${source.baseUrl}/${lastTwo}/trace_recent_${hex}.json`, + ]; - const res = await fetch(fullUrl, { - signal: controller.signal, - headers: traceHeaders(source), - }); - clearTimeout(timer); + for (const traceUrl of urlsToTry) { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TRACE_TIMEOUT_MS); - if (res.ok) { - // Skip non-JSON responses (CloudFlare challenges, maintenance pages) - const ct = res.headers.get("content-type") ?? ""; - if (ct.includes("text/html") || ct.includes("text/xml")) continue; + const res = await fetch(traceUrl, { + signal: controller.signal, + headers: traceHeaders(source), + }); + clearTimeout(timer); - const data = (await res.json()) as unknown; - const track = parseReadsbTrace(hex, data); - if (track && track.path.length >= 2) { - return NextResponse.json( - { track, source: source.name }, - { headers: { "Cache-Control": "private, max-age=30" } }, - ); + if (res.ok) { + // Skip non-JSON responses (CloudFlare challenges, maintenance pages) + const ct = res.headers.get("content-type") ?? ""; + if (ct.includes("text/html") || ct.includes("text/xml")) continue; + + const data = (await res.json()) as unknown; + const track = parseReadsbTrace(hex, data); + if (track && track.path.length >= 2) { + return NextResponse.json( + { track, source: source.name }, + { headers: { "Cache-Control": "private, max-age=30" } }, + ); + } } + } catch { + // Next URL / source } - } catch { - // Next source } } diff --git a/src/app/api/weather-tiles/route.ts b/src/app/api/weather-tiles/route.ts new file mode 100644 index 0000000..a6aee65 --- /dev/null +++ b/src/app/api/weather-tiles/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from "next/server"; + +// ── RainViewer Weather Tile Proxy ────────────────────────────────────── +// +// Proxies radar tile requests to RainViewer's tile cache. This avoids +// CORS issues (MapLibre GL JS v5 loads raster tiles via fetch() in a +// web worker, which requires CORS headers that RainViewer doesn't send). +// +// Query params: ts (timestamp), z, x, y +// Upstream URL: +// https://tilecache.rainviewer.com/v2/radar/{ts}/256/{z}/{x}/{y}/2/1_1.png +// +// Tiles are cached by the browser (10 min max-age). +// ──────────────────────────────────────────────────────────────────────── + +const FETCH_TIMEOUT_MS = 8_000; +const VALID_COORD = /^[0-9]{1,3}$/; +const VALID_TIMESTAMP = /^[0-9]{8,12}$/; + +const TRANSPARENT_1x1_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQIHWNgAAIABQABNjN9GQAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAAA0lEQVQI12P4z8BQDwAEgAF/QualzQAAAABJRU5ErkJggg==", + "base64", +); + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + const ts = searchParams.get("ts"); + const z = searchParams.get("z"); + const x = searchParams.get("x"); + const y = searchParams.get("y"); + + if (!ts || !z || !x || !y) { + return NextResponse.json({ error: "Missing params" }, { status: 400 }); + } + + if ( + !VALID_TIMESTAMP.test(ts) || + !VALID_COORD.test(z) || + !VALID_COORD.test(x) || + !VALID_COORD.test(y) + ) { + return NextResponse.json({ error: "Invalid params" }, { status: 400 }); + } + + const url = `https://tilecache.rainviewer.com/v2/radar/${ts}/256/${z}/${x}/${y}/2/1_1.png`; + + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + const res = await fetch(url, { signal: controller.signal }); + clearTimeout(timer); + + if (!res.ok) { + // Return transparent 1x1 PNG for missing tiles (no data = no rain) + return new NextResponse(TRANSPARENT_1x1_PNG, { + status: 200, + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=600", + }, + }); + } + + const data = await res.arrayBuffer(); + + return new NextResponse(data, { + status: 200, + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=600", + }, + }); + } catch { + return new NextResponse(TRANSPARENT_1x1_PNG, { + status: 200, + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=60", + }, + }); + } +} diff --git a/src/app/api/weather/metar/route.ts b/src/app/api/weather/metar/route.ts new file mode 100644 index 0000000..596b977 --- /dev/null +++ b/src/app/api/weather/metar/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from "next/server"; + +// ── METAR Proxy ──────────────────────────────────────────────────────── +// +// Proxies METAR requests to NOAA Aviation Weather API. +// No API key required. Validates ICAO code to prevent SSRF. + +const NOAA_BASE = "https://aviationweather.gov/api/data/metar"; +const FETCH_TIMEOUT_MS = 8_000; + +/** Only allow 4-letter ICAO codes (uppercase alpha). */ +const VALID_ICAO = /^[A-Z]{4}$/; + +export async function GET(request: NextRequest): Promise { + const icao = request.nextUrl.searchParams.get("icao")?.trim().toUpperCase(); + + if (!icao || !VALID_ICAO.test(icao)) { + return NextResponse.json( + { error: "Invalid or missing 'icao' parameter (4-letter ICAO code)" }, + { status: 400, headers: { "Cache-Control": "no-store" } }, + ); + } + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + const url = `${NOAA_BASE}?ids=${encodeURIComponent(icao)}&format=json`; + const res = await fetch(url, { + signal: controller.signal, + headers: { Accept: "application/json" }, + }); + + clearTimeout(timeout); + + if (!res.ok) { + return NextResponse.json( + { error: `Upstream returned ${res.status}` }, + { status: 502, headers: { "Cache-Control": "no-store" } }, + ); + } + + const data = await res.json(); + + return NextResponse.json(data, { + headers: { + // METAR updates every 30-60 min; 10-min cache + stale-while-revalidate. + "Cache-Control": + "public, max-age=600, s-maxage=600, stale-while-revalidate=300", + }, + }); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + return NextResponse.json( + { error: "Upstream timeout" }, + { status: 504, headers: { "Cache-Control": "no-store" } }, + ); + } + return NextResponse.json( + { error: "Failed to fetch METAR" }, + { status: 502, headers: { "Cache-Control": "no-store" } }, + ); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 41da85b..a0054c9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,34 +2,100 @@ @custom-variant dark (&:is(.dark *)); +/* ── Light mode (default) ─────────────────────────────────────────── */ :root { - --background: 0 0% 0%; - --foreground: 0 0% 100%; - --muted: 0 0% 12%; - --muted-foreground: 0 0% 60%; - --border: 0 0% 14%; - --ring: 0 0% 30%; --radius: 0.625rem; + --background: oklch(0.985 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(0.99 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.965 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.965 0 0); + --muted-foreground: oklch(0.45 0 0); + --accent: oklch(0.965 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.985 0 0); + --border: oklch(0.9 0 0); + --input: oklch(0.9 0 0); + --ring: oklch(0.708 0 0); + /* RGB triplets for inline style backward compat */ + --ui-fg: 0 0 0; + --ui-bg: 255 255 255; +} + +/* ── Dark mode ────────────────────────────────────────────────────── */ +.dark { + --background: oklch(0 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.1 0.002 260); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.055 0.004 270); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.18 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.18 0 0); + --muted-foreground: oklch(0.65 0 0); + --accent: oklch(0.18 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --destructive-foreground: oklch(0.985 0 0); + --border: oklch(1 0 0 / 8%); + --input: oklch(1 0 0 / 12%); + --ring: oklch(0.45 0 0); + /* RGB triplets for inline style backward compat */ + --ui-fg: 255 255 255; + --ui-bg: 0 0 0; } @theme inline { - --color-background: hsl(var(--background)); - --color-foreground: hsl(var(--foreground)); - --color-muted: hsl(var(--muted)); - --color-muted-foreground: hsl(var(--muted-foreground)); - --color-border: hsl(var(--border)); - --color-ring: hsl(var(--ring)); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); --radius: var(--radius); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif; --font-mono: "SF Mono", ui-monospace, monospace; } -* { - border-color: hsl(var(--border)); +@layer base { + * { + border-color: var(--border); + } + body { + background: var(--background); + color: var(--foreground); + } } :focus-visible { - outline: 2px solid hsl(var(--ring)); + outline: 2px solid var(--ring); outline-offset: 2px; } @@ -40,8 +106,6 @@ body { margin: 0; padding: 0; overflow: hidden; - background: hsl(var(--background)); - color: hsl(var(--foreground)); } /* Custom attribution component replaces built-in controls */ @@ -53,16 +117,6 @@ body { display: none !important; } -[data-map-theme="dark"] { - --ui-fg: 255 255 255; - --ui-bg: 0 0 0; -} - -[data-map-theme="light"] { - --ui-fg: 0 0 0; - --ui-bg: 255 255 255; -} - .scrollbar-none::-webkit-scrollbar { display: none; } @@ -96,7 +150,7 @@ body { font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; - color: rgb(255 255 255 / 0.15); + color: rgb(var(--ui-fg) / 0.15); user-select: none; } @@ -126,7 +180,7 @@ body { } .aeris-cmdk .search-item[data-selected="true"] { - background: rgb(255 255 255 / 0.05); + background: rgb(var(--ui-fg) / 0.05); } .aeris-cmdk @@ -141,5 +195,5 @@ body { } .aeris-cmdk .search-item:active:not([data-disabled="true"]) { - background: rgb(255 255 255 / 0.07); + background: rgb(var(--ui-fg) / 0.07); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a30e9f4..36bb8e2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import Script from "next/script"; import { Toaster } from "sonner"; +import { ThemeProvider } from "@/components/theme-provider"; import "./globals.css"; const inter = Inter({ @@ -94,7 +95,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - {children} - + + {children} + + ); diff --git a/src/components/error-boundary.tsx b/src/components/error-boundary.tsx index cb0327c..f7071f0 100644 --- a/src/components/error-boundary.tsx +++ b/src/components/error-boundary.tsx @@ -23,15 +23,15 @@ export class ErrorBoundary extends Component { return (

Something went wrong

-

+

{this.state.error.message}

diff --git a/src/components/flight-tracker-brand.tsx b/src/components/flight-tracker-brand.tsx index 1f5ab55..a732293 100644 --- a/src/components/flight-tracker-brand.tsx +++ b/src/components/flight-tracker-brand.tsx @@ -6,13 +6,9 @@ import { formatStarCount, } from "@/components/flight-tracker-utils"; -export function Brand({ isDark }: { isDark: boolean }) { +export function Brand({ isDark: _isDark }: { isDark: boolean }) { return ( - + aeris ); diff --git a/src/components/flight-tracker.tsx b/src/components/flight-tracker.tsx index c99676e..46a5725 100644 --- a/src/components/flight-tracker.tsx +++ b/src/components/flight-tracker.tsx @@ -8,14 +8,20 @@ import { useRef, useSyncExternalStore, } from "react"; -import { AnimatePresence } from "motion/react"; +import { AnimatePresence, motion } from "motion/react"; import dynamic from "next/dynamic"; +import { useTheme } from "next-themes"; import { ErrorBoundary } from "@/components/error-boundary"; import { Map as MapView } from "@/components/map/map"; import { CameraController } from "@/components/map/camera-controller"; import { AirportLayer } from "@/components/map/airport-layer"; import { AirspaceLayer } from "@/components/map/airspace-layer"; +import { WeatherRadarLayer } from "@/components/map/weather-radar-layer"; import { FlightLayers } from "@/components/map/flight-layers"; +import { + MapStateTracker, + type MapViewState, +} from "@/components/map/map-state-tracker"; const FlightCard = dynamic(() => import("@/components/ui/flight-card").then((mod) => mod.FlightCard), ); @@ -28,6 +34,9 @@ import { CameraControls } from "@/components/ui/camera-controls"; import { StatusBar } from "@/components/ui/status-bar"; import { MapAttribution } from "@/components/ui/map-attribution"; import { AtcPlayerBar } from "@/components/ui/atc-panel"; +const AirportBoard = dynamic(() => + import("@/components/ui/airport-board").then((mod) => mod.AirportBoard), +); import { Brand, GitHubBadge } from "@/components/flight-tracker-brand"; import { SettingsProvider, useSettings } from "@/hooks/use-settings"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; @@ -38,11 +47,12 @@ import { useMergedTrails } from "@/hooks/use-merged-trails"; import { useFlightMonitors } from "@/hooks/use-flight-monitors"; import { useAtcStream } from "@/hooks/use-atc-stream"; import { useIsMobile } from "@/hooks/use-is-mobile"; +import { useAirportBoard } from "@/hooks/use-airport-board"; import { MobileFlightToast } from "@/components/ui/mobile-flight-toast"; -import { toast } from "sonner"; import type { MapStyle } from "@/lib/map-styles"; import type { City } from "@/lib/cities"; import type { FlightState } from "@/lib/opensky"; + import { fetchFlightByHex, fetchFlightByCallsign } from "@/lib/flight-api"; import { formatCallsign } from "@/lib/flight-utils"; import type { PickingInfo } from "@deck.gl/core"; @@ -103,6 +113,12 @@ function FlightTrackerInner() { const activeCity = cityOverride ?? hydratedCity; const mapStyle = styleOverride ?? hydratedStyle; const { settings, update } = useSettings(); + const { setTheme } = useTheme(); + + // Sync document theme with current map style (dark/light) + useEffect(() => { + setTheme(mapStyle.dark ? "dark" : "light"); + }, [mapStyle.dark, setTheme]); const setActiveCity = useCallback((city: City) => { setCityOverride(city); @@ -112,6 +128,16 @@ function FlightTrackerInner() { syncCityToUrl(city); }, []); + /** Called when user clicks an airport dot on the map — navigates AND opens the board. */ + const handleAirportDotClick = useCallback((city: City) => { + setCityOverride(city); + setSelectedIcao24(null); + setFpvIcao24(null); + setFollowIcao24(null); + syncCityToUrl(city); + setSelectedAirportIata(city.iata); + }, []); + const setMapStyle = useCallback((style: MapStyle) => { setStyleOverride(style); saveMapStyle(style); @@ -133,15 +159,7 @@ function FlightTrackerInner() { return m; }, [displayFlights]); - const selectedFlightForTrack = useMemo(() => { - if (!selectedIcao24) return null; - return displayFlightMap.get(selectedIcao24) ?? null; - }, [selectedIcao24, displayFlightMap]); - - const shouldFetchSelectedTrack = - !!selectedIcao24 && - !fpvIcao24 && - !(selectedFlightForTrack?.onGround ?? false); + const shouldFetchSelectedTrack = !!selectedIcao24 && !fpvIcao24; const { track: selectedTrack, fetchedAtMs: selectedTrackFetchedAtMs } = useFlightTrack(selectedIcao24, { @@ -161,6 +179,11 @@ function FlightTrackerInner() { return displayFlightMap.get(selectedIcao24) ?? null; }, [selectedIcao24, displayFlightMap]); + const selectedTrail = useMemo(() => { + if (!selectedIcao24) return null; + return mergedTrails.find((t) => t.icao24 === selectedIcao24) ?? null; + }, [selectedIcao24, mergedTrails]); + const followFlight = useMemo(() => { if (!followIcao24) return null; return displayFlightMap.get(followIcao24) ?? null; @@ -198,6 +221,39 @@ function FlightTrackerInner() { const fpvFlightOrCached = fpvFlight; const displayFlight = selectedFlight; + // ── Airport Board state ────────────────────────────────────────────── + const mapStateRef = useRef({ + zoom: 9.2, + center: { lat: 0, lng: 0 }, + }); + const [mapViewState, setMapViewState] = useState({ + zoom: 9.2, + center: { lat: 0, lng: 0 }, + }); + const [selectedAirportIata, setSelectedAirportIata] = useState( + null, + ); + + const handleMapStateChange = useCallback((state: MapViewState) => { + setMapViewState(state); + }, []); + + const airportBoard = useAirportBoard( + displayFlights, + mapViewState.center, + mapViewState.zoom, + activeCity.iata, + selectedAirportIata, + ); + + const handleAirportBoardSelect = useCallback((icao24: string) => { + setSelectedIcao24((prev) => (prev === icao24 ? null : icao24)); + }, []); + + const handleAirportBoardClose = useCallback(() => { + setSelectedAirportIata(null); + }, []); + const [atcToggle, setAtcToggle] = useState(0); const handleToggleAtc = useCallback(() => { setAtcToggle((c) => c + 1); @@ -370,74 +426,12 @@ function FlightTrackerInner() { }); const isMobile = useIsMobile(); - const mobileToastIdRef = useRef(null); - // Stable close handler that both dismisses the toast and deselects the flight - const handleMobileToastClose = useCallback(() => { - if (mobileToastIdRef.current !== null) { - toast.dismiss(mobileToastIdRef.current); - mobileToastIdRef.current = null; - } - handleDeselectFlight(); - }, [handleDeselectFlight]); - - // Show/dismiss mobile flight toast - useEffect(() => { - // Dismiss when not applicable - if (!isMobile || fpvIcao24 || !displayFlight) { - if (mobileToastIdRef.current !== null) { - toast.dismiss(mobileToastIdRef.current); - mobileToastIdRef.current = null; - } - return; - } - - // Use a stable ID based on the selected flight - const stableId = `mobile-flight-${displayFlight.icao24}`; - - // If switching to a different flight, dismiss the old toast first - if ( - mobileToastIdRef.current !== null && - mobileToastIdRef.current !== stableId - ) { - toast.dismiss(mobileToastIdRef.current); - } - - toast.custom( - () => ( - - ), - { - id: stableId, - duration: Infinity, - dismissible: false, - }, - ); - mobileToastIdRef.current = stableId; - }, [ - isMobile, - displayFlight, - fpvIcao24, - handleMobileToastClose, - handleToggleFpv, - ]); - - // Cleanup on unmount - useEffect(() => { - return () => { - if (mobileToastIdRef.current !== null) { - toast.dismiss(mobileToastIdRef.current); - } - }; - }, []); + // Whether to show the mobile bottom sheet flight card + const showMobileFlightCard = isMobile && !fpvIcao24 && !!displayFlight; return ( -
+
+ + -
+
{!fpvIcao24 && (
@@ -490,6 +489,7 @@ function FlightTrackerInner() {
)} + + {/* Airport Departure/Arrival Board — hide on mobile when flight card is open */} + {!fpvIcao24 && !showMobileFlightCard && ( + + {airportBoard.isActive && ( +
+ +
+ )} +
+ )} + + {/* Mobile flight card — native bottom sheet with drag-to-dismiss */} + + {showMobileFlightCard && displayFlight && ( + { + if (info.offset.y > 80 || info.velocity.y > 300) { + handleDeselectFlight(); + } + }} + > + + + )} +
diff --git a/src/components/map/aircraft-appearance.ts b/src/components/map/aircraft-appearance.ts index 5a4d96e..dcdf221 100644 --- a/src/components/map/aircraft-appearance.ts +++ b/src/components/map/aircraft-appearance.ts @@ -58,10 +58,35 @@ export function tintAircraftColor( ]; } +/** Apply military (amber) or emergency (red) tint on top of normal color. */ +export function applySpecialTint( + color: [number, number, number, number], + dbFlags?: number | null, + emergencyStatus?: string | null, +): [number, number, number, number] { + // Emergency overrides military + if (emergencyStatus && emergencyStatus !== "none") { + return [ + Math.round(color[0] * 0.3 + 255 * 0.7), + Math.round(color[1] * 0.3 + 60 * 0.7), + Math.round(color[2] * 0.3 + 60 * 0.7), + color[3], + ]; + } + if (((dbFlags ?? 0) & 1) !== 0) { + return [ + Math.round(color[0] * 0.4 + 255 * 0.6), + Math.round(color[1] * 0.4 + 190 * 0.6), + Math.round(color[2] * 0.4 + 80 * 0.6), + color[3], + ]; + } + return color; +} + // ── Selection pulse timing ───────────────────────────────────────────── -export const PULSE_PERIOD_MS = 7000; -export const RING_PERIOD_MS = 5500; +export const PULSE_PERIOD_MS = 9000; // ── Canvas Atlas Generators ──────────────────────────────────────────── @@ -76,16 +101,18 @@ export function createHaloAtlas(): HTMLCanvasElement { for (let r = 0; r < c; r++) { const norm = r / c; let alpha = 0; - if (norm < 0.18) { + if (norm < 0.4) { + // Large clear center — no glow within ~40% of radius so it never + // overlaps the aircraft icon even at the largest category size. 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; + const t = (norm - 0.4) / 0.15; + alpha = t * t * 0.4; + } else if (norm < 0.72) { + alpha = 0.4 - ((norm - 0.55) / 0.17) * 0.15; } else { - const t = (norm - 0.55) / 0.45; - alpha = 0.4 * (1 - t) * (1 - t); + const t = (norm - 0.72) / 0.28; + alpha = 0.25 * (1 - t) * (1 - t); } if (alpha < 0.003) continue; ctx.strokeStyle = `rgba(255,255,255,${alpha})`; diff --git a/src/components/map/aircraft-model-layers.ts b/src/components/map/aircraft-model-layers.ts index be50511..6c8fb4a 100644 --- a/src/components/map/aircraft-model-layers.ts +++ b/src/components/map/aircraft-model-layers.ts @@ -18,10 +18,7 @@ import { ScenegraphLayer } from "@deck.gl/mesh-layers"; import type { FlightState } from "@/lib/opensky"; import { altitudeToColor, altitudeToElevation } from "@/lib/flight-utils"; -import { - categorySizeMultiplier, - tintAircraftColor, -} from "./aircraft-appearance"; +import { tintAircraftColor, applySpecialTint } from "./aircraft-appearance"; import { type PickingInfo } from "@deck.gl/core"; import { AIRCRAFT_MIN_PIXELS, @@ -77,7 +74,7 @@ export interface AircraftLayerParams { * between animation frames). Accessors look up interpolated positions from * the `interpolatedMap`. `updateTriggers` selectively recompute: * - getPosition / getOrientation: every frame (via frameCounter) - * - getColor / getScale: only on new data (via dataVersion) + * - getColor: only on new data (via dataVersion) * * This eliminates per-frame color/scale attribute recomputation for all * 14 layers and massively reduces GC pressure from array allocations. @@ -155,20 +152,18 @@ export function buildAircraftModelLayers( }, getColor: (d) => { const base = altColors ? altitudeToColor(d.baroAltitude) : defaultColor; - return tintAircraftColor(base, d.category); + const catColor = tintAircraftColor(base, d.category); + return applySpecialTint(catColor, d.dbFlags, d.emergencyStatus); }, scenegraph: modelUrl(modelKey), - getScale: (d) => { - const catScale = categorySizeMultiplier(d.category); - const s = catScale * normScale; - return [s, s, s]; + getScale: () => { + return [normScale, normScale, normScale]; }, sizeScale: BASE_AIRCRAFT_SIZE, updateTriggers: { getPosition: [frameCounter, elevScale], getOrientation: frameCounter, getColor: [dataVersion, altColors], - getScale: dataVersion, }, sizeMinPixels: AIRCRAFT_MIN_PIXELS, sizeMaxPixels: AIRCRAFT_MAX_PIXELS, diff --git a/src/components/map/aircraft-model-mapping.ts b/src/components/map/aircraft-model-mapping.ts index e0f16c8..9b75097 100644 --- a/src/components/map/aircraft-model-mapping.ts +++ b/src/components/map/aircraft-model-mapping.ts @@ -82,23 +82,43 @@ export function modelUrl(key: AircraftModelKey): string { // ── Per-Model Size Normalization ─────────────────────────────────────── // -// Factors normalize all models to a consistent visual base (~40 units). -// categorySizeMultiplier in aircraft-appearance.ts adds per-category scaling. +// Each factor combines TWO concerns: +// +// 1. **Mesh equalisation** — raw GLBs have wildly different coordinate +// scales. The base factor brings every silhouette to a common +// reference size (~40 internal units). +// +// 2. **Realistic wingspan proportion** — a √(wingspan / 36 m) multiplier +// derived from real ICAO Doc 8643 representative wingspans. Square- +// root compression keeps the visual range manageable (~4×) while +// preserving clear differentiation between light GA, business jets, +// narrowbodies, widebodies, and the A380. +// +// Representative wingspans used (metres): +// light-prop 11 (C172) fighter 11 (F-16 / Eurofighter) +// helicopter 11 (H145 rotor) glider 18 (ASG 29) +// bizjet 24 (avg G550/CX)regional 25 (CRJ-900 / E175) +// turboprop 27 (ATR 72) drone 5 (small UAV) +// narrowbody 36 (A320) B737 36 (B737-800) +// widebody-2 65 (B777-300ER) widebody-4 64 (B747-400) +// A380 80 (A380-800) +// +// Formula per key: meshEqualise × √(wingspan / 36) const MODEL_NORMALIZE: Readonly> = { - a380: 0.42, + a380: 0.46, b737: 0.55, narrowbody: 1.0, - "widebody-2eng": 0.85, - "widebody-4eng": 0.42, - "regional-jet": 1.0, - "light-prop": 2.8, - turboprop: 0.9, - helicopter: 2.2, - bizjet: 2.2, - glider: 2.0, - fighter: 2.8, - drone: 2.8, + "widebody-2eng": 0.89, + "widebody-4eng": 0.44, + "regional-jet": 0.85, + "light-prop": 1.86, + turboprop: 0.8, + helicopter: 1.44, + bizjet: 1.73, + glider: 1.56, + fighter: 1.86, + drone: 1.43, generic: 1.0, }; diff --git a/src/components/map/camera-controller.tsx b/src/components/map/camera-controller.tsx index 3db0e76..2e710e7 100644 --- a/src/components/map/camera-controller.tsx +++ b/src/components/map/camera-controller.tsx @@ -15,7 +15,7 @@ const DEFAULT_PITCH = 49; const DEFAULT_BEARING = 27.4; const FOLLOW_ZOOM = 10.5; const FOLLOW_PITCH = 55; -const FOLLOW_EASE_MS = 1200; +const FOLLOW_EASE_MS = 2000; type FpvPosition = { lng: number; lat: number; alt: number; track: number }; diff --git a/src/components/map/flight-animation-helpers.ts b/src/components/map/flight-animation-helpers.ts index b5b12ec..46d2453 100644 --- a/src/components/map/flight-animation-helpers.ts +++ b/src/components/map/flight-animation-helpers.ts @@ -3,8 +3,10 @@ import type { TrailEntry } from "@/hooks/use-trail-history"; import { snapLngToReference, unwrapLngPath } from "@/lib/geo"; import { removeSpikePoints, + removeDistanceOutliers, roundSharpCorners3D, catmullRomSpline3D, + removePathLoops, } from "@/lib/trail-smoothing"; import type { ElevatedPoint, Snapshot } from "./flight-layer-constants"; import { @@ -19,10 +21,11 @@ import { 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! > 0 ? f.velocity! : 200; + if (f.trueTrack == null || !Number.isFinite(f.trueTrack)) return []; + if (f.velocity == null || !Number.isFinite(f.velocity) || f.velocity <= 0) + return []; + const heading = (f.trueTrack * Math.PI) / 180; + const speed = f.velocity; const degPerSecond = speed / 111_320; const path: [number, number][] = []; @@ -168,19 +171,43 @@ export function smoothElevatedPath( // ── Altitude Smoothing ───────────────────────────────────────────────── /** - * 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. + * Multi-pass altitude smoothing with outlier pre-filtering and a wider + * kernel to prevent near-vertical "wall" artifacts on climb/descent trails. */ export function smoothAnimationAltitudes( values: number[], passes: number = 3, ): number[] { - if (values.length < 3 || passes <= 0) return values; + if (values.length < 2 || passes <= 0) return values; - let result = values; + // For 2 points, apply a gentle blend toward the mean to reduce the + // visual snap when the 3rd point arrives and full smoothing kicks in. + if (values.length === 2) { + const mean = (values[0] + values[1]) * 0.5; + return [values[0] * 0.85 + mean * 0.15, values[1] * 0.85 + mean * 0.15]; + } + + // Pre-pass: reject altitude spikes (>800m from local median). + const SPIKE_THRESHOLD = 800; + let result = [...values]; + if (result.length >= 5) { + for (let i = 2; i < result.length - 2; i++) { + const window = [ + result[i - 2], + result[i - 1], + result[i], + result[i + 1], + result[i + 2], + ]; + const sorted = [...window].sort((a, b) => a - b); + const med = sorted[2]; + if (Math.abs(result[i] - med) > SPIKE_THRESHOLD) { + result[i] = (result[i - 1] + result[i + 1]) / 2; + } + } + } + + // Main smoothing passes for (let p = 0; p < passes; p++) { const next = [...result]; for (let i = 1; i < result.length - 1; i++) { @@ -204,7 +231,12 @@ export function trimPathAheadOfAircraft( let bestIndex = points.length - 2; let bestDistanceSq = Number.POSITIVE_INFINITY; - const searchStart = Math.max(0, Math.floor(points.length * 0.9)); + + // Search only the last 15% (min 12) to prevent clip-point jump-backs. + const searchStart = Math.max( + 0, + points.length - Math.max(12, Math.ceil(points.length * 0.15)), + ); for (let i = searchStart; i < points.length - 1; i++) { const a = points[i]; @@ -249,7 +281,8 @@ export function trimPathAheadOfAircraft( const dot = hLen > 1e-10 ? (hdx * dx + hdy * dy) / (hLen * dist) : 0; // Scale lever by alignment: 0 when perpendicular/behind (no loop), // up to 0.4 when heading straight at the aircraft (smooth arc). - const lever = Math.max(0, dot) * 0.4; + const lever = + Math.max(0, dot) * Math.min(0.3, 0.4 * Math.min(1, dist / 0.01)); const ux = hLen > 1e-10 ? hdx / hLen : 0; const uy = hLen > 1e-10 ? hdy / hLen : 0; const cx = lastPt[0] + ux * dist * lever; @@ -373,18 +406,28 @@ export function buildTrailBasePath( Math.max(0, altitudeMeters[i] ?? trail.baroAltitude ?? 0), ] as ElevatedPoint, ); - return elevated.length >= 3 ? roundSharpCorners3D(elevated, 15) : elevated; + if (elevated.length >= 3) { + const rounded = roundSharpCorners3D(elevated, 15); + return removePathLoops(rounded); + } + return elevated; } - // Active trails: remove GPS glitches (V-spikes), smooth positions to - // reduce measurement noise, smooth altitudes, then apply Catmull-Rom - // spline for consistent visual smoothness with historical trails. - const spikeResult = removeSpikePoints(pathSlice, altitudeSlice); + // Active trails: remove GPS glitches (distance outliers + V-spikes), + // smooth positions to reduce measurement noise, smooth altitudes, then + // apply Catmull-Rom spline for consistent visual smoothness. - // Pre-smooth 2D positions: 5 passes of a 0.25/0.5/0.25 kernel removes - // GPS measurement jitter (~10-20m noise) while preserving the overall - // path shape. Without this, the interpolating Catmull-Rom spline would - // amplify noise into visible oscillations between control points. + // Step 1: Remove distance outliers — catches random GPS/MLAT points + // that deviate far from the local path trend. + const outlierResult = removeDistanceOutliers(pathSlice, altitudeSlice, 3.0); + + // Step 2: Remove V-shaped direction-reversal spikes. + const spikeResult = removeSpikePoints( + outlierResult.path, + outlierResult.altitudes, + ); + + // Pre-smooth 2D positions to reduce GPS jitter before spline interpolation. let smoothedPath = spikeResult.path; if (smoothedPath.length >= 3) { for (let pass = 0; pass < 5; pass++) { @@ -416,12 +459,10 @@ export function buildTrailBasePath( ]); if (elevated.length >= 2) { - // Round sharp corners (>15° heading change) before spline to remove - // GPS-noise kinks and tight arcs at genuine turns. const rounded = roundSharpCorners3D(elevated, 15); - // Moderate density (5-14 pts/seg) produces smooth curves without - // the point bloat that higher density would cause across 200+ trails. - return catmullRomSpline3D(rounded, 5, 14); + const splined = catmullRomSpline3D(rounded, 5, 14); + // Remove self-intersecting loops from spline overshoot. + return removePathLoops(splined); } return elevated; } diff --git a/src/components/map/flight-layer-builders.ts b/src/components/map/flight-layer-builders.ts index 15a5d26..9e97556 100644 --- a/src/components/map/flight-layer-builders.ts +++ b/src/components/map/flight-layer-builders.ts @@ -10,7 +10,6 @@ import { } from "./flight-layer-constants"; import { PULSE_PERIOD_MS, - RING_PERIOD_MS, HALO_MAPPING, RING_MAPPING, } from "./aircraft-appearance"; @@ -71,7 +70,8 @@ function limitTrailSlope( 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)]; + const avg = (fwd[i] + bwd[i]) / 2; + return [p[0], p[1], Math.max(0, Number.isFinite(avg) ? avg : p[2])]; }); } @@ -266,7 +266,15 @@ export function buildTrailLayers(params: TrailLayerParams) { ), ] as [number, number, number], ); - const result = limitTrailSlope(raw); + // Final NaN defense: filter out any invalid coordinates before + // passing to PathLayer to prevent WebGL rendering errors. + const clean = raw.filter( + (p) => + Number.isFinite(p[0]) && + Number.isFinite(p[1]) && + Number.isFinite(p[2]), + ); + const result = limitTrailSlope(clean); trailPathCache?.set(d.icao24, { key: pathKey, result }); return result; }, @@ -275,7 +283,9 @@ export function buildTrailLayers(params: TrailLayerParams) { const visiblePoints = getVisibleTrailPoints(d, animFlight); const len = visiblePoints.length; - const colorKey = `${len}_${altColors}_${d.fullHistory ?? false}_${d.baroAltitude != null ? Math.round(d.baroAltitude / 200) : "n"}`; + // Use floor with a 500m bucket to avoid cache key flicker at + // round-number boundaries (Math.round toggles at exact midpoints). + const colorKey = `${len}_${altColors}_${d.fullHistory ?? false}_${d.baroAltitude != null ? Math.floor(d.baroAltitude / 500) : "n"}`; if (trailColorCache) { const cached = trailColorCache.get(d.icao24); if (cached && cached.key === colorKey) return cached.result; @@ -361,7 +371,7 @@ export function buildSelectionPulseLayers( } // Build stable layers for both "sel" and "prev" prefixes. - // Always emit all 8 IDs; use `visible` to toggle rather than omitting layers. + // Always emit all 4 IDs; use `visible` to toggle rather than omitting layers. const prefixes = ["sel", "prev"] as const; for (const prefix of prefixes) { const isSelected = prefix === "sel"; @@ -387,8 +397,11 @@ export function buildSelectionPulseLayers( const breath = Math.sin(breathT * Math.PI * 2); const softBreath = smoothStep(smoothStep((breath + 1) / 2)) * 2 - 1; - const haloSize = 90 + 10 * softBreath; - const haloAlpha = Math.round((22 + 10 * softBreath) * op); + // Subtle background glow — barely visible, provides soft ambient light. + // At 86px with 40% clear center: clear zone = 17px radius, well outside + // the largest aircraft icon (~12px radius). + const haloSize = 86 + 3 * softBreath; + const haloAlpha = Math.round((10 + 4 * softBreath) * op); layers.push( new IconLayer({ @@ -409,34 +422,34 @@ export function buildSelectionPulseLayers( }), ); - 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 = 35 + 70 * eased; - const fade = 1 - t; - const ringAlpha = Math.round(80 * fade * fade * fade * fade * op); + // Single clean ring that gently breathes in size and opacity. + // No expansion animation — just a calm, static indicator. + // At 68px, ring inner edge = 0.57 * 34 = 19px — clears the aircraft. + const ringBreathT = + ((elapsed + PULSE_PERIOD_MS * 0.25) % PULSE_PERIOD_MS) / PULSE_PERIOD_MS; + const ringBreath = Math.sin(ringBreathT * Math.PI * 2); + const softRingBreath = smoothStep(smoothStep((ringBreath + 1) / 2)) * 2 - 1; + const ringSize = 68 + 3 * softRingBreath; + const ringAlpha = Math.round((30 + 10 * softRingBreath) * op); - layers.push( - new IconLayer({ - id: `${prefix}-ring-${i}`, - pickable: false, - 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, - }), - ); - }); + layers.push( + new IconLayer({ + id: `${prefix}-ring-0`, + pickable: false, + 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-layers.tsx b/src/components/map/flight-layers.tsx index d18f073..95569e1 100644 --- a/src/components/map/flight-layers.tsx +++ b/src/components/map/flight-layers.tsx @@ -32,6 +32,7 @@ import { import { categorySizeMultiplier, tintAircraftColor, + applySpecialTint, AIRCRAFT_ICON_MAPPING, getHaloUrl, getRingUrl, @@ -691,7 +692,7 @@ export function FlightLayers({ // Selection pulse layers (halo + rings) — skip entirely when // nothing is selected and no fade-out is in progress. Saves - // constructing 8 IconLayer objects + deck.gl diffing per frame. + // constructing 4 IconLayer objects + deck.gl diffing per frame. if (selectedIcao24Ref.current || prevSelectedRef.current) { const pulseResult = buildSelectionPulseLayers({ selectionChangeTime: selectionChangeTimeRef.current, @@ -763,7 +764,8 @@ export function FlightLayers({ const base = altColors ? altitudeToColor(d.baroAltitude) : DEFAULT_COLOR; - return tintAircraftColor(base, d.category); + const catColor = tintAircraftColor(base, d.category); + return applySpecialTint(catColor, d.dbFlags, d.emergencyStatus); }, getAngle: (d) => 360 - (Number.isFinite(d.trueTrack) ? d.trueTrack! : 0), diff --git a/src/components/map/map-state-tracker.tsx b/src/components/map/map-state-tracker.tsx new file mode 100644 index 0000000..4ef183c --- /dev/null +++ b/src/components/map/map-state-tracker.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useMap } from "@/components/map/map"; + +export type MapViewState = { + zoom: number; + center: { lat: number; lng: number }; +}; + +type MapStateTrackerProps = { + /** Mutable ref updated on every moveend — avoids re-renders. */ + stateRef: React.MutableRefObject; + /** Optional callback on state change (throttled internally). */ + onChange?: (state: MapViewState) => void; +}; + +/** + * Invisible component that sits inside and tracks zoom + center. + * Updates a parent-owned ref (zero re-renders) and optionally calls onChange. + */ +export function MapStateTracker({ stateRef, onChange }: MapStateTrackerProps) { + const { map, isLoaded } = useMap(); + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + useEffect(() => { + if (!map || !isLoaded) return; + + function update() { + if (!map) return; + const center = map.getCenter(); + const zoom = map.getZoom(); + const next: MapViewState = { + zoom, + center: { lat: center.lat, lng: center.lng }, + }; + stateRef.current = next; + onChangeRef.current?.(next); + } + + // Seed initial state + update(); + + map.on("moveend", update); + map.on("zoomend", update); + + return () => { + map.off("moveend", update); + map.off("zoomend", update); + }; + }, [map, isLoaded, stateRef]); + + return null; +} diff --git a/src/components/map/use-fpv-camera.ts b/src/components/map/use-fpv-camera.ts index 0e1dccb..b9bbad3 100644 --- a/src/components/map/use-fpv-camera.ts +++ b/src/components/map/use-fpv-camera.ts @@ -20,11 +20,11 @@ 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_CENTER_ALPHA = 0.09; +const FPV_BEARING_ALPHA = 0.06; +const FPV_ZOOM_ALPHA = 0.03; const FPV_IDLE_RECENTER_MS = 1200; -const FPV_EASE_IN_MS = 600; +const FPV_EASE_IN_MS = 1000; type FpvPosition = { lng: number; lat: number; alt: number; track: number }; @@ -201,8 +201,10 @@ export function useFpvCamera( const liveBearing = posTrack !== null && Number.isFinite(posTrack) ? posTrack : prevBearing; + // Update prevBearing to track live heading (used as fallback when + // tracking strength is zero and for tab-resume reset). const bearingDelta = ((liveBearing - prevBearing + 540) % 360) - 180; - prevBearing = prevBearing + bearingDelta * FPV_BEARING_ALPHA; + prevBearing = prevBearing + bearingDelta * 0.15; if (trackingStrength > 0.001) { const safeAlt = Number.isFinite(posAlt) ? posAlt : 5000; @@ -232,26 +234,28 @@ export function useFpvCamera( if (deltaPx) { const desiredX = fpvOffsetX - deltaPx.dx; const desiredY = fpvOffsetY - deltaPx.dy; - const offsetAlpha = 0.08 * trackingStrength; + const offsetAlpha = 0.05 * trackingStrength; fpvOffsetX = lerp(fpvOffsetX, desiredX, offsetAlpha); fpvOffsetY = lerp(fpvOffsetY, desiredY, offsetAlpha); } else { - const decayAlpha = 0.1 * trackingStrength; + const decayAlpha = 0.06 * 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); + const maxOffset = 0.25 * maxScale * Math.min(canvasW, canvasH); fpvOffsetX = Math.max(-maxOffset, Math.min(maxOffset, fpvOffsetX)); fpvOffsetY = Math.max(-maxOffset, Math.min(maxOffset, fpvOffsetY)); + // Single-level bearing interpolation — lerp map bearing directly + // toward the live heading. Avoids the double-smoothing oscillation + // that occurred when prevBearing was intermediated separately. const currentBearing = map.getBearing(); - const bearingToCurrent = - ((prevBearing - currentBearing + 540) % 360) - 180; + const bearingToLive = + ((liveBearing - currentBearing + 540) % 360) - 180; const newMapBearing = - currentBearing + - bearingToCurrent * FPV_BEARING_ALPHA * trackingStrength; + currentBearing + bearingToLive * FPV_BEARING_ALPHA * trackingStrength; const pitchAlpha = 0.05 * trackingStrength; const newPitch = lerp(currentPitch, FPV_PITCH, pitchAlpha); diff --git a/src/components/map/weather-radar-layer.tsx b/src/components/map/weather-radar-layer.tsx new file mode 100644 index 0000000..ee3b1b0 --- /dev/null +++ b/src/components/map/weather-radar-layer.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { useEffect, useRef, useCallback } from "react"; +import { useMap } from "./map"; + +const RAINVIEWER_API = "https://api.rainviewer.com/public/weather-maps.json"; +const REFRESH_INTERVAL_MS = 10 * 60_000; // 10 minutes +const SOURCE_ID = "rainviewer-radar"; +const LAYER_ID = "rainviewer-radar-layer"; + +// RainViewer tiles are only available up to zoom level 7. +// MapLibre will over-zoom level 7 tiles for higher zoom levels. +const RAINVIEWER_MAX_ZOOM = 7; + +/** Build tile URL via our server proxy (avoids CORS issues with RainViewer). */ +function proxyTileUrl(timestamp: number): string { + return `/api/weather-tiles?ts=${timestamp}&z={z}&x={x}&y={y}`; +} + +type RainViewerFrame = { time: number; path: string }; +type RainViewerResponse = { + host: string; + radar: { past: RainViewerFrame[] }; +}; + +type WeatherRadarLayerProps = { + visible: boolean; + opacity: number; +}; + +export function WeatherRadarLayer({ + visible, + opacity, +}: WeatherRadarLayerProps) { + const { map, isLoaded } = useMap(); + const intervalRef = useRef | null>(null); + const currentTimeRef = useRef(null); + const visibleRef = useRef(visible); + const opacityRef = useRef(opacity); + + // Keep refs current without recreating callbacks + visibleRef.current = visible; + opacityRef.current = opacity; + + const updateRadarTiles = useCallback(async () => { + if (!map) return; + try { + const res = await fetch(RAINVIEWER_API); + if (!res.ok) return; + const data: RainViewerResponse = await res.json(); + const frames = data.radar?.past; + if (!frames || frames.length === 0) return; + + const latest = frames[frames.length - 1]; + + // Skip if same frame already loaded AND the source still exists on the map + const sourceExists = !!map.getSource(SOURCE_ID); + if (currentTimeRef.current === latest.time && sourceExists) return; + currentTimeRef.current = latest.time; + + const tileUrl = proxyTileUrl(latest.time); + + const source = map.getSource(SOURCE_ID); + if (source && "setTiles" in source) { + (source as { setTiles: (tiles: string[]) => void }).setTiles([tileUrl]); + } else if (!source) { + map.addSource(SOURCE_ID, { + type: "raster", + tiles: [tileUrl], + tileSize: 256, + maxzoom: RAINVIEWER_MAX_ZOOM, + attribution: '© RainViewer', + }); + + // Insert below the first symbol layer so labels remain readable + const layers = map.getStyle()?.layers ?? []; + const firstSymbol = layers.find((l) => l.type === "symbol"); + + map.addLayer( + { + id: LAYER_ID, + type: "raster", + source: SOURCE_ID, + paint: { + "raster-opacity": visibleRef.current ? opacityRef.current : 0, + "raster-fade-duration": 300, + }, + }, + firstSymbol?.id, + ); + } + } catch { + // Network failure — silently ignore, will retry next interval + } + }, [map]); + + // Initial fetch + periodic refresh + useEffect(() => { + if (!map || !isLoaded || !visible) return; + + updateRadarTiles(); + intervalRef.current = setInterval(updateRadarTiles, REFRESH_INTERVAL_MS); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [map, isLoaded, visible, updateRadarTiles]); + + // Toggle visibility and opacity + useEffect(() => { + if (!map || !isLoaded) return; + if (!map.getLayer(LAYER_ID)) return; + + map.setPaintProperty(LAYER_ID, "raster-opacity", visible ? opacity : 0); + }, [map, isLoaded, visible, opacity]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (!map) return; + try { + if (map.getLayer(LAYER_ID)) map.removeLayer(LAYER_ID); + if (map.getSource(SOURCE_ID)) map.removeSource(SOURCE_ID); + } catch { + /* map may already be removed */ + } + }; + }, [map]); + + // Re-add source/layer after style change (MapLibre removes custom layers on style swap) + useEffect(() => { + if (!map || !isLoaded) return; + + const onStyleLoad = () => { + // Only re-add if we had a valid timestamp and source was removed by style swap + if ( + currentTimeRef.current && + !map.getSource(SOURCE_ID) && + visibleRef.current + ) { + const tileUrl = proxyTileUrl(currentTimeRef.current); + map.addSource(SOURCE_ID, { + type: "raster", + tiles: [tileUrl], + tileSize: 256, + maxzoom: RAINVIEWER_MAX_ZOOM, + attribution: '© RainViewer', + }); + const layers = map.getStyle()?.layers ?? []; + const firstSymbol = layers.find((l) => l.type === "symbol"); + map.addLayer( + { + id: LAYER_ID, + type: "raster", + source: SOURCE_ID, + paint: { + "raster-opacity": opacityRef.current, + "raster-fade-duration": 300, + }, + }, + firstSymbol?.id, + ); + } + }; + + map.on("style.load", onStyleLoad); + return () => { + map.off("style.load", onStyleLoad); + }; + }, [map, isLoaded]); + + return null; +} diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx new file mode 100644 index 0000000..189a2b1 --- /dev/null +++ b/src/components/theme-provider.tsx @@ -0,0 +1,11 @@ +"use client"; + +import * as React from "react"; +import { ThemeProvider as NextThemesProvider } from "next-themes"; + +export function ThemeProvider({ + children, + ...props +}: React.ComponentProps) { + return {children}; +} diff --git a/src/components/ui/aircraft-photos.tsx b/src/components/ui/aircraft-photos.tsx index 428cd11..72bbefb 100644 --- a/src/components/ui/aircraft-photos.tsx +++ b/src/components/ui/aircraft-photos.tsx @@ -54,13 +54,13 @@ const Thumbnail = memo(function Thumbnail({ ref={ref} type="button" onClick={() => onClick(index)} - className="group relative h-20 w-32 shrink-0 cursor-pointer overflow-hidden rounded-lg border border-white/8 bg-white/5 transition-all hover:border-white/20 hover:brightness-110 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white/30" + className="group relative h-20 w-32 shrink-0 cursor-pointer overflow-hidden rounded-lg border border-foreground/8 bg-foreground/5 transition-all hover:border-foreground/20 hover:brightness-110 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30" aria-label={`View photo ${index + 1}${photo.photographer ? ` by ${photo.photographer}` : ""}`} > {!loaded && ( )} {visible && ( @@ -74,7 +74,7 @@ const Thumbnail = memo(function Thumbnail({ className={`h-full w-full object-cover transition-opacity duration-300 ${loaded ? "opacity-100" : "opacity-0"}`} /> )} - + ); }); @@ -129,7 +129,7 @@ export function Lightbox({ animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.3, ease: "easeOut" }} - className="fixed inset-0 z-9999 flex items-center justify-center bg-black/92 backdrop-blur-2xl" + className="fixed inset-0 z-9999 flex items-center justify-center bg-background/92 backdrop-blur-2xl" onClick={onClose} role="dialog" aria-modal="true" @@ -138,13 +138,13 @@ export function Lightbox({ - + {index + 1} / {photos.length} @@ -158,14 +158,14 @@ export function Lightbox({ > {!loaded && !imgError && (
-
+
)} {imgError ? ( -
- -

Failed to load image

+
+ +

Failed to load image

) : ( @@ -198,7 +198,7 @@ export function Lightbox({ e.stopPropagation(); goNext(); }} - className="absolute right-2 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white/80 backdrop-blur-sm transition-all duration-200 hover:bg-white/25 hover:text-white sm:right-6 sm:h-14 sm:w-14" + className="absolute right-2 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/10 text-foreground/80 backdrop-blur-sm transition-all duration-200 hover:bg-foreground/25 hover:text-foreground sm:right-6 sm:h-14 sm:w-14" aria-label="Next photo" > @@ -216,34 +216,34 @@ export function Lightbox({ transition={{ duration: 0.3, delay: 0.15 }} className="absolute bottom-3 left-1/2 z-10 w-[92vw] max-w-lg -translate-x-1/2 sm:bottom-8" > - + {photo.photographer && ( - + {photo.photographer} )} {photo.photographer && photo.location && ( - | + | )} {photo.location && ( - {photo.location} + {photo.location} )} {(photo.photographer || photo.location) && photo.dateTaken && ( - | + | )} {photo.dateTaken && ( - {photo.dateTaken} + {photo.dateTaken} )} {photo.link && ( <> {(photo.photographer || photo.location || photo.dateTaken) && ( - | + | )} e.stopPropagation()} > Source @@ -323,12 +323,12 @@ export function AircraftPhotos({ if (aircraft?.airline && !detailParts.includes(aircraft.airline)) { detailParts.push(aircraft.airline); } - const detailLine = detailParts.join(" · "); + const detailLine = detailParts.join(" · "); return ( <>
-
+
@@ -371,7 +371,7 @@ export function AircraftPhotos({ {[0, 1, 2].map((i) => (
))}
@@ -395,7 +395,7 @@ export function AircraftPhotos({ + + +
+
+ ); +} + +// ── Main component ───────────────────────────────────────────────────── + +export function AirportBoard({ + data, + onSelectFlight, + selectedIcao24, + onClose, +}: AirportBoardProps) { + const [collapsed, setCollapsed] = useState(false); + const [activeTab, setActiveTab] = useState("arrivals"); + const prevAirportRef = useRef(data.airport?.iata); + + const { arrivals, departures, airport, totalFlights } = data; + + // Reset collapsed state when switching airports + useEffect(() => { + if (airport?.iata !== prevAirportRef.current) { + prevAirportRef.current = airport?.iata; + setCollapsed(false); + setActiveTab("arrivals"); + } + }, [airport?.iata]); + + // Smart tab management: auto-switch only when current tab empties + useEffect(() => { + if ( + activeTab === "arrivals" && + arrivals.length === 0 && + departures.length > 0 + ) { + setActiveTab("departures"); + } else if ( + activeTab === "departures" && + departures.length === 0 && + arrivals.length > 0 + ) { + setActiveTab("arrivals"); + } + }, [arrivals.length, departures.length, activeTab]); + + const handleToggleCollapse = useCallback(() => { + setCollapsed((c) => !c); + }, []); + + const handleHeaderKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleToggleCollapse(); + } + }, + [handleToggleCollapse], + ); + + if (!airport) return null; + + const currentFlights = activeTab === "arrivals" ? arrivals : departures; + + return ( + +
+ {/* ── Header ── */} +
+
+ {/* Live pulse */} +
+ + +
+ + {/* Airport IATA + name */} +
+ + {airport.iata} + + + {airport.name} + +
+ + {/* Flight count badge */} + + {totalFlights} {totalFlights === 1 ? "flight" : "flights"} + +
+ +
+ {/* Collapse indicator */} + + + + + {/* Close button — outside of the role="button" div via stopPropagation */} + +
+
+ + {/* ── Body ── */} + + {!collapsed && ( + + {/* Gradient divider */} +
+ + {totalFlights === 0 ? ( + + ) : ( + <> + {/* ── Desktop: side-by-side columns ── */} +
+ +
+ + + + + +
+
+ + {/* ── Mobile: segmented control + single list ── */} +
+ + +
+ + + + + +
+
+ + )} + + )} + +
+
+ ); +} diff --git a/src/components/ui/airport-info-card.tsx b/src/components/ui/airport-info-card.tsx new file mode 100644 index 0000000..4505553 --- /dev/null +++ b/src/components/ui/airport-info-card.tsx @@ -0,0 +1,415 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import { motion, AnimatePresence } from "motion/react"; +import { + X, + Wind, + Eye, + Thermometer, + Gauge, + Cloud, + Radio, + MapPin, + Loader2, +} from "lucide-react"; +import type { Airport } from "@/lib/airports"; +import { findNearbyAtcFeeds, iataToIcao } from "@/lib/atc-lookup"; +import { ScrollArea } from "@/components/ui/scroll-area"; + +type MetarData = { + rawOb?: string; + temp?: number; + dewp?: number; + wdir?: number | string; + wspd?: number; + wgst?: number; + visib?: number | string; + altim?: number; + clouds?: { cover: string; base?: number }[]; + fltcat?: string; + name?: string; +}; + +type AirportInfoCardProps = { + airport: Airport | null; + onClose: () => void; +}; + +function decodeFltCat(cat: string | undefined): { + label: string; + color: string; + dotColor: string; +} { + switch (cat?.toUpperCase()) { + case "VFR": + return { + label: "VFR", + color: "text-emerald-400", + dotColor: "bg-emerald-400", + }; + case "MVFR": + return { label: "MVFR", color: "text-blue-400", dotColor: "bg-blue-400" }; + case "IFR": + return { label: "IFR", color: "text-red-400", dotColor: "bg-red-400" }; + case "LIFR": + return { + label: "LIFR", + color: "text-purple-400", + dotColor: "bg-purple-400", + }; + default: + return { + label: "—", + color: "text-foreground/40", + dotColor: "bg-foreground/20", + }; + } +} + +function formatVisibility(vis: number | string | undefined): string { + if (vis === undefined || vis === null) return "—"; + if (typeof vis === "string") return vis; + if (vis >= 9999) return "10+ SM"; + return `${vis} SM`; +} + +function cloudCoverLabel(cover: string): string { + switch (cover.toUpperCase()) { + case "SKC": + case "CLR": + case "NCD": + return "Clear"; + case "FEW": + return "Few"; + case "SCT": + return "Scattered"; + case "BKN": + return "Broken"; + case "OVC": + return "Overcast"; + case "OVX": + return "Obscured"; + default: + return cover; + } +} + +// ── Client-side METAR cache (10 min TTL) ─────────────────────────────── +const METAR_CACHE_TTL_MS = 10 * 60 * 1000; +const metarCache = new Map(); + +export function AirportInfoCard({ airport, onClose }: AirportInfoCardProps) { + const [metar, setMetar] = useState(null); + const [metarLoading, setMetarLoading] = useState(false); + const abortRef = useRef(null); + + const fetchMetar = useCallback(async (icao: string) => { + // Check client-side cache first + const cached = metarCache.get(icao); + if (cached && Date.now() - cached.fetchedAt < METAR_CACHE_TTL_MS) { + setMetar(cached.data); + setMetarLoading(false); + return; + } + + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + setMetarLoading(true); + // Show stale cached data while re-fetching instead of blank + if (cached) setMetar(cached.data); + else setMetar(null); + + try { + const res = await fetch( + `/api/weather/metar?icao=${encodeURIComponent(icao)}`, + { signal: controller.signal }, + ); + if (!res.ok) { + return; + } + const data = await res.json(); + if (controller.signal.aborted) return; + + // NOAA returns an array of METAR observations + const obs = Array.isArray(data) ? data[0] : data; + if (obs) { + metarCache.set(icao, { data: obs, fetchedAt: Date.now() }); + } + setMetar(obs ?? null); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") return; + } finally { + if (!controller.signal.aborted) setMetarLoading(false); + } + }, []); + + useEffect(() => { + if (!airport) { + setMetar(null); + return; + } + const icao = iataToIcao(airport.iata); + if (icao) fetchMetar(icao); + else setMetar(null); + + return () => { + abortRef.current?.abort(); + }; + }, [airport, fetchMetar]); + + const icao = airport ? iataToIcao(airport.iata) : null; + + const nearbyAtc = + airport && icao ? findNearbyAtcFeeds(airport.lat, airport.lng, 30, 6) : []; + + // Group feeds for this airport only + const airportFeeds = nearbyAtc.find((r) => r.icao === icao); + + const fltCat = decodeFltCat(metar?.fltcat); + + return ( + + {airport && ( + +
+
+ {/* Header */} +
+
+
+ {metar ? ( + + ) : ( + + )} +

+ {airport.iata} +

+ {icao && ( + + {icao} + + )} + {metar && ( + + {fltCat.label} + + )} +
+

+ {airport.name} +

+

+ {airport.city}, {airport.country} +

+
+ +
+ + {/* Weather Section */} +
+ + {metarLoading && ( +
+ + + Loading weather... + +
+ )} + + {metar && !metarLoading && ( +
+

+ Current Weather +

+ +
+ {/* Wind */} + } + label="Wind" + value={ + metar.wspd !== undefined + ? `${metar.wdir ?? "VRB"}° ${metar.wspd}kt${metar.wgst ? ` G${metar.wgst}` : ""}` + : "Calm" + } + /> + + {/* Visibility */} + } + label="Visibility" + value={formatVisibility(metar.visib)} + /> + + {/* Temperature */} + } + label="Temp / Dew" + value={ + metar.temp !== undefined + ? `${metar.temp}°C / ${metar.dewp ?? "—"}°C` + : "—" + } + /> + + {/* QNH */} + } + label="QNH" + value={ + metar.altim !== undefined + ? `${metar.altim.toFixed(0)} hPa` + : "—" + } + /> +
+ + {/* Clouds */} + {metar.clouds && metar.clouds.length > 0 && ( +
+ +
+ + Cloud Layers + +

+ {metar.clouds + .map( + (c) => + `${cloudCoverLabel(c.cover)}${c.base != null ? ` ${(c.base * 100).toLocaleString()}ft` : ""}`, + ) + .join(" · ")} +

+
+
+ )} + + {/* Raw METAR */} + {metar.rawOb && ( +
+

+ {metar.rawOb} +

+
+ )} +
+ )} + + {!metar && !metarLoading && icao && ( +
+

+ No weather data available +

+
+ )} + + {/* ATC Frequencies */} + {airportFeeds && airportFeeds.feeds.length > 0 && ( + <> +
+
+
+ +

+ ATC Frequencies +

+ + {airportFeeds.feeds.length} + +
+ +
+ {airportFeeds.feeds.map((feed) => ( +
+
+ + + {feed.name} + +
+ + {feed.frequency} + +
+ ))} +
+
+
+ + )} + + {/* Coordinates */} +
+
+ +

+ {Math.abs(airport.lat).toFixed(4)}° + {airport.lat >= 0 ? "N" : "S"},{" "} + {Math.abs(airport.lng).toFixed(4)}° + {airport.lng >= 0 ? "E" : "W"} +

+
+
+
+ + )} + + ); +} + +function WeatherMetric({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: string; +}) { + return ( +
+
+ {icon} + + {label} + +
+

+ {value} +

+
+ ); +} diff --git a/src/components/ui/airport-search-input.tsx b/src/components/ui/airport-search-input.tsx index 7c8071c..a0702f8 100644 --- a/src/components/ui/airport-search-input.tsx +++ b/src/components/ui/airport-search-input.tsx @@ -108,16 +108,16 @@ export function AirportSearchInput({ setIsOpen(true); requestAnimationFrame(() => inputRef.current?.focus()); }} - className="flex w-full items-center gap-2 rounded-xl border border-white/8 bg-white/4 px-3 py-2.5 text-left transition-colors hover:bg-white/6" + className="flex w-full items-center gap-2 rounded-xl border border-foreground/8 bg-foreground/4 px-3 py-2.5 text-left transition-colors hover:bg-foreground/6" > -
- +
+
- + {selected.iata} - + {selected.city}
@@ -127,7 +127,7 @@ export function AirportSearchInput({ e.stopPropagation(); handleClear(); }} - className="shrink-0 text-white/20 hover:text-white/40 transition-colors" + className="shrink-0 text-foreground/20 hover:text-foreground/40 transition-colors" aria-label="Clear selection" > @@ -135,8 +135,8 @@ export function AirportSearchInput({ )} ) : ( -
- +
+ setIsOpen(true)} placeholder={placeholder} aria-label={label} - className="flex-1 bg-transparent text-[13px] font-medium text-white/90 placeholder:text-white/20 outline-none" + className="flex-1 bg-transparent text-[13px] font-medium text-foreground/90 placeholder:text-foreground/20 outline-none" /> {query && ( ); }); diff --git a/src/components/ui/atc-panel.tsx b/src/components/ui/atc-panel.tsx index a7d3cd3..6840740 100644 --- a/src/components/ui/atc-panel.tsx +++ b/src/components/ui/atc-panel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useCallback, useRef } from "react"; +import { useMemo, useCallback, useRef, useState, useEffect } from "react"; import { motion, AnimatePresence } from "motion/react"; import { Radio, @@ -11,13 +11,16 @@ import { AlertTriangle, Server, ChevronUp, + AudioLines, } from "lucide-react"; +import { AtcSpectrum } from "@/components/ui/atc-spectrum"; import type { AtcFeed, AtcFeedType } from "@/lib/atc-types"; import { FEED_TYPE_PRIORITY } from "@/lib/atc-types"; import { lookupAtcFeeds, findNearbyAtcFeeds } from "@/lib/atc-lookup"; import { AtcWaveform } from "@/components/ui/atc-waveform"; import type { UseAtcStreamReturn } from "@/hooks/use-atc-stream"; import { useDropdownDismiss } from "@/hooks/use-dropdown-dismiss"; +import { ScrollArea } from "@/components/ui/scroll-area"; // ── Feed helpers ─────────────────────────────────────────────────────── @@ -122,16 +125,18 @@ export function AtcFeedDropdown({ animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 8, scale: 0.97 }} transition={{ type: "spring", stiffness: 500, damping: 30 }} - className="absolute bottom-full left-0 z-50 mb-2 w-[calc(100vw-2rem)] max-w-70 overflow-hidden rounded-xl border shadow-2xl shadow-black/60 backdrop-blur-2xl sm:w-70 sm:max-w-none" + className="absolute bottom-full left-0 z-50 mb-2 w-[calc(100vw-2rem)] max-w-70 overflow-hidden rounded-[18px] backdrop-blur-3xl sm:w-70 sm:max-w-none" style={{ - borderColor: "rgb(var(--ui-fg) / 0.08)", - backgroundColor: "rgb(var(--ui-bg) / 0.75)", + border: "0.5px solid rgb(var(--ui-fg) / 0.08)", + backgroundColor: "rgb(var(--ui-bg) / 0.7)", + boxShadow: + "0 12px 40px rgb(0 0 0 / 0.4), inset 0 0.5px 0 rgb(var(--ui-fg) / 0.04)", }} > {/* Header */}
@@ -145,7 +150,7 @@ export function AtcFeedDropdown({
) : ( -
- {groupedFeeds.map((group) => ( -
- {group.feeds.map((feed) => { - const isPlaying = - atc.feed?.id === feed.id && atc.status === "playing"; - const isLoading = - atc.feed?.id === feed.id && atc.status === "loading"; - const isFeedError = - atc.feed?.id === feed.id && - (atc.status === "error" || atc.status === "blocked"); - const isSelected = atc.feed?.id === feed.id; + +
+ {groupedFeeds.map((group) => ( +
+ {group.feeds.map((feed) => { + const isPlaying = + atc.feed?.id === feed.id && atc.status === "playing"; + const isLoading = + atc.feed?.id === feed.id && atc.status === "loading"; + const isFeedError = + atc.feed?.id === feed.id && + (atc.status === "error" || atc.status === "blocked"); + const isSelected = atc.feed?.id === feed.id; - return ( - - ); - })} -
- ))} -
+ + ); + })} +
+ ))} +
+ )} )} @@ -268,103 +275,153 @@ export function AtcPlayerBar({ atc, onOpenFeedSelector }: AtcPlayerBarProps) { const isStreaming = atc.status === "playing" || atc.status === "loading"; const isError = atc.status === "error" || atc.status === "blocked"; const isBlocked = atc.status === "blocked"; + const [spectrumOpen, setSpectrumOpen] = useState(false); + + // Close spectrum on Escape key + useEffect(() => { + if (!spectrumOpen) return; + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") setSpectrumOpen(false); + } + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [spectrumOpen]); if (!atc.feed) return null; return ( - - {/* Waveform or blocked play icon (left) */} - {isBlocked ? ( +
+ {/* Expanded Spectrum Visualizer */} + + {spectrumOpen && ( +
+ +
+ )} +
+ + {/* Player Bar */} + + {/* Waveform or blocked play icon (left) */} + {isBlocked ? ( + + ) : ( + + )} + + {/* Feed name + frequency (stacked, center) — clickable to open selector */} - ) : ( - - )} - - {/* Feed name + frequency (stacked, center) — clickable to open selector */} - +
+
+ + {atc.feed.frequency} + + {atc.usingProxy && atc.status === "playing" && ( + + + proxy + + )} +
+ - {/* Close / Stop (right) */} - -
+ {/* Spectrum toggle (right of center) */} + {!isBlocked && ( + + )} + + {/* Close / Stop (right) */} + + +
); } @@ -389,7 +446,7 @@ export function AtcTrigger({ + ))} +
+ ); +} + +// ── Component ────────────────────────────────────────────────────────── + +export function AtcSpectrum({ + audioElement, + active, + feedName, + feedFrequency, +}: { + audioElement: HTMLAudioElement | null; + active: boolean; + feedName?: string; + feedFrequency?: string; +}) { + const canvasRef = useRef(null); + const analyserRef = useRef(null); + const rafRef = useRef(0); + const barsRef = useRef(new Array(BAR_COUNT).fill(0)); + const [mode, setMode] = useState("combined"); + + // ── Connect to Web Audio API ──────────────────────────────────────── + useEffect(() => { + if (!active || !audioElement) { + barsRef.current = new Array(BAR_COUNT).fill(0); + analyserRef.current = null; + return; + } + + analyserRef.current = getOrCreateConnection(audioElement); + }, [active, audioElement]); + + // ── Resize observer for responsive canvas ─────────────────────────── + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const container = canvas.parentElement; + if (!container) return; + + const ro = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + const dpr = window.devicePixelRatio || 1; + canvas.width = width * dpr; + canvas.height = height * dpr; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + } + }); + + ro.observe(container); + return () => ro.disconnect(); + }, []); + + // ── Main render loop ──────────────────────────────────────────────── + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + let freqData: Uint8Array | null = null; + let timeData: Uint8Array | null = null; + let binRanges: [number, number][] | null = null; + let lastBinCount = 0; + + function draw() { + rafRef.current = requestAnimationFrame(draw); + + const dpr = window.devicePixelRatio || 1; + const W = canvas!.width / dpr; + const H = canvas!.height / dpr; + + if (W === 0 || H === 0) return; + + ctx!.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx!.clearRect(0, 0, W, H); + + const analyser = analyserRef.current; + const binCount = analyser?.frequencyBinCount ?? 128; + + // Re-allocate if bin count changed + if (binCount !== lastBinCount) { + freqData = new Uint8Array(binCount) as Uint8Array; + timeData = new Uint8Array(binCount) as Uint8Array; + binRanges = buildBinRanges(binCount, BAR_COUNT); + lastBinCount = binCount; + } + + if (analyser && freqData && timeData) { + analyser.getByteFrequencyData(freqData); + analyser.getByteTimeDomainData(timeData); + } + + const now = performance.now(); + const drawW = W - CANVAS_PADDING * 2; + const baseY = H - CANVAS_PADDING; + const maxBarH = H - CANVAS_PADDING * 2; + const currentMode = mode; + + // ── Pre-compute bar values ──────────────────────────────────── + let hasSignal = false; + let peakVal = 0; + + for (let i = 0; i < BAR_COUNT; i++) { + const [startBin, endBin] = binRanges![i]; + let sum = 0; + const count = endBin - startBin; + for (let b = startBin; b < endBin; b++) { + sum += freqData ? freqData[b] : 0; + } + const raw = analyser && count > 0 ? sum / count / 255 : 0; + + // Breathing: barely perceptible, organic phase per bar + const breathPeriod = 2600 + (i % 5) * 280; + const breathPhase = + ((now / breathPeriod) + i * 0.15) % (Math.PI * 2); + const breathVal = 0.02 + Math.sin(breathPhase) * 0.008; + const target = raw > 0.02 ? raw : breathVal; + + const lerp = target > barsRef.current[i] ? LERP_UP : LERP_DOWN; + barsRef.current[i] += (target - barsRef.current[i]) * lerp; + + if (raw > 0.02) hasSignal = true; + if (barsRef.current[i] > peakVal) peakVal = barsRef.current[i]; + } + + // ── Ambient glow from bottom ───────────────────────────────── + if (hasSignal && peakVal > 0.12) { + const glowAlpha = Math.min(peakVal * 0.05, 0.035); + const glow = ctx!.createRadialGradient( + W / 2, H + 20, 0, + W / 2, H + 20, W * 0.55, + ); + glow.addColorStop(0, accent(0.5, glowAlpha)); + glow.addColorStop(1, "transparent"); + ctx!.fillStyle = glow; + ctx!.fillRect(0, 0, W, H); + } + + // ── Spectrum bars ───────────────────────────────────────────── + if (currentMode === "spectrum" || currentMode === "combined") { + const totalBarW = drawW / BAR_COUNT; + const barW = Math.max(2, totalBarW * 0.55); + const radius = Math.min(barW * 0.45, 3.5); + + for (let i = 0; i < BAR_COUNT; i++) { + const val = barsRef.current[i]; + const isActive = val > 0.03; + + const barH = Math.max(2, val * maxBarH * 0.88); + const x = + CANVAS_PADDING + i * totalBarW + (totalBarW - barW) / 2; + const y = baseY - barH; + const alpha = isActive ? 0.45 + val * 0.55 : 0.04; + + // Gradient fill for active bars, flat tint for idle + if (isActive && barH > 5) { + const grad = ctx!.createLinearGradient(0, y, 0, y + barH); + grad.addColorStop(0, accent(val, alpha)); + grad.addColorStop(0.7, accent(val * 0.65, alpha * 0.8)); + grad.addColorStop(1, accent(val * 0.2, alpha * 0.4)); + ctx!.fillStyle = grad; + } else { + ctx!.fillStyle = isActive + ? accent(val, alpha) + : `rgba(255, 255, 255, ${alpha})`; + } + + ctx!.beginPath(); + ctx!.roundRect(x, y, barW, barH, radius); + ctx!.fill(); + + // Soft top glow on loud bars + if (val > 0.45 && isActive) { + ctx!.save(); + ctx!.shadowColor = accent(val, 0.35); + ctx!.shadowBlur = 6 + val * 8; + ctx!.fillStyle = accent(val, 0.05); + ctx!.beginPath(); + ctx!.roundRect(x, y, barW, Math.min(barH, 8), radius); + ctx!.fill(); + ctx!.restore(); + } + } + } + + // ── Waveform / Oscilloscope ─────────────────────────────────── + if ( + (currentMode === "waveform" || currentMode === "combined") && + timeData + ) { + const waveH = currentMode === "waveform" ? H * 0.5 : H * 0.14; + const waveMid = currentMode === "waveform" ? H * 0.5 : H * 0.5; + const waveAlpha = currentMode === "combined" ? 0.12 : 0.45; + + const step = Math.max(1, Math.floor(timeData.length / 128)); + const pts: { x: number; y: number }[] = []; + let waveSignal = false; + + for (let i = 0; i < timeData.length; i += step) { + const v = (timeData[i] - 128) / 128; + if (Math.abs(v) > 0.02) waveSignal = true; + pts.push({ + x: CANVAS_PADDING + (i / (timeData.length - 1)) * drawW, + y: waveMid + v * waveH, + }); + } + + // Catmull-Rom spline renderer + function spline(lw: number, style: string) { + if (pts.length < 2) return; + ctx!.beginPath(); + ctx!.strokeStyle = style; + ctx!.lineWidth = lw; + ctx!.lineJoin = "round"; + ctx!.lineCap = "round"; + ctx!.moveTo(pts[0].x, pts[0].y); + + for (let j = 0; j < pts.length - 1; j++) { + const p0 = pts[Math.max(0, j - 1)]; + const p1 = pts[j]; + const p2 = pts[Math.min(pts.length - 1, j + 1)]; + const p3 = pts[Math.min(pts.length - 1, j + 2)]; + + ctx!.bezierCurveTo( + p1.x + (p2.x - p0.x) / 6, + p1.y + (p2.y - p0.y) / 6, + p2.x - (p3.x - p1.x) / 6, + p2.y - (p3.y - p1.y) / 6, + p2.x, + p2.y, + ); + } + ctx!.stroke(); + } + + // Outer glow + if (waveSignal) { + ctx!.save(); + ctx!.shadowColor = accent(0.5, waveAlpha * 0.15); + ctx!.shadowBlur = 12; + spline(3, accent(0.5, waveAlpha * 0.06)); + ctx!.restore(); + } + + // Main trace + spline(1.5, accent(0.6, waveAlpha)); + } + } + + draw(); + return () => cancelAnimationFrame(rafRef.current); + }, [mode]); + + return ( + + {/* Header */} +
+ {/* Feed info */} +
+ {/* Live indicator */} +
+ {active && ( + + )} + +
+
+ + {feedName ?? "ATC Audio"} + + {feedFrequency && ( + + {feedFrequency} + + )} +
+
+ + {/* Mode selector */} + +
+ + {/* Visualization canvas */} +
+
+
+ ); +} diff --git a/src/components/ui/atc-waveform.tsx b/src/components/ui/atc-waveform.tsx index a2613a1..4b66240 100644 --- a/src/components/ui/atc-waveform.tsx +++ b/src/components/ui/atc-waveform.tsx @@ -2,11 +2,9 @@ import { useRef, useEffect } from "react"; -const BAR_COUNT = 12; +const DEFAULT_BAR_COUNT = 12; const BAR_WIDTH = 2.5; const BAR_GAP = 2; -const CANVAS_W = BAR_COUNT * BAR_WIDTH + (BAR_COUNT - 1) * BAR_GAP; -const CANVAS_H = 28; const MIN_BAR_H = 2.5; const LERP = 0.22; @@ -22,7 +20,7 @@ const capturedElements = new WeakMap< { source: MediaElementAudioSourceNode; analyser: AnalyserNode } >(); -function getOrCreateConnection( +export function getOrCreateConnection( audioElement: HTMLAudioElement, ): AnalyserNode | null { if (!sharedCtx || sharedCtx.state === "closed") { @@ -93,12 +91,18 @@ export function AtcWaveform({ const canvasRef = useRef(null); const analyserRef = useRef(null); const rafRef = useRef(0); - const barsRef = useRef(new Array(BAR_COUNT).fill(0)); + const barsRef = useRef(new Array(DEFAULT_BAR_COUNT).fill(0)); + /** Tracks canvas CSS size → derive bar count dynamically */ + const layoutRef = useRef({ + w: DEFAULT_BAR_COUNT * BAR_WIDTH + (DEFAULT_BAR_COUNT - 1) * BAR_GAP, + h: 28, + barCount: DEFAULT_BAR_COUNT, + }); // ── Connect to Web Audio API ────────────────────────────────────── useEffect(() => { if (!active || !audioElement) { - barsRef.current = new Array(BAR_COUNT).fill(0); + barsRef.current = new Array(layoutRef.current.barCount).fill(0); analyserRef.current = null; return; } @@ -128,15 +132,41 @@ export function AtcWaveform({ const draw2d = canvas.getContext("2d"); if (!draw2d) return; - const dpr = window.devicePixelRatio || 1; - canvas.width = CANVAS_W * dpr; - canvas.height = CANVAS_H * dpr; - draw2d.scale(dpr, dpr); + /** Recompute canvas backing-store size from CSS dimensions. */ + function syncCanvasSize() { + if (!canvas || !draw2d) return; + const rect = canvas.getBoundingClientRect(); + const w = rect.width; + const h = rect.height; + if (w < 1 || h < 1) return; + + const barCount = Math.max( + 4, + Math.floor((w + BAR_GAP) / (BAR_WIDTH + BAR_GAP)), + ); + layoutRef.current = { w, h, barCount }; + + // Resize bars array when count changes + if (barsRef.current.length !== barCount) { + barsRef.current = new Array(barCount).fill(0); + } + + const dpr = window.devicePixelRatio || 1; + canvas.width = w * dpr; + canvas.height = h * dpr; + draw2d.setTransform(dpr, 0, 0, dpr, 0, 0); + } + + syncCanvasSize(); + + const ro = new ResizeObserver(() => syncCanvasSize()); + ro.observe(canvas); // Hoist allocations out of draw loop — only reallocate when binCount changes let dataArray: Uint8Array | null = null; let binRanges: [number, number][] | null = null; let lastBinCount = 0; + let lastBarCount = 0; function draw() { rafRef.current = requestAnimationFrame(draw); @@ -144,17 +174,25 @@ export function AtcWaveform({ const now = performance.now(); const analyser = analyserRef.current; const binCount = analyser?.frequencyBinCount ?? 128; + const { w: cW, h: cH, barCount } = layoutRef.current; - if (binCount !== lastBinCount) { + if (binCount !== lastBinCount || barCount !== lastBarCount) { dataArray = new Uint8Array(binCount) as Uint8Array; - binRanges = buildBinRanges(binCount, BAR_COUNT); + binRanges = buildBinRanges(binCount, barCount); lastBinCount = binCount; + lastBarCount = barCount; } if (analyser && dataArray) analyser.getByteFrequencyData(dataArray); - draw2d!.clearRect(0, 0, CANVAS_W, CANVAS_H); + draw2d!.clearRect(0, 0, cW, cH); - for (let i = 0; i < BAR_COUNT; i++) { + // Compute theme once per frame (not per bar) + const isDark = document.documentElement.classList.contains("dark"); + const idleFill = isDark + ? "rgba(255, 255, 255, 0.1)" + : "rgba(0, 0, 0, 0.1)"; + + for (let i = 0; i < barCount; i++) { // Average frequency bins in this bar's range const [startBin, endBin] = binRanges![i]; let sum = 0; @@ -172,16 +210,16 @@ export function AtcWaveform({ barsRef.current[i] += (target - barsRef.current[i]) * LERP; const val = barsRef.current[i]; - const barH = Math.max(MIN_BAR_H, val * (CANVAS_H - 2)); + const barH = Math.max(MIN_BAR_H, val * (cH - 2)); const x = i * (BAR_WIDTH + BAR_GAP); - const y = CANVAS_H - barH; + const y = cH - barH; - // Emerald when signal, dim white breathing when idle + // Emerald when signal, dim fill when idle if (raw > 0.04) { const intensity = Math.min(val * 1.6, 1); draw2d!.fillStyle = `rgba(52, 211, 153, ${0.5 + intensity * 0.5})`; } else { - draw2d!.fillStyle = "rgba(255, 255, 255, 0.1)"; + draw2d!.fillStyle = idleFill; } draw2d!.beginPath(); draw2d!.roundRect(x, y, BAR_WIDTH, barH, 1); @@ -190,14 +228,16 @@ export function AtcWaveform({ } draw(); - return () => cancelAnimationFrame(rafRef.current); + return () => { + cancelAnimationFrame(rafRef.current); + ro.disconnect(); + }; }, []); return (
- +
+ {query && ( @@ -478,10 +478,10 @@ export function SearchContent({ }} className="search-item" > -
- +
+
- + {r}
-
+
); @@ -177,12 +198,12 @@ export function ShortcutsContent() { {SHORTCUTS.map(({ key, description }) => (
- + {description} - + {key}
@@ -218,13 +239,15 @@ function OrbitSpeedSlider({ return (
-
+
-

Orbit speed

- +

+ Orbit speed +

+ {activeLabel}
@@ -249,7 +272,7 @@ function OrbitSpeedSlider({ @@ -271,15 +294,15 @@ function TrailThicknessSlider({ }) { return (
-
+
-

+

Trail thickness

- + {value.toFixed(1)} px
@@ -305,15 +328,15 @@ function TrailDistanceSlider({ }) { return (
-
+
-

+

Trail distance

- + {value} pts
@@ -339,15 +362,15 @@ function AirspaceOpacitySlider({ }) { return (
-
+
-

+

Airspace opacity

- + {Math.round(value * 100)}%
@@ -364,13 +387,47 @@ function AirspaceOpacitySlider({ ); } +function WeatherRadarOpacitySlider({ + value, + onChange, +}: { + value: number; + onChange: (v: number) => void; +}) { + return ( +
+
+ +
+
+
+

+ Radar opacity +

+ + {Math.round(value * 100)}% + +
+ onChange(vals[0])} + aria-label="Weather radar opacity" + /> +
+
+ ); +} + function SectionHeader({ title }: { title: string }) { return (
- + {title} -
+
); } @@ -395,21 +452,21 @@ function SettingRow({ role="switch" aria-checked={checked} onClick={() => onChange(!checked)} - className="flex w-full items-center gap-3.5 rounded-xl px-3 py-3 text-left transition-colors hover:bg-white/4 active:bg-white/6" + className="flex w-full items-center gap-3.5 rounded-xl px-3 py-3 text-left transition-colors hover:bg-foreground/4 active:bg-foreground/6" > -
+
{icon}
-

{title}

+

{title}

{badge && ( {badge} )}
-

+

{description}

@@ -433,16 +490,16 @@ function SegmentRow({ }) { return (
-
+
{icon}
-

+

{title}

{options.map((opt) => { const isActive = opt.value === value; @@ -453,13 +510,15 @@ function SegmentRow({ aria-checked={isActive} onClick={() => onChange(opt.value)} className={`relative rounded-md px-2 py-1 text-[11px] font-semibold transition-colors ${ - isActive ? "text-white/90" : "text-white/30 hover:text-white/50" + isActive + ? "text-foreground/90" + : "text-foreground/30 hover:text-foreground/50" }`} > {isActive && (
@@ -573,11 +632,11 @@ export function AboutContent() { return (
-

+

Aeris

-
+

Live flight tracking in 3D. The planes you see are real — position data comes from ADS-B Exchange, adsb.lol, and OpenSky Network, @@ -592,40 +651,49 @@ export function AboutContent() {

-
+
-

- Built by{" "} +

+ Built by a human, not just LLMs.{" "} kewonit + {" · "} + + @kewonit + . Open to internships —{" "} kew@edbn.me

-

+

Source is on{" "} GitHub . Got a question or just wanna say hi?{" "} aeris@edbn.me @@ -639,16 +707,16 @@ export function ChangelogContent() { return (

- {CHANGELOG.map((entry) => ( -
- + {CHANGELOG.map((entry, i) => ( +
+ {entry.date}
-

+

{entry.title}

-

+

{entry.description}

diff --git a/src/components/ui/control-panel-styles.tsx b/src/components/ui/control-panel-styles.tsx index 39207c8..2d0366b 100644 --- a/src/components/ui/control-panel-styles.tsx +++ b/src/components/ui/control-panel-styles.tsx @@ -27,8 +27,8 @@ export function StyleContent({ /> ))}
-
-

+

+

Satellite © Esri · Terrain © AWS/Mapzen Terrain Tiles · Base maps © CARTO

@@ -63,8 +63,8 @@ function StyleTile({
- + )} @@ -108,14 +108,14 @@ function StyleTile({ {style.name} {style.dark && ( - + )}
diff --git a/src/components/ui/control-panel.tsx b/src/components/ui/control-panel.tsx index 8758e43..aa236f3 100644 --- a/src/components/ui/control-panel.tsx +++ b/src/components/ui/control-panel.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useRef, type ReactNode } from "react"; +import { createPortal } from "react-dom"; import { motion, AnimatePresence } from "motion/react"; import { Search, @@ -69,6 +70,11 @@ export function ControlPanel({ onLookupFlight, }: ControlPanelProps) { const [openTab, setOpenTab] = useState(null); + const [portalMounted, setPortalMounted] = useState(false); + + useEffect(() => { + setPortalMounted(true); + }, []); useEffect(() => { function handleOpenSearch() { @@ -125,25 +131,29 @@ export function ControlPanel({ - - {openTab && ( - { - onSelectCity(c); - close(); - }} - activeStyle={activeStyle} - onSelectStyle={onSelectStyle} - flights={flights} - activeFlightIcao24={activeFlightIcao24} - onLookupFlight={onLookupFlight} - /> + {portalMounted && + createPortal( + + {openTab && ( + { + onSelectCity(c); + close(); + }} + activeStyle={activeStyle} + onSelectStyle={onSelectStyle} + flights={flights} + activeFlightIcao24={activeFlightIcao24} + onLookupFlight={onLookupFlight} + /> + )} + , + document.body, )} - ); } @@ -225,7 +235,7 @@ function PanelDialog({ animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }} - className="fixed inset-0 z-80 bg-black/70" + className="fixed inset-0 z-80 bg-background/70" onClick={onClose} /> @@ -245,10 +255,10 @@ function PanelDialog({ aria-modal="true" aria-labelledby="panel-dialog-title" > -
+
{/* Desktop sidebar (hidden on mobile) */} -
-

+

+

Controls