Skip to main content

thrust_wasm/
faa_arcgis.rs

1use std::collections::HashMap;
2
3use js_sys::Array;
4use serde_json::Value;
5use thrust::data::faa::arcgis::parse_arcgis_features;
6use wasm_bindgen::prelude::*;
7
8use crate::models::{
9    normalize_airway_name, AirportRecord, AirspaceCompositeRecord, AirspaceLayerRecord, AirspaceRecord, AirwayRecord,
10    NavpointRecord,
11};
12
13fn compose_airspace(records: Vec<AirspaceRecord>) -> Option<AirspaceCompositeRecord> {
14    let first = records.first()?;
15    let designator = first.designator.clone();
16    let source = first.source.clone();
17    let name = records.iter().find_map(|r| r.name.clone());
18    let type_ = records.iter().find_map(|r| r.type_.clone());
19    let layers = records
20        .into_iter()
21        .map(|r| AirspaceLayerRecord {
22            lower: r.lower,
23            upper: r.upper,
24            coordinates: r.coordinates,
25        })
26        .collect();
27
28    Some(AirspaceCompositeRecord {
29        designator,
30        name,
31        type_,
32        layers,
33        source,
34    })
35}
36
37#[wasm_bindgen]
38pub struct FaaArcgisResolver {
39    airports: Vec<AirportRecord>,
40    airspaces: Vec<AirspaceRecord>,
41    navaids: Vec<NavpointRecord>,
42    airways: Vec<AirwayRecord>,
43    airport_index: HashMap<String, Vec<usize>>,
44    airspace_index: HashMap<String, Vec<usize>>,
45    navaid_index: HashMap<String, Vec<usize>>,
46    airway_index: HashMap<String, Vec<usize>>,
47    sid_index: HashMap<String, Vec<usize>>,
48    star_index: HashMap<String, Vec<usize>>,
49}
50
51fn procedure_lookup_keys(name: &str) -> Vec<String> {
52    let upper = name.trim().to_uppercase();
53    if upper.is_empty() {
54        return Vec::new();
55    }
56    let mut out = vec![upper.clone()];
57    let compact = upper.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>();
58    if !compact.is_empty() {
59        out.push(compact.clone());
60        if compact.len() > 4 && compact[compact.len() - 4..].chars().all(|c| c.is_ascii_alphabetic()) {
61            out.push(compact[..compact.len() - 4].to_string());
62        }
63    }
64    out.sort();
65    out.dedup();
66    out
67}
68
69#[wasm_bindgen]
70impl FaaArcgisResolver {
71    #[wasm_bindgen(constructor)]
72    pub fn new(feature_collections_json: JsValue) -> Result<FaaArcgisResolver, JsValue> {
73        let payloads = Array::from(&feature_collections_json);
74        let mut features: Vec<Value> = Vec::new();
75        for payload in payloads.iter() {
76            let value: Value =
77                serde_wasm_bindgen::from_value(payload).map_err(|e| JsValue::from_str(&e.to_string()))?;
78            let arr = value
79                .get("features")
80                .and_then(|x| x.as_array())
81                .cloned()
82                .unwrap_or_default();
83            features.extend(arr);
84        }
85
86        let dataset = parse_arcgis_features(&features);
87        let airports: Vec<AirportRecord> = dataset.airports.into_iter().map(Into::into).collect();
88        let airspaces: Vec<AirspaceRecord> = dataset.airspaces.into_iter().map(Into::into).collect();
89        let navaids: Vec<NavpointRecord> = dataset.navaids.into_iter().map(Into::into).collect();
90        let airways: Vec<AirwayRecord> = dataset.airways.into_iter().map(Into::into).collect();
91
92        let mut airport_index: HashMap<String, Vec<usize>> = HashMap::new();
93        for (i, a) in airports.iter().enumerate() {
94            airport_index.entry(a.code.clone()).or_default().push(i);
95            if let Some(v) = &a.iata {
96                airport_index.entry(v.clone()).or_default().push(i);
97            }
98            if let Some(v) = &a.icao {
99                airport_index.entry(v.clone()).or_default().push(i);
100            }
101        }
102
103        let mut airspace_index: HashMap<String, Vec<usize>> = HashMap::new();
104        for (i, a) in airspaces.iter().enumerate() {
105            airspace_index.entry(a.designator.to_uppercase()).or_default().push(i);
106        }
107
108        let mut navaid_index: HashMap<String, Vec<usize>> = HashMap::new();
109        for (i, n) in navaids.iter().enumerate() {
110            navaid_index.entry(n.code.clone()).or_default().push(i);
111        }
112
113        let mut airway_index: HashMap<String, Vec<usize>> = HashMap::new();
114        let mut sid_index: HashMap<String, Vec<usize>> = HashMap::new();
115        let mut star_index: HashMap<String, Vec<usize>> = HashMap::new();
116        for (i, a) in airways.iter().enumerate() {
117            airway_index.entry(normalize_airway_name(&a.name)).or_default().push(i);
118            airway_index.entry(a.name.to_uppercase()).or_default().push(i);
119            match a.route_class.as_deref().map(|s| s.to_uppercase()) {
120                Some(rc) if rc == "DP" => {
121                    for key in procedure_lookup_keys(&a.name) {
122                        sid_index.entry(key).or_default().push(i);
123                    }
124                }
125                Some(rc) if rc == "AP" => {
126                    for key in procedure_lookup_keys(&a.name) {
127                        star_index.entry(key).or_default().push(i);
128                    }
129                }
130                _ => {}
131            }
132        }
133
134        Ok(Self {
135            airports,
136            airspaces,
137            navaids,
138            airways,
139            airport_index,
140            airspace_index,
141            navaid_index,
142            airway_index,
143            sid_index,
144            star_index,
145        })
146    }
147
148    fn resolve_procedure_airway_by_kind(&self, kind: &str, name: &str) -> Option<AirwayRecord> {
149        let index = match kind {
150            "SID" => &self.sid_index,
151            "STAR" => &self.star_index,
152            _ => return None,
153        };
154        for key in procedure_lookup_keys(name) {
155            if let Some(i) = index.get(&key).and_then(|idx| idx.first()).copied() {
156                if let Some(item) = self.airways.get(i) {
157                    return Some(item.clone());
158                }
159            }
160        }
161        None
162    }
163
164    pub fn airports(&self) -> Result<JsValue, JsValue> {
165        serde_wasm_bindgen::to_value(&self.airports).map_err(|e| JsValue::from_str(&e.to_string()))
166    }
167
168    pub fn fixes(&self) -> Result<JsValue, JsValue> {
169        serde_wasm_bindgen::to_value(&self.navaids).map_err(|e| JsValue::from_str(&e.to_string()))
170    }
171
172    pub fn navaids(&self) -> Result<JsValue, JsValue> {
173        serde_wasm_bindgen::to_value(&self.navaids).map_err(|e| JsValue::from_str(&e.to_string()))
174    }
175
176    pub fn airways(&self) -> Result<JsValue, JsValue> {
177        serde_wasm_bindgen::to_value(&self.airways).map_err(|e| JsValue::from_str(&e.to_string()))
178    }
179
180    pub fn airspaces(&self) -> Result<JsValue, JsValue> {
181        let mut keys = self.airspace_index.keys().cloned().collect::<Vec<_>>();
182        keys.sort();
183        let rows = keys
184            .into_iter()
185            .filter_map(|key| {
186                let records = self
187                    .airspace_index
188                    .get(&key)
189                    .into_iter()
190                    .flat_map(|indices| indices.iter().copied())
191                    .filter_map(|idx| self.airspaces.get(idx).cloned())
192                    .collect::<Vec<_>>();
193                compose_airspace(records)
194            })
195            .collect::<Vec<_>>();
196        serde_wasm_bindgen::to_value(&rows).map_err(|e| JsValue::from_str(&e.to_string()))
197    }
198
199    pub fn resolve_airspace(&self, designator: String) -> Result<JsValue, JsValue> {
200        let key = designator.to_uppercase();
201        let records = self
202            .airspace_index
203            .get(&key)
204            .into_iter()
205            .flat_map(|indices| indices.iter().copied())
206            .filter_map(|idx| self.airspaces.get(idx).cloned())
207            .collect::<Vec<_>>();
208
209        serde_wasm_bindgen::to_value(&compose_airspace(records)).map_err(|e| JsValue::from_str(&e.to_string()))
210    }
211
212    pub fn resolve_fix(&self, code: String) -> Result<JsValue, JsValue> {
213        let key = code.to_uppercase();
214        // Prefer records with kind == "fix"; fall back to the first match.
215        let item = self
216            .navaid_index
217            .get(&key)
218            .and_then(|indices| {
219                indices
220                    .iter()
221                    .filter_map(|&i| self.navaids.get(i))
222                    .find(|r| r.kind == "fix")
223                    .or_else(|| indices.first().and_then(|&i| self.navaids.get(i)))
224            })
225            .cloned();
226
227        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
228    }
229
230    pub fn resolve_navaid(&self, code: String) -> Result<JsValue, JsValue> {
231        let key = code.to_uppercase();
232        // Prefer records with kind == "navaid"; fall back to the first match.
233        let item = self
234            .navaid_index
235            .get(&key)
236            .and_then(|indices| {
237                indices
238                    .iter()
239                    .filter_map(|&i| self.navaids.get(i))
240                    .find(|r| r.kind == "navaid")
241                    .or_else(|| indices.first().and_then(|&i| self.navaids.get(i)))
242            })
243            .cloned();
244
245        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
246    }
247
248    pub fn resolve_airway(&self, name: String) -> Result<JsValue, JsValue> {
249        let key = normalize_airway_name(&name);
250        let item = self
251            .airway_index
252            .get(&key)
253            .and_then(|idx| idx.first().copied())
254            .and_then(|i| self.airways.get(i))
255            .cloned();
256
257        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
258    }
259
260    pub fn resolve_sid(&self, name: String) -> Result<JsValue, JsValue> {
261        let item = self.resolve_procedure_airway_by_kind("SID", &name);
262        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
263    }
264
265    pub fn resolve_star(&self, name: String) -> Result<JsValue, JsValue> {
266        let item = self.resolve_procedure_airway_by_kind("STAR", &name);
267        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
268    }
269
270    pub fn resolve_airport(&self, code: String) -> Result<JsValue, JsValue> {
271        let key = code.to_uppercase();
272        let item = self
273            .airport_index
274            .get(&key)
275            .and_then(|idx| idx.first().copied())
276            .and_then(|i| self.airports.get(i))
277            .cloned();
278
279        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
280    }
281
282    /// Parse and resolve a raw ICAO field 15 route string into geographic segments.
283    ///
284    /// Same contract as `EurocontrolResolver::enrichRoute` and `NasrResolver::enrichRoute` —
285    /// returns a JS array of `{ start, end, name? }` segment objects resolved against the
286    /// FAA ArcGIS nav data.
287    #[wasm_bindgen(js_name = enrichRoute)]
288    pub fn enrich_route(&self, route: String) -> Result<JsValue, JsValue> {
289        use crate::field15::ResolvedPoint as WasmPoint;
290        use crate::field15::RouteSegment;
291        use thrust::data::field15::{Connector, Field15Element, Field15Parser, Point};
292
293        let elements = Field15Parser::parse(&route);
294        let mut segments: Vec<RouteSegment> = Vec::new();
295        let mut last_point: Option<WasmPoint> = None;
296        let mut pending_airway: Option<(String, WasmPoint)> = None;
297        let mut current_connector: Option<String> = None;
298        let mut current_segment_type: Option<String> = None;
299
300        let resolve_code = |code: &str| -> Option<WasmPoint> {
301            let key = code.split('/').next().unwrap_or(code).trim().to_uppercase();
302            if let Some(idx) = self.airport_index.get(&key).and_then(|v| v.first()) {
303                if let Some(a) = self.airports.get(*idx) {
304                    return Some(WasmPoint {
305                        latitude: a.latitude,
306                        longitude: a.longitude,
307                        name: Some(a.code.clone()),
308                        kind: Some("airport".to_string()),
309                    });
310                }
311            }
312            if let Some(idx) = self.navaid_index.get(&key).and_then(|v| v.first()) {
313                if let Some(n) = self.navaids.get(*idx) {
314                    return Some(WasmPoint {
315                        latitude: n.latitude,
316                        longitude: n.longitude,
317                        name: Some(n.code.clone()),
318                        kind: Some(n.kind.clone()),
319                    });
320                }
321            }
322            None
323        };
324
325        let expand_airway =
326            |airway_name: &str, entry: &WasmPoint, exit: &WasmPoint, segs: &mut Vec<RouteSegment>| -> bool {
327                let key = crate::models::normalize_airway_name(airway_name);
328                let airway = match self
329                    .airway_index
330                    .get(&key)
331                    .and_then(|v| v.first())
332                    .and_then(|i| self.airways.get(*i))
333                {
334                    Some(a) => a,
335                    None => return false,
336                };
337                let pts = &airway.points;
338                let entry_name = entry.name.as_deref().unwrap_or("").to_uppercase();
339                let exit_name = exit.name.as_deref().unwrap_or("").to_uppercase();
340                let entry_pos = pts.iter().position(|p| p.code.to_uppercase() == entry_name);
341                let exit_pos = pts.iter().position(|p| p.code.to_uppercase() == exit_name);
342                let (from, to) = match (entry_pos, exit_pos) {
343                    (Some(f), Some(t)) => (f, t),
344                    _ => return false,
345                };
346                let slice: Vec<&crate::models::AirwayPointRecord> = if from <= to {
347                    pts[from..=to].iter().collect()
348                } else {
349                    pts[to..=from].iter().rev().collect()
350                };
351                if slice.len() < 2 {
352                    return false;
353                }
354                let mut prev = entry.clone();
355                for pt in &slice[1..] {
356                    let next = WasmPoint {
357                        latitude: pt.latitude,
358                        longitude: pt.longitude,
359                        name: Some(pt.code.clone()),
360                        kind: Some(pt.kind.clone()),
361                    };
362                    segs.push(RouteSegment {
363                        start: prev,
364                        end: next.clone(),
365                        name: Some(airway_name.to_string()),
366                        segment_type: Some("route".to_string()),
367                        connector: Some(airway_name.to_string()),
368                    });
369                    prev = next;
370                }
371                true
372            };
373
374        let expand_procedure_from_entry =
375            |kind: &str, procedure_name: &str, entry: &WasmPoint, segs: &mut Vec<RouteSegment>| -> Option<WasmPoint> {
376                let airway = self.resolve_procedure_airway_by_kind(kind, procedure_name)?;
377                let pts = &airway.points;
378                if pts.len() < 2 {
379                    return None;
380                }
381                let entry_name = entry.name.as_deref().unwrap_or("").to_uppercase();
382                let start_idx = pts.iter().position(|p| p.code.to_uppercase() == entry_name)?;
383                if start_idx >= pts.len() - 1 {
384                    return None;
385                }
386                let mut prev = entry.clone();
387                for pt in &pts[start_idx + 1..] {
388                    let next = WasmPoint {
389                        latitude: pt.latitude,
390                        longitude: pt.longitude,
391                        name: Some(pt.code.clone()),
392                        kind: Some(pt.kind.clone()),
393                    };
394                    segs.push(RouteSegment {
395                        start: prev,
396                        end: next.clone(),
397                        name: Some(procedure_name.to_string()),
398                        segment_type: Some(kind.to_string()),
399                        connector: Some(procedure_name.to_string()),
400                    });
401                    prev = next;
402                }
403                Some(prev)
404            };
405
406        for element in &elements {
407            match element {
408                Field15Element::Point(point) => {
409                    let resolved = match point {
410                        Point::Waypoint(name) | Point::Aerodrome(name) => resolve_code(name),
411                        Point::Coordinates((lat, lon)) => Some(WasmPoint {
412                            latitude: *lat,
413                            longitude: *lon,
414                            name: None,
415                            kind: Some("coords".to_string()),
416                        }),
417                        Point::BearingDistance { point, .. } => match point.as_ref() {
418                            Point::Waypoint(name) | Point::Aerodrome(name) => resolve_code(name),
419                            Point::Coordinates((lat, lon)) => Some(WasmPoint {
420                                latitude: *lat,
421                                longitude: *lon,
422                                name: None,
423                                kind: Some("coords".to_string()),
424                            }),
425                            _ => None,
426                        },
427                    };
428                    if let Some(exit) = resolved {
429                        if let Some((airway_name, entry)) = pending_airway.take() {
430                            let expanded = expand_airway(&airway_name, &entry, &exit, &mut segments);
431                            if !expanded {
432                                segments.push(RouteSegment {
433                                    start: entry,
434                                    end: exit.clone(),
435                                    name: Some(airway_name.clone()),
436                                    segment_type: Some("unresolved".to_string()),
437                                    connector: Some(airway_name),
438                                });
439                            }
440                        } else if let Some(prev) = last_point.take() {
441                            let seg_name = current_connector.take();
442                            let seg_type = current_segment_type.take();
443                            let seg_connector = if seg_type.as_deref() == Some("dct") {
444                                Some("DCT".to_string())
445                            } else {
446                                seg_name.clone()
447                            };
448                            segments.push(RouteSegment {
449                                start: prev,
450                                end: exit.clone(),
451                                name: seg_name,
452                                segment_type: seg_type,
453                                connector: seg_connector,
454                            });
455                        } else {
456                            current_connector = None;
457                            current_segment_type = None;
458                        }
459                        last_point = Some(exit);
460                    }
461                }
462                Field15Element::Connector(connector) => match connector {
463                    Connector::Airway(name) => {
464                        if let Some(entry) = last_point.take() {
465                            pending_airway = Some((name.clone(), entry));
466                            current_segment_type = None;
467                        } else {
468                            current_connector = Some(name.clone());
469                            current_segment_type = Some("unresolved".to_string());
470                        }
471                    }
472                    Connector::Direct => {
473                        current_connector = None;
474                        current_segment_type = Some("dct".to_string());
475                    }
476                    Connector::Sid(name) => {
477                        if let Some(entry) = last_point.clone() {
478                            if let Some(end) = expand_procedure_from_entry("SID", name, &entry, &mut segments) {
479                                last_point = Some(end);
480                                current_connector = None;
481                                pending_airway = None;
482                                current_segment_type = None;
483                            } else {
484                                current_connector = Some(name.clone());
485                                current_segment_type = Some("unresolved".to_string());
486                            }
487                        } else {
488                            current_connector = Some(name.clone());
489                            current_segment_type = Some("unresolved".to_string());
490                        }
491                    }
492                    Connector::Star(name) => {
493                        if let Some(entry) = last_point.clone() {
494                            if let Some(end) = expand_procedure_from_entry("STAR", name, &entry, &mut segments) {
495                                last_point = Some(end);
496                                current_connector = None;
497                                pending_airway = None;
498                                current_segment_type = None;
499                            } else {
500                                current_connector = Some(name.clone());
501                                current_segment_type = Some("unresolved".to_string());
502                            }
503                        } else {
504                            current_connector = Some(name.clone());
505                            current_segment_type = Some("unresolved".to_string());
506                        }
507                    }
508                    Connector::Nat(name) => {
509                        current_connector = Some(name.clone());
510                        current_segment_type = Some("NAT".to_string());
511                    }
512                    Connector::Pts(name) => {
513                        current_connector = Some(name.clone());
514                        current_segment_type = Some("PTS".to_string());
515                    }
516                    _ => {}
517                },
518                Field15Element::Modifier(_) => {}
519            }
520        }
521
522        serde_wasm_bindgen::to_value(&segments).map_err(|e| JsValue::from_str(&e.to_string()))
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::{procedure_lookup_keys, FaaArcgisResolver};
529    use crate::models::{AirwayPointRecord, AirwayRecord};
530
531    #[test]
532    fn procedure_lookup_keys_extracts_base_designator() {
533        let keys = procedure_lookup_keys("KEPER9ELFBO");
534        assert!(keys.contains(&"KEPER9ELFBO".to_string()));
535        assert!(keys.contains(&"KEPER9E".to_string()));
536    }
537
538    #[test]
539    fn resolve_star_uses_ap_airway_records() {
540        let resolver = FaaArcgisResolver {
541            airports: Vec::new(),
542            airspaces: Vec::new(),
543            navaids: Vec::new(),
544            airways: vec![AirwayRecord {
545                name: "KEPER9ELFBO".to_string(),
546                source: "faa_arcgis".to_string(),
547                route_class: Some("AP".to_string()),
548                points: vec![
549                    AirwayPointRecord {
550                        code: "KEPER".to_string(),
551                        raw_code: "KEPER".to_string(),
552                        kind: "fix".to_string(),
553                        latitude: 44.0,
554                        longitude: 2.0,
555                    },
556                    AirwayPointRecord {
557                        code: "LFBO".to_string(),
558                        raw_code: "LFBO".to_string(),
559                        kind: "airport".to_string(),
560                        latitude: 43.6,
561                        longitude: 1.4,
562                    },
563                ],
564            }],
565            airport_index: std::collections::HashMap::new(),
566            airspace_index: std::collections::HashMap::new(),
567            navaid_index: std::collections::HashMap::new(),
568            airway_index: std::collections::HashMap::new(),
569            sid_index: std::collections::HashMap::new(),
570            star_index: {
571                let mut m = std::collections::HashMap::new();
572                m.insert("KEPER9E".to_string(), vec![0]);
573                m
574            },
575        };
576
577        let star = resolver
578            .resolve_procedure_airway_by_kind("STAR", "KEPER9E")
579            .expect("missing STAR");
580        assert_eq!(star.route_class.as_deref(), Some("AP"));
581        assert_eq!(star.name, "KEPER9ELFBO");
582    }
583}