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}