Added Charts
This commit is contained in:
Generated
+323
-1
@@ -6,13 +6,19 @@
|
|||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/font": "^7.4.47",
|
"@mdi/font": "^7.4.47",
|
||||||
|
"apexcharts": "^5.10.5",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
"flag-icons": "^7.5.0",
|
"flag-icons": "^7.5.0",
|
||||||
|
"maplibre-gl": "^5.22.0",
|
||||||
|
"vue-echarts": "^8.0.1",
|
||||||
|
"vue3-apexcharts": "^1.11.1",
|
||||||
"vuetify": "^4.0.5"
|
"vuetify": "^4.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@inertiajs/vue3": "^2.0.0",
|
"@inertiajs/vue3": "^2.0.0",
|
||||||
"@tailwindcss/forms": "^0.5.3",
|
"@tailwindcss/forms": "^0.5.3",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.0",
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
"autoprefixer": "^10.4.12",
|
"autoprefixer": "^10.4.12",
|
||||||
@@ -176,6 +182,111 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/point-geometry": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/tiny-sdf": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-uFJhNh36BR4OCuWIEiWaEix9CA2WzT6CAIcqVjWYpnx8+QDtS+oC4QehRrx5cX4mgWs37MmKnwUejeHxVymzNg==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/unitbezier": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/vector-tile": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "~1.1.0",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"pbf": "^4.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/whoots-js": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/geojson-vt": {
|
||||||
|
"version": "6.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.0.4.tgz",
|
||||||
|
"integrity": "sha512-HYv3POhMRCdhP3UPPATM/hfcy6/WuVIf5FKboH8u/ZuFMTnAIcSVlq5nfOqroLokd925w2QtE7YwquFOIacwVQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"kdbush": "^4.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||||
|
"version": "24.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.8.1.tgz",
|
||||||
|
"integrity": "sha512-zxa92qF96ZNojLxeAjnaRpjVCy+swoUNJvDhtpC90k7u5F0TMr4GmvNqMKvYrMoPB8d7gRSXbMG1hBbmgESIsw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
||||||
|
"@mapbox/unitbezier": "^0.0.1",
|
||||||
|
"json-stringify-pretty-compact": "^4.0.0",
|
||||||
|
"minimist": "^1.2.8",
|
||||||
|
"quickselect": "^3.0.0",
|
||||||
|
"rw": "^1.3.3",
|
||||||
|
"tinyqueue": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"gl-style-format": "dist/gl-style-format.mjs",
|
||||||
|
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||||
|
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/mlt": {
|
||||||
|
"version": "1.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.8.tgz",
|
||||||
|
"integrity": "sha512-8vtfYGidr1rNkv5IwIoU2lfe3Oy+Wa8HluzQYcQi9cveU9K3pweAal/poQj4GJ0K/EW4bTQp2wVAs09g2yDRZg==",
|
||||||
|
"license": "(MIT OR Apache-2.0)",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/vt-pbf": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "^1.1.0",
|
||||||
|
"@mapbox/vector-tile": "^2.0.4",
|
||||||
|
"@maplibre/geojson-vt": "^5.0.4",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"@types/supercluster": "^7.1.3",
|
||||||
|
"pbf": "^4.0.1",
|
||||||
|
"supercluster": "^8.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/@mdi/font": {
|
"node_modules/@mdi/font": {
|
||||||
"version": "7.4.47",
|
"version": "7.4.47",
|
||||||
"resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz",
|
"resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz",
|
||||||
@@ -804,6 +915,22 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/leaflet": {
|
||||||
|
"version": "1.9.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||||
|
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/lodash": {
|
"node_modules/@types/lodash": {
|
||||||
"version": "4.17.24",
|
"version": "4.17.24",
|
||||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
|
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
|
||||||
@@ -832,6 +959,15 @@
|
|||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/supercluster": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-vue": {
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
"version": "6.0.5",
|
"version": "6.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz",
|
||||||
@@ -1030,6 +1166,13 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/apexcharts": {
|
||||||
|
"version": "5.10.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.10.5.tgz",
|
||||||
|
"integrity": "sha512-RirosfLQLqYpWBdn4Pdv9B1M0M2FepzVxPRpcuXQPTilvuZvKt02vgVlEexhCVu2p4fApDIV/3yC9voAIK+qjw==",
|
||||||
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/arg": {
|
"node_modules/arg": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||||
@@ -2499,6 +2642,28 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/earcut": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/echarts": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.3.0",
|
||||||
|
"zrender": "6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/echarts/node_modules/tslib": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.331",
|
"version": "1.5.331",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz",
|
||||||
@@ -2835,6 +3000,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gl-matrix": {
|
||||||
|
"version": "3.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||||
|
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@@ -3096,6 +3267,12 @@
|
|||||||
"jsesc": "bin/jsesc"
|
"jsesc": "bin/jsesc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/json-stringify-pretty-compact": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/json5": {
|
"node_modules/json5": {
|
||||||
"version": "0.5.1",
|
"version": "0.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz",
|
||||||
@@ -3106,6 +3283,12 @@
|
|||||||
"json5": "lib/cli.js"
|
"json5": "lib/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kdbush": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/laravel-precognition": {
|
"node_modules/laravel-precognition": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-1.0.2.tgz",
|
||||||
@@ -3455,6 +3638,40 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/maplibre-gl": {
|
||||||
|
"version": "5.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.22.0.tgz",
|
||||||
|
"integrity": "sha512-nc8YA+YSEioMZg5W0cb6Cf3wQ8aJge66dsttyBgpOArOnlmFJO1Kc5G32kYVPeUYhLpBja83T99uanmJvYAIyQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||||
|
"@mapbox/point-geometry": "^1.1.0",
|
||||||
|
"@mapbox/tiny-sdf": "^2.0.7",
|
||||||
|
"@mapbox/unitbezier": "^0.0.1",
|
||||||
|
"@mapbox/vector-tile": "^2.0.4",
|
||||||
|
"@mapbox/whoots-js": "^3.1.0",
|
||||||
|
"@maplibre/geojson-vt": "^6.0.4",
|
||||||
|
"@maplibre/maplibre-gl-style-spec": "^24.8.1",
|
||||||
|
"@maplibre/mlt": "^1.1.8",
|
||||||
|
"@maplibre/vt-pbf": "^4.3.0",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"earcut": "^3.0.2",
|
||||||
|
"gl-matrix": "^3.4.4",
|
||||||
|
"kdbush": "^4.0.2",
|
||||||
|
"murmurhash-js": "^1.0.0",
|
||||||
|
"pbf": "^4.0.1",
|
||||||
|
"potpack": "^2.1.0",
|
||||||
|
"quickselect": "^3.0.0",
|
||||||
|
"tinyqueue": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.14.0",
|
||||||
|
"npm": ">=8.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -3552,7 +3769,6 @@
|
|||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@@ -3578,6 +3794,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/murmurhash-js": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/mz": {
|
"node_modules/mz": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||||
@@ -3695,6 +3917,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pbf": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"resolve-protobuf-schema": "^2.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pbf": "bin/pbf"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -3898,6 +4132,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/potpack": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/private": {
|
"node_modules/private": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz",
|
||||||
@@ -3908,6 +4148,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/protocol-buffers-schema": {
|
||||||
|
"version": "3.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||||
|
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
@@ -3962,6 +4208,12 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/quickselect": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@@ -4109,6 +4361,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resolve-protobuf-schema": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"protocol-buffers-schema": "^3.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reusify": {
|
"node_modules/reusify": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||||
@@ -4178,6 +4439,12 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"queue-microtask": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rw": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/rxjs": {
|
"node_modules/rxjs": {
|
||||||
"version": "7.8.2",
|
"version": "7.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
@@ -4390,6 +4657,15 @@
|
|||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/supercluster": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"kdbush": "^4.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "8.1.1",
|
"version": "8.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||||
@@ -4522,6 +4798,12 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyqueue": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/to-fast-properties": {
|
"node_modules/to-fast-properties": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz",
|
||||||
@@ -4821,6 +5103,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-echarts": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"echarts": "^6.0.0",
|
||||||
|
"vue": "^3.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vue3-apexcharts": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue3-apexcharts/-/vue3-apexcharts-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-MbN3vg8bMG19wc0Lm1HkeQvODgLm56DgpIxtNUO0xpf/JCzYWVGE4jzXp2JISzy2s3Kul1yOxNQUYsLvKQ5L9g==",
|
||||||
|
"license": "see LICENSE in LICENSE",
|
||||||
|
"peerDependencies": {
|
||||||
|
"apexcharts": ">=5.10.0",
|
||||||
|
"vue": ">=3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"apexcharts": {
|
||||||
|
"optional": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vuetify": {
|
"node_modules/vuetify": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.0.5.tgz",
|
||||||
@@ -4925,6 +5232,21 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zrender": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zrender/node_modules/tslib": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||||
|
"license": "0BSD"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"@inertiajs/vue3": "^2.0.0",
|
"@inertiajs/vue3": "^2.0.0",
|
||||||
"@tailwindcss/forms": "^0.5.3",
|
"@tailwindcss/forms": "^0.5.3",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.0",
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
"autoprefixer": "^10.4.12",
|
"autoprefixer": "^10.4.12",
|
||||||
@@ -27,7 +28,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/font": "^7.4.47",
|
"@mdi/font": "^7.4.47",
|
||||||
|
"apexcharts": "^5.10.5",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
"flag-icons": "^7.5.0",
|
"flag-icons": "^7.5.0",
|
||||||
|
"maplibre-gl": "^5.22.0",
|
||||||
|
"vue-echarts": "^8.0.1",
|
||||||
|
"vue3-apexcharts": "^1.11.1",
|
||||||
"vuetify": "^4.0.5"
|
"vuetify": "^4.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ a {
|
|||||||
|
|
||||||
|
|
||||||
.glass {
|
.glass {
|
||||||
background: rgba(17, 24, 39, 0.2); /* --surface at 60% */
|
background: rgb(0 12 41 / 0.6);
|
||||||
-webkit-backdrop-filter: blur(12px) saturate(180%);
|
-webkit-backdrop-filter: blur(12px) saturate(180%);
|
||||||
backdrop-filter: blur(12px) saturate(180%);
|
backdrop-filter: blur(12px) saturate(180%);
|
||||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(56, 189, 248, 0.05);
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(56, 189, 248, 0.05);
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const searchAirlines = async (query: string) => {
|
|||||||
<template #item="{ item, props: itemProps }">
|
<template #item="{ item, props: itemProps }">
|
||||||
<v-list-item v-bind="itemProps">
|
<v-list-item v-bind="itemProps">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<img style="padding:0.25em" width="40" height="40" :src="`${page.logo_api_url}/airlines/logos/tail/id/${item.value}`" />
|
<img style="padding:0.25em" width="40" height="40" :src="`${page.logo_api_url}/airlines/logos/tail/id/${item.value}`" alt=""/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<AirportToolTip :airport="airport">
|
||||||
|
<div class="airport-marker arrival">
|
||||||
|
<div class="airport-marker__dot" />
|
||||||
|
<div class="airport-marker__ring" />
|
||||||
|
</div>
|
||||||
|
</AirportToolTip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type {Airport} from "@/Types/types";
|
||||||
|
import AirportToolTip from "@/Components/FlightsGoneBy/AirportToolTip.vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
airport: Airport
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.airport-marker {
|
||||||
|
position: relative;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── arrival ────────────────────────────────────────────────────────────────── */
|
||||||
|
.airport-marker.arrival .airport-marker__dot {
|
||||||
|
position: absolute;
|
||||||
|
inset: 4px;
|
||||||
|
background: #4da6ff;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 6px rgba(77, 166, 255, 0.8);
|
||||||
|
}
|
||||||
|
.airport-marker.arrival .airport-marker__ring {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: 1px solid rgba(77, 166, 255, 0.5);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pm-pulse 2.2s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── departure ──────────────────────────────────────────────────────────────── */
|
||||||
|
.airport-marker.departure {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
.airport-marker.departure .airport-marker__dot {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: #ff9f4a;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(255, 159, 74, 0.4);
|
||||||
|
box-shadow: 0 0 8px rgba(255, 159, 74, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pm-pulse {
|
||||||
|
0% { transform: scale(1); opacity: 0.7; }
|
||||||
|
100% { transform: scale(2.5); opacity: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -61,14 +61,98 @@ function classKey(flight: Flight): string {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="flight.seat_number" class="pass-meta-row">
|
<div v-if="flight.duration_display || flight.distance || flight.seat_number" class="pass-tear">
|
||||||
<span class="pass-meta-pill pass-meta-pill--seat">SEAT {{ flight.seat_number }}</span>
|
<div class="pass-tear-notch pass-tear-notch--left" />
|
||||||
|
<div class="pass-tear-notch pass-tear-notch--right" />
|
||||||
|
<div v-if="flight.duration_display || flight.distance " class="pass-stats-row">
|
||||||
|
<span v-if="flight.seat_number" class="pass-stat">
|
||||||
|
<span class="pass-stat-label">SEAT</span>
|
||||||
|
<span class="pass-stat-value">{{ flight.seat_number }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="pass-stat-divider" v-if="flight.duration_display && flight.distance">·</span>
|
||||||
|
<span v-if="flight.duration_display" class="pass-stat">
|
||||||
|
<span class="pass-stat-label">DURATION</span>
|
||||||
|
<span class="pass-stat-value">{{ flight.duration_display }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="pass-stat-divider" v-if="flight.duration_display && flight.distance">·</span>
|
||||||
|
<span v-if="flight.distance" class="pass-stat">
|
||||||
|
<span class="pass-stat-label">DISTANCE</span>
|
||||||
|
<span class="pass-stat-value">{{ Math.round(flight.distance).toLocaleString() }} km</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.pass-stats-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
to right,
|
||||||
|
rgba(255,255,255,0.08) 0px,
|
||||||
|
rgba(255,255,255,0.08) 6px,
|
||||||
|
transparent 6px,
|
||||||
|
transparent 12px
|
||||||
|
);
|
||||||
|
background-size: 100% 1px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: top center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pass-tear {
|
||||||
|
position: relative;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pass-tear-notch {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
background: #0e1018; /* match your page background */
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pass-tear-notch--left { left: -1.6rem; } /* bleeds into the padding */
|
||||||
|
.pass-tear-notch--right { right: -1.6rem; }
|
||||||
|
|
||||||
|
.pass-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pass-stat-label {
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: #445566;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pass-stat-value {
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #9aa;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pass-stat-divider {
|
||||||
|
color: #334;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.boarding-pass {
|
.boarding-pass {
|
||||||
background: #181b24;
|
background: #181b24;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -190,29 +274,4 @@ function classKey(flight: Flight): string {
|
|||||||
margin-left: 0.15rem;
|
margin-left: 0.15rem;
|
||||||
vertical-align: super;
|
vertical-align: super;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pass-meta-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.4rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pass-meta-pill {
|
|
||||||
font-family: 'Share Tech Mono', monospace;
|
|
||||||
font-size: 0.62rem;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
padding: 0.15rem 0.5rem;
|
|
||||||
border-radius: 2px;
|
|
||||||
border: 1px solid rgba(255,193,7,0.25);
|
|
||||||
background: rgba(255,193,7,0.07);
|
|
||||||
color: #ffc107;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pass-meta-pill--seat {
|
|
||||||
border-color: rgba(200,205,216,0.2);
|
|
||||||
background: rgba(200,205,216,0.06);
|
|
||||||
color: #c8cdd8;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<div class="chart-title">Top countries</div>
|
||||||
|
|
||||||
|
<div v-if="series.length" class="chart-outer">
|
||||||
|
<div class="chart-scroll" :style="{ height: scrollHeight }">
|
||||||
|
<apexchart
|
||||||
|
type="bar"
|
||||||
|
:height="chartHeight"
|
||||||
|
:options="chartOptions"
|
||||||
|
:series="chartSeries"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="chart-footer">
|
||||||
|
<span class="total-count">{{ totalCountries }}</span>
|
||||||
|
<span class="total-label">total countries</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="chart-empty">No country data available</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { Flight } from '@/Types/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
flights: Flight[]
|
||||||
|
upcomingFlights: Flight[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const MAX_VISIBLE = 12
|
||||||
|
const BAR_HEIGHT = 32
|
||||||
|
|
||||||
|
function countCountries(flights: Flight[]): Map<string, number> {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
flights.forEach(f => {
|
||||||
|
const depCountry = f.departure_airport?.region?.country?.name ?? null
|
||||||
|
const arrCountry = f.arrival_airport?.region?.country?.name ?? null
|
||||||
|
if (depCountry) counts.set(depCountry, (counts.get(depCountry) ?? 0) + 1)
|
||||||
|
if (arrCountry && arrCountry !== depCountry) {
|
||||||
|
counts.set(arrCountry, (counts.get(arrCountry) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
const series = computed(() => {
|
||||||
|
const past = countCountries(props.flights)
|
||||||
|
const upcoming = countCountries(props.upcomingFlights)
|
||||||
|
|
||||||
|
const allCountries = new Set([...past.keys(), ...upcoming.keys()])
|
||||||
|
|
||||||
|
return [...allCountries]
|
||||||
|
.map(name => ({
|
||||||
|
name,
|
||||||
|
past: past.get(name) ?? 0,
|
||||||
|
upcoming: upcoming.get(name) ?? 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (b.past + b.upcoming) - (a.past + a.upcoming))
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalCountries = computed(() => series.value.length)
|
||||||
|
|
||||||
|
const chartHeight = computed(() => series.value.length * BAR_HEIGHT + 40)
|
||||||
|
|
||||||
|
const scrollHeight = computed(() => {
|
||||||
|
const visible = Math.min(series.value.length, MAX_VISIBLE)
|
||||||
|
return `${visible * BAR_HEIGHT + 40}px`
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartSeries = computed(() => [
|
||||||
|
{
|
||||||
|
name: 'Flights',
|
||||||
|
data: series.value.map(s => s.past),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Upcoming',
|
||||||
|
data: series.value.map(s => s.upcoming),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const chartOptions = computed(() => ({
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
background: 'transparent',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
toolbar: { show: false },
|
||||||
|
animations: { enabled: false },
|
||||||
|
stacked: true,
|
||||||
|
},
|
||||||
|
theme: { mode: 'dark' },
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: true,
|
||||||
|
barHeight: '60%',
|
||||||
|
borderRadius: 3,
|
||||||
|
borderRadiusWhenStacked: 'last',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
colors: ['#4da6ff', '#ffc107'],
|
||||||
|
dataLabels: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: series.value.map(s => s.name),
|
||||||
|
labels: { show: false },
|
||||||
|
axisBorder: { show: false },
|
||||||
|
axisTicks: { show: false },
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
colors: '#778899',
|
||||||
|
fontSize: '12px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
horizontalAlign: 'right',
|
||||||
|
labels: { colors: '#778899' },
|
||||||
|
markers: { width: 8, height: 8, radius: 2 },
|
||||||
|
itemMargin: { horizontal: 8 },
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
theme: 'dark',
|
||||||
|
shared: true,
|
||||||
|
intersect: false,
|
||||||
|
y: {
|
||||||
|
formatter: (val: number) => `${val} flights`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
states: {
|
||||||
|
hover: { filter: { type: 'lighten', value: 0.1 } },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #556677;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-outer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-scroll {
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #334455 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-scroll::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: #334455;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-count {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e0e6f0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #556677;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-empty {
|
||||||
|
height: 280px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #445566;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<div class="chart-title">Flight classes</div>
|
||||||
|
<apexchart
|
||||||
|
v-if="series.length"
|
||||||
|
type="donut"
|
||||||
|
height="280"
|
||||||
|
:options="chartOptions"
|
||||||
|
:series="seriesData"
|
||||||
|
/>
|
||||||
|
<div v-else class="chart-empty">No flight class data available</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { Flight } from '@/Types/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
flights: Flight[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const series = computed(() => {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
props.flights.forEach(f => {
|
||||||
|
const cls = f.flight_class?.name ?? 'Unknown'
|
||||||
|
counts.set(cls, (counts.get(cls) ?? 0) + 1)
|
||||||
|
})
|
||||||
|
return [...counts.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([name, count]) => ({ name, count }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const seriesData = computed(() => series.value.map(s => s.count))
|
||||||
|
|
||||||
|
const chartOptions = computed(() => ({
|
||||||
|
chart: {
|
||||||
|
type: 'donut',
|
||||||
|
background: 'transparent',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
},
|
||||||
|
theme: { mode: 'dark' },
|
||||||
|
labels: series.value.map(s => s.name),
|
||||||
|
colors: ['#4da6ff', '#ffc107', '#a150d5', '#22c55e', '#f97316', '#e11d48', '#06b6d4'],
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: (val: number) => `${Math.round(val)}%`,
|
||||||
|
style: { fontSize: '12px', fontWeight: 400 },
|
||||||
|
dropShadow: { enabled: false },
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
pie: {
|
||||||
|
donut: {
|
||||||
|
size: '60%',
|
||||||
|
labels: {
|
||||||
|
show: true,
|
||||||
|
total: {
|
||||||
|
show: true,
|
||||||
|
label: 'Total',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#556677',
|
||||||
|
formatter: () => props.flights.length.toString(),
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
fontSize: '22px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#e0e6f0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: { colors: '#778899' },
|
||||||
|
markers: { width: 8, height: 8, radius: 2 },
|
||||||
|
itemMargin: { horizontal: 12, vertical: 4 },
|
||||||
|
},
|
||||||
|
stroke: { width: 0 },
|
||||||
|
tooltip: {
|
||||||
|
theme: 'dark',
|
||||||
|
y: {
|
||||||
|
formatter: (val: number) => `${val} flights`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #556677;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-empty {
|
||||||
|
height: 280px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #445566;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<div class="chart-title">Flight reasons</div>
|
||||||
|
<apexchart
|
||||||
|
v-if="series.length"
|
||||||
|
type="donut"
|
||||||
|
height="280"
|
||||||
|
:options="chartOptions"
|
||||||
|
:series="seriesData"
|
||||||
|
/>
|
||||||
|
<div v-else class="chart-empty">No flight reason data available</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { Flight } from '@/Types/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
flights: Flight[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const series = computed(() => {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
props.flights.forEach(f => {
|
||||||
|
const reason = f.flight_reason?.name ?? 'Unknown'
|
||||||
|
counts.set(reason, (counts.get(reason) ?? 0) + 1)
|
||||||
|
})
|
||||||
|
return [...counts.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([name, count]) => ({ name, count }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const seriesData = computed(() => series.value.map(s => s.count))
|
||||||
|
|
||||||
|
const chartOptions = computed(() => ({
|
||||||
|
chart: {
|
||||||
|
type: 'donut',
|
||||||
|
background: 'transparent',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
},
|
||||||
|
theme: { mode: 'dark' },
|
||||||
|
labels: series.value.map(s => s.name),
|
||||||
|
colors: ['#4da6ff', '#ffc107', '#a150d5', '#22c55e', '#f97316', '#e11d48', '#06b6d4'],
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: (val: number) => `${Math.round(val)}%`,
|
||||||
|
style: { fontSize: '12px', fontWeight: 400 },
|
||||||
|
dropShadow: { enabled: false },
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
pie: {
|
||||||
|
donut: {
|
||||||
|
size: '60%',
|
||||||
|
labels: {
|
||||||
|
show: true,
|
||||||
|
total: {
|
||||||
|
show: true,
|
||||||
|
label: 'Total',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#556677',
|
||||||
|
formatter: () => props.flights.length.toString(),
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
fontSize: '22px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#e0e6f0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: { colors: '#778899' },
|
||||||
|
markers: { width: 8, height: 8, radius: 2 },
|
||||||
|
itemMargin: { horizontal: 12, vertical: 4 },
|
||||||
|
},
|
||||||
|
stroke: { width: 0 },
|
||||||
|
tooltip: {
|
||||||
|
theme: 'dark',
|
||||||
|
y: {
|
||||||
|
formatter: (val: number) => `${val} flights`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #556677;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-empty {
|
||||||
|
height: 280px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #445566;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<div class="chart-title">Flights per month</div>
|
||||||
|
<apexchart
|
||||||
|
type="bar"
|
||||||
|
height="220"
|
||||||
|
:options="chartOptions"
|
||||||
|
:series="series"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { Flight } from '@/Types/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
flights: Flight[]
|
||||||
|
upcomingFlights: Flight[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||||
|
|
||||||
|
const countByMonth = (list: Flight[]) =>
|
||||||
|
MONTHS.map((_, i) =>
|
||||||
|
list.filter(f => new Date(f.departure_date).getMonth() === i).length
|
||||||
|
)
|
||||||
|
|
||||||
|
const series = computed(() => [
|
||||||
|
{ name: 'Flown', data: countByMonth(props.flights) },
|
||||||
|
{ name: 'Upcoming', data: countByMonth(props.upcomingFlights) },
|
||||||
|
])
|
||||||
|
|
||||||
|
const chartOptions = computed(() => ({
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
stacked: true,
|
||||||
|
toolbar: { show: false },
|
||||||
|
background: 'transparent',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
},
|
||||||
|
theme: { mode: 'dark' },
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
borderRadius: 3,
|
||||||
|
borderRadiusWhenStacked: 'last',
|
||||||
|
columnWidth: '55%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
colors: ['#4da6ff', '#ffc107'],
|
||||||
|
dataLabels: { enabled: false },
|
||||||
|
grid: {
|
||||||
|
borderColor: 'rgba(255,255,255,0.05)',
|
||||||
|
yaxis: { lines: { show: true } },
|
||||||
|
xaxis: { lines: { show: false } },
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: MONTHS,
|
||||||
|
axisBorder: { show: false },
|
||||||
|
axisTicks: { show: false },
|
||||||
|
labels: {
|
||||||
|
style: { colors: '#445566', fontSize: '12px' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
labels: {
|
||||||
|
style: { colors: '#445566', fontSize: '12px' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
horizontalAlign: 'right',
|
||||||
|
labels: { colors: '#778899' },
|
||||||
|
markers: { width: 8, height: 8, radius: 2 },
|
||||||
|
itemMargin: { horizontal: 12 },
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
theme: 'dark',
|
||||||
|
shared: true,
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
states: {
|
||||||
|
hover: { filter: { type: 'lighten', value: 0.1 } },
|
||||||
|
active: { filter: { type: 'none' } },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #556677;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<div class="chart-title">Flights per year</div>
|
||||||
|
<apexchart
|
||||||
|
type="bar"
|
||||||
|
height="220"
|
||||||
|
:options="chartOptions"
|
||||||
|
:series="series"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { Flight } from '@/Types/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
flights: Flight[]
|
||||||
|
upcomingFlights: Flight[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const years = computed(() => {
|
||||||
|
const allYears = new Set<number>()
|
||||||
|
;[...props.flights, ...props.upcomingFlights].forEach(f =>
|
||||||
|
allYears.add(new Date(f.departure_date).getFullYear())
|
||||||
|
)
|
||||||
|
const sorted = [...allYears].sort((a, b) => a - b)
|
||||||
|
|
||||||
|
let min = sorted[0]
|
||||||
|
let max = sorted[sorted.length - 1]
|
||||||
|
|
||||||
|
while (max - min + 1 < 5) {
|
||||||
|
min--
|
||||||
|
if (max - min + 1 < 5) max++
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from({ length: max - min + 1 }, (_, i) => min + i)
|
||||||
|
})
|
||||||
|
const countByYear = (list: Flight[]) =>
|
||||||
|
years.value.map(year =>
|
||||||
|
list.filter(f => new Date(f.departure_date).getFullYear() === year).length
|
||||||
|
)
|
||||||
|
|
||||||
|
const series = computed(() => [
|
||||||
|
{ name: 'Flown', data: countByYear(props.flights) },
|
||||||
|
{ name: 'Upcoming', data: countByYear(props.upcomingFlights) },
|
||||||
|
])
|
||||||
|
|
||||||
|
const chartOptions = computed(() => ({
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
stacked: true,
|
||||||
|
toolbar: { show: false },
|
||||||
|
background: 'transparent',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
},
|
||||||
|
theme: { mode: 'dark' },
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
borderRadius: 3,
|
||||||
|
borderRadiusWhenStacked: 'last',
|
||||||
|
columnWidth: '55%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
colors: ['#4da6ff', '#ffc107'],
|
||||||
|
dataLabels: { enabled: false },
|
||||||
|
grid: {
|
||||||
|
borderColor: 'rgba(255,255,255,0.05)',
|
||||||
|
yaxis: { lines: { show: true } },
|
||||||
|
xaxis: { lines: { show: false } },
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: years.value,
|
||||||
|
axisBorder: { show: false },
|
||||||
|
axisTicks: { show: false },
|
||||||
|
labels: {
|
||||||
|
style: { colors: '#445566', fontSize: '12px' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
labels: {
|
||||||
|
style: { colors: '#445566', fontSize: '12px' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
horizontalAlign: 'right',
|
||||||
|
labels: { colors: '#778899' },
|
||||||
|
markers: { width: 8, height: 8, radius: 2 },
|
||||||
|
itemMargin: { horizontal: 12 },
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
theme: 'dark',
|
||||||
|
shared: true,
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
states: {
|
||||||
|
hover: { filter: { type: 'lighten', value: 0.1 } },
|
||||||
|
active: { filter: { type: 'none' } },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #556677;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<div class="chart-title">Seat types</div>
|
||||||
|
<apexchart
|
||||||
|
v-if="series.length"
|
||||||
|
type="donut"
|
||||||
|
height="280"
|
||||||
|
:options="chartOptions"
|
||||||
|
:series="seriesData"
|
||||||
|
/>
|
||||||
|
<div v-else class="chart-empty">No seat type data available</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { Flight } from '@/Types/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
flights: Flight[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const series = computed(() => {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
props.flights.forEach(f => {
|
||||||
|
const seat = f.seat_type?.name ?? 'Unknown'
|
||||||
|
counts.set(seat, (counts.get(seat) ?? 0) + 1)
|
||||||
|
})
|
||||||
|
return [...counts.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([name, count]) => ({ name, count }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const seriesData = computed(() => series.value.map(s => s.count))
|
||||||
|
|
||||||
|
const chartOptions = computed(() => ({
|
||||||
|
chart: {
|
||||||
|
type: 'donut',
|
||||||
|
background: 'transparent',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
},
|
||||||
|
theme: { mode: 'dark' },
|
||||||
|
labels: series.value.map(s => s.name),
|
||||||
|
colors: ['#4da6ff', '#ffc107', '#a150d5', '#22c55e', '#f97316', '#e11d48', '#06b6d4'],
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: (val: number) => `${Math.round(val)}%`,
|
||||||
|
style: { fontSize: '12px', fontWeight: 400 },
|
||||||
|
dropShadow: { enabled: false },
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
pie: {
|
||||||
|
donut: {
|
||||||
|
size: '60%',
|
||||||
|
labels: {
|
||||||
|
show: true,
|
||||||
|
total: {
|
||||||
|
show: true,
|
||||||
|
label: 'Total',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#556677',
|
||||||
|
formatter: () => props.flights.length.toString(),
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
fontSize: '22px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#e0e6f0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: { colors: '#778899' },
|
||||||
|
markers: { width: 8, height: 8, radius: 2 },
|
||||||
|
itemMargin: { horizontal: 12, vertical: 4 },
|
||||||
|
},
|
||||||
|
stroke: { width: 0 },
|
||||||
|
tooltip: {
|
||||||
|
theme: 'dark',
|
||||||
|
y: {
|
||||||
|
formatter: (val: number) => `${val} flights`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #556677;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-empty {
|
||||||
|
height: 280px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #445566;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<div class="chart-title">Top airlines</div>
|
||||||
|
|
||||||
|
<div v-if="series.length" class="chart-outer">
|
||||||
|
<div class="chart-scroll" :style="{ height: scrollHeight }">
|
||||||
|
<apexchart
|
||||||
|
type="bar"
|
||||||
|
:height="chartHeight"
|
||||||
|
:options="chartOptions"
|
||||||
|
:series="chartSeries"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="chart-footer">
|
||||||
|
<span class="total-count">{{ totalAirlines }}</span>
|
||||||
|
<span class="total-label">total airlines</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="chart-empty">No airline data available</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { Flight } from '@/Types/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
flights: Flight[]
|
||||||
|
upcomingFlights: Flight[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const MAX_VISIBLE = 12
|
||||||
|
const BAR_HEIGHT = 32
|
||||||
|
|
||||||
|
function countAirlines(flights: Flight[]): Map<string, number> {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
flights.forEach(f => {
|
||||||
|
const name = f.airline?.name ?? null
|
||||||
|
if (name) counts.set(name, (counts.get(name) ?? 0) + 1)
|
||||||
|
})
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
const series = computed(() => {
|
||||||
|
const past = countAirlines(props.flights)
|
||||||
|
const upcoming = countAirlines(props.upcomingFlights)
|
||||||
|
|
||||||
|
const allAirlines = new Set([...past.keys(), ...upcoming.keys()])
|
||||||
|
|
||||||
|
return [...allAirlines]
|
||||||
|
.map(name => ({
|
||||||
|
name,
|
||||||
|
past: past.get(name) ?? 0,
|
||||||
|
upcoming: upcoming.get(name) ?? 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (b.past + b.upcoming) - (a.past + a.upcoming))
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalAirlines = computed(() => series.value.length)
|
||||||
|
const chartHeight = computed(() => series.value.length * BAR_HEIGHT + 40)
|
||||||
|
const scrollHeight = computed(() => {
|
||||||
|
const visible = Math.min(series.value.length, MAX_VISIBLE)
|
||||||
|
return `${visible * BAR_HEIGHT + 40}px`
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartSeries = computed(() => [
|
||||||
|
{ name: 'Flights', data: series.value.map(s => s.past) },
|
||||||
|
{ name: 'Upcoming', data: series.value.map(s => s.upcoming) },
|
||||||
|
])
|
||||||
|
|
||||||
|
const chartOptions = computed(() => ({
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
background: 'transparent',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
toolbar: { show: false },
|
||||||
|
animations: { enabled: false },
|
||||||
|
stacked: true,
|
||||||
|
},
|
||||||
|
theme: { mode: 'dark' },
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: true,
|
||||||
|
barHeight: '60%',
|
||||||
|
borderRadius: 3,
|
||||||
|
borderRadiusWhenStacked: 'last',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
colors: ['#4da6ff', '#ffc107'],
|
||||||
|
dataLabels: { enabled: false },
|
||||||
|
xaxis: {
|
||||||
|
categories: series.value.map(s => s.name),
|
||||||
|
labels: { show: false },
|
||||||
|
axisBorder: { show: false },
|
||||||
|
axisTicks: { show: false },
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
colors: '#778899',
|
||||||
|
fontSize: '12px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: { show: false },
|
||||||
|
legend: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
horizontalAlign: 'right',
|
||||||
|
labels: { colors: '#778899' },
|
||||||
|
markers: { width: 8, height: 8, radius: 2 },
|
||||||
|
itemMargin: { horizontal: 8 },
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
theme: 'dark',
|
||||||
|
shared: true,
|
||||||
|
intersect: false,
|
||||||
|
y: { formatter: (val: number) => `${val} flights` },
|
||||||
|
},
|
||||||
|
states: {
|
||||||
|
hover: { filter: { type: 'lighten', value: 0.1 } },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #556677;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-outer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-scroll {
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #334455 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-scroll::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: #334455;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-count {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e0e6f0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #556677;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-empty {
|
||||||
|
height: 280px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #445566;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<div class="chart-title">Top airports</div>
|
||||||
|
|
||||||
|
<div v-if="series.length" class="chart-outer">
|
||||||
|
<div class="chart-scroll" :style="{ height: scrollHeight }">
|
||||||
|
<apexchart
|
||||||
|
type="bar"
|
||||||
|
:height="chartHeight"
|
||||||
|
:options="chartOptions"
|
||||||
|
:series="chartSeries"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="chart-footer">
|
||||||
|
<span class="total-count">{{ totalAirports }}</span>
|
||||||
|
<span class="total-label">total airports</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="chart-empty">No airport data available</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type {Airport, Flight} from '@/Types/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
flights: Flight[]
|
||||||
|
upcomingFlights: Flight[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const MAX_VISIBLE = 12
|
||||||
|
const BAR_HEIGHT = 32
|
||||||
|
|
||||||
|
interface AirportCounts {
|
||||||
|
departures: number
|
||||||
|
arrivals: number
|
||||||
|
upcoming: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAirportMap(flights: Flight[], key: 'departures' | 'arrivals' | 'upcoming'): Map<string, AirportCounts> {
|
||||||
|
const map = new Map<string, AirportCounts>()
|
||||||
|
|
||||||
|
const empty = (): AirportCounts => ({ departures: 0, arrivals: 0, upcoming: 0 })
|
||||||
|
|
||||||
|
flights.forEach(f => {
|
||||||
|
const depName = f.departure_airport?.iata_code
|
||||||
|
? `${f.departure_airport.iata_code} – ${f.departure_airport.name}`
|
||||||
|
: f.departure_airport?.name ?? null
|
||||||
|
|
||||||
|
const arrName = f.arrival_airport?.iata_code
|
||||||
|
? `${f.arrival_airport.iata_code} – ${f.arrival_airport.name}`
|
||||||
|
: f.arrival_airport?.name ?? null
|
||||||
|
|
||||||
|
if (depName) {
|
||||||
|
const existing = map.get(depName) ?? empty()
|
||||||
|
existing[key]++
|
||||||
|
map.set(depName, existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arrName) {
|
||||||
|
const existing = map.get(arrName) ?? empty()
|
||||||
|
if (key !== 'upcoming') existing.arrivals++
|
||||||
|
else existing[key]++
|
||||||
|
map.set(arrName, existing)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
function airportLabel(airport: Airport | null | undefined): string | null {
|
||||||
|
if (!airport) return null
|
||||||
|
return airport.iata_code ?? airport.icao_code ?? airport.name ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const series = computed(() => {
|
||||||
|
const past = new Map<string, AirportCounts & { label: string; fullName: string }>()
|
||||||
|
const empty = () => ({ departures: 0, arrivals: 0, upcoming: 0, label: '', fullName: '' })
|
||||||
|
|
||||||
|
props.flights.forEach(f => {
|
||||||
|
const depLabel = airportLabel(f.departure_airport)
|
||||||
|
const arrLabel = airportLabel(f.arrival_airport)
|
||||||
|
|
||||||
|
if (depLabel) {
|
||||||
|
const e = past.get(depLabel) ?? empty()
|
||||||
|
e.departures++
|
||||||
|
e.label = depLabel
|
||||||
|
e.fullName = f.departure_airport?.name ?? depLabel
|
||||||
|
past.set(depLabel, e)
|
||||||
|
}
|
||||||
|
if (arrLabel) {
|
||||||
|
const e = past.get(arrLabel) ?? empty()
|
||||||
|
e.arrivals++
|
||||||
|
e.label = arrLabel
|
||||||
|
e.fullName = f.arrival_airport?.name ?? arrLabel
|
||||||
|
past.set(arrLabel, e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
props.upcomingFlights.forEach(f => {
|
||||||
|
const depLabel = airportLabel(f.departure_airport)
|
||||||
|
const arrLabel = airportLabel(f.arrival_airport)
|
||||||
|
|
||||||
|
if (depLabel) {
|
||||||
|
const e = past.get(depLabel) ?? empty()
|
||||||
|
e.upcoming++
|
||||||
|
e.label = depLabel
|
||||||
|
e.fullName = f.departure_airport?.name ?? depLabel
|
||||||
|
past.set(depLabel, e)
|
||||||
|
}
|
||||||
|
if (arrLabel) {
|
||||||
|
const e = past.get(arrLabel) ?? empty()
|
||||||
|
e.upcoming++
|
||||||
|
e.label = arrLabel
|
||||||
|
e.fullName = f.arrival_airport?.name ?? arrLabel
|
||||||
|
past.set(arrLabel, e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return [...past.entries()]
|
||||||
|
.map(([, counts]) => ({ ...counts }))
|
||||||
|
.sort((a, b) =>
|
||||||
|
(b.departures + b.arrivals + b.upcoming) - (a.departures + a.arrivals + a.upcoming)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalAirports = computed(() => series.value.length)
|
||||||
|
const chartHeight = computed(() => series.value.length * BAR_HEIGHT + 40)
|
||||||
|
const scrollHeight = computed(() => {
|
||||||
|
const visible = Math.min(series.value.length, MAX_VISIBLE)
|
||||||
|
return `${visible * BAR_HEIGHT + 40}px`
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartSeries = computed(() => [
|
||||||
|
{ name: 'Departures', data: series.value.map(s => s.departures) },
|
||||||
|
{ name: 'Arrivals', data: series.value.map(s => s.arrivals) },
|
||||||
|
{ name: 'Upcoming', data: series.value.map(s => s.upcoming) },
|
||||||
|
])
|
||||||
|
|
||||||
|
const chartOptions = computed(() => ({
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
background: 'transparent',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
toolbar: { show: false },
|
||||||
|
animations: { enabled: false },
|
||||||
|
stacked: true,
|
||||||
|
},
|
||||||
|
theme: { mode: 'dark' },
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: true,
|
||||||
|
barHeight: '60%',
|
||||||
|
borderRadius: 3,
|
||||||
|
borderRadiusWhenStacked: 'last',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
colors: ['#4da6ff', '#a150d5', '#ffc107'],
|
||||||
|
dataLabels: { enabled: false },
|
||||||
|
xaxis: {
|
||||||
|
categories: series.value.map(s => s.label),
|
||||||
|
labels: { show: false },
|
||||||
|
axisBorder: { show: false },
|
||||||
|
axisTicks: { show: false },
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
colors: '#778899',
|
||||||
|
fontSize: '12px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: { show: false },
|
||||||
|
legend: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
horizontalAlign: 'right',
|
||||||
|
labels: { colors: '#778899' },
|
||||||
|
markers: { width: 8, height: 8, radius: 2 },
|
||||||
|
itemMargin: { horizontal: 8 },
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
theme: 'dark',
|
||||||
|
shared: true,
|
||||||
|
intersect: false,
|
||||||
|
custom: ({ dataPointIndex }: { dataPointIndex: number }) => {
|
||||||
|
const airport = series.value[dataPointIndex]
|
||||||
|
if (!airport) return ''
|
||||||
|
const rows = [
|
||||||
|
{ label: 'Departures', value: airport.departures, },
|
||||||
|
{ label: 'Arrivals', value: airport.arrivals, color: '#a150d5' },
|
||||||
|
{ label: 'Upcoming', value: airport.upcoming, color: '#ffc107' },
|
||||||
|
]
|
||||||
|
.filter(r => r.value > 0)
|
||||||
|
.map(r => `
|
||||||
|
<div class="glass" style="display:flex;align-items:center;gap:6px;padding:2px 0">
|
||||||
|
<span style="width:8px;height:8px;border-radius:2px;background:${r.color};flex-shrink:0"></span>
|
||||||
|
<span style="color:#778899">${r.label}:</span>
|
||||||
|
<span style="color:#e0e6f0;margin-left:auto;padding-left:12px">${r.value} flights</span>
|
||||||
|
</div>
|
||||||
|
`).join('')
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="glass" style="padding:10px 12px;min-width:180px">
|
||||||
|
<div style="color:#e0e6f0;font-weight:500;margin-bottom:6px">${airport.fullName}</div>
|
||||||
|
<div style="color:#556677;font-size:11px;margin-bottom:8px">${airport.label}</div>
|
||||||
|
${rows}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
states: {
|
||||||
|
hover: { filter: { type: 'lighten', value: 0.1 } },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #556677;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-outer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-scroll {
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #334455 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-scroll::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: #334455;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-count {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e0e6f0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #556677;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-empty {
|
||||||
|
height: 280px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #445566;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -21,6 +21,8 @@ const headers = [
|
|||||||
{ title: 'DATE', key: 'departure_date', sortable: true },
|
{ title: 'DATE', key: 'departure_date', sortable: true },
|
||||||
{ title: 'DEPART', key: 'departure_time_display', sortable: false },
|
{ title: 'DEPART', key: 'departure_time_display', sortable: false },
|
||||||
{ title: 'ARRIVE', key: 'arrival_time_display', sortable: false },
|
{ title: 'ARRIVE', key: 'arrival_time_display', sortable: false },
|
||||||
|
{ title: 'DURATION', key: 'duration', sortable: true },
|
||||||
|
{ title: 'DISTANCE', key: 'distance', sortable: true },
|
||||||
{ title: 'AIRCRAFT', key: 'aircraft.designator', sortable: true },
|
{ title: 'AIRCRAFT', key: 'aircraft.designator', sortable: true },
|
||||||
{ title: 'CLASS', key: 'flight_class', sortable: true },
|
{ title: 'CLASS', key: 'flight_class', sortable: true },
|
||||||
]
|
]
|
||||||
@@ -40,7 +42,8 @@ const customKeySort = {
|
|||||||
},
|
},
|
||||||
airline: (a: Flight['airline'], b: Flight['airline']) => {
|
airline: (a: Flight['airline'], b: Flight['airline']) => {
|
||||||
return (a?.IATA_code ?? '').localeCompare(b?.IATA_code ?? '')
|
return (a?.IATA_code ?? '').localeCompare(b?.IATA_code ?? '')
|
||||||
}
|
},
|
||||||
|
duration: (a: any, b: any) => (a ?? 0) - (b ?? 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track active sort state
|
// Track active sort state
|
||||||
@@ -176,6 +179,17 @@ const tableItems = computed(() =>
|
|||||||
</sup>
|
</sup>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
<td class="v-data-table__td">
|
||||||
|
<span class="mono-tag">{{ (item as Flight).duration_display ?? '—' }}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Distance -->
|
||||||
|
<td class="v-data-table__td">
|
||||||
|
<span class="mono-tag distance-cell">
|
||||||
|
{{ (item as Flight).distance ? Math.round((item as Flight).distance).toLocaleString() + ' km' : '' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
<!-- Aircraft -->
|
<!-- Aircraft -->
|
||||||
<td class="v-data-table__td">
|
<td class="v-data-table__td">
|
||||||
<AircraftToolTip v-if="(item as any).aircraft" :aircraft="(item as any).aircraft">
|
<AircraftToolTip v-if="(item as any).aircraft" :aircraft="(item as any).aircraft">
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flight-charts glass">
|
||||||
|
<FlightsPerYearChart :flights="flights" :upcoming-flights="upcomingFlights"/>
|
||||||
|
</div>
|
||||||
|
<div class="flight-charts glass">
|
||||||
|
<FlightsPerMonthChart :flights="flights" :upcoming-flights="upcomingFlights" />
|
||||||
|
</div>
|
||||||
|
<div class="flight-charts glass charts-row">
|
||||||
|
<FlightReasonsChart :flights="[...flights, ...upcomingFlights]" />
|
||||||
|
<FlightClassChart :flights="[...flights, ...upcomingFlights]" />
|
||||||
|
<SeatTypeChart :flights="[...flights, ...upcomingFlights]" />
|
||||||
|
</div>
|
||||||
|
<div class="flight-charts glass charts-row">
|
||||||
|
<CountriesChart :flights="flights" :upcoming-flights="upcomingFlights" />
|
||||||
|
</div>
|
||||||
|
<div class="flight-charts glass charts-row">
|
||||||
|
<TopAirlinesChart :flights="flights" :upcoming-flights="upcomingFlights" />
|
||||||
|
</div>
|
||||||
|
<div class="flight-charts glass charts-row">
|
||||||
|
<TopAirportsChart :flights="flights" :upcoming-flights="upcomingFlights" />
|
||||||
|
</div>
|
||||||
|
<div class="flight-charts glass charts-row">
|
||||||
|
<TopRoutesChart :flights="flights" :upcoming-flights="upcomingFlights" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Flight } from '@/Types/types'
|
||||||
|
import FlightsPerYearChart from "@/Components/FlightsGoneBy/Charts/FlightsPerYearChart.vue";
|
||||||
|
import FlightsPerMonthChart from "@/Components/FlightsGoneBy/Charts/FlightsPerMonthChart.vue";
|
||||||
|
import FlightReasonsChart from "@/Components/FlightsGoneBy/Charts/FlightReasonsChart.vue";
|
||||||
|
import FlightClassChart from "@/Components/FlightsGoneBy/Charts/FlightClassChart.vue";
|
||||||
|
import SeatTypeChart from "@/Components/FlightsGoneBy/Charts/SeatTypeChart.vue";
|
||||||
|
import CountriesChart from "@/Components/FlightsGoneBy/Charts/CountriesChart.vue";
|
||||||
|
import TopAirlinesChart from "@/Components/FlightsGoneBy/Charts/TopAirlinesChart.vue";
|
||||||
|
import TopAirportsChart from "@/Components/FlightsGoneBy/Charts/TopAirportsChart.vue";
|
||||||
|
import TopRoutesChart from "@/Components/FlightsGoneBy/Charts/TopRoutesChart.vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
flights: Flight[]
|
||||||
|
upcomingFlights: Flight[]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.flight-charts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-row {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-row > :deep(*) {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.charts-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-row > :deep(*) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flight-map-wrapper">
|
||||||
|
<div ref="mapContainer" class="map-container" />
|
||||||
|
<div v-if="!flights.length" class="empty-state">
|
||||||
|
<span class="mdi mdi-earth-off" />
|
||||||
|
<p>No flight data available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { defineComponent, ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||||
|
import maplibregl from 'maplibre-gl'
|
||||||
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
|
|
||||||
|
function greatCircleGeoJSON(from, to, steps = 64) {
|
||||||
|
const toRad = d => d * Math.PI / 180
|
||||||
|
const toDeg = r => r * 180 / Math.PI
|
||||||
|
const lat1 = toRad(from[1]), lng1 = toRad(from[0])
|
||||||
|
const lat2 = toRad(to[1]), lng2 = toRad(to[0])
|
||||||
|
const d = 2 * Math.asin(Math.sqrt(
|
||||||
|
Math.sin((lat2 - lat1) / 2) ** 2 +
|
||||||
|
Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2
|
||||||
|
))
|
||||||
|
if (d === 0) return [[from[0], from[1]], [to[0], to[1]]]
|
||||||
|
const points = []
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const f = i / steps
|
||||||
|
const A = Math.sin((1 - f) * d) / Math.sin(d)
|
||||||
|
const B = Math.sin(f * d) / Math.sin(d)
|
||||||
|
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
|
||||||
|
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
|
||||||
|
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
|
||||||
|
points.push([toDeg(Math.atan2(y, x)), toDeg(Math.atan2(z, Math.sqrt(x * x + y * y)))])
|
||||||
|
}
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
const diff = points[i][0] - points[i - 1][0]
|
||||||
|
if (diff > 180) points[i][0] -= 360
|
||||||
|
if (diff < -180) points[i][0] += 360
|
||||||
|
}
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
|
||||||
|
function airportPopupHTML(airport) {
|
||||||
|
const elevation = airport.elevation_ft !== null
|
||||||
|
? `<div class="ap-row"><span class="ap-label">Elevation</span><span class="ap-value">${airport.elevation_ft.toLocaleString()} ft</span></div>`
|
||||||
|
: ''
|
||||||
|
const city = airport.municipality
|
||||||
|
? `<div class="ap-row"><span class="ap-label">City</span><span class="ap-value">${airport.municipality}</span></div>`
|
||||||
|
: ''
|
||||||
|
const country = airport.region?.country
|
||||||
|
? `<div class="ap-row"><span class="ap-label">Country</span><span class="ap-value">${airport.region.country.name} <span class="fi fi-${airport.region.country.code.toLowerCase()}"></span></span></div>`
|
||||||
|
: ''
|
||||||
|
const iata = airport.iata_code ? `<span class="ap-badge">${airport.iata_code}</span>` : ''
|
||||||
|
const icao = airport.icao_code ? `<span class="ap-badge">${airport.icao_code}</span>` : ''
|
||||||
|
return `
|
||||||
|
<div class="ap-tooltip glass">
|
||||||
|
<div class="ap-header">
|
||||||
|
<div class="ap-name">${airport.name}</div>
|
||||||
|
<div class="ap-badges">${iata}${icao}</div>
|
||||||
|
</div>
|
||||||
|
<div class="ap-divider"></div>
|
||||||
|
<div class="ap-rows">
|
||||||
|
${city}${country}${elevation}
|
||||||
|
<div class="ap-row"><span class="ap-label">Timezone</span><span class="ap-value ap-mono">${airport.timezone}</span></div>
|
||||||
|
<div class="ap-row"><span class="ap-label">Coordinates</span><span class="ap-value ap-mono ap-muted">${airport.latitude_deg.toFixed(4)}, ${airport.longitude_deg.toFixed(4)}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function routePopupHTML(historical, future) {
|
||||||
|
const groupByDirection = (list) => {
|
||||||
|
const dirs = new Map()
|
||||||
|
list.forEach(f => {
|
||||||
|
const key = `${f.departure_airport.id}-${f.arrival_airport.id}`
|
||||||
|
const label = `${f.departure_airport.municipality} to ${f.arrival_airport.municipality}`
|
||||||
|
if (!dirs.has(key)) dirs.set(key, { label, airlines: [] })
|
||||||
|
const airline = f.airline?.name
|
||||||
|
if (airline && !dirs.get(key).airlines.includes(airline)) {
|
||||||
|
dirs.get(key).airlines.push(airline)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return [...dirs.values()]
|
||||||
|
}
|
||||||
|
const renderSection = (title, list) => {
|
||||||
|
if (!list.length) return ''
|
||||||
|
const rows = groupByDirection(list).map(({ label, airlines }) => `
|
||||||
|
<div class="rp-direction">
|
||||||
|
<div class="rp-route">${label}</div>
|
||||||
|
<div class="rp-airlines">${airlines.join(', ') || '—'}</div>
|
||||||
|
</div>`).join('')
|
||||||
|
return `<div class="rp-section"><div class="rp-section-title">${title}</div>${rows}</div>`
|
||||||
|
}
|
||||||
|
const divider = historical.length && future.length ? '<div class="rp-divider"></div>' : ''
|
||||||
|
return `
|
||||||
|
<div class="rp-tooltip glass">
|
||||||
|
${renderSection('Flown', historical)}
|
||||||
|
${divider}
|
||||||
|
${renderSection('Upcoming', future)}
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'FlightMap',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
flights: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
setup(props) {
|
||||||
|
const mapContainer = ref(null)
|
||||||
|
let map = null
|
||||||
|
let popup = null
|
||||||
|
let pulseFrame = null
|
||||||
|
|
||||||
|
const PULSE_PHASES = 6
|
||||||
|
const airportById = new Map()
|
||||||
|
const routeFlights = new Map()
|
||||||
|
let selectedAirportId = null
|
||||||
|
let suppressRoutePopup = false
|
||||||
|
|
||||||
|
const isTouchDevice = () => window.matchMedia('(pointer: coarse)').matches
|
||||||
|
|
||||||
|
const showPopup = (lngLat, html) => {
|
||||||
|
popup.setLngLat(lngLat).setHTML(html).addTo(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filter ────────────────────────────────────────────────────────────
|
||||||
|
const applyFilter = () => {
|
||||||
|
if (!map || !map.isStyleLoaded()) return
|
||||||
|
const id = selectedAirportId
|
||||||
|
const filter = id
|
||||||
|
? ['any', ['==', ['get', 'depId'], id], ['==', ['get', 'arrId'], id]]
|
||||||
|
: null
|
||||||
|
|
||||||
|
map.setFilter('routes-line', filter)
|
||||||
|
map.setFilter('routes-hit', filter)
|
||||||
|
map.setFilter('routes-future-line', filter)
|
||||||
|
map.setFilter('routes-future-hit', filter)
|
||||||
|
|
||||||
|
for (let i = 0; i < PULSE_PHASES; i++) {
|
||||||
|
map.setPaintProperty(`airports-dot-${i}`, 'circle-opacity',
|
||||||
|
id ? ['case', ['==', ['get', 'id'], id], 1, 0.25] : 1)
|
||||||
|
map.setPaintProperty(`airports-dot-${i}`, 'circle-stroke-opacity',
|
||||||
|
id ? ['case', ['==', ['get', 'id'], id], 1, 0.25] : 1)
|
||||||
|
map.setPaintProperty(`airports-pulse-${i}`, 'circle-stroke-opacity',
|
||||||
|
id ? ['case', ['==', ['get', 'id'], id], 0.6, 0] : 0.6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Route flight lookup ───────────────────────────────────────────────
|
||||||
|
const buildRouteFlights = () => {
|
||||||
|
routeFlights.clear()
|
||||||
|
const now = new Date()
|
||||||
|
props.flights.forEach((flight) => {
|
||||||
|
const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-')
|
||||||
|
if (!routeFlights.has(key)) routeFlights.set(key, { historical: [], future: [] })
|
||||||
|
routeFlights.get(key)[new Date(flight.departure_date) > now ? 'future' : 'historical'].push(flight)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GeoJSON builders ──────────────────────────────────────────────────
|
||||||
|
const buildRoutesGeoJSON = () => {
|
||||||
|
buildRouteFlights()
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
const routeCounts = new Map()
|
||||||
|
props.flights.forEach((flight) => {
|
||||||
|
if (new Date(flight.departure_date) > now) return
|
||||||
|
const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-')
|
||||||
|
routeCounts.set(key, (routeCounts.get(key) ?? 0) + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
const routeColor = (count) => {
|
||||||
|
if (count >= 5) return '#f97316'
|
||||||
|
if (count >= 3) return '#eab308'
|
||||||
|
if (count >= 2) return '#22c55e'
|
||||||
|
return '#a150d5'
|
||||||
|
}
|
||||||
|
|
||||||
|
const historicalFeatures = props.flights
|
||||||
|
.filter(f => new Date(f.departure_date) <= now)
|
||||||
|
.map((flight) => {
|
||||||
|
const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-')
|
||||||
|
const count = routeCounts.get(key) ?? 1
|
||||||
|
return {
|
||||||
|
type: 'Feature',
|
||||||
|
properties: {
|
||||||
|
color: routeColor(count), routeKey: key,
|
||||||
|
depId: flight.departure_airport.id,
|
||||||
|
arrId: flight.arrival_airport.id,
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: greatCircleGeoJSON(
|
||||||
|
[flight.departure_airport.longitude_deg, flight.departure_airport.latitude_deg],
|
||||||
|
[flight.arrival_airport.longitude_deg, flight.arrival_airport.latitude_deg],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const historicalKeys = new Set(routeCounts.keys())
|
||||||
|
const futureFeatures = props.flights
|
||||||
|
.filter((flight) => {
|
||||||
|
if (new Date(flight.departure_date) <= now) return false
|
||||||
|
const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-')
|
||||||
|
return !historicalKeys.has(key)
|
||||||
|
})
|
||||||
|
.map((flight) => {
|
||||||
|
const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-')
|
||||||
|
return {
|
||||||
|
type: 'Feature',
|
||||||
|
properties: { routeKey: key, depId: flight.departure_airport.id, arrId: flight.arrival_airport.id },
|
||||||
|
geometry: {
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: greatCircleGeoJSON(
|
||||||
|
[flight.departure_airport.longitude_deg, flight.departure_airport.latitude_deg],
|
||||||
|
[flight.arrival_airport.longitude_deg, flight.arrival_airport.latitude_deg],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
historical: { type: 'FeatureCollection', features: historicalFeatures },
|
||||||
|
future: { type: 'FeatureCollection', features: futureFeatures },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildAirportsGeoJSON = () => {
|
||||||
|
const seen = new Map()
|
||||||
|
props.flights.forEach(({ departure_airport: dep, arrival_airport: arr }) => {
|
||||||
|
if (!seen.has(dep.id)) seen.set(dep.id, dep)
|
||||||
|
if (!seen.has(arr.id)) seen.set(arr.id, arr)
|
||||||
|
})
|
||||||
|
const buckets = Array.from({ length: PULSE_PHASES }, () => [])
|
||||||
|
;[...seen.values()].forEach(airport => buckets[airport.id % PULSE_PHASES].push(airport))
|
||||||
|
return buckets.map(airports => ({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: airports.map(airport => ({
|
||||||
|
type: 'Feature',
|
||||||
|
properties: { id: airport.id },
|
||||||
|
geometry: { type: 'Point', coordinates: [airport.longitude_deg, airport.latitude_deg] },
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Map layers ────────────────────────────────────────────────────────
|
||||||
|
const addLayers = () => {
|
||||||
|
const { historical, future } = buildRoutesGeoJSON()
|
||||||
|
|
||||||
|
map.addSource('routes', { type: 'geojson', data: historical })
|
||||||
|
map.addLayer({
|
||||||
|
id: 'routes-line', type: 'line', source: 'routes',
|
||||||
|
paint: { 'line-color': ['get', 'color'], 'line-opacity': 0.75, 'line-width': 1.8 },
|
||||||
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||||
|
})
|
||||||
|
map.addLayer({
|
||||||
|
id: 'routes-hit', type: 'line', source: 'routes',
|
||||||
|
paint: { 'line-color': 'transparent', 'line-width': 12 },
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addSource('routes-future', { type: 'geojson', data: future })
|
||||||
|
map.addLayer({
|
||||||
|
id: 'routes-future-line', type: 'line', source: 'routes-future',
|
||||||
|
paint: { 'line-color': '#ffffff', 'line-opacity': 0.5, 'line-width': 1.5, 'line-dasharray': [3, 3] },
|
||||||
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||||
|
})
|
||||||
|
map.addLayer({
|
||||||
|
id: 'routes-future-hit', type: 'line', source: 'routes-future',
|
||||||
|
paint: { 'line-color': 'transparent', 'line-width': 12 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const airportBuckets = buildAirportsGeoJSON()
|
||||||
|
airportBuckets.forEach((data, i) => {
|
||||||
|
map.addSource(`airports-${i}`, { type: 'geojson', data })
|
||||||
|
map.addLayer({
|
||||||
|
id: `airports-pulse-${i}`, type: 'circle', source: `airports-${i}`,
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 8, 'circle-color': 'transparent',
|
||||||
|
'circle-stroke-width': 1.5, 'circle-stroke-color': 'rgba(77,166,255,0.6)',
|
||||||
|
'circle-stroke-opacity': 0.6,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
map.addLayer({
|
||||||
|
id: `airports-dot-${i}`, type: 'circle', source: `airports-${i}`,
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 5, 'circle-color': '#4da6ff',
|
||||||
|
'circle-stroke-width': 1.5, 'circle-stroke-color': 'rgba(255,255,255,0.9)',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const isTouch = isTouchDevice()
|
||||||
|
popup = new maplibregl.Popup({
|
||||||
|
closeButton: isTouch,
|
||||||
|
closeOnClick: false,
|
||||||
|
className: 'ap-popup',
|
||||||
|
maxWidth: 'none',
|
||||||
|
offset: 12,
|
||||||
|
})
|
||||||
|
|
||||||
|
for (let i = 0; i < PULSE_PHASES; i++) {
|
||||||
|
// Desktop: hover to show airport popup
|
||||||
|
map.on('mouseenter', `airports-dot-${i}`, (e) => {
|
||||||
|
map.getCanvas().style.cursor = 'pointer'
|
||||||
|
if (isTouch) return
|
||||||
|
const airport = airportById.get(Number(e.features[0].properties.id))
|
||||||
|
if (airport) showPopup(e.features[0].geometry.coordinates, airportPopupHTML(airport))
|
||||||
|
})
|
||||||
|
map.on('mouseleave', `airports-dot-${i}`, () => {
|
||||||
|
map.getCanvas().style.cursor = ''
|
||||||
|
if (isTouch) return
|
||||||
|
popup.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Click: select airport, suppress route popup, close any open popup
|
||||||
|
map.on('click', `airports-dot-${i}`, (e) => {
|
||||||
|
suppressRoutePopup = true
|
||||||
|
popup.remove()
|
||||||
|
|
||||||
|
const id = Number(e.features[0].properties.id)
|
||||||
|
selectedAirportId = selectedAirportId === id ? null : id
|
||||||
|
applyFilter()
|
||||||
|
|
||||||
|
setTimeout(() => { suppressRoutePopup = false }, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop: hover to show route popup
|
||||||
|
map.on('mouseenter', 'routes-hit', (e) => {
|
||||||
|
if (isTouch) return
|
||||||
|
map.getCanvas().style.cursor = 'pointer'
|
||||||
|
const rf = routeFlights.get(e.features[0].properties.routeKey)
|
||||||
|
if (rf) showPopup(e.lngLat, routePopupHTML(rf.historical, rf.future))
|
||||||
|
})
|
||||||
|
map.on('mouseleave', 'routes-hit', () => {
|
||||||
|
if (isTouch) return
|
||||||
|
map.getCanvas().style.cursor = ''
|
||||||
|
popup.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Touch: tap to show route popup (suppressed if airport was just tapped)
|
||||||
|
map.on('click', 'routes-hit', (e) => {
|
||||||
|
if (!isTouch) return
|
||||||
|
if (suppressRoutePopup) return
|
||||||
|
const rf = routeFlights.get(e.features[0].properties.routeKey)
|
||||||
|
if (rf) showPopup(e.lngLat, routePopupHTML(rf.historical, rf.future))
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on('mouseenter', 'routes-future-hit', (e) => {
|
||||||
|
if (isTouch) return
|
||||||
|
map.getCanvas().style.cursor = 'pointer'
|
||||||
|
const rf = routeFlights.get(e.features[0].properties.routeKey)
|
||||||
|
if (rf) showPopup(e.lngLat, routePopupHTML(rf.historical, rf.future))
|
||||||
|
})
|
||||||
|
map.on('mouseleave', 'routes-future-hit', () => {
|
||||||
|
if (isTouch) return
|
||||||
|
map.getCanvas().style.cursor = ''
|
||||||
|
popup.remove()
|
||||||
|
})
|
||||||
|
map.on('click', 'routes-future-hit', (e) => {
|
||||||
|
if (!isTouch) return
|
||||||
|
if (suppressRoutePopup) return
|
||||||
|
const rf = routeFlights.get(e.features[0].properties.routeKey)
|
||||||
|
if (rf) showPopup(e.lngLat, routePopupHTML(rf.historical, rf.future))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tap on empty map: deselect airport and close any popup
|
||||||
|
map.on('click', (e) => {
|
||||||
|
const features = map.queryRenderedFeatures(e.point, {
|
||||||
|
layers: Array.from({ length: PULSE_PHASES }, (_, i) => `airports-dot-${i}`),
|
||||||
|
})
|
||||||
|
if (!features.length) {
|
||||||
|
selectedAirportId = null
|
||||||
|
applyFilter()
|
||||||
|
popup.remove()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const period = 2200
|
||||||
|
const animate = () => {
|
||||||
|
const now = Date.now()
|
||||||
|
for (let i = 0; i < PULSE_PHASES; i++) {
|
||||||
|
const t = ((now + (i / PULSE_PHASES) * period) % period) / period
|
||||||
|
map.setPaintProperty(`airports-pulse-${i}`, 'circle-radius', 5 + t * 13)
|
||||||
|
if (!selectedAirportId) {
|
||||||
|
map.setPaintProperty(`airports-pulse-${i}`, 'circle-stroke-opacity', 0.7 * (1 - t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pulseFrame = requestAnimationFrame(animate)
|
||||||
|
}
|
||||||
|
animate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fitBounds = () => {
|
||||||
|
if (!props.flights.length) return
|
||||||
|
const lngs = props.flights.flatMap(f => [f.departure_airport.longitude_deg, f.arrival_airport.longitude_deg])
|
||||||
|
const lats = props.flights.flatMap(f => [f.departure_airport.latitude_deg, f.arrival_airport.latitude_deg])
|
||||||
|
map.fitBounds(
|
||||||
|
[[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]],
|
||||||
|
{ padding: 60, duration: 0 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Map init ──────────────────────────────────────────────────────────
|
||||||
|
const initMap = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
map = new maplibregl.Map({
|
||||||
|
container: mapContainer.value,
|
||||||
|
style: {
|
||||||
|
version: 8,
|
||||||
|
sources: {
|
||||||
|
'carto-dark': {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: [
|
||||||
|
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||||
|
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||||
|
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||||
|
'https://d.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||||
|
],
|
||||||
|
tileSize: 256,
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
|
||||||
|
maxzoom: 19,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: [{ id: 'carto-dark', type: 'raster', source: 'carto-dark' }],
|
||||||
|
},
|
||||||
|
center: [0, 20],
|
||||||
|
zoom: 2,
|
||||||
|
renderWorldCopies: true,
|
||||||
|
})
|
||||||
|
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right')
|
||||||
|
map.on('load', () => { addLayers(); fitBounds() })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data updates ──────────────────────────────────────────────────────
|
||||||
|
const updateData = () => {
|
||||||
|
if (!map || !map.isStyleLoaded()) return
|
||||||
|
airportById.clear()
|
||||||
|
props.flights.forEach(({ departure_airport: dep, arrival_airport: arr }) => {
|
||||||
|
airportById.set(dep.id, dep)
|
||||||
|
airportById.set(arr.id, arr)
|
||||||
|
})
|
||||||
|
const { historical, future } = buildRoutesGeoJSON()
|
||||||
|
map.getSource('routes')?.setData(historical)
|
||||||
|
map.getSource('routes-future')?.setData(future)
|
||||||
|
buildAirportsGeoJSON().forEach((data, i) => map.getSource(`airports-${i}`)?.setData(data))
|
||||||
|
fitBounds()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
props.flights.forEach(({ departure_airport: dep, arrival_airport: arr }) => {
|
||||||
|
airportById.set(dep.id, dep)
|
||||||
|
airportById.set(arr.id, arr)
|
||||||
|
})
|
||||||
|
initMap()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.flights, updateData, { deep: true })
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (pulseFrame) cancelAnimationFrame(pulseFrame)
|
||||||
|
if (map) { map.remove(); map = null }
|
||||||
|
})
|
||||||
|
|
||||||
|
return { mapContainer }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.flight-map-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #3a4a58;
|
||||||
|
gap: 12px;
|
||||||
|
z-index: 900;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .mdi { font-size: 48px; }
|
||||||
|
.empty-state p { font-size: 13px; letter-spacing: 0.1em; text-transform: uppercase; margin: 0; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ap-popup .maplibregl-popup-content {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none; border-radius: 0; padding: 0;
|
||||||
|
box-shadow: none; backdrop-filter: none;
|
||||||
|
color: #c8cdd8; width: max-content;
|
||||||
|
}
|
||||||
|
.ap-popup .maplibregl-popup-tip { border-top-color: rgba(10,14,22,0.95); }
|
||||||
|
|
||||||
|
/* Close button styling for touch devices */
|
||||||
|
.ap-popup .maplibregl-popup-close-button {
|
||||||
|
color: #556677;
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
right: 4px;
|
||||||
|
top: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.ap-popup .maplibregl-popup-close-button:hover { color: #c8cdd8; }
|
||||||
|
|
||||||
|
.ap-tooltip { padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.ap-header { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.ap-name { font-size: 1.2em; color: #e0e6f0; line-height: 1.3; white-space: nowrap; }
|
||||||
|
.ap-badges { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||||
|
.ap-badge {
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
font-size: 0.65rem; letter-spacing: 0.1em;
|
||||||
|
background: rgba(77,166,255,0.12); border: 1px solid rgba(77,166,255,0.25);
|
||||||
|
color: #4da6ff; border-radius: 3px; padding: 1px 6px;
|
||||||
|
}
|
||||||
|
.ap-divider { height: 1px; background: rgba(255,255,255,0.08); }
|
||||||
|
.ap-rows { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.ap-row { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
|
||||||
|
.ap-label {
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
font-size: 0.6rem; letter-spacing: 0.15em; color: #445566;
|
||||||
|
text-transform: uppercase; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ap-value { font-size: 0.78rem; color: #c8cdd8; text-align: right; }
|
||||||
|
.ap-mono { font-family: 'Share Tech Mono', monospace; font-size: 0.7rem; letter-spacing: 0.03em; }
|
||||||
|
.ap-muted { font-family: 'Share Tech Mono', monospace; font-size: 0.68rem; color: #556677; letter-spacing: 0.03em; }
|
||||||
|
.ap-value .fi { display: inline-block; vertical-align: middle; }
|
||||||
|
|
||||||
|
.rp-tooltip { padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; min-width: 160px; }
|
||||||
|
.rp-section { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.rp-section-title {
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
font-size: 0.65rem; letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase; color: #c8cdd8; margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.rp-direction { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.rp-route { font-size: 0.82rem; color: #e0e6f0; }
|
||||||
|
.rp-airlines { font-size: 0.75rem; color: #778899; }
|
||||||
|
.rp-divider { height: 1px; background: rgba(255,255,255,0.08); margin: 2px 0; }
|
||||||
|
|
||||||
|
.maplibregl-ctrl-group { background: rgba(10,14,22,0.85) !important; border: 1px solid rgba(255,255,255,0.08) !important; }
|
||||||
|
.maplibregl-ctrl-group button { color: #a0b4c8 !important; }
|
||||||
|
.maplibregl-ctrl-group button:hover { background: rgba(77,166,255,0.15) !important; color: #4da6ff !important; }
|
||||||
|
.maplibregl-ctrl-attrib { background: rgba(10,14,22,0.7) !important; color: #666 !important; }
|
||||||
|
.maplibregl-ctrl-attrib a { color: #666 !important; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
<template>
|
||||||
|
<div class="stats-bar glass">
|
||||||
|
<div class="stat">
|
||||||
|
<template v-if="flights.length">
|
||||||
|
<div class="stat-primary">
|
||||||
|
<span class="stat-num">{{ flights.length.toLocaleString() }}</span>
|
||||||
|
<span class="unit">flights</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-if="upcomingFlights.length">
|
||||||
|
<div :class="flights.length ? 'stat-upcoming' : 'stat-primary'">
|
||||||
|
<span :class="flights.length ? 'stat-upcoming-num' : 'stat-num'">{{ upcomingFlights.length.toLocaleString() }}</span>
|
||||||
|
<span :class="flights.length ? 'stat-upcoming-lbl' : 'unit'">{{ flights.length ? 'upcoming' : 'flights' }}</span>
|
||||||
|
<span v-if="!flights.length" class="upcoming-badge">upcoming</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat">
|
||||||
|
<template v-if="uniqueRoutes">
|
||||||
|
<div class="stat-primary">
|
||||||
|
<span class="stat-num">{{ uniqueRoutes.toLocaleString() }}</span>
|
||||||
|
<span class="unit">routes</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-if="uniqueUpcomingRoutes">
|
||||||
|
<div :class="uniqueRoutes ? 'stat-upcoming' : 'stat-primary'">
|
||||||
|
<span :class="uniqueRoutes ? 'stat-upcoming-num' : 'stat-num'">{{ uniqueUpcomingRoutes.toLocaleString() }}</span>
|
||||||
|
<span :class="uniqueRoutes ? 'stat-upcoming-lbl' : 'unit'">{{ uniqueRoutes ? 'upcoming' : 'routes' }}</span>
|
||||||
|
<span v-if="!uniqueRoutes" class="upcoming-badge">upcoming</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat">
|
||||||
|
<template v-if="totalDistanceKm">
|
||||||
|
<div class="stat-primary">
|
||||||
|
<span class="stat-num">{{ totalDistanceKm.toLocaleString() }}</span>
|
||||||
|
<span class="unit">km</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-sub">{{ totalDistanceMi.toLocaleString() }} miles</div>
|
||||||
|
</template>
|
||||||
|
<template v-if="upcomingDistanceKm">
|
||||||
|
<div :class="totalDistanceKm ? 'stat-upcoming' : 'stat-primary'">
|
||||||
|
<span :class="totalDistanceKm ? 'stat-upcoming-num' : 'stat-num'">{{ upcomingDistanceKm.toLocaleString() }}</span>
|
||||||
|
<span :class="totalDistanceKm ? 'stat-upcoming-lbl' : 'unit'">{{ totalDistanceKm ? 'km upcoming' : 'km' }}</span>
|
||||||
|
<span v-if="!totalDistanceKm" class="upcoming-badge">upcoming</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!totalDistanceKm" class="stat-sub">{{ upcomingDistanceMi.toLocaleString() }} miles</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat">
|
||||||
|
<template v-if="uniqueCountries">
|
||||||
|
<div class="stat-primary">
|
||||||
|
<span class="stat-num">{{ uniqueCountries }}</span>
|
||||||
|
<span class="unit">countries</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-if="uniqueUpcomingCountries">
|
||||||
|
<div :class="uniqueCountries ? 'stat-upcoming' : 'stat-primary'">
|
||||||
|
<span :class="uniqueCountries ? 'stat-upcoming-num' : 'stat-num'">{{ uniqueUpcomingCountries }}</span>
|
||||||
|
<span :class="uniqueCountries ? 'stat-upcoming-lbl' : 'unit'">{{ uniqueCountries ? 'upcoming' : 'countries' }}</span>
|
||||||
|
<span v-if="!uniqueCountries" class="upcoming-badge">upcoming</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat">
|
||||||
|
<template v-if="uniqueAirports">
|
||||||
|
<div class="stat-primary">
|
||||||
|
<span class="stat-num">{{ uniqueAirports }}</span>
|
||||||
|
<span class="unit">airports</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-if="uniqueUpcomingAirports">
|
||||||
|
<div :class="uniqueAirports ? 'stat-upcoming' : 'stat-primary'">
|
||||||
|
<span :class="uniqueAirports ? 'stat-upcoming-num' : 'stat-num'">{{ uniqueUpcomingAirports }}</span>
|
||||||
|
<span :class="uniqueAirports ? 'stat-upcoming-lbl' : 'unit'">{{ uniqueAirports ? 'upcoming' : 'airports' }}</span>
|
||||||
|
<span v-if="!uniqueAirports" class="upcoming-badge">upcoming</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat">
|
||||||
|
<template v-if="durationDisplay.hours">
|
||||||
|
<div class="stat-primary">
|
||||||
|
<span class="stat-num">{{ durationDisplay.hours.toLocaleString() }}</span>
|
||||||
|
<span class="unit">hours in the air</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-sub">{{ durationDisplay.days }} days · {{ durationDisplay.weeks }} weeks</div>
|
||||||
|
</template>
|
||||||
|
<template v-if="upcomingDurationDisplay.hours">
|
||||||
|
<div :class="durationDisplay.hours ? 'stat-upcoming' : 'stat-primary'">
|
||||||
|
<span :class="durationDisplay.hours ? 'stat-upcoming-num' : 'stat-num'">{{ upcomingDurationDisplay.hours.toLocaleString() }}</span>
|
||||||
|
<span :class="durationDisplay.hours ? 'stat-upcoming-lbl' : 'unit'">{{ durationDisplay.hours ? 'hrs upcoming' : 'hours in the air' }}</span>
|
||||||
|
<span v-if="!durationDisplay.hours" class="upcoming-badge">upcoming</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!durationDisplay.hours" class="stat-sub">{{ upcomingDurationDisplay.days }} days · {{ upcomingDurationDisplay.weeks }} weeks</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { Flight } from '@/Types/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
flights: Flight[]
|
||||||
|
upcomingFlights: Flight[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// ── Past ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const totalDistanceKm = computed(() =>
|
||||||
|
Math.round(props.flights.reduce((sum, f) => sum + (f.distance ?? 0), 0))
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalDistanceMi = computed(() =>
|
||||||
|
Math.round(totalDistanceKm.value * 0.621371)
|
||||||
|
)
|
||||||
|
|
||||||
|
const uniqueRoutes = computed(() => {
|
||||||
|
const keys = new Set(props.flights.map(f =>
|
||||||
|
[f.departure_airport.id, f.arrival_airport.id].sort().join('-')
|
||||||
|
))
|
||||||
|
return keys.size
|
||||||
|
})
|
||||||
|
|
||||||
|
const uniqueCountries = computed(() => {
|
||||||
|
const codes = new Set<string>()
|
||||||
|
props.flights.forEach(f => {
|
||||||
|
const depCode = f.departure_airport.region?.country?.code
|
||||||
|
const arrCode = f.arrival_airport.region?.country?.code
|
||||||
|
if (depCode) codes.add(depCode)
|
||||||
|
if (arrCode) codes.add(arrCode)
|
||||||
|
})
|
||||||
|
return codes.size
|
||||||
|
})
|
||||||
|
|
||||||
|
const uniqueAirports = computed(() => {
|
||||||
|
const ids = new Set<number>()
|
||||||
|
props.flights.forEach(f => {
|
||||||
|
ids.add(f.departure_airport.id)
|
||||||
|
ids.add(f.arrival_airport.id)
|
||||||
|
})
|
||||||
|
return ids.size
|
||||||
|
})
|
||||||
|
|
||||||
|
const durationDisplay = computed(() => {
|
||||||
|
const totalMinutes = props.flights.reduce((sum, f) => sum + (f.duration ?? 0), 0)
|
||||||
|
const totalHours = Math.floor(totalMinutes / 60)
|
||||||
|
return {
|
||||||
|
hours: totalHours,
|
||||||
|
days: Math.floor(totalHours / 24),
|
||||||
|
weeks: Math.floor(totalHours / 24 / 7),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Upcoming ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const upcomingDistanceKm = computed(() =>
|
||||||
|
Math.round(props.upcomingFlights.reduce((sum, f) => sum + (f.distance ?? 0), 0))
|
||||||
|
)
|
||||||
|
|
||||||
|
const upcomingDistanceMi = computed(() =>
|
||||||
|
Math.round(upcomingDistanceKm.value * 0.621371)
|
||||||
|
)
|
||||||
|
|
||||||
|
const uniqueUpcomingRoutes = computed(() => {
|
||||||
|
const keys = new Set(props.upcomingFlights.map(f =>
|
||||||
|
[f.departure_airport.id, f.arrival_airport.id].sort().join('-')
|
||||||
|
))
|
||||||
|
return keys.size
|
||||||
|
})
|
||||||
|
|
||||||
|
const uniqueUpcomingCountries = computed(() => {
|
||||||
|
const codes = new Set<string>()
|
||||||
|
props.upcomingFlights.forEach(f => {
|
||||||
|
const depCode = f.departure_airport.region?.country?.code
|
||||||
|
const arrCode = f.arrival_airport.region?.country?.code
|
||||||
|
if (depCode) codes.add(depCode)
|
||||||
|
if (arrCode) codes.add(arrCode)
|
||||||
|
})
|
||||||
|
return codes.size
|
||||||
|
})
|
||||||
|
|
||||||
|
const uniqueUpcomingAirports = computed(() => {
|
||||||
|
const ids = new Set<number>()
|
||||||
|
props.upcomingFlights.forEach(f => {
|
||||||
|
ids.add(f.departure_airport.id)
|
||||||
|
ids.add(f.arrival_airport.id)
|
||||||
|
})
|
||||||
|
return ids.size
|
||||||
|
})
|
||||||
|
|
||||||
|
const upcomingDurationDisplay = computed(() => {
|
||||||
|
const totalMinutes = props.upcomingFlights.reduce((sum, f) => sum + (f.duration ?? 0), 0)
|
||||||
|
const totalHours = Math.floor(totalMinutes / 60)
|
||||||
|
return {
|
||||||
|
hours: totalHours,
|
||||||
|
days: Math.floor(totalHours / 24),
|
||||||
|
weeks: Math.floor(totalHours / 24 / 7),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stats-bar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
padding: 18px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-primary {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-num {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #e0e6f0;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #3a5566;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #334455;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-upcoming {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 5px;
|
||||||
|
padding-top: 5px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-upcoming-num {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4a8fa8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-upcoming-lbl {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #335566;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4a8fa8;
|
||||||
|
background: rgba(74, 143, 168, 0.12);
|
||||||
|
border: 1px solid rgba(74, 143, 168, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<template>
|
||||||
|
<FlightMap :flights="mappedFlights" class="profile-map__map" />
|
||||||
|
|
||||||
|
<div class="profile-map__toolbar">
|
||||||
|
<v-select
|
||||||
|
v-model="selectedYears"
|
||||||
|
:items="availableYears"
|
||||||
|
label="Year"
|
||||||
|
multiple
|
||||||
|
clearable
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
<template #selection="{ item, index }">
|
||||||
|
<span v-if="index < 2" class="v-select__selection-text">
|
||||||
|
{{ item }}<span v-if="index < Math.min(selectedYears.length, 2) - 1">, </span>
|
||||||
|
</span>
|
||||||
|
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedYears.length - 2 }}</span>
|
||||||
|
</template>
|
||||||
|
</v-select>
|
||||||
|
|
||||||
|
<v-select
|
||||||
|
v-model="selectedAirlines"
|
||||||
|
:items="availableAirlines"
|
||||||
|
item-title="name"
|
||||||
|
item-value="id"
|
||||||
|
label="Airline"
|
||||||
|
multiple
|
||||||
|
clearable
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
<template #item="{ item, props: itemProps }">
|
||||||
|
<v-list-item v-bind="itemProps">
|
||||||
|
<template #prepend="{ isSelected }">
|
||||||
|
<v-checkbox-btn :model-value="isSelected" tabindex="-1" />
|
||||||
|
</template>
|
||||||
|
<template #title>
|
||||||
|
<img
|
||||||
|
:src="airlineLogoUrl((item as any).id)"
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
style="object-fit: contain; margin-right: 8px; vertical-align: middle;"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
{{ (item as any).name }}
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
<template #selection="{ item, index }">
|
||||||
|
<span v-if="index < 2" class="v-select__selection-text">
|
||||||
|
{{ (item as any).name }}<span v-if="index < Math.min(selectedAirlines.length, 2) - 1">, </span>
|
||||||
|
</span>
|
||||||
|
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedAirlines.length - 2 }}</span>
|
||||||
|
</template>
|
||||||
|
</v-select>
|
||||||
|
|
||||||
|
<v-select
|
||||||
|
v-model="selectedCountries"
|
||||||
|
:items="availableCountries"
|
||||||
|
item-title="name"
|
||||||
|
item-value="code"
|
||||||
|
label="Country"
|
||||||
|
multiple
|
||||||
|
clearable
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
<template #item="{ item, props: itemProps }">
|
||||||
|
<v-list-item v-bind="itemProps">
|
||||||
|
<template #prepend="{ isSelected }">
|
||||||
|
<v-checkbox-btn :model-value="isSelected" tabindex="-1" />
|
||||||
|
</template>
|
||||||
|
<template #title>
|
||||||
|
<span :class="countryFlagClass((item as any).code)" style="margin-right: 8px; font-size: 1.1em;" />
|
||||||
|
{{ (item as any).name }}
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
<template #selection="{ item, index }">
|
||||||
|
<span v-if="index < 2" class="v-select__selection-text">
|
||||||
|
<span :class="countryFlagClass((item as any).code)" style="margin-right: 4px;" />
|
||||||
|
{{ (item as any).name }}<span v-if="index < Math.min(selectedCountries.length, 2) - 1">, </span>
|
||||||
|
</span>
|
||||||
|
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedCountries.length - 2 }}</span>
|
||||||
|
</template>
|
||||||
|
</v-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FlightStatsBar :flights="pastFlights" :upcoming-flights="upcomingFlights" />
|
||||||
|
<FlightCharts :flights="pastFlights" :upcoming-flights="upcomingFlights" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { usePage } from '@inertiajs/vue3'
|
||||||
|
import FlightMap from '@/Components/FlightsGoneBy/FlightMap.vue'
|
||||||
|
import type { Flight } from '@/Types/types'
|
||||||
|
import FlightStatsBar from "@/Components/FlightsGoneBy/FlightStatsBar.vue";
|
||||||
|
import FlightCharts from "@/Components/FlightsGoneBy/FlightCharts.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
flights: Flight[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const mappedFlights = computed(() => [
|
||||||
|
...pastFlights.value,
|
||||||
|
...upcomingFlights.value,
|
||||||
|
])
|
||||||
|
|
||||||
|
const page = usePage()
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
const selectedYears = ref<number[]>([])
|
||||||
|
const selectedAirlines = ref<number[]>([])
|
||||||
|
const selectedCountries = ref<string[]>([])
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const airlineLogoUrl = (id: number) =>
|
||||||
|
`${page.props.logo_api_url}/airlines/logos/tail/id/${id}`
|
||||||
|
|
||||||
|
const countryFlagClass = (code: string) =>
|
||||||
|
`fi fi-${code.toLowerCase()}`
|
||||||
|
|
||||||
|
// ── Available filter options ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const availableYears = computed(() => {
|
||||||
|
const years = new Set<number>()
|
||||||
|
props.flights.forEach(f => years.add(new Date(f.departure_date).getFullYear()))
|
||||||
|
return [...years].sort((a, b) => b - a)
|
||||||
|
})
|
||||||
|
|
||||||
|
const availableAirlines = computed((): { id: number; name: string }[] => {
|
||||||
|
const map = new Map<number, { id: number; name: string }>()
|
||||||
|
props.flights.forEach(f => {
|
||||||
|
if (f.airline?.id && f.airline?.name) {
|
||||||
|
map.set(f.airline.id, { id: f.airline.id, name: f.airline.name })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
})
|
||||||
|
|
||||||
|
const availableCountries = computed((): { code: string; name: string }[] => {
|
||||||
|
const map = new Map<string, { code: string; name: string }>()
|
||||||
|
props.flights.forEach(f => {
|
||||||
|
const dep = f.departure_airport.region?.country
|
||||||
|
const arr = f.arrival_airport.region?.country
|
||||||
|
if (dep) map.set(dep.code, { code: dep.code, name: dep.name })
|
||||||
|
if (arr) map.set(arr.code, { code: arr.code, name: arr.name })
|
||||||
|
})
|
||||||
|
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Filtered flights ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const pastFlights = computed(() => {
|
||||||
|
return props.flights.filter(f => {
|
||||||
|
const date = new Date(f.departure_date)
|
||||||
|
if (date > now) return false
|
||||||
|
if (selectedYears.value.length && !selectedYears.value.includes(date.getFullYear())) return false
|
||||||
|
if (selectedAirlines.value.length && !selectedAirlines.value.includes(f.airline?.id ?? -1)) return false
|
||||||
|
if (selectedCountries.value.length) {
|
||||||
|
const depCode = f.departure_airport.region?.country?.code
|
||||||
|
const arrCode = f.arrival_airport.region?.country?.code
|
||||||
|
if (!selectedCountries.value.includes(depCode ?? '') && !selectedCountries.value.includes(arrCode ?? '')) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const upcomingFlights = computed(() => {
|
||||||
|
return props.flights.filter(f => {
|
||||||
|
const date = new Date(f.departure_date)
|
||||||
|
if (date <= now) return false
|
||||||
|
if (selectedYears.value.length && !selectedYears.value.includes(date.getFullYear())) return false
|
||||||
|
if (selectedAirlines.value.length && !selectedAirlines.value.includes(f.airline?.id ?? -1)) return false
|
||||||
|
if (selectedCountries.value.length) {
|
||||||
|
const depCode = f.departure_airport.region?.country?.code
|
||||||
|
const arrCode = f.arrival_airport.region?.country?.code
|
||||||
|
if (!selectedCountries.value.includes(depCode ?? '') && !selectedCountries.value.includes(arrCode ?? '')) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Stats ─────────────────────────────────────────────────────────────────────
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.profile-map__toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-map__toolbar .v-select {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -70,8 +70,10 @@
|
|||||||
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
<div class="radar-content">
|
||||||
<slot/>
|
<slot/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -82,6 +84,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
.radar-svg {
|
.radar-svg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -90,6 +93,12 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
opacity:0.1;
|
opacity:0.1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
z-index: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ring { fill: none; stroke: rgba(56,189,248,0.12); stroke-width: 1; }
|
.ring { fill: none; stroke: rgba(56,189,248,0.12); stroke-width: 1; }
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ router.on('success', () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Flight } from "@/Types/types";
|
|||||||
import DepartureBoard from "@/Components/FlightsGoneBy/DepartureBoard.vue";
|
import DepartureBoard from "@/Components/FlightsGoneBy/DepartureBoard.vue";
|
||||||
import BoardingPasses from "@/Components/FlightsGoneBy/BoardingPasses.vue";
|
import BoardingPasses from "@/Components/FlightsGoneBy/BoardingPasses.vue";
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
|
import ProfileMap from "@/Components/FlightsGoneBy/ProfileMap.vue";
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
layout: MainLayout
|
layout: MainLayout
|
||||||
@@ -19,8 +20,8 @@ defineProps<{
|
|||||||
flights: Flight[]
|
flights: Flight[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
type View = 'board' | 'passes'
|
type View = 'board' | 'passes' | 'map'
|
||||||
const activeView = ref<View>('board')
|
const activeView = ref<View>('map')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -40,12 +41,20 @@ const activeView = ref<View>('board')
|
|||||||
|
|
||||||
<!-- View switcher toolbar -->
|
<!-- View switcher toolbar -->
|
||||||
<div class="view-toolbar">
|
<div class="view-toolbar">
|
||||||
|
<button
|
||||||
|
class="view-btn"
|
||||||
|
:class="{ active: activeView === 'map' }"
|
||||||
|
@click="activeView = 'map'"
|
||||||
|
>
|
||||||
|
<span class="view-btn-icon mdi mdi-earth"></span>
|
||||||
|
<span class="view-btn-label">Map</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="view-btn"
|
class="view-btn"
|
||||||
:class="{ active: activeView === 'board' }"
|
:class="{ active: activeView === 'board' }"
|
||||||
@click="activeView = 'board'"
|
@click="activeView = 'board'"
|
||||||
>
|
>
|
||||||
<span class="view-btn-icon">▦</span>
|
<span class="view-btn-icon mdi mdi-table"></span>
|
||||||
<span class="view-btn-label">DEPARTURE BOARD</span>
|
<span class="view-btn-label">DEPARTURE BOARD</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -53,13 +62,14 @@ const activeView = ref<View>('board')
|
|||||||
:class="{ active: activeView === 'passes' }"
|
:class="{ active: activeView === 'passes' }"
|
||||||
@click="activeView = 'passes'"
|
@click="activeView = 'passes'"
|
||||||
>
|
>
|
||||||
<span class="view-btn-icon">◫</span>
|
<span class="view-btn-icon mdi mdi-ticket"></span>
|
||||||
<span class="view-btn-label">BOARDING PASSES</span>
|
<span class="view-btn-label">BOARDING PASSES</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DepartureBoard v-if="activeView === 'board'" :flights="flights" />
|
<DepartureBoard v-if="activeView === 'board'" :flights="flights" />
|
||||||
<BoardingPasses v-else :flights="flights" />
|
<BoardingPasses v-else-if="activeView === 'passes'" :flights="flights" />
|
||||||
|
<ProfileMap v-else-if="activeView === 'map'" :flights="flights" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -69,7 +79,6 @@ const activeView = ref<View>('board')
|
|||||||
/* ── Wrapper ── */
|
/* ── Wrapper ── */
|
||||||
.board-wrapper {
|
.board-wrapper {
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
background: #0d0f14;
|
|
||||||
padding: 2.5rem 2rem;
|
padding: 2.5rem 2rem;
|
||||||
font-family: 'Barlow', sans-serif;
|
font-family: 'Barlow', sans-serif;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { createApp, h, DefineComponent } from 'vue';
|
|||||||
import vuetify from './plugins/vuetify';
|
import vuetify from './plugins/vuetify';
|
||||||
import '@mdi/font/css/materialdesignicons.css'
|
import '@mdi/font/css/materialdesignicons.css'
|
||||||
import 'flag-icons/css/flag-icons.min.css'
|
import 'flag-icons/css/flag-icons.min.css'
|
||||||
|
import VueApexCharts from 'vue3-apexcharts'
|
||||||
|
|
||||||
|
|
||||||
const appName = document.querySelector('meta[name="app-name"]')?.getAttribute('content') || 'Laravel';
|
const appName = document.querySelector('meta[name="app-name"]')?.getAttribute('content') || 'Laravel';
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ createInertiaApp({
|
|||||||
.use(plugin)
|
.use(plugin)
|
||||||
.use(ZiggyVue)
|
.use(ZiggyVue)
|
||||||
.use(vuetify)
|
.use(vuetify)
|
||||||
|
.use(VueApexCharts)
|
||||||
.mount(el);
|
.mount(el);
|
||||||
},
|
},
|
||||||
progress: {
|
progress: {
|
||||||
|
|||||||
Reference in New Issue
Block a user