From 31351eb8d283b823debca203cfd4214eacbecc0a Mon Sep 17 00:00:00 2001 From: Celeo Date: Tue, 15 Oct 2024 20:35:02 -0700 Subject: [PATCH] Shows flights currently in airspace Both on the homepage and also the flights page. --- Cargo.lock | 131 ++++++++++++++++++++- README.md | 6 +- vzdv-site/Cargo.toml | 1 + vzdv-site/src/endpoints/airspace.rs | 66 +++-------- vzdv-site/src/endpoints/homepage.rs | 42 +++---- vzdv-site/src/flights.rs | 125 ++++++++++++++++++++ vzdv-site/src/main.rs | 1 + vzdv-site/templates/homepage/flights.jinja | 10 +- 8 files changed, 299 insertions(+), 83 deletions(-) create mode 100644 vzdv-site/src/flights.rs diff --git a/Cargo.lock b/Cargo.lock index cfa2560..09edaef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,6 +129,15 @@ version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "async-trait" version = "0.1.81" @@ -655,6 +664,16 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "earcutr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79127ed59a85d7687c409e9978547cffb7dc79675355ed22da6b66fd5f6ead01" +dependencies = [ + "itertools 0.11.0", + "num-traits", +] + [[package]] name = "either" version = "1.13.0" @@ -763,6 +782,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + [[package]] name = "flume" version = "0.11.0" @@ -912,6 +937,44 @@ dependencies = [ "version_check", ] +[[package]] +name = "geo" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f811f663912a69249fa620dcd2a005db7254529da2d8a0b23942e81f47084501" +dependencies = [ + "earcutr", + "float_next_after", + "geo-types", + "geographiclib-rs", + "log", + "num-traits", + "robust", + "rstar", + "spade", +] + +[[package]] +name = "geo-types" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ff16065e5720f376fbced200a5ae0f47ace85fd70b7e54269790281353b6d61" +dependencies = [ + "approx", + "num-traits", + "rstar", + "serde", +] + +[[package]] +name = "geographiclib-rs" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e5ed84f8089c70234b0a8e0aedb6dc733671612ddc0d37c6066052f9781960" +dependencies = [ + "libm", +] + [[package]] name = "getrandom" version = "0.2.15" @@ -973,6 +1036,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -992,6 +1064,16 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -1319,6 +1401,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -2121,6 +2212,12 @@ dependencies = [ "serde", ] +[[package]] +name = "robust" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf4a6aa5f6d6888f39e980649f3ad6b666acdce1d78e95b8a2cb076e687ae30" + [[package]] name = "rsa" version = "0.9.6" @@ -2141,6 +2238,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rstar" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133315eb94c7b1e8d0cb097e5a710d850263372fd028fff18969de708afc7008" +dependencies = [ + "heapless", + "num-traits", + "smallvec", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -2484,6 +2592,18 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "spade" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f5ef1f863aca7d1d7dda7ccfc36a0a4279bd6d3c375176e5e0712e25cb4889" +dependencies = [ + "hashbrown", + "num-traits", + "robust", + "smallvec", +] + [[package]] name = "spin" version = "0.5.2" @@ -2721,6 +2841,12 @@ dependencies = [ "url", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + [[package]] name = "stacker" version = "0.1.15" @@ -3495,7 +3621,7 @@ dependencies = [ "chrono", "fern", "humantime", - "itertools", + "itertools 0.13.0", "log", "reqwest 0.12.5", "serde", @@ -3544,7 +3670,8 @@ dependencies = [ "chrono", "chrono-tz", "clap", - "itertools", + "geo", + "itertools 0.13.0", "lettre", "log", "mini-moka", diff --git a/README.md b/README.md index 47fec3c..6a9e649 100644 --- a/README.md +++ b/README.md @@ -51,10 +51,10 @@ This app makes few assertions about how it should be ran. You can run it directl Licensed under either of -* Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE)) -* MIT license ([LICENSE-MIT](LICENSE-MIT)) +- Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE)) +- MIT license ([LICENSE-MIT](LICENSE-MIT)) -Loading indicator from [SamHerbert/SVG-Loaders](https://github.com/SamHerbert/SVG-Loaders). +Loading indicator from [SamHerbert/SVG-Loaders](https://github.com/SamHerbert/SVG-Loaders). Geo boundary data from [vatspy-data-project](https://github.com/vatsimnetwork/vatspy-data-project). See 'Cargo.toml' files for a list of used libraries. ## Contributing diff --git a/vzdv-site/Cargo.toml b/vzdv-site/Cargo.toml index 3ff8bac..17628f8 100644 --- a/vzdv-site/Cargo.toml +++ b/vzdv-site/Cargo.toml @@ -40,3 +40,4 @@ tower-sessions-sqlx-store = { version = "0.13.0", features = ["sqlite"] } uuid = { version = "1.10.0", features = ["v4", "fast-rng"] } vatsim_utils = "0.5.0" voca_rs = "1.15.2" +geo = "0.28.0" diff --git a/vzdv-site/src/endpoints/airspace.rs b/vzdv-site/src/endpoints/airspace.rs index 6e2ad47..5786c26 100644 --- a/vzdv-site/src/endpoints/airspace.rs +++ b/vzdv-site/src/endpoints/airspace.rs @@ -2,6 +2,7 @@ use crate::{ flashed_messages, + flights::get_relevant_flights, shared::{AppError, AppState, CacheEntry, UserInfo, SESSION_USER_INFO_KEY}, }; use axum::{ @@ -13,9 +14,9 @@ use axum::{ use itertools::Itertools; use log::{info, warn}; use minijinja::{context, Environment}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use serde_json::json; -use std::{sync::Arc, time::Instant}; +use std::{collections::HashSet, sync::Arc, time::Instant}; use thousands::Separable; use tower_sessions::Session; use vatsim_utils::live_api::Vatsim; @@ -38,60 +39,31 @@ async fn page_flights( State(state): State>, session: Session, ) -> Result, AppError> { - #[derive(Serialize, Default)] - struct OnlineFlight<'a> { - pilot_name: &'a str, - pilot_cid: u64, - callsign: &'a str, - departure: &'a str, - arrival: &'a str, - altitude: String, - speed: String, - } - - // cache this endpoint's returned data for 60 seconds + // cache this endpoint's returned data for 15 seconds let cache_key = "ONLINE_FLIGHTS_FULL"; if let Some(cached) = state.cache.get(&cache_key) { let elapsed = Instant::now() - cached.inserted; - if elapsed.as_secs() < 60 { + if elapsed.as_secs() < 15 { return Ok(Html(cached.data)); } state.cache.invalidate(&cache_key); } - let artcc_fields: Vec<_> = state - .config - .airports - .all - .iter() - .map(|airport| &airport.code) - .collect(); let vatsim_data = Vatsim::new().await?.get_v3_data().await?; - let flights: Vec = vatsim_data - .pilots - .iter() - .flat_map(|flight| { - if let Some(plan) = &flight.flight_plan { - let from = artcc_fields.contains(&&plan.departure); - let to = artcc_fields.contains(&&plan.arrival); - if from || to { - Some(OnlineFlight { - pilot_name: &flight.name, - pilot_cid: flight.cid, - callsign: &flight.callsign, - departure: &plan.departure, - arrival: &plan.arrival, - altitude: flight.altitude.separate_with_commas(), - speed: flight.groundspeed.separate_with_commas(), - }) - } else { - None - } - } else { - None - } - }) - .collect(); + let flights = { + let all = get_relevant_flights(&state.config, &vatsim_data.pilots); + let mut flights = HashSet::with_capacity(all.plan_within.len()); // won't be all of them, but might save one allocation + flights.extend(all.actually_within); + flights.extend(all.plan_from); + flights.extend(all.plan_to); + flights.extend(all.plan_within); + let flights: Vec<_> = flights + .iter() + .cloned() + .sorted_by(|a, b| a.callsign.cmp(&b.callsign)) + .collect(); + flights + }; let user_info: Option = session.get(SESSION_USER_INFO_KEY).await?; let template = state.templates.get_template("airspace/flights")?; diff --git a/vzdv-site/src/endpoints/homepage.rs b/vzdv-site/src/endpoints/homepage.rs index 59e8c73..66c7b5f 100644 --- a/vzdv-site/src/endpoints/homepage.rs +++ b/vzdv-site/src/endpoints/homepage.rs @@ -2,6 +2,7 @@ use crate::{ flashed_messages, + flights::get_relevant_flights, shared::{AppError, AppState, CacheEntry, UserInfo, SESSION_USER_INFO_KEY}, }; use axum::{extract::State, response::Html, routing::get, Router}; @@ -102,45 +103,30 @@ async fn snippet_weather(State(state): State>) -> Result>) -> Result, AppError> { #[derive(Serialize, Default)] struct OnlineFlights { - within: u16, - from: u16, - to: u16, + plan_within: usize, + plan_from: usize, + plan_to: usize, + actually_within: usize, } - // cache this endpoint's returned data for 60 seconds + // cache this endpoint's returned data for 15 seconds let cache_key = "ONLINE_FLIGHTS_HOMEPAGE"; if let Some(cached) = state.cache.get(&cache_key) { let elapsed = Instant::now() - cached.inserted; - if elapsed.as_secs() < 60 { + if elapsed.as_secs() < 15 { return Ok(Html(cached.data)); } state.cache.invalidate(&cache_key); } - let artcc_fields: Vec<_> = state - .config - .airports - .all - .iter() - .map(|airport| &airport.code) - .collect(); let data = Vatsim::new().await?.get_v3_data().await?; - let flights: OnlineFlights = - data.pilots - .iter() - .fold(OnlineFlights::default(), |mut flights, flight| { - if let Some(plan) = &flight.flight_plan { - let from = artcc_fields.contains(&&plan.departure); - let to = artcc_fields.contains(&&plan.arrival); - match (from, to) { - (true, true) => flights.within += 1, - (false, true) => flights.to += 1, - (true, false) => flights.from += 1, - _ => {} - } - }; - flights - }); + let flights = get_relevant_flights(&state.config, &data.pilots); + let flights = OnlineFlights { + plan_within: flights.plan_within.len(), + plan_from: flights.plan_from.len(), + plan_to: flights.plan_to.len(), + actually_within: flights.actually_within.len(), + }; let template = state.templates.get_template("homepage/flights")?; let rendered = template.render(context! { flights })?; diff --git a/vzdv-site/src/flights.rs b/vzdv-site/src/flights.rs new file mode 100644 index 0000000..ef6c171 --- /dev/null +++ b/vzdv-site/src/flights.rs @@ -0,0 +1,125 @@ +use geo::{Contains, LineString, Point, Polygon}; +use serde::Serialize; +use std::sync::LazyLock; +use thousands::Separable; +use vatsim_utils::models::Pilot; +use vzdv::config::Config; + +// source: https://github.com/vatsimnetwork/vatspy-data-project/blob/a88517cece1e81cd1d18552e7c630e47ddd7739e/Boundaries.geojson?short_path=531fb38#L181 +const ZDV_COORDINATES: [(f64, f64); 33] = [ + (-103.166667, 44.958333), + (-101.483333, 44.7), + (-101.408333, 43.708333), + (-100.1, 43.288889), + (-99.016667, 42.0), + (-99.058333, 39.983333), + (-98.8, 39.466667), + (-102.55, 37.5), + (-105.0, 36.716667), + (-106.083333, 36.716667), + (-107.466667, 36.2), + (-108.216667, 36.033333), + (-110.233333, 35.7), + (-111.841667, 35.766667), + (-111.504167, 36.420833), + (-111.608333, 36.733333), + (-111.879167, 37.4125), + (-110.883333, 37.833333), + (-110.156944, 38.129167), + (-109.983333, 38.2), + (-109.983333, 38.933333), + (-109.983333, 39.216667), + (-110.3, 39.583333), + (-109.166667, 40.0), + (-109.1, 40.85), + (-108.275, 41.366667), + (-108.0, 41.608333), + (-107.05, 42.416667), + (-107.283333, 43.883333), + (-106.266667, 44.316667), + (-106.0, 45.2375), + (-104.25, 45.116667), + (-103.166667, 44.958333), +]; + +/// Polygon of ZDV airspace boundaries. +static ZDV_POLYGON: LazyLock = + LazyLock::new(|| Polygon::new(LineString::from(ZDV_COORDINATES.to_vec()), Vec::new())); + +#[derive(Clone, Default, Serialize, Hash, PartialEq, Eq)] +pub struct OnlineFlight { + pub pilot_name: String, + pub pilot_cid: u64, + pub callsign: String, + pub departure: String, + pub arrival: String, + pub altitude: String, + pub speed: String, +} + +#[derive(Clone, Default, Serialize)] +pub struct OnlineFlights { + pub plan_within: Vec, + pub plan_from: Vec, + pub plan_to: Vec, + pub actually_within: Vec, +} + +/// Return a list of flights that are relevant to the airspace. +/// +/// Relevancy is determined as: +/// - Starting at a facility airport +/// - Ending at a facility airport +/// - Starting and ending at a facility airport +/// - Within the facility's airspace +pub fn get_relevant_flights(config: &Config, pilot_data: &[Pilot]) -> OnlineFlights { + let artcc_fields: Vec<_> = config + .airports + .all + .iter() + .map(|airport| &airport.code) + .collect(); + + let mut flights = OnlineFlights::default(); + for flight in pilot_data { + if let Some(plan) = &flight.flight_plan { + let flight_data = OnlineFlight { + pilot_name: flight.name.clone(), + pilot_cid: flight.cid, + callsign: flight.callsign.clone(), + departure: plan.departure.clone(), + arrival: plan.arrival.clone(), + altitude: flight.altitude.separate_with_commas(), + speed: flight.groundspeed.separate_with_commas(), + }; + if ZDV_POLYGON.contains(&Point::new(flight.longitude, flight.latitude)) { + flights.actually_within.push(flight_data.clone()); + } + let from = artcc_fields.contains(&&plan.departure); + let to = artcc_fields.contains(&&plan.arrival); + match (from, to) { + (true, true) => flights.plan_within.push(flight_data), + (false, true) => flights.plan_to.push(flight_data), + (true, false) => flights.plan_from.push(flight_data), + _ => {} + } + }; + } + + flights +} + +#[cfg(test)] +mod tests { + use super::ZDV_POLYGON; + use geo::{Contains, Point}; + + #[test] + fn test_polygon_contains() { + let contains = ZDV_POLYGON.contains(&Point::new(-107.57511, 39.23782)); + assert!(contains); + + let contains = ZDV_POLYGON.contains(&Point::new(-113.54264, 36.56797)); + assert!(!contains); + } +} diff --git a/vzdv-site/src/main.rs b/vzdv-site/src/main.rs index 27bce44..ad9aa81 100644 --- a/vzdv-site/src/main.rs +++ b/vzdv-site/src/main.rs @@ -27,6 +27,7 @@ mod discord; mod email; mod endpoints; mod flashed_messages; +mod flights; mod middleware; mod shared; diff --git a/vzdv-site/templates/homepage/flights.jinja b/vzdv-site/templates/homepage/flights.jinja index 1c0a5ba..77d1875 100644 --- a/vzdv-site/templates/homepage/flights.jinja +++ b/vzdv-site/templates/homepage/flights.jinja @@ -6,14 +6,18 @@

- Within: {{ flights.within }} + Planned within: {{ flights.plan_within }}
- From: {{ flights.from }} + Planned from: {{ flights.plan_from }}
- To: {{ flights.to }} + Planned to: {{ flights.plan_to }} + +
+ + In airspace: {{ flights.actually_within }}