Added Charts

This commit is contained in:
2026-04-11 17:30:16 +10:00
parent 7a07616f03
commit e83fd3bdca
24 changed files with 2915 additions and 39 deletions
+323 -1
View File
@@ -6,13 +6,19 @@
"": {
"dependencies": {
"@mdi/font": "^7.4.47",
"apexcharts": "^5.10.5",
"echarts": "^6.0.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"
},
"devDependencies": {
"@inertiajs/vue3": "^2.0.0",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/vite": "^4.0.0",
"@types/leaflet": "^1.9.21",
"@types/node": "^25.5.0",
"@vitejs/plugin-vue": "^6.0.0",
"autoprefixer": "^10.4.12",
@@ -176,6 +182,111 @@
"@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": {
"version": "7.4.47",
"resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz",
@@ -804,6 +915,22 @@
"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": {
"version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
@@ -832,6 +959,15 @@
"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": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz",
@@ -1030,6 +1166,13 @@
"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": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@@ -2499,6 +2642,28 @@
"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": {
"version": "1.5.331",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz",
@@ -2835,6 +3000,12 @@
"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": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -3096,6 +3267,12 @@
"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": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz",
@@ -3106,6 +3283,12 @@
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-1.0.2.tgz",
@@ -3455,6 +3638,40 @@
"@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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -3552,7 +3769,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -3578,6 +3794,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
"license": "MIT"
},
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -3695,6 +3917,18 @@
"dev": true,
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3898,6 +4132,12 @@
"dev": true,
"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": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz",
@@ -3908,6 +4148,12 @@
"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": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -3962,6 +4208,12 @@
],
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -4109,6 +4361,15 @@
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -4178,6 +4439,12 @@
"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": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
@@ -4390,6 +4657,15 @@
"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": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
@@ -4522,6 +4798,12 @@
"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": {
"version": "1.0.3",
"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": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.0.5.tgz",
@@ -4925,6 +5232,21 @@
"engines": {
"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"
}
}
}
+6
View File
@@ -11,6 +11,7 @@
"@inertiajs/vue3": "^2.0.0",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/vite": "^4.0.0",
"@types/leaflet": "^1.9.21",
"@types/node": "^25.5.0",
"@vitejs/plugin-vue": "^6.0.0",
"autoprefixer": "^10.4.12",
@@ -27,7 +28,12 @@
},
"dependencies": {
"@mdi/font": "^7.4.47",
"apexcharts": "^5.10.5",
"echarts": "^6.0.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"
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ a {
.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%);
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);
@@ -64,7 +64,7 @@ const searchAirlines = async (query: string) => {
<template #item="{ item, props: itemProps }">
<v-list-item v-bind="itemProps">
<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>
</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 v-if="flight.seat_number" class="pass-meta-row">
<span class="pass-meta-pill pass-meta-pill--seat">SEAT {{ flight.seat_number }}</span>
<div v-if="flight.duration_display || flight.distance || flight.seat_number" class="pass-tear">
<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>
</template>
<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 {
background: #181b24;
border-radius: 10px;
@@ -190,29 +274,4 @@ function classKey(flight: Flight): string {
margin-left: 0.15rem;
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>
@@ -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: 'DEPART', key: 'departure_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: 'CLASS', key: 'flight_class', sortable: true },
]
@@ -40,7 +42,8 @@ const customKeySort = {
},
airline: (a: Flight['airline'], b: Flight['airline']) => {
return (a?.IATA_code ?? '').localeCompare(b?.IATA_code ?? '')
}
},
duration: (a: any, b: any) => (a ?? 0) - (b ?? 0),
}
// Track active sort state
@@ -176,6 +179,17 @@ const tableItems = computed(() =>
</sup>
</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 -->
<td class="v-data-table__td">
<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">,&nbsp;</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">,&nbsp;</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">,&nbsp;</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,7 +70,9 @@
</svg>
<slot/>
<div class="radar-content">
<slot/>
</div>
</div>
</template>
@@ -82,6 +84,7 @@
position: relative;
min-height: 100dvh;
background: var(--bg);
isolation: isolate;
}
.radar-svg {
position: absolute;
@@ -90,6 +93,12 @@
height: 100%;
opacity:0.1;
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; }
-1
View File
@@ -33,7 +33,6 @@ router.on('success', () => {
display: flex;
flex-direction: column;
min-height: 100dvh;
background: var(--bg);
color: var(--text);
overflow-y: auto;
}
+15 -6
View File
@@ -5,6 +5,7 @@ import { Flight } from "@/Types/types";
import DepartureBoard from "@/Components/FlightsGoneBy/DepartureBoard.vue";
import BoardingPasses from "@/Components/FlightsGoneBy/BoardingPasses.vue";
import { ref } from "vue";
import ProfileMap from "@/Components/FlightsGoneBy/ProfileMap.vue";
defineOptions({
layout: MainLayout
@@ -19,8 +20,8 @@ defineProps<{
flights: Flight[]
}>()
type View = 'board' | 'passes'
const activeView = ref<View>('board')
type View = 'board' | 'passes' | 'map'
const activeView = ref<View>('map')
</script>
<template>
@@ -40,12 +41,20 @@ const activeView = ref<View>('board')
<!-- View switcher 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
class="view-btn"
:class="{ active: 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>
</button>
<button
@@ -53,13 +62,14 @@ const activeView = ref<View>('board')
:class="{ active: 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>
</button>
</div>
<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>
</template>
@@ -69,7 +79,6 @@ const activeView = ref<View>('board')
/* ── Wrapper ── */
.board-wrapper {
min-height: 100dvh;
background: #0d0f14;
padding: 2.5rem 2rem;
font-family: 'Barlow', sans-serif;
width: 100%;
+3
View File
@@ -8,6 +8,8 @@ import { createApp, h, DefineComponent } from 'vue';
import vuetify from './plugins/vuetify';
import '@mdi/font/css/materialdesignicons.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';
@@ -23,6 +25,7 @@ createInertiaApp({
.use(plugin)
.use(ZiggyVue)
.use(vuetify)
.use(VueApexCharts)
.mount(el);
},
progress: {