Skip to main content

thrust_wasm/
faa_arcgis.rs

1use std::collections::HashMap;
2
3use js_sys::Array;
4use serde_json::Value;
5use wasm_bindgen::prelude::*;
6
7use crate::models::{
8    normalize_airway_name, AirportRecord, AirspaceRecord, AirwayPointRecord, AirwayRecord, NavpointRecord,
9};
10
11fn value_to_f64(v: Option<&Value>) -> Option<f64> {
12    v.and_then(|x| x.as_f64().or_else(|| x.as_i64().map(|n| n as f64)))
13}
14
15fn parse_coord(value: Option<&Value>) -> Option<f64> {
16    let value = value?;
17    if let Some(v) = value.as_f64() {
18        return Some(v);
19    }
20    let s = value.as_str()?.trim();
21    let hemi = s.chars().last()?;
22    let sign = match hemi {
23        'N' | 'E' => 1.0,
24        'S' | 'W' => -1.0,
25        _ => 1.0,
26    };
27    let core = s.strip_suffix(hemi).unwrap_or(s);
28    let parts: Vec<&str> = core.split('-').collect();
29    if parts.len() != 3 {
30        return core.parse::<f64>().ok();
31    }
32    let deg = parts[0].parse::<f64>().ok()?;
33    let min = parts[1].parse::<f64>().ok()?;
34    let sec = parts[2].parse::<f64>().ok()?;
35    Some(sign * (deg + min / 60.0 + sec / 3600.0))
36}
37
38fn value_to_string(v: Option<&Value>) -> Option<String> {
39    v.and_then(|x| x.as_str().map(|s| s.to_string()))
40}
41
42fn value_to_i64(v: Option<&Value>) -> Option<i64> {
43    v.and_then(|x| x.as_i64().or_else(|| x.as_f64().map(|n| n as i64)))
44}
45
46fn geometry_to_polygons(geometry: &Value) -> Vec<Vec<(f64, f64)>> {
47    let gtype = geometry.get("type").and_then(|v| v.as_str());
48    let coords = geometry.get("coordinates");
49
50    match (gtype, coords) {
51        (Some("Polygon"), Some(c)) => c
52            .as_array()
53            .and_then(|rings| rings.first())
54            .and_then(|ring| ring.as_array())
55            .map(|ring| {
56                ring.iter()
57                    .filter_map(|pt| {
58                        let arr = pt.as_array()?;
59                        if arr.len() < 2 {
60                            return None;
61                        }
62                        let lon = arr[0].as_f64()?;
63                        let lat = arr[1].as_f64()?;
64                        Some((lon, lat))
65                    })
66                    .collect::<Vec<_>>()
67            })
68            .into_iter()
69            .collect(),
70        (Some("MultiPolygon"), Some(c)) => c
71            .as_array()
72            .map(|polys| {
73                polys
74                    .iter()
75                    .filter_map(|poly| {
76                        let ring = poly.as_array()?.first()?.as_array()?;
77                        Some(
78                            ring.iter()
79                                .filter_map(|pt| {
80                                    let arr = pt.as_array()?;
81                                    if arr.len() < 2 {
82                                        return None;
83                                    }
84                                    let lon = arr[0].as_f64()?;
85                                    let lat = arr[1].as_f64()?;
86                                    Some((lon, lat))
87                                })
88                                .collect::<Vec<_>>(),
89                        )
90                    })
91                    .collect::<Vec<_>>()
92            })
93            .unwrap_or_default(),
94        _ => vec![],
95    }
96}
97
98fn arcgis_features_to_airspaces(features: &[Value]) -> Vec<AirspaceRecord> {
99    let mut out = Vec::new();
100    for feature in features {
101        let properties = feature.get("properties").unwrap_or(&Value::Null);
102        let geometry = feature.get("geometry").unwrap_or(&Value::Null);
103        let polygons = geometry_to_polygons(geometry);
104        if polygons.is_empty() {
105            continue;
106        }
107
108        let designator = value_to_string(properties.get("IDENT"))
109            .or_else(|| value_to_string(properties.get("NAME")))
110            .unwrap_or_else(|| "UNKNOWN".to_string());
111        let name = value_to_string(properties.get("NAME"));
112        let type_ = value_to_string(properties.get("TYPE_CODE"));
113        let lower =
114            value_to_f64(properties.get("LOWER_VAL")).map(|v| if (v + 9998.0).abs() < f64::EPSILON { 0.0 } else { v });
115        let upper = value_to_f64(properties.get("UPPER_VAL")).map(|v| {
116            if (v + 9998.0).abs() < f64::EPSILON {
117                f64::INFINITY
118            } else {
119                v
120            }
121        });
122
123        for coords in polygons {
124            if coords.len() < 3 {
125                continue;
126            }
127            out.push(AirspaceRecord {
128                designator: designator.clone(),
129                name: name.clone(),
130                type_: type_.clone(),
131                lower,
132                upper,
133                coordinates: coords,
134                source: "faa_arcgis".to_string(),
135            });
136        }
137    }
138    out
139}
140
141fn arcgis_features_to_navpoints(features: &[Value]) -> (Vec<NavpointRecord>, Vec<NavpointRecord>) {
142    let mut fixes = Vec::new();
143    let mut navaid_groups: HashMap<String, NavpointRecord> = HashMap::new();
144    let mut navaid_components: HashMap<String, (bool, bool, bool, bool)> = HashMap::new();
145
146    for feature in features {
147        let props = feature.get("properties").unwrap_or(&Value::Null);
148        let ident = value_to_string(props.get("IDENT")).unwrap_or_default().to_uppercase();
149        if ident.is_empty() {
150            continue;
151        }
152
153        if props.get("NAV_TYPE").is_some() || props.get("FREQUENCY").is_some() {
154            let latitude = parse_coord(props.get("LATITUDE")).unwrap_or(0.0);
155            let longitude = parse_coord(props.get("LONGITUDE")).unwrap_or(0.0);
156            let group_key = value_to_string(props.get("NAVSYS_ID"))
157                .filter(|s| !s.is_empty())
158                .unwrap_or_else(|| ident.clone());
159
160            navaid_groups
161                .entry(group_key.clone())
162                .or_insert_with(|| NavpointRecord {
163                    code: ident.clone(),
164                    identifier: ident,
165                    kind: "navaid".to_string(),
166                    name: value_to_string(props.get("NAME")),
167                    latitude,
168                    longitude,
169                    description: value_to_string(props.get("NAME")),
170                    frequency: value_to_f64(props.get("FREQUENCY")),
171                    point_type: value_to_string(props.get("TYPE_CODE")),
172                    region: value_to_string(props.get("US_AREA")),
173                    source: "faa_arcgis".to_string(),
174                });
175
176            let entry = navaid_components
177                .entry(group_key)
178                .or_insert((false, false, false, false));
179            match value_to_i64(props.get("NAV_TYPE")) {
180                Some(1) => entry.0 = true,
181                Some(2) => entry.1 = true,
182                Some(3) => entry.2 = true,
183                Some(4) => entry.3 = true,
184                _ => {}
185            }
186        } else {
187            let latitude = parse_coord(props.get("LATITUDE")).unwrap_or(0.0);
188            let longitude = parse_coord(props.get("LONGITUDE")).unwrap_or(0.0);
189            fixes.push(NavpointRecord {
190                code: ident.clone(),
191                identifier: ident.clone(),
192                kind: "fix".to_string(),
193                name: Some(ident),
194                latitude,
195                longitude,
196                description: value_to_string(props.get("REMARKS")),
197                frequency: None,
198                point_type: value_to_string(props.get("TYPE_CODE")).map(|s| s.to_uppercase()),
199                region: value_to_string(props.get("US_AREA")).or_else(|| value_to_string(props.get("STATE"))),
200                source: "faa_arcgis".to_string(),
201            });
202        }
203    }
204
205    let mut navaids: Vec<NavpointRecord> = navaid_groups
206        .into_iter()
207        .map(|(group_key, mut record)| {
208            if let Some((has_ndb, has_dme, has_vor, has_tacan)) = navaid_components.get(&group_key).copied() {
209                record.point_type = Some(
210                    if has_vor && has_tacan {
211                        "VORTAC"
212                    } else if has_vor && has_dme {
213                        "VOR_DME"
214                    } else if has_vor {
215                        "VOR"
216                    } else if has_tacan {
217                        "TACAN"
218                    } else if has_dme {
219                        "DME"
220                    } else if has_ndb {
221                        "NDB"
222                    } else {
223                        record.point_type.as_deref().unwrap_or("NAVAID")
224                    }
225                    .to_string(),
226                );
227            }
228            record
229        })
230        .collect();
231    navaids.sort_by(|a, b| a.code.cmp(&b.code));
232
233    (fixes, navaids)
234}
235
236fn arcgis_features_to_airports(features: &[Value]) -> Vec<AirportRecord> {
237    let mut airports = Vec::new();
238
239    for feature in features {
240        let props = feature.get("properties").unwrap_or(&Value::Null);
241        let ident = value_to_string(props.get("IDENT")).unwrap_or_default().to_uppercase();
242        let icao = value_to_string(props.get("ICAO_ID")).map(|x| x.to_uppercase());
243        if ident.is_empty() && icao.is_none() {
244            continue;
245        }
246
247        let latitude = parse_coord(props.get("LATITUDE"));
248        let longitude = parse_coord(props.get("LONGITUDE"));
249        let (latitude, longitude) = match (latitude, longitude) {
250            (Some(lat), Some(lon)) => (lat, lon),
251            _ => continue,
252        };
253
254        let code = if ident.is_empty() {
255            icao.clone().unwrap_or_default()
256        } else {
257            ident.clone()
258        };
259        if code.is_empty() {
260            continue;
261        }
262
263        airports.push(AirportRecord {
264            code,
265            iata: if ident.len() == 3 { Some(ident) } else { None },
266            icao,
267            name: value_to_string(props.get("NAME")),
268            latitude,
269            longitude,
270            region: value_to_string(props.get("STATE")).or_else(|| value_to_string(props.get("US_AREA"))),
271            source: "faa_arcgis".to_string(),
272        });
273    }
274
275    airports
276}
277
278fn arcgis_features_to_airways(features: &[Value]) -> Vec<AirwayRecord> {
279    let mut grouped: HashMap<String, Vec<AirwayPointRecord>> = HashMap::new();
280    let mut point_id_to_ident: HashMap<String, String> = HashMap::new();
281
282    for feature in features {
283        let props = feature.get("properties").unwrap_or(&Value::Null);
284        let global_id = value_to_string(props.get("GLOBAL_ID")).map(|s| s.to_uppercase());
285        let ident = value_to_string(props.get("IDENT")).map(|s| s.to_uppercase());
286        if let (Some(gid), Some(idt)) = (global_id, ident) {
287            if !gid.is_empty() && !idt.is_empty() {
288                point_id_to_ident.entry(gid).or_insert(idt);
289            }
290        }
291    }
292
293    for feature in features {
294        let props = feature.get("properties").unwrap_or(&Value::Null);
295        let name = value_to_string(props.get("IDENT")).unwrap_or_default().to_uppercase();
296        if name.is_empty() {
297            continue;
298        }
299
300        let geom = feature.get("geometry").unwrap_or(&Value::Null);
301        if geom.get("type").and_then(|x| x.as_str()) != Some("LineString") {
302            continue;
303        }
304        let coords = geom
305            .get("coordinates")
306            .and_then(|x| x.as_array())
307            .cloned()
308            .unwrap_or_default();
309
310        let start_id = value_to_string(props.get("STARTPT_ID")).map(|s| s.to_uppercase());
311        let end_id = value_to_string(props.get("ENDPT_ID")).map(|s| s.to_uppercase());
312        let start_code = start_id
313            .as_ref()
314            .and_then(|id| point_id_to_ident.get(id).cloned())
315            .or(start_id.clone());
316        let end_code = end_id
317            .as_ref()
318            .and_then(|id| point_id_to_ident.get(id).cloned())
319            .or(end_id.clone());
320
321        let entry = grouped.entry(name).or_default();
322        let coord_len = coords.len();
323        for (idx, p) in coords.into_iter().enumerate() {
324            let arr = match p.as_array() {
325                Some(v) if v.len() >= 2 => v,
326                _ => continue,
327            };
328            let lon = arr[0].as_f64().unwrap_or(0.0);
329            let lat = arr[1].as_f64().unwrap_or(0.0);
330            if entry
331                .last()
332                .map(|x| (x.latitude, x.longitude) == (lat, lon))
333                .unwrap_or(false)
334            {
335                continue;
336            }
337
338            let raw_code = if idx == 0 {
339                start_code.clone().unwrap_or_default()
340            } else if idx + 1 == coord_len {
341                end_code.clone().unwrap_or_default()
342            } else {
343                String::new()
344            };
345            let code = if raw_code.is_empty() {
346                format!("{},{}", lat, lon)
347            } else {
348                raw_code.clone()
349            };
350
351            entry.push(AirwayPointRecord {
352                code,
353                raw_code,
354                kind: "point".to_string(),
355                latitude: lat,
356                longitude: lon,
357            });
358        }
359    }
360
361    grouped
362        .into_iter()
363        .map(|(name, points)| AirwayRecord {
364            name,
365            source: "faa_arcgis".to_string(),
366            points,
367        })
368        .collect()
369}
370
371#[wasm_bindgen]
372pub struct FaaArcgisResolver {
373    airports: Vec<AirportRecord>,
374    airspaces: Vec<AirspaceRecord>,
375    fixes: Vec<NavpointRecord>,
376    navaids: Vec<NavpointRecord>,
377    airways: Vec<AirwayRecord>,
378    airport_index: HashMap<String, Vec<usize>>,
379    airspace_index: HashMap<String, Vec<usize>>,
380    fix_index: HashMap<String, Vec<usize>>,
381    navaid_index: HashMap<String, Vec<usize>>,
382    airway_index: HashMap<String, Vec<usize>>,
383}
384
385#[wasm_bindgen]
386impl FaaArcgisResolver {
387    #[wasm_bindgen(constructor)]
388    pub fn new(feature_collections_json: JsValue) -> Result<FaaArcgisResolver, JsValue> {
389        let payloads = Array::from(&feature_collections_json);
390        let mut features: Vec<Value> = Vec::new();
391        for payload in payloads.iter() {
392            let value: Value =
393                serde_wasm_bindgen::from_value(payload).map_err(|e| JsValue::from_str(&e.to_string()))?;
394            let arr = value
395                .get("features")
396                .and_then(|x| x.as_array())
397                .cloned()
398                .unwrap_or_default();
399            features.extend(arr);
400        }
401
402        let airports = arcgis_features_to_airports(&features);
403        let airspaces = arcgis_features_to_airspaces(&features);
404        let (fixes, navaids) = arcgis_features_to_navpoints(&features);
405        let airways = arcgis_features_to_airways(&features);
406
407        let mut airport_index: HashMap<String, Vec<usize>> = HashMap::new();
408        for (i, a) in airports.iter().enumerate() {
409            airport_index.entry(a.code.clone()).or_default().push(i);
410            if let Some(v) = &a.iata {
411                airport_index.entry(v.clone()).or_default().push(i);
412            }
413            if let Some(v) = &a.icao {
414                airport_index.entry(v.clone()).or_default().push(i);
415            }
416        }
417
418        let mut airspace_index: HashMap<String, Vec<usize>> = HashMap::new();
419        for (i, a) in airspaces.iter().enumerate() {
420            airspace_index.entry(a.designator.to_uppercase()).or_default().push(i);
421        }
422
423        let mut fix_index: HashMap<String, Vec<usize>> = HashMap::new();
424        for (i, n) in fixes.iter().enumerate() {
425            fix_index.entry(n.code.clone()).or_default().push(i);
426        }
427
428        let mut navaid_index: HashMap<String, Vec<usize>> = HashMap::new();
429        for (i, n) in navaids.iter().enumerate() {
430            navaid_index.entry(n.code.clone()).or_default().push(i);
431        }
432
433        let mut airway_index: HashMap<String, Vec<usize>> = HashMap::new();
434        for (i, a) in airways.iter().enumerate() {
435            airway_index.entry(normalize_airway_name(&a.name)).or_default().push(i);
436            airway_index.entry(a.name.to_uppercase()).or_default().push(i);
437        }
438
439        Ok(Self {
440            airports,
441            airspaces,
442            fixes,
443            navaids,
444            airways,
445            airport_index,
446            airspace_index,
447            fix_index,
448            navaid_index,
449            airway_index,
450        })
451    }
452
453    pub fn airports(&self) -> Result<JsValue, JsValue> {
454        serde_wasm_bindgen::to_value(&self.airports).map_err(|e| JsValue::from_str(&e.to_string()))
455    }
456
457    pub fn fixes(&self) -> Result<JsValue, JsValue> {
458        serde_wasm_bindgen::to_value(&self.fixes).map_err(|e| JsValue::from_str(&e.to_string()))
459    }
460
461    pub fn navaids(&self) -> Result<JsValue, JsValue> {
462        serde_wasm_bindgen::to_value(&self.navaids).map_err(|e| JsValue::from_str(&e.to_string()))
463    }
464
465    pub fn airways(&self) -> Result<JsValue, JsValue> {
466        serde_wasm_bindgen::to_value(&self.airways).map_err(|e| JsValue::from_str(&e.to_string()))
467    }
468
469    pub fn airspaces(&self) -> Result<JsValue, JsValue> {
470        serde_wasm_bindgen::to_value(&self.airspaces).map_err(|e| JsValue::from_str(&e.to_string()))
471    }
472
473    pub fn resolve_airspace(&self, designator: String) -> Result<JsValue, JsValue> {
474        let key = designator.to_uppercase();
475        let item = self
476            .airspace_index
477            .get(&key)
478            .and_then(|idx| idx.first().copied())
479            .and_then(|i| self.airspaces.get(i))
480            .cloned();
481
482        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
483    }
484
485    pub fn resolve_fix(&self, code: String) -> Result<JsValue, JsValue> {
486        let key = code.to_uppercase();
487        let item = self
488            .fix_index
489            .get(&key)
490            .and_then(|idx| idx.first().copied())
491            .and_then(|i| self.fixes.get(i))
492            .cloned();
493
494        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
495    }
496
497    pub fn resolve_navaid(&self, code: String) -> Result<JsValue, JsValue> {
498        let key = code.to_uppercase();
499        let item = self
500            .navaid_index
501            .get(&key)
502            .and_then(|idx| idx.first().copied())
503            .and_then(|i| self.navaids.get(i))
504            .cloned();
505
506        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
507    }
508
509    pub fn resolve_airway(&self, name: String) -> Result<JsValue, JsValue> {
510        let key = normalize_airway_name(&name);
511        let item = self
512            .airway_index
513            .get(&key)
514            .and_then(|idx| idx.first().copied())
515            .and_then(|i| self.airways.get(i))
516            .cloned();
517
518        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
519    }
520
521    pub fn resolve_airport(&self, code: String) -> Result<JsValue, JsValue> {
522        let key = code.to_uppercase();
523        let item = self
524            .airport_index
525            .get(&key)
526            .and_then(|idx| idx.first().copied())
527            .and_then(|i| self.airports.get(i))
528            .cloned();
529
530        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
531    }
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use serde_json::json;
538
539    #[test]
540    fn arcgis_navpoints_parse_coords_and_types() {
541        let features = vec![
542            json!({
543                "properties": {
544                    "IDENT": "BAF",
545                    "NAME": "BARNES",
546                    "LATITUDE": "42-09-43.053N",
547                    "LONGITUDE": "072-42-58.318W",
548                    "NAV_TYPE": 3,
549                    "FREQUENCY": 113.0,
550                    "NAVSYS_ID": "NAV-BAF",
551                    "US_AREA": "US"
552                }
553            }),
554            json!({
555                "properties": {
556                    "IDENT": "BAF",
557                    "NAME": "BARNES",
558                    "LATITUDE": "42-09-43.053N",
559                    "LONGITUDE": "072-42-58.318W",
560                    "NAV_TYPE": 4,
561                    "FREQUENCY": 113.0,
562                    "NAVSYS_ID": "NAV-BAF",
563                    "US_AREA": "US"
564                }
565            }),
566            json!({
567                "properties": {
568                    "IDENT": "BASYE",
569                    "LATITUDE": "41-20-37.400N",
570                    "LONGITUDE": "073-47-54.990W",
571                    "TYPE_CODE": "RPT",
572                    "US_AREA": "US"
573                }
574            }),
575        ];
576
577        let (fixes, navaids) = arcgis_features_to_navpoints(&features);
578
579        let basye = fixes.iter().find(|f| f.code == "BASYE").unwrap();
580        assert!(basye.latitude.abs() > 1.0);
581        assert!(basye.longitude.abs() > 1.0);
582        assert_eq!(basye.point_type.as_deref(), Some("RPT"));
583
584        let baf = navaids.iter().find(|n| n.code == "BAF").unwrap();
585        assert!(baf.latitude.abs() > 1.0);
586        assert!(baf.longitude.abs() > 1.0);
587        assert_eq!(baf.point_type.as_deref(), Some("VORTAC"));
588    }
589
590    #[test]
591    fn arcgis_airways_use_endpoint_identifiers_when_available() {
592        let features = vec![
593            json!({
594                "properties": {
595                    "GLOBAL_ID": "START-GID",
596                    "IDENT": "LANNA"
597                }
598            }),
599            json!({
600                "properties": {
601                    "GLOBAL_ID": "END-GID",
602                    "IDENT": "MOL"
603                }
604            }),
605            json!({
606                "properties": {
607                    "IDENT": "J48",
608                    "STARTPT_ID": "START-GID",
609                    "ENDPT_ID": "END-GID"
610                },
611                "geometry": {
612                    "type": "LineString",
613                    "coordinates": [
614                        [-75.0, 40.5],
615                        [-76.0, 40.0],
616                        [-79.1, 37.9]
617                    ]
618                }
619            }),
620        ];
621
622        let airways = arcgis_features_to_airways(&features);
623        let j48 = airways.iter().find(|a| a.name == "J48").unwrap();
624        assert!(!j48.points.is_empty());
625
626        let first = j48.points.first().unwrap();
627        let last = j48.points.last().unwrap();
628
629        assert_eq!(first.code, "LANNA");
630        assert_eq!(first.raw_code, "LANNA");
631        assert_eq!(last.code, "MOL");
632        assert_eq!(last.raw_code, "MOL");
633    }
634}