diff --git a/package-lock.json b/package-lock.json index 2db034c..03324b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-wrap-balancer": "^1.1.1", + "recharts": "^3.8.1", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0" }, @@ -2983,6 +2984,42 @@ } } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2990,6 +3027,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -3352,6 +3401,69 @@ "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", "license": "MIT" }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3429,6 +3541,12 @@ "@types/geojson": "*" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", @@ -4661,6 +4779,127 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -4740,6 +4979,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -5057,6 +5302,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -5552,6 +5807,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6138,6 +6399,16 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -6186,6 +6457,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -7943,9 +8223,31 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -8039,6 +8341,51 @@ "util-deprecate": "~1.0.1" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -8083,6 +8430,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -8803,6 +9156,12 @@ "integrity": "sha512-oB7yIimd8SuGptespDAZnNkzIz+NWaJCu2RMsbs4Wmp9zSDUM8Nhi3s2OOcqYuv3mN4hitXc8DVx+LyUmbUDiA==", "license": "ISC" }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -9207,12 +9566,43 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/wgsl_reflect": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/wgsl_reflect/-/wgsl_reflect-1.2.3.tgz", diff --git a/package.json b/package.json index 91ed8ee..67f2d9c 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@luma.gl/core": "^9.2.6", "@luma.gl/webgl": "^9.2.6", "@next/third-parties": "^16.2.4", + "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-tabs": "^1.1.13", "@wrksz/themes": "^0.9.0", @@ -38,6 +39,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-wrap-balancer": "^1.1.1", + "recharts": "^3.8.1", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fc2b8d..ccae89e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@next/third-parties': specifier: ^16.2.4 version: 16.2.4(next@16.2.4(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + '@radix-ui/react-scroll-area': + specifier: 1.2.10 + version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-slider': specifier: ^1.3.6 version: 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -80,6 +83,9 @@ importers: react-wrap-balancer: specifier: ^1.1.1 version: 1.1.1(react@19.2.4) + recharts: + specifier: ^3.8.1 + version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1085,6 +1091,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slider@1.3.6': resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} peerDependencies: @@ -1183,9 +1202,26 @@ packages: '@types/react': optional: true + '@reduxjs/toolkit@2.12.0': + resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -1308,6 +1344,33 @@ packages: '@types/crypto-js@4.2.2': resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1340,6 +1403,9 @@ packages: '@types/supercluster@7.1.3': resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@typescript-eslint/eslint-plugin@8.59.0': resolution: {integrity: sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1711,6 +1777,50 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -1743,6 +1853,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1824,6 +1937,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es-toolkit@1.46.1: + resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -1957,6 +2073,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2161,6 +2280,12 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.8: + resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -2176,6 +2301,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -2714,6 +2843,18 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -2756,6 +2897,22 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + recharts@3.8.1: + resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -2764,6 +2921,9 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2960,6 +3120,9 @@ packages: third-party-capital@1.0.20: resolution: {integrity: sha512-oB7yIimd8SuGptespDAZnNkzIz+NWaJCu2RMsbs4Wmp9zSDUM8Nhi3s2OOcqYuv3mN4hitXc8DVx+LyUmbUDiA==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} @@ -3059,9 +3222,17 @@ packages: '@types/react': optional: true + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + wgsl_reflect@1.2.3: resolution: {integrity: sha512-BQWBIsOn411M+ffBxmA6QRLvAOVbuz3Uk4NusxnqC1H7aeQcVLhdA3k2k/EFFFtqVjhz3z7JOOZF1a9hj2tv4Q==} @@ -4093,6 +4264,23 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/number': 1.1.1 @@ -4182,8 +4370,24 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.8 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.4 + react-redux: 9.3.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + '@rtsao/scc@1.1.0': {} + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -4295,6 +4499,30 @@ snapshots: '@types/crypto-js@4.2.2': {} + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/estree@1.0.8': {} '@types/geojson@7946.0.16': {} @@ -4323,6 +4551,8 @@ snapshots: dependencies: '@types/geojson': 7946.0.16 + '@types/use-sync-external-store@0.0.6': {} + '@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4708,6 +4938,44 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + damerau-levenshtein@1.0.8: {} data-view-buffer@1.0.2: @@ -4736,6 +5004,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + deep-is@0.1.4: {} deep-strict-equal@0.2.0: @@ -4884,6 +5154,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.46.1: {} + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -5122,6 +5394,8 @@ snapshots: esutils@2.0.3: {} + eventemitter3@5.0.4: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -5302,6 +5576,10 @@ snapshots: immediate@3.0.6: {} + immer@10.2.0: {} + + immer@11.1.8: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -5317,6 +5595,8 @@ snapshots: hasown: 2.0.3 side-channel: 1.1.0 + internmap@2.0.3: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -5839,6 +6119,15 @@ snapshots: react-is@16.13.1: {} + react-redux@9.3.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): dependencies: react: 19.2.4 @@ -5882,6 +6171,32 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.46.1 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-is: 16.13.1 + react-redux: 9.3.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.4) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -5902,6 +6217,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + reselect@5.1.1: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -6152,6 +6469,8 @@ snapshots: third-party-capital@1.0.20: {} + tiny-invariant@1.3.3: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -6291,8 +6610,29 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + util-deprecate@1.0.2: {} + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + wgsl_reflect@1.2.3: {} which-boxed-primitive@1.1.1: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..285a39b --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + esbuild: set this to true or false + sharp: set this to true or false + unrs-resolver: set this to true or false diff --git a/src/app/api/aircraft-photos/route.test.ts b/src/app/api/aircraft-photos/route.test.ts new file mode 100644 index 0000000..12eec60 --- /dev/null +++ b/src/app/api/aircraft-photos/route.test.ts @@ -0,0 +1,194 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { NextRequest } from "next/server"; + +function jsonResponse(body: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + ...init, + }); +} + +function requestHeader(init: RequestInit | undefined, name: string): string | null { + return new Headers(init?.headers).get(name); +} + +test("GET sends an app-identifying user agent to Planespotters and parses current JetAPI photos", async () => { + const originalFetch = globalThis.fetch; + const urls: string[] = []; + let planespottersUserAgent: string | null = null; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + urls.push(url); + + if (url === "https://api.planespotters.net/pub/photos/hex/ae1233") { + planespottersUserAgent = requestHeader(init, "user-agent"); + return jsonResponse({ + photos: [ + { + id: "1619136", + thumbnail_large: { + src: "https://t.plnspttrs.net/37298/1619136_280.jpg", + }, + thumbnail: { + src: "https://t.plnspttrs.net/37298/1619136_t.jpg", + }, + link: "https://www.planespotters.net/photo/1619136?utm_source=api", + photographer: "Matthias Becker", + }, + ], + }); + } + + if (url === "https://api.adsbdb.com/v0/aircraft/ae1233") { + return jsonResponse({ + response: { + aircraft: { + registration: "03-3122", + manufacturer: "Boeing", + type: "C-17A Globemaster III", + icao_type: "C17", + registered_owner: "United States Air Force", + url_photo: "https://airport-data.com/images/stale.jpg", + }, + }, + }); + } + + if ( + url === + "https://www.jetapi.dev/api?reg=03-3122&photos=10&only_jp=true" + ) { + return jsonResponse({ + Reg: "03-3122", + Images: [ + { + Image: "https://cdn.jetphotos.com/full/example.jpg", + Thumbnail: "https://cdn.jetphotos.com/400/example.jpg", + Link: "https://www.jetphotos.com/photo/11848786", + Photographer: "FOC Radix Isatidis", + Location: "Honolulu Int'l Airport - PHNL", + DateTaken: "Sep 18, 2025", + }, + ], + }); + } + + if ( + url === + "https://www.airport-data.com/api/ac_thumb.json?m=03-3122&n=5" + ) { + return jsonResponse({}, { status: 404 }); + } + + return jsonResponse({}, { status: 404 }); + }) as typeof fetch; + + try { + const routeModule = await import("./route"); + const request = new NextRequest( + "https://aeris.edbn.me/api/aircraft-photos?hex=AE1233®=03-3122", + ); + + const response = await routeModule.GET(request); + const body = (await response.json()) as { + photos: Array<{ id: string; url: string }>; + aircraft: { registration: string } | null; + }; + + assert.equal(response.status, 200); + assert.match(planespottersUserAgent ?? "", /^AerisFlightTracker\//); + assert.equal(body.aircraft?.registration, "03-3122"); + assert.equal(body.photos[0]?.url, "https://cdn.jetphotos.com/full/example.jpg"); + assert.equal(body.photos[1]?.url, "https://t.plnspttrs.net/37298/1619136_280.jpg"); + assert.equal( + urls.some((url) => url.startsWith("https://hexdb.io/api/v1/aircraft/")), + false, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("GET uses adsbdb registration metadata for registration-based photo providers", async () => { + const originalFetch = globalThis.fetch; + const urls: string[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + urls.push(url); + + if (url === "https://api.planespotters.net/pub/photos/hex/abcdef") { + return jsonResponse({ photos: [] }); + } + + if (url === "https://api.adsbdb.com/v0/aircraft/abcdef") { + return jsonResponse({ + response: { + aircraft: { + registration: "RA-89031", + manufacturer: "Sukhoi", + type: "Superjet 100-95B", + icao_type: "SU95", + registered_owner: "Rossiya", + }, + }, + }); + } + + if (url === "https://hexdb.io/api/v1/aircraft/abcdef") { + return jsonResponse({ + Registration: "N00000", + Manufacturer: "Fallback", + }); + } + + if ( + url === + "https://www.jetapi.dev/api?reg=RA-89031&photos=10&only_jp=true" + ) { + return jsonResponse({ + Reg: "RA-89031", + Images: [{ Image: "https://cdn.jetphotos.com/full/ra.jpg" }], + }); + } + + if ( + url === + "https://www.airport-data.com/api/ac_thumb.json?m=RA-89031&n=5" || + url === "https://api.planespotters.net/pub/photos/reg/RA-89031" + ) { + return jsonResponse({ photos: [] }); + } + + return jsonResponse({}, { status: 404 }); + }) as typeof fetch; + + try { + const routeModule = await import("./route"); + const request = new NextRequest( + "https://aeris.edbn.me/api/aircraft-photos?hex=abcdef", + ); + + const response = await routeModule.GET(request); + const body = (await response.json()) as { + photos: Array<{ url: string }>; + aircraft: { registration: string; manufacturer: string | null } | null; + }; + + assert.equal(response.status, 200); + assert.equal(body.aircraft?.registration, "RA-89031"); + assert.equal(body.aircraft?.manufacturer, "Sukhoi"); + assert.equal(body.photos[0]?.url, "https://cdn.jetphotos.com/full/ra.jpg"); + assert.ok( + urls.includes( + "https://www.jetapi.dev/api?reg=RA-89031&photos=10&only_jp=true", + ), + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/src/app/api/aircraft-photos/route.ts b/src/app/api/aircraft-photos/route.ts index c01f072..e61a9a5 100644 --- a/src/app/api/aircraft-photos/route.ts +++ b/src/app/api/aircraft-photos/route.ts @@ -4,7 +4,9 @@ const FETCH_TIMEOUT_MS = 5_000; const AIRPORT_DATA_TIMEOUT_MS = 5_000; const JETAPI_TIMEOUT_MS = 5_000; const HEX_REGEX = /^[0-9a-f]{6}$/; -const REG_REGEX = /^[A-Z0-9][-A-Z0-9]{1,6}$/; +const REG_REGEX = /^[A-Z0-9][A-Z0-9-]{1,9}$/; +const UPSTREAM_USER_AGENT = + "AerisFlightTracker/0.8 (+https://github.com/kewonit/aeris)"; // ── Upstream types ────────────────────────────────────────────────────────── @@ -44,6 +46,14 @@ type AdsbdbResponse = { }; }; +type HexdbAircraft = { + Registration?: string; + Manufacturer?: string; + ICAOTypeCode?: string; + Type?: string; + RegisteredOwners?: string; +}; + // ── Output types ──────────────────────────────────────────────────────────── type NormalizedPhoto = { @@ -86,6 +96,18 @@ function extractSrc(value: unknown): string | null { return null; } +function normalizeRegistration(value: string | null | undefined): string | null { + const normalized = value?.trim().toUpperCase().replace(/\s+/g, "") ?? ""; + return normalized && REG_REGEX.test(normalized) ? normalized : null; +} + +function fulfilledOr( + result: PromiseSettledResult, + fallback: T, +): T { + return result.status === "fulfilled" ? result.value : fallback; +} + async function fetchWithTimeout( url: string, timeoutMs: number, @@ -96,7 +118,10 @@ async function fetchWithTimeout( try { return await fetch(url, { signal: controller.signal, - headers: { Accept: "application/json" }, + headers: { + Accept: "application/json", + "User-Agent": UPSTREAM_USER_AGENT, + }, }); } finally { clearTimeout(timer); @@ -127,10 +152,13 @@ function sanitizePhoto(photo: NormalizedPhoto): NormalizedPhoto | null { // ── Planespotters.net ─────────────────────────────────────────────────────── -async function fetchPlanespotters(hex: string): Promise { +async function fetchPlanespotters( + identifier: string, + lookup: "hex" | "reg" = "hex", +): Promise { try { const res = await fetchWithTimeout( - `https://api.planespotters.net/pub/photos/hex/${encodeURIComponent(hex)}`, + `https://api.planespotters.net/pub/photos/${lookup}/${encodeURIComponent(identifier)}`, FETCH_TIMEOUT_MS, ); if (!res.ok) return []; @@ -152,7 +180,7 @@ async function fetchPlanespotters(hex: string): Promise { seenUrls.add(fullUrl); photos.push({ - id: `ps-${typeof p.id === "string" || typeof p.id === "number" ? p.id : photos.length}`, + id: `ps-${lookup}-${typeof p.id === "string" || typeof p.id === "number" ? p.id : photos.length}`, url: fullUrl, thumbnail: thumbSrc ?? largeSrc ?? src, photographer: @@ -188,26 +216,25 @@ async function fetchAdsbdb(hex: string): Promise<{ const ac = data?.response?.aircraft; if (!ac) return { aircraft: null, photo: null }; - const registration = - typeof ac.registration === "string" && ac.registration - ? ac.registration - : null; - if (!registration) return { aircraft: null, photo: null }; - - const aircraft: AircraftDetails = { - registration, - manufacturer: - typeof ac.manufacturer === "string" && ac.manufacturer - ? ac.manufacturer - : null, - type: typeof ac.type === "string" && ac.type ? ac.type : null, - typeCode: - typeof ac.icao_type === "string" && ac.icao_type ? ac.icao_type : null, - owner: - typeof ac.registered_owner === "string" && ac.registered_owner - ? ac.registered_owner - : null, - }; + const registration = normalizeRegistration(ac.registration); + const aircraft: AircraftDetails | null = registration + ? { + registration, + manufacturer: + typeof ac.manufacturer === "string" && ac.manufacturer + ? ac.manufacturer + : null, + type: typeof ac.type === "string" && ac.type ? ac.type : null, + typeCode: + typeof ac.icao_type === "string" && ac.icao_type + ? ac.icao_type + : null, + owner: + typeof ac.registered_owner === "string" && ac.registered_owner + ? ac.registered_owner + : null, + } + : null; let photo: NormalizedPhoto | null = null; if (typeof ac.url_photo === "string" && ac.url_photo) { @@ -231,6 +258,41 @@ async function fetchAdsbdb(hex: string): Promise<{ } } +// ── hexdb.io (metadata fallback) ───────────────────────────────────────────── + +async function fetchHexdbAircraft(hex: string): Promise { + try { + const res = await fetchWithTimeout( + `https://hexdb.io/api/v1/aircraft/${encodeURIComponent(hex)}`, + FETCH_TIMEOUT_MS, + ); + if (!res.ok) return null; + + const ac = (await res.json()) as HexdbAircraft; + const registration = normalizeRegistration(ac.Registration); + if (!registration) return null; + + return { + registration, + manufacturer: + typeof ac.Manufacturer === "string" && ac.Manufacturer + ? ac.Manufacturer + : null, + type: typeof ac.Type === "string" && ac.Type ? ac.Type : null, + typeCode: + typeof ac.ICAOTypeCode === "string" && ac.ICAOTypeCode + ? ac.ICAOTypeCode + : null, + owner: + typeof ac.RegisteredOwners === "string" && ac.RegisteredOwners + ? ac.RegisteredOwners + : null, + }; + } catch { + return null; + } +} + // ── airport-data.com (additional photos) ──────────────────────────────────── type AirportDataEntry = { @@ -240,10 +302,10 @@ type AirportDataEntry = { photographer?: string; }; -async function fetchAirportData(hex: string): Promise { +async function fetchAirportData(identifier: string): Promise { try { const res = await fetchWithTimeout( - `https://www.airport-data.com/api/ac_thumb.json?m=${encodeURIComponent(hex)}&n=5`, + `https://www.airport-data.com/api/ac_thumb.json?m=${encodeURIComponent(identifier)}&n=5`, AIRPORT_DATA_TIMEOUT_MS, ); if (!res.ok) return []; @@ -274,7 +336,7 @@ async function fetchAirportData(hex: string): Promise { seenUrls.add(imageUrl); photos.push({ - id: `apd-${photos.length}-${hex}`, + id: `apd-${photos.length}-${identifier}`, url: imageUrl, thumbnail: typeof entry.thumbnail === "string" && entry.thumbnail @@ -312,6 +374,8 @@ type JetApiImage = { }; type JetApiResponse = { + Reg?: string; + Images?: JetApiImage[]; JetPhotos?: { Reg?: string; Images?: JetApiImage[]; @@ -327,7 +391,8 @@ async function fetchJetApi(reg: string): Promise { if (!res.ok) return []; const data = (await res.json()) as JetApiResponse; - const images = data?.JetPhotos?.Images; + const payload = data.JetPhotos ?? data; + const images = payload?.Images; if (!images || !Array.isArray(images)) return []; const photos: NormalizedPhoto[] = []; @@ -373,8 +438,9 @@ async function fetchJetApi(reg: string): Promise { export async function GET(request: NextRequest): Promise { const hex = request.nextUrl.searchParams.get("hex")?.trim().toLowerCase(); - const reg = - request.nextUrl.searchParams.get("reg")?.trim().toUpperCase() || null; + const validReg = normalizeRegistration( + request.nextUrl.searchParams.get("reg"), + ); if (!hex || !HEX_REGEX.test(hex)) { return NextResponse.json( @@ -383,28 +449,50 @@ export async function GET(request: NextRequest): Promise { ); } - // Validate registration - skip JetAPI if invalid - const validReg = reg && REG_REGEX.test(reg) ? reg : null; - - const [psResult, adbResult, apdResult, jpResult] = await Promise.allSettled([ - fetchPlanespotters(hex), + const [ + psResult, + adbResult, + hexdbResult, + apdInitialResult, + jpInitialResult, + ] = await Promise.allSettled([ + fetchPlanespotters(hex, "hex"), fetchAdsbdb(hex), - fetchAirportData(hex), + validReg ? Promise.resolve(null) : fetchHexdbAircraft(hex), + validReg + ? fetchAirportData(validReg) + : Promise.resolve([] as NormalizedPhoto[]), validReg ? fetchJetApi(validReg) : Promise.resolve([] as NormalizedPhoto[]), ]); - const planespottersPhotos = - psResult.status === "fulfilled" ? psResult.value : []; - const adsbdb = - adbResult.status === "fulfilled" - ? adbResult.value - : { aircraft: null, photo: null }; - const airportDataPhotos = - apdResult.status === "fulfilled" ? apdResult.value : []; - const jetApiPhotos = jpResult.status === "fulfilled" ? jpResult.value : []; - - // Priority: JetAPI (full-res, multiple) → adsbdb (full-res) → - // airport-data (full-res) → planespotters (low-res fallback). + let planespottersPhotos = fulfilledOr(psResult, []); + const adsbdb = fulfilledOr(adbResult, { aircraft: null, photo: null }); + const hexdbAircraft = fulfilledOr(hexdbResult, null); + let airportDataPhotos = fulfilledOr(apdInitialResult, []); + let jetApiPhotos = fulfilledOr(jpInitialResult, []); + + const aircraft = adsbdb.aircraft ?? hexdbAircraft; + const lookupReg = validReg ?? aircraft?.registration ?? null; + + if (!validReg && lookupReg) { + const [apdDerivedResult, jpDerivedResult] = await Promise.allSettled([ + fetchAirportData(lookupReg), + fetchJetApi(lookupReg), + ]); + airportDataPhotos = fulfilledOr(apdDerivedResult, []); + jetApiPhotos = fulfilledOr(jpDerivedResult, []); + } + + if (lookupReg && planespottersPhotos.length === 0) { + const [regPlanespottersResult] = await Promise.allSettled([ + fetchPlanespotters(lookupReg, "reg"), + ]); + planespottersPhotos = fulfilledOr(regPlanespottersResult, []); + } + + // Priority: JetAPI (current full-res JetPhotos) -> Planespotters + // thumbnails -> adsbdb/airport-data direct URLs. adsbdb currently sources + // many photos from airport-data, whose historical direct URLs can go stale. // All photos are sanitized to strip dangerous URI schemes (XSS). const seenUrls = new Set(); const photos: NormalizedPhoto[] = []; @@ -418,13 +506,13 @@ export async function GET(request: NextRequest): Promise { } for (const p of jetApiPhotos) addPhoto(p); + for (const p of planespottersPhotos) addPhoto(p); if (adsbdb.photo) addPhoto(adsbdb.photo); for (const p of airportDataPhotos) addPhoto(p); - for (const p of planespottersPhotos) addPhoto(p); const response: AircraftPhotosResponse = { photos, - aircraft: adsbdb.aircraft, + aircraft, }; return NextResponse.json(response, { diff --git a/src/app/api/routes/route.ts b/src/app/api/routes/route.ts index cdc8e10..8533e2a 100644 --- a/src/app/api/routes/route.ts +++ b/src/app/api/routes/route.ts @@ -8,6 +8,8 @@ const ROUTE_HIT_CACHE_CONTROL = "public, max-age=300, s-maxage=900, stale-while-revalidate=1800"; const ROUTE_MISS_CACHE_CONTROL = "public, max-age=60, s-maxage=120"; +const SOURCES = ["adsbdb", "hexdb", "opensky"]; + export async function GET(request: NextRequest): Promise { const callsign = normalizeRouteCallsign( request.nextUrl.searchParams.get("callsign"), @@ -29,7 +31,7 @@ export async function GET(request: NextRequest): Promise { { error: "Route lookup temporarily unavailable", callsign, - sources: ["adsbdb", "hexdb"], + sources: SOURCES, }, { status: 503, headers: { "Cache-Control": "no-store" } }, ); @@ -39,7 +41,7 @@ export async function GET(request: NextRequest): Promise { { error: "Route unavailable", callsign, - sources: ["adsbdb", "hexdb"], + sources: SOURCES, }, { status: 404, diff --git a/src/components/flight-tracker.tsx b/src/components/flight-tracker.tsx index 53d2f7d..5797ba1 100644 --- a/src/components/flight-tracker.tsx +++ b/src/components/flight-tracker.tsx @@ -56,7 +56,6 @@ import type { FlightState } from "@/lib/opensky"; import { fetchFlightByHex, fetchFlightByCallsign } from "@/lib/flight-api"; import { expandFlightQuery, flightQueryMatches } from "@/lib/airlines"; -import { processDepartures } from "@/lib/route-detection"; import { findNearestAirport, airportToCity } from "@/lib/airports"; import { computeAirspaceBounds, @@ -236,11 +235,6 @@ function FlightTrackerInner({ const mergedTrails = trailState.trails; const selectedTrack = trailState.selectedTrack; - // Feed flights into departure detection for route estimation - useEffect(() => { - if (displayFlights.length > 0) processDepartures(displayFlights); - }, [displayFlights]); - // Single Map for O(1) flight lookups - replaces 4× O(n) find() calls per poll const displayFlightMap = useMemo(() => { const m = new Map(); @@ -641,7 +635,7 @@ function FlightTrackerInner({ )} {!fpvIcao24 && !isMobile && ( -
+
{airportBoard.isActive && !displayFlight ? ( = { + autopilot: "AP", + althold: "ALT HLD", + vnav: "VNAV", + lnav: "LNAV", + approach: "APP", + tcas: "TCAS", +}; + +const NAV_MODE_ICONS: Record = { + autopilot: , + althold: , + vnav: , + lnav: , + approach: , + tcas: , +}; + +function navModeLabel(mode: string): string { + return NAV_MODE_LABELS[mode.toLowerCase()] ?? mode.toUpperCase(); +} + +function navModeIcon(mode: string): React.ReactNode { + return NAV_MODE_ICONS[mode.toLowerCase()] ?? ; +} + +function navModeStyle(mode: string): string { + const m = mode.toLowerCase(); + if (m === "tcas") + return "border-amber-500/30 bg-amber-500/10 text-amber-400"; + if (m === "autopilot") + return "border-sky-500/25 bg-sky-500/10 text-sky-400/90"; + return "border-emerald-500/25 bg-emerald-500/10 text-emerald-400/90"; +} + +function DataCard({ + icon, + label, + value, + unit, + subvalue, + highlight = false, +}: { + icon: React.ReactNode; + label: string; + value: string | number; + unit?: string; + subvalue?: string | null; + highlight?: boolean; +}) { + return ( +
+
+ {icon} + + {label} + +
+

+ {value} + {unit ? ( + + {unit} + + ) : null} +

+ {subvalue ? ( +

{subvalue}

+ ) : null} +
+ ); +} + +function ModeBadge({ mode }: { mode: string }) { + return ( + + {navModeIcon(mode)} + {navModeLabel(mode)} + + ); +} + +export function AvionicsSection({ + flight, + unitSystem, +}: AvionicsSectionProps) { + const [open, setOpen] = useState(false); + + const iasValue = speedValueFromKnots(flight.ias, unitSystem); + const machNum = + typeof flight.mach === "number" && Number.isFinite(flight.mach) + ? flight.mach + : null; + const windDir = + typeof flight.windDirection === "number" && + Number.isFinite(flight.windDirection) + ? flight.windDirection + : null; + const windSpd = speedValueFromKnots(flight.windSpeed, unitSystem); + const oatC = + typeof flight.oat === "number" && Number.isFinite(flight.oat) + ? flight.oat + : null; + const rollDeg = + typeof flight.roll === "number" && Number.isFinite(flight.roll) + ? flight.roll + : null; + const trackRateVal = + typeof flight.trackRate === "number" && Number.isFinite(flight.trackRate) + ? flight.trackRate + : null; + const qnhValue = + typeof flight.navQnh === "number" && Number.isFinite(flight.navQnh) + ? flight.navQnh + : null; + + const navModes = + flight.navModes && flight.navModes.length > 0 ? flight.navModes : null; + const mcpAlt = altitudeValueFromFeet(flight.navAltitudeMcp, unitSystem); + const fmsAlt = altitudeValueFromFeet(flight.navAltitudeFms, unitSystem); + const selHdg = + typeof flight.navHeading === "number" && + Number.isFinite(flight.navHeading) + ? Math.round(flight.navHeading) + : null; + + const hasFlightData = + iasValue !== null || + machNum !== null || + (windDir !== null && windSpd !== null) || + oatC !== null || + (rollDeg !== null && Math.abs(rollDeg) > 1) || + (trackRateVal !== null && Math.abs(trackRateVal) >= 0.1) || + qnhValue !== null; + + const hasAutopilot = + (navModes !== null && navModes.length > 0) || + mcpAlt !== null || + fmsAlt !== null || + selHdg !== null; + + if (!hasFlightData && !hasAutopilot) return null; + + const altUnit = altitudeUnitLabel(unitSystem); + const spdUnit = speedUnitLabel(unitSystem); + + const bankText = + rollDeg !== null && Math.abs(rollDeg) > 1 + ? `${rollDeg > 0 ? "R" : "L"}${Math.round(Math.abs(rollDeg))}°` + : null; + const turnText = + trackRateVal !== null && Math.abs(trackRateVal) >= 0.1 + ? `${trackRateVal > 0 ? "R" : "L"}${Math.abs(trackRateVal).toFixed(1)}°/s` + : null; + const bankDisplay = [bankText, turnText].filter(Boolean).join(" · ") || null; + + return ( +
+ + + + {open && ( + +
+ {/* ── Flight Instruments ── */} + {hasFlightData && ( +
+
+
+ + Flight Instruments + +
+
+
+ {iasValue !== null && ( + } + label="Airspeed" + value={iasValue} + unit={spdUnit} + /> + )} + {machNum !== null && ( + } + label="Mach" + value={machNum.toFixed(2)} + /> + )} + {windDir !== null && windSpd !== null && ( + } + label="Wind" + value={`${Math.round(windDir)}° / ${Math.round(windSpd)}`} + unit={spdUnit} + /> + )} + {oatC !== null && ( + } + label="OAT" + value={formatTemperatureC(oatC, unitSystem)} + /> + )} + {bankDisplay && ( + } + label="Bank" + value={bankDisplay} + /> + )} + {qnhValue !== null && ( + } + label="Altimeter" + value={formatPressureHpa(qnhValue, unitSystem)} + /> + )} +
+
+ )} + + {/* ── Autopilot ── */} + {hasAutopilot && ( +
+
+
+ + Autopilot + +
+
+ + {navModes !== null && navModes.length > 0 && ( +
+ {navModes.map((mode) => ( + + ))} +
+ )} + +
+ {mcpAlt !== null && ( + } + label="MCP ALT" + value={mcpAlt.toLocaleString()} + unit={altUnit} + highlight + /> + )} + {fmsAlt !== null && + (mcpAlt === null || fmsAlt !== mcpAlt) && ( + } + label="FMS ALT" + value={fmsAlt.toLocaleString()} + unit={altUnit} + highlight + /> + )} + {selHdg !== null && ( + } + label="SEL HDG" + value={`${selHdg}°`} + highlight + /> + )} +
+
+ )} +
+ + )} + +
+ ); +} diff --git a/src/components/ui/control-panel-search.tsx b/src/components/ui/control-panel-search.tsx index 7a17d51..b58f055 100644 --- a/src/components/ui/control-panel-search.tsx +++ b/src/components/ui/control-panel-search.tsx @@ -25,6 +25,7 @@ import { formatAltitude, formatSpeed } from "@/lib/unit-formatters"; import { lookupAirline, flightQueryMatches } from "@/lib/airlines"; import { CountryFlag } from "@/components/ui/country-flag"; import { AirlineLogo } from "@/components/ui/airline-logo"; +import { PositionSourceBadge } from "@/components/ui/flight-badges"; import { searchFlightsGlobal } from "@/lib/search-flight-client"; import { searchLocalLocations, @@ -71,7 +72,7 @@ function AltitudeDot({ altitude }: { altitude: number | null }) { ); } -// ── Segmented Control (Apple-style) ──────────────────────────────────── +// ── Segmented Control ──────────────────────────────────── function SegmentedControl({ value, @@ -390,6 +391,7 @@ export function SearchContent({ Global )} +
@@ -398,6 +400,22 @@ export function SearchContent({ query={query} /> + {(flight.typeCode || flight.typeDescription) && ( + <> + · + + {flight.typeCode ?? flight.typeDescription} + + + )} + {flight.registration && ( + <> + · + + {flight.registration} + + + )} · + {/* ── Advanced ── */} + + + } + title="Show debug data" + description="Display raw receiver metrics and route source" + checked={settings.showDebugData} + onChange={(v) => update("showDebugData", v)} + /> +
diff --git a/src/components/ui/debug-data-section.tsx b/src/components/ui/debug-data-section.tsx new file mode 100644 index 0000000..8fe8a39 --- /dev/null +++ b/src/components/ui/debug-data-section.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { + Signal, + Timer, + MessageSquare, + Shield, + AlertTriangle, + Cpu, +} from "lucide-react"; +import type { FlightDebugData } from "@/lib/opensky"; + +type DebugDataSectionProps = { + data: FlightDebugData | null | undefined; +}; + +function DebugChip({ + icon, + label, + value, + unit, +}: { + icon: React.ReactNode; + label: string; + value: string | number | null | undefined; + unit?: string; +}) { + if (value == null) return null; + return ( +
+ {icon} + + {label} + + + {value} + {unit ? {unit} : null} + +
+ ); +} + +/** + * Debug data section for FlightCard. + * Shows raw receiver/integrity metrics: NIC, NAC, SIL, version, alert, + * messages, seen, rssi. + * + * Returns null if no debug data is available. + */ +export function DebugDataSection({ data }: DebugDataSectionProps) { + if (!data) return null; + + const hasAnyData = + data.nic != null || + data.nacP != null || + data.nacV != null || + data.sil != null || + data.version != null || + (data.alert != null && data.alert !== 0) || + data.messages != null || + data.seen != null || + data.rssi != null; + + if (!hasAnyData) return null; + + return ( +
+
+ + + Raw Data + +
+
+ } label="NIC" value={data.nic} /> + } label="NAC-P" value={data.nacP} /> + } label="NAC-V" value={data.nacV} /> + } label="SIL" value={data.sil} /> + } label="VER" value={data.version} /> + {/* Only show alert when active (non-zero) */} + {data.alert !== null && data.alert !== 0 && ( + } + label="ALERT" + value={data.alert} + /> + )} + } + label="MSG" + value={data.messages} + /> + } + label="SEEN" + value={data.seen} + unit="s" + /> + } + label="RSSI" + value={data.rssi} + unit="dB" + /> +
+
+ ); +} diff --git a/src/components/ui/flight-badges.tsx b/src/components/ui/flight-badges.tsx new file mode 100644 index 0000000..b0bd172 --- /dev/null +++ b/src/components/ui/flight-badges.tsx @@ -0,0 +1,152 @@ +"use client"; + +// ── Flight Badges ────────────────────────────────────────────────────── +// +// Small, source-aware badges for flight metadata. +// All badges return null when data is unavailable — they never render +// placeholders or "-". +// ──────────────────────────────────────────────────────────────────────── + +import type { PositionSource } from "@/lib/opensky-types"; + +type BadgeProps = { + source: PositionSource; +}; + +const SOURCE_STYLES: Record< + NonNullable, + { label: string; className: string } +> = { + adsb: { + label: "ADS-B", + className: + "border-emerald-500/25 bg-emerald-500/10 text-emerald-400/80", + }, + asterix: { + label: "ASTERIX", + className: "border-sky-500/25 bg-sky-500/10 text-sky-400/80", + }, + mlat: { + label: "MLAT", + className: "border-amber-500/25 bg-amber-500/10 text-amber-400/80", + }, + flarm: { + label: "FLARM", + className: "border-violet-500/25 bg-violet-500/10 text-violet-400/80", + }, + tisb: { + label: "TIS-B", + className: "border-orange-500/25 bg-orange-500/10 text-orange-400/80", + }, + adsc: { + label: "ADS-C", + className: "border-cyan-500/25 bg-cyan-500/10 text-cyan-400/80", + }, + other: { + label: "OTHER", + className: "border-foreground/10 bg-foreground/[0.04] text-foreground/30", + }, +}; + +/** Position source badge — ADS-B, MLAT, TIS-B, etc. */ +export function PositionSourceBadge({ source }: BadgeProps) { + if (!source) return null; + const style = SOURCE_STYLES[source]; + if (!style) return null; + + return ( + + {style.label} + + ); +} + +/** Ground indicator — shown only when aircraft is on ground. */ +export function OnGroundBadge({ onGround }: { onGround: boolean }) { + if (!onGround) return null; + + return ( + + GND + + ); +} + +/** + * Aircraft type + registration line. + * + * Examples: + * "Boeing 737-800 · G-EUUY" + * "B738 · N38451" + * "G-EUUY" (registration only) + * null (nothing available) + */ +export function AircraftTypeLine({ + typeCode, + typeDescription, + registration, +}: { + typeCode: string | null | undefined; + typeDescription: string | null | undefined; + registration: string | null | undefined; +}) { + const tc = typeCode?.trim(); + const td = typeDescription?.trim(); + const reg = registration?.trim(); + + // Use description if available, otherwise type code + const typeLabel = td ?? tc; + + if (!typeLabel && !reg) return null; + + return ( +

+ {typeLabel} + {typeLabel && reg ? ( + · + ) : null} + {reg ? ( + + {reg} + + ) : null} +

+ ); +} + +/** + * Manufacturer + owner line. + * Only shows when different from the airline name to avoid duplication. + */ +export function AircraftOperatorLine({ + manufacturer, + owner, + airlineName, +}: { + manufacturer: string | null | undefined; + owner: string | null | undefined; + airlineName: string | null | undefined; +}) { + const mfr = manufacturer?.trim(); + const own = owner?.trim(); + const airline = airlineName?.trim(); + + // Filter out values that duplicate the airline name + const parts: string[] = []; + if (mfr) parts.push(mfr); + if (own && own !== airline) parts.push(own); + + if (parts.length === 0) return null; + + return ( +

+ {parts.join(" · ")} +

+ ); +} diff --git a/src/components/ui/flight-card.tsx b/src/components/ui/flight-card.tsx index 06a3ef5..e5df28f 100644 --- a/src/components/ui/flight-card.tsx +++ b/src/components/ui/flight-card.tsx @@ -11,7 +11,6 @@ import { Globe, X, Navigation, - Building2, Eye, ChevronRight, ChevronDown, @@ -23,7 +22,7 @@ import { useAircraftPhotos } from "@/hooks/use-aircraft-photos"; import type { FlightRouteInfo } from "@/hooks/use-route-info"; import { AircraftPhotos } from "@/components/ui/aircraft-photos"; import { HeroBanner } from "@/components/ui/hero-banner"; -import type { FlightState, FlightTrack } from "@/lib/opensky"; +import type { FlightState } from "@/lib/opensky"; import { VerticalProfile } from "@/components/ui/vertical-profile"; import type { TrailEntry } from "@/hooks/use-trail-history"; import { useSettings } from "@/hooks/use-settings"; @@ -31,8 +30,7 @@ import { formatCallsign, headingToCardinal, } from "@/lib/flight-utils"; -import { lookupAirline, parseFlightNumber } from "@/lib/airlines"; -import { aircraftTypeHint } from "@/lib/aircraft"; +import { lookupAirline } from "@/lib/airlines"; import { airlineLogoCandidates } from "@/lib/airline-logos"; import { loadedAirlineLogoUrls, @@ -42,16 +40,26 @@ import { } from "@/lib/logo-cache"; import { useRouteInfo } from "@/hooks/use-route-info"; import { formatAirportCode } from "@/lib/route-lookup"; +import { + PositionSourceBadge, + OnGroundBadge, + AircraftTypeLine, + AircraftOperatorLine, +} from "@/components/ui/flight-badges"; import { formatAltitude, formatSpeed, + formatSpeedFromKnots, formatVerticalSpeed, } from "@/lib/unit-formatters"; +import { AvionicsSection } from "@/components/ui/avionics-section"; +import { FlightWeatherSection } from "@/components/ui/flight-weather-section"; +import { DebugDataSection } from "@/components/ui/debug-data-section"; +import { ScrollArea } from "@/components/ui/scroll-area"; type FlightCardProps = { flight: FlightState | null; trail?: TrailEntry | null; - track?: FlightTrack | null; onClose: () => void; onToggleFpv?: (icao24: string) => void; isFpvActive?: boolean; @@ -60,18 +68,16 @@ type FlightCardProps = { export function FlightCard({ flight, trail, - track, onClose, onToggleFpv, isFpvActive = false, }: FlightCardProps) { const { settings } = useSettings(); - const routeInfo = useRouteInfo(flight, track); + const routeInfo = useRouteInfo(flight); const airline = flight ? lookupAirline(flight.callsign) : null; - const flightNum = flight ? parseFlightNumber(flight.callsign) : null; const company = airline ?? (flight ? `${flight.originCountry} operator` : null); - const model = flight ? aircraftTypeHint(flight.category) : null; + // Type display uses typeCode/typeDescription from API; aircraftTypeHint is fallback const logoCandidates = airlineLogoCandidates(airline, flight?.callsign); const heading = flight?.trueTrack ?? null; const cardinal = heading !== null ? headingToCardinal(heading) : null; @@ -114,7 +120,6 @@ export function FlightCard({ loading: photosLoading, error: photosError, } = useAircraftPhotos(flight?.icao24 ?? null, flight?.registration); - const heroPhoto = photos[0] ?? null; const [vpOpen, setVpOpen] = useState(false); return ( @@ -131,352 +136,391 @@ export function FlightCard({ damping: 28, mass: 0.8, }} - className="w-72 sm:w-80" + className="h-[calc(100dvh-9rem)] max-h-full w-72 sm:w-80" role="complementary" aria-label="Selected flight details" aria-live="polite" > -
- - -
-
-
- {showLogo ? ( - - {!logoLoaded && ( -