Skip to main content

thrust_wasm/
eurocontrol.rs

1use std::collections::HashMap;
2use std::io::{BufReader, Cursor, Read};
3
4use quick_xml::{events::Event, name::QName, Reader};
5use wasm_bindgen::prelude::*;
6use zip::read::ZipArchive;
7
8use crate::models::{normalize_airway_name, AirportRecord, AirwayPointRecord, AirwayRecord, NavpointRecord};
9
10const AIXM_EXPECTED_FILES: [&str; 10] = [
11    "AirportHeliport.BASELINE.zip",
12    "Navaid.BASELINE.zip",
13    "DesignatedPoint.BASELINE.zip",
14    "Route.BASELINE.zip",
15    "RouteSegment.BASELINE.zip",
16    "ArrivalLeg.BASELINE.zip",
17    "DepartureLeg.BASELINE.zip",
18    "StandardInstrumentArrival.BASELINE.zip",
19    "StandardInstrumentDeparture.BASELINE.zip",
20    "Airspace.BASELINE.zip",
21];
22
23const DDR_EXPECTED_FILES: [&str; 8] = [
24    "navpoints.nnpt",
25    "routes.routes",
26    "airports.arp",
27    "sectors.are",
28    "sectors.sls",
29    "free_route.are",
30    "free_route.sls",
31    "free_route.frp",
32];
33
34type DynError = Box<dyn std::error::Error>;
35type PointRefIndex = HashMap<String, AirwayPointRecord>;
36type NavpointsWithRefIndex = (Vec<NavpointRecord>, PointRefIndex);
37
38struct Node<'a> {
39    name: QName<'a>,
40    attributes: HashMap<String, String>,
41}
42
43fn find_node<'a, R: std::io::BufRead>(
44    reader: &mut Reader<R>,
45    lookup: &[QName<'a>],
46    end: Option<QName>,
47) -> Result<Node<'a>, Box<dyn std::error::Error>> {
48    let mut buf = Vec::new();
49    loop {
50        match reader.read_event_into(&mut buf) {
51            Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
52                let attributes = e
53                    .attributes()
54                    .flatten()
55                    .map(|a| {
56                        (
57                            String::from_utf8_lossy(a.key.as_ref()).to_string(),
58                            String::from_utf8_lossy(a.value.as_ref()).to_string(),
59                        )
60                    })
61                    .collect::<HashMap<_, _>>();
62                for elt in lookup {
63                    if e.name() == *elt {
64                        return Ok(Node { name: *elt, attributes });
65                    }
66                }
67            }
68            Ok(Event::End(ref e)) => {
69                if let Some(end_tag) = end {
70                    if e.name() == end_tag {
71                        break;
72                    }
73                }
74            }
75            Ok(Event::Eof) => break,
76            Err(e) => return Err(Box::new(e)),
77            _ => {}
78        }
79        buf.clear();
80    }
81    Err(Box::new(std::io::Error::other("Node not found")))
82}
83
84fn read_text<R: std::io::BufRead>(reader: &mut Reader<R>, end: QName) -> Result<String, Box<dyn std::error::Error>> {
85    let mut buf = Vec::new();
86    let mut text = String::new();
87    loop {
88        match reader.read_event_into(&mut buf) {
89            Ok(Event::Text(e)) => text.push_str(&e.decode()?),
90            Ok(Event::End(e)) if e.name() == end => break,
91            Ok(Event::Eof) => break,
92            Err(e) => return Err(Box::new(e)),
93            _ => {}
94        }
95        buf.clear();
96    }
97    Ok(text)
98}
99
100fn read_baseline_xml_documents(zip_bytes: &[u8]) -> Result<Vec<String>, DynError> {
101    let cursor = Cursor::new(zip_bytes);
102    let mut archive = ZipArchive::new(cursor)?;
103    let mut xmls = Vec::new();
104    for i in 0..archive.len() {
105        let mut file = archive.by_index(i)?;
106        if !file.name().ends_with(".BASELINE") {
107            continue;
108        }
109        let mut xml = String::new();
110        file.read_to_string(&mut xml)?;
111        if !xml.is_empty() {
112            xmls.push(xml);
113        }
114    }
115    Ok(xmls)
116}
117
118fn parse_aixm_airports(zip_bytes: &[u8]) -> Result<Vec<AirportRecord>, DynError> {
119    let mut out = Vec::new();
120    for xml in read_baseline_xml_documents(zip_bytes)? {
121        let mut reader = Reader::from_reader(BufReader::new(Cursor::new(xml.into_bytes())));
122        while find_node(&mut reader, &[QName(b"aixm:AirportHeliport")], None).is_ok() {
123            let mut identifier = String::new();
124            let mut icao = String::new();
125            let mut iata = None;
126            let mut name = String::new();
127            let mut latitude = 0.0_f64;
128            let mut longitude = 0.0_f64;
129            while let Ok(node) = find_node(
130                &mut reader,
131                &[
132                    QName(b"gml:identifier"),
133                    QName(b"aixm:locationIndicatorICAO"),
134                    QName(b"aixm:designatorIATA"),
135                    QName(b"aixm:name"),
136                    QName(b"aixm:ElevatedPoint"),
137                ],
138                Some(QName(b"aixm:AirportHeliport")),
139            ) {
140                match node.name {
141                    QName(b"gml:identifier") => identifier = read_text(&mut reader, node.name)?,
142                    QName(b"aixm:locationIndicatorICAO") => icao = read_text(&mut reader, node.name)?,
143                    QName(b"aixm:designatorIATA") => iata = Some(read_text(&mut reader, node.name)?),
144                    QName(b"aixm:name") => name = read_text(&mut reader, node.name)?,
145                    QName(b"aixm:ElevatedPoint") => {
146                        while let Ok(pos) = find_node(&mut reader, &[QName(b"gml:pos")], Some(node.name)) {
147                            let coords: Vec<f64> = read_text(&mut reader, pos.name)?
148                                .split_whitespace()
149                                .filter_map(|s| s.parse::<f64>().ok())
150                                .collect();
151                            if coords.len() == 2 {
152                                latitude = coords[0];
153                                longitude = coords[1];
154                            }
155                        }
156                    }
157                    _ => {}
158                }
159            }
160
161            if !icao.is_empty() {
162                let name_value = if name.is_empty() { None } else { Some(name.clone()) };
163                out.push(AirportRecord {
164                    code: icao.to_uppercase(),
165                    iata: iata.clone().map(|v| v.to_uppercase()),
166                    icao: Some(icao.to_uppercase()),
167                    name: name_value.clone(),
168                    latitude,
169                    longitude,
170                    region: None,
171                    source: "eurocontrol_aixm".to_string(),
172                });
173                if let Some(iata_code) = iata {
174                    out.push(AirportRecord {
175                        code: iata_code.to_uppercase(),
176                        iata: Some(iata_code.to_uppercase()),
177                        icao: Some(icao.to_uppercase()),
178                        name: name_value,
179                        latitude,
180                        longitude,
181                        region: None,
182                        source: "eurocontrol_aixm".to_string(),
183                    });
184                }
185            } else if !identifier.is_empty() {
186                let _ = identifier;
187            }
188        }
189    }
190    Ok(out)
191}
192
193fn parse_aixm_designated_points(zip_bytes: &[u8]) -> Result<NavpointsWithRefIndex, DynError> {
194    let mut out = Vec::new();
195    let mut by_id: HashMap<String, AirwayPointRecord> = HashMap::new();
196    for xml in read_baseline_xml_documents(zip_bytes)? {
197        let mut reader = Reader::from_reader(BufReader::new(Cursor::new(xml.into_bytes())));
198        while find_node(&mut reader, &[QName(b"aixm:DesignatedPoint")], None).is_ok() {
199            let mut identifier = String::new();
200            let mut designator = String::new();
201            let mut name = None;
202            let mut latitude = 0.0_f64;
203            let mut longitude = 0.0_f64;
204            let mut point_type = None;
205            while let Ok(node) = find_node(
206                &mut reader,
207                &[
208                    QName(b"gml:identifier"),
209                    QName(b"aixm:name"),
210                    QName(b"aixm:designator"),
211                    QName(b"aixm:type"),
212                    QName(b"aixm:Point"),
213                ],
214                Some(QName(b"aixm:DesignatedPoint")),
215            ) {
216                match node.name {
217                    QName(b"gml:identifier") => identifier = read_text(&mut reader, node.name)?,
218                    QName(b"aixm:name") => name = Some(read_text(&mut reader, node.name)?),
219                    QName(b"aixm:designator") => designator = read_text(&mut reader, node.name)?,
220                    QName(b"aixm:type") => point_type = Some(read_text(&mut reader, node.name)?),
221                    QName(b"aixm:Point") => {
222                        while let Ok(pos) = find_node(&mut reader, &[QName(b"gml:pos")], Some(node.name)) {
223                            let coords: Vec<f64> = read_text(&mut reader, pos.name)?
224                                .split_whitespace()
225                                .filter_map(|s| s.parse::<f64>().ok())
226                                .collect();
227                            if coords.len() == 2 {
228                                latitude = coords[0];
229                                longitude = coords[1];
230                            }
231                        }
232                    }
233                    _ => {}
234                }
235            }
236
237            if !designator.is_empty() {
238                let code = designator.to_uppercase();
239                out.push(NavpointRecord {
240                    code: code.clone(),
241                    identifier: code.clone(),
242                    kind: "fix".to_string(),
243                    name,
244                    latitude,
245                    longitude,
246                    description: None,
247                    frequency: None,
248                    point_type,
249                    region: None,
250                    source: "eurocontrol_aixm".to_string(),
251                });
252                if !identifier.is_empty() {
253                    by_id.insert(
254                        identifier,
255                        AirwayPointRecord {
256                            code,
257                            raw_code: designator.to_uppercase(),
258                            kind: "fix".to_string(),
259                            latitude,
260                            longitude,
261                        },
262                    );
263                }
264            }
265        }
266    }
267    Ok((out, by_id))
268}
269
270fn parse_aixm_navaids(zip_bytes: &[u8]) -> Result<NavpointsWithRefIndex, DynError> {
271    let mut out = Vec::new();
272    let mut by_id: HashMap<String, AirwayPointRecord> = HashMap::new();
273    for xml in read_baseline_xml_documents(zip_bytes)? {
274        let mut reader = Reader::from_reader(BufReader::new(Cursor::new(xml.into_bytes())));
275        while find_node(&mut reader, &[QName(b"aixm:Navaid")], None).is_ok() {
276            let mut identifier = String::new();
277            let mut designator = None;
278            let mut description = None;
279            let mut point_type = None;
280            let mut latitude = 0.0_f64;
281            let mut longitude = 0.0_f64;
282            while let Ok(node) = find_node(
283                &mut reader,
284                &[
285                    QName(b"gml:identifier"),
286                    QName(b"aixm:designator"),
287                    QName(b"aixm:type"),
288                    QName(b"aixm:name"),
289                    QName(b"aixm:ElevatedPoint"),
290                ],
291                Some(QName(b"aixm:Navaid")),
292            ) {
293                match node.name {
294                    QName(b"gml:identifier") => identifier = read_text(&mut reader, node.name)?,
295                    QName(b"aixm:designator") => designator = Some(read_text(&mut reader, node.name)?),
296                    QName(b"aixm:type") => point_type = Some(read_text(&mut reader, node.name)?),
297                    QName(b"aixm:name") => description = Some(read_text(&mut reader, node.name)?),
298                    QName(b"aixm:ElevatedPoint") => {
299                        while let Ok(pos) = find_node(&mut reader, &[QName(b"gml:pos")], Some(node.name)) {
300                            let coords: Vec<f64> = read_text(&mut reader, pos.name)?
301                                .split_whitespace()
302                                .filter_map(|s| s.parse::<f64>().ok())
303                                .collect();
304                            if coords.len() == 2 {
305                                latitude = coords[0];
306                                longitude = coords[1];
307                            }
308                        }
309                    }
310                    _ => {}
311                }
312            }
313
314            if let Some(code) = designator {
315                let upper = code.to_uppercase();
316                out.push(NavpointRecord {
317                    code: upper.clone(),
318                    identifier: upper.clone(),
319                    kind: "navaid".to_string(),
320                    name: Some(code),
321                    latitude,
322                    longitude,
323                    description,
324                    frequency: None,
325                    point_type,
326                    region: None,
327                    source: "eurocontrol_aixm".to_string(),
328                });
329                if !identifier.is_empty() {
330                    by_id.insert(
331                        identifier,
332                        AirwayPointRecord {
333                            code: upper.clone(),
334                            raw_code: upper,
335                            kind: "navaid".to_string(),
336                            latitude,
337                            longitude,
338                        },
339                    );
340                }
341            }
342        }
343    }
344    Ok((out, by_id))
345}
346
347fn parse_aixm_airways(
348    route_zip_bytes: &[u8],
349    route_segment_zip_bytes: &[u8],
350    points_by_id: &HashMap<String, AirwayPointRecord>,
351) -> Result<Vec<AirwayRecord>, Box<dyn std::error::Error>> {
352    let mut route_name_by_id: HashMap<String, String> = HashMap::new();
353    for xml in read_baseline_xml_documents(route_zip_bytes)? {
354        let mut reader = Reader::from_reader(BufReader::new(Cursor::new(xml.into_bytes())));
355        while find_node(&mut reader, &[QName(b"aixm:Route")], None).is_ok() {
356            let mut identifier = String::new();
357            let mut prefix = String::new();
358            let mut second = String::new();
359            let mut number = String::new();
360            let mut multiple = String::new();
361            while let Ok(node) = find_node(
362                &mut reader,
363                &[
364                    QName(b"gml:identifier"),
365                    QName(b"aixm:designatorPrefix"),
366                    QName(b"aixm:designatorSecondLetter"),
367                    QName(b"aixm:designatorNumber"),
368                    QName(b"aixm:multipleIdentifier"),
369                ],
370                Some(QName(b"aixm:Route")),
371            ) {
372                match node.name {
373                    QName(b"gml:identifier") => identifier = read_text(&mut reader, node.name)?,
374                    QName(b"aixm:designatorPrefix") => prefix = read_text(&mut reader, node.name)?,
375                    QName(b"aixm:designatorSecondLetter") => second = read_text(&mut reader, node.name)?,
376                    QName(b"aixm:designatorNumber") => number = read_text(&mut reader, node.name)?,
377                    QName(b"aixm:multipleIdentifier") => multiple = read_text(&mut reader, node.name)?,
378                    _ => {}
379                }
380            }
381            if !identifier.is_empty() {
382                route_name_by_id.insert(identifier, format!("{prefix}{second}{number}{multiple}").to_uppercase());
383            }
384        }
385    }
386
387    let mut grouped: HashMap<String, Vec<AirwayPointRecord>> = HashMap::new();
388    for xml in read_baseline_xml_documents(route_segment_zip_bytes)? {
389        let mut reader = Reader::from_reader(BufReader::new(Cursor::new(xml.into_bytes())));
390        while find_node(&mut reader, &[QName(b"aixm:RouteSegment")], None).is_ok() {
391            let mut route_id: Option<String> = None;
392            let mut start_id: Option<String> = None;
393            let mut end_id: Option<String> = None;
394
395            while let Ok(node) = find_node(
396                &mut reader,
397                &[
398                    QName(b"aixm:routeFormed"),
399                    QName(b"aixm:start"),
400                    QName(b"aixm:end"),
401                    QName(b"aixm:pointChoice_fixDesignatedPoint"),
402                    QName(b"aixm:pointChoice_navaidSystem"),
403                    QName(b"aixm:extension"),
404                    QName(b"aixm:annotation"),
405                    QName(b"aixm:availability"),
406                ],
407                Some(QName(b"aixm:RouteSegment")),
408            ) {
409                let href_id = |key: &str| {
410                    node.attributes
411                        .get(key)
412                        .map(|s| s.trim_start_matches("urn:uuid:").to_string())
413                };
414                match node.name {
415                    QName(b"aixm:routeFormed") => route_id = href_id("xlink:href"),
416                    QName(b"aixm:start") => {
417                        while let Ok(point_node) = find_node(
418                            &mut reader,
419                            &[
420                                QName(b"aixm:pointChoice_fixDesignatedPoint"),
421                                QName(b"aixm:pointChoice_navaidSystem"),
422                            ],
423                            Some(QName(b"aixm:start")),
424                        ) {
425                            start_id = point_node
426                                .attributes
427                                .get("xlink:href")
428                                .map(|s| s.trim_start_matches("urn:uuid:").to_string());
429                        }
430                    }
431                    QName(b"aixm:end") => {
432                        while let Ok(point_node) = find_node(
433                            &mut reader,
434                            &[
435                                QName(b"aixm:pointChoice_fixDesignatedPoint"),
436                                QName(b"aixm:pointChoice_navaidSystem"),
437                            ],
438                            Some(QName(b"aixm:end")),
439                        ) {
440                            end_id = point_node
441                                .attributes
442                                .get("xlink:href")
443                                .map(|s| s.trim_start_matches("urn:uuid:").to_string());
444                        }
445                    }
446                    _ => {}
447                }
448            }
449
450            let Some(route_name) = route_id.and_then(|id| route_name_by_id.get(&id).cloned()) else {
451                continue;
452            };
453            let Some(start) = start_id.and_then(|id| points_by_id.get(&id).cloned()) else {
454                continue;
455            };
456            let Some(end) = end_id.and_then(|id| points_by_id.get(&id).cloned()) else {
457                continue;
458            };
459
460            let entry = grouped.entry(route_name).or_default();
461            if entry.last().map(|x| x.code.as_str()) != Some(start.code.as_str()) {
462                entry.push(start);
463            }
464            if entry.last().map(|x| x.code.as_str()) != Some(end.code.as_str()) {
465                entry.push(end);
466            }
467        }
468    }
469
470    Ok(grouped
471        .into_iter()
472        .map(|(name, points)| AirwayRecord {
473            name,
474            source: "eurocontrol_aixm".to_string(),
475            points,
476        })
477        .collect())
478}
479
480fn parse_ddr_navpoints(text: &str) -> Vec<NavpointRecord> {
481    let mut out = Vec::new();
482    for line in text.lines() {
483        let line = line.trim();
484        if line.is_empty() || line.starts_with('#') {
485            continue;
486        }
487        let fields: Vec<&str> = line.split(';').collect();
488        if fields.len() < 5 {
489            continue;
490        }
491        let lat = match fields[2].trim().parse::<f64>() {
492            Ok(v) => v,
493            Err(_) => continue,
494        };
495        let lon = match fields[3].trim().parse::<f64>() {
496            Ok(v) => v,
497            Err(_) => continue,
498        };
499        let code = fields[0].trim().to_uppercase();
500        let point_type = fields[1].trim().to_uppercase();
501        let kind = if point_type.contains("FIX") || point_type == "WPT" || point_type == "WP" {
502            "fix"
503        } else {
504            "navaid"
505        }
506        .to_string();
507        let description = fields
508            .get(4)
509            .map(|s| s.trim().to_string())
510            .filter(|s| !s.is_empty() && s != "_");
511
512        out.push(NavpointRecord {
513            code: code.clone(),
514            identifier: code,
515            kind,
516            name: description.clone(),
517            latitude: lat,
518            longitude: lon,
519            description,
520            frequency: None,
521            point_type: Some(point_type),
522            region: None,
523            source: "eurocontrol_ddr".to_string(),
524        });
525    }
526    out
527}
528
529fn parse_ddr_airports(text: &str) -> Vec<AirportRecord> {
530    let mut out = Vec::new();
531    for line in text.lines() {
532        let line = line.trim();
533        if line.is_empty() || line.starts_with('#') {
534            continue;
535        }
536        let parts: Vec<&str> = line.split_whitespace().collect();
537        if parts.len() < 3 {
538            continue;
539        }
540        let code = parts[0].trim().to_uppercase();
541        if code.len() != 4 {
542            continue;
543        }
544        let lat_raw = match parts[1].parse::<f64>() {
545            Ok(v) => v,
546            Err(_) => continue,
547        };
548        let lon_raw = match parts[2].parse::<f64>() {
549            Ok(v) => v,
550            Err(_) => continue,
551        };
552        out.push(AirportRecord {
553            code: code.clone(),
554            iata: None,
555            icao: Some(code),
556            name: None,
557            latitude: lat_raw / 100.0,
558            longitude: lon_raw / 100.0,
559            region: None,
560            source: "eurocontrol_ddr".to_string(),
561        });
562    }
563    out
564}
565
566fn parse_ddr_airways(text: &str, point_lookup: &HashMap<String, (f64, f64, String)>) -> Vec<AirwayRecord> {
567    let mut grouped: HashMap<String, Vec<(i32, AirwayPointRecord)>> = HashMap::new();
568    for line in text.lines() {
569        let line = line.trim();
570        if line.is_empty() || line.starts_with('#') {
571            continue;
572        }
573        let fields: Vec<&str> = line.split(';').collect();
574        if fields.len() < 8 {
575            continue;
576        }
577        let route = fields[1].trim().to_uppercase();
578        let navaid = fields[5].trim().to_uppercase();
579        let seq = fields[7].trim().parse::<i32>().unwrap_or(0);
580        let (lat, lon, kind) = point_lookup
581            .get(&navaid)
582            .cloned()
583            .unwrap_or((0.0, 0.0, "point".to_string()));
584
585        grouped.entry(route).or_default().push((
586            seq,
587            AirwayPointRecord {
588                code: navaid.clone(),
589                raw_code: navaid,
590                kind,
591                latitude: lat,
592                longitude: lon,
593            },
594        ));
595    }
596
597    let mut out = Vec::new();
598    for (name, mut points) in grouped {
599        points.sort_by_key(|(seq, _)| *seq);
600        let deduped = points
601            .into_iter()
602            .map(|(_, p)| p)
603            .fold(Vec::<AirwayPointRecord>::new(), |mut acc, p| {
604                if acc.last().map(|x| x.code.as_str()) != Some(p.code.as_str()) {
605                    acc.push(p);
606                }
607                acc
608            });
609        out.push(AirwayRecord {
610            name,
611            source: "eurocontrol_ddr".to_string(),
612            points: deduped,
613        });
614    }
615    out
616}
617
618fn file_basename(name: &str) -> &str {
619    name.rsplit('/').next().unwrap_or(name)
620}
621
622fn find_zip_text_entry(ddr_archive: &[u8], predicate: impl Fn(&str) -> bool) -> Result<String, JsValue> {
623    let mut archive = ZipArchive::new(Cursor::new(ddr_archive))
624        .map_err(|e| JsValue::from_str(&format!("invalid DDR zip archive: {e}")))?;
625    for idx in 0..archive.len() {
626        let mut entry = archive
627            .by_index(idx)
628            .map_err(|e| JsValue::from_str(&format!("unable to read DDR zip entry: {e}")))?;
629        if entry.is_dir() {
630            continue;
631        }
632        let name = file_basename(entry.name()).to_string();
633        if !predicate(&name) {
634            continue;
635        }
636        let mut text = String::new();
637        entry
638            .read_to_string(&mut text)
639            .map_err(|e| JsValue::from_str(&format!("unable to decode DDR entry '{name}' as UTF-8 text: {e}")))?;
640        return Ok(text);
641    }
642    Err(JsValue::from_str("matching DDR file not found in archive"))
643}
644
645type DdrEntryMatcher = (&'static str, fn(&str) -> bool);
646
647fn ddr_file_key_and_matchers() -> [DdrEntryMatcher; 8] {
648    [
649        ("navpoints.nnpt", |name: &str| {
650            let lower = name.to_ascii_lowercase();
651            lower.starts_with("airac_") && lower.ends_with(".nnpt")
652        }),
653        ("routes.routes", |name: &str| {
654            let lower = name.to_ascii_lowercase();
655            lower.starts_with("airac_") && lower.ends_with(".routes")
656        }),
657        ("airports.arp", |name: &str| {
658            let lower = name.to_ascii_lowercase();
659            lower.starts_with("vst_") && lower.ends_with("_airports.arp")
660        }),
661        ("sectors.are", |name: &str| {
662            let lower = name.to_ascii_lowercase();
663            lower.starts_with("sectors_") && lower.ends_with(".are")
664        }),
665        ("sectors.sls", |name: &str| {
666            let lower = name.to_ascii_lowercase();
667            lower.starts_with("sectors_") && lower.ends_with(".sls")
668        }),
669        ("free_route.are", |name: &str| {
670            let lower = name.to_ascii_lowercase();
671            lower.starts_with("free_route_") && lower.ends_with(".are")
672        }),
673        ("free_route.sls", |name: &str| {
674            let lower = name.to_ascii_lowercase();
675            lower.starts_with("free_route_") && lower.ends_with(".sls")
676        }),
677        ("free_route.frp", |name: &str| {
678            let lower = name.to_ascii_lowercase();
679            lower.starts_with("free_route_") && lower.ends_with(".frp")
680        }),
681    ]
682}
683
684fn build_from_ddr_text_files(files: HashMap<String, String>) -> Result<EurocontrolResolver, JsValue> {
685    for name in DDR_EXPECTED_FILES {
686        if !files.contains_key(name) {
687            return Err(JsValue::from_str(&format!(
688                "missing DDR file '{name}' in dataset payload"
689            )));
690        }
691    }
692
693    let mut fixes = Vec::new();
694    let mut navaids = Vec::new();
695
696    let ddr_points = parse_ddr_navpoints(
697        files
698            .get("navpoints.nnpt")
699            .ok_or_else(|| JsValue::from_str("missing navpoints.nnpt"))?,
700    );
701    for p in &ddr_points {
702        if p.kind == "fix" {
703            fixes.push(p.clone());
704        } else {
705            navaids.push(p.clone());
706        }
707    }
708
709    let airports = parse_ddr_airports(
710        files
711            .get("airports.arp")
712            .ok_or_else(|| JsValue::from_str("missing airports.arp"))?,
713    );
714    let mut point_lookup: HashMap<String, (f64, f64, String)> = HashMap::new();
715    for p in &ddr_points {
716        point_lookup.insert(p.code.clone(), (p.latitude, p.longitude, p.kind.clone()));
717    }
718    let airways = parse_ddr_airways(
719        files
720            .get("routes.routes")
721            .ok_or_else(|| JsValue::from_str("missing routes.routes"))?,
722        &point_lookup,
723    );
724
725    EurocontrolResolver::build(airports, fixes, navaids, airways)
726}
727
728#[wasm_bindgen]
729pub struct EurocontrolResolver {
730    airports: Vec<AirportRecord>,
731    fixes: Vec<NavpointRecord>,
732    navaids: Vec<NavpointRecord>,
733    airways: Vec<AirwayRecord>,
734    airport_index: HashMap<String, Vec<usize>>,
735    fix_index: HashMap<String, Vec<usize>>,
736    navaid_index: HashMap<String, Vec<usize>>,
737    airway_index: HashMap<String, Vec<usize>>,
738}
739
740#[wasm_bindgen]
741impl EurocontrolResolver {
742    #[wasm_bindgen(constructor)]
743    pub fn new(aixm_folder: JsValue) -> Result<EurocontrolResolver, JsValue> {
744        let files: HashMap<String, Vec<u8>> =
745            serde_wasm_bindgen::from_value(aixm_folder).map_err(|e| JsValue::from_str(&e.to_string()))?;
746        for name in AIXM_EXPECTED_FILES {
747            if !files.contains_key(name) {
748                return Err(JsValue::from_str(&format!(
749                    "missing AIXM file '{name}' in dataset folder payload"
750                )));
751            }
752        }
753
754        let airports = parse_aixm_airports(
755            files
756                .get("AirportHeliport.BASELINE.zip")
757                .ok_or_else(|| JsValue::from_str("missing AirportHeliport.BASELINE.zip"))?,
758        )
759        .map_err(|e| JsValue::from_str(&e.to_string()))?;
760        let (fixes, fixes_by_id) = parse_aixm_designated_points(
761            files
762                .get("DesignatedPoint.BASELINE.zip")
763                .ok_or_else(|| JsValue::from_str("missing DesignatedPoint.BASELINE.zip"))?,
764        )
765        .map_err(|e| JsValue::from_str(&e.to_string()))?;
766        let (navaids, navaids_by_id) = parse_aixm_navaids(
767            files
768                .get("Navaid.BASELINE.zip")
769                .ok_or_else(|| JsValue::from_str("missing Navaid.BASELINE.zip"))?,
770        )
771        .map_err(|e| JsValue::from_str(&e.to_string()))?;
772        let mut point_refs = fixes_by_id;
773        point_refs.extend(navaids_by_id);
774        let airways = parse_aixm_airways(
775            files
776                .get("Route.BASELINE.zip")
777                .ok_or_else(|| JsValue::from_str("missing Route.BASELINE.zip"))?,
778            files
779                .get("RouteSegment.BASELINE.zip")
780                .ok_or_else(|| JsValue::from_str("missing RouteSegment.BASELINE.zip"))?,
781            &point_refs,
782        )
783        .map_err(|e| JsValue::from_str(&e.to_string()))?;
784
785        Self::build(airports, fixes, navaids, airways)
786    }
787
788    #[wasm_bindgen(js_name = fromDdrFolder)]
789    pub fn from_ddr_folder(ddr_folder: JsValue) -> Result<EurocontrolResolver, JsValue> {
790        let files: HashMap<String, String> =
791            serde_wasm_bindgen::from_value(ddr_folder).map_err(|e| JsValue::from_str(&e.to_string()))?;
792        build_from_ddr_text_files(files)
793    }
794
795    #[wasm_bindgen(js_name = fromDdrArchive)]
796    pub fn from_ddr_archive(ddr_archive: Vec<u8>) -> Result<EurocontrolResolver, JsValue> {
797        let mut files: HashMap<String, String> = HashMap::new();
798        for (key, matcher) in ddr_file_key_and_matchers() {
799            let text = find_zip_text_entry(&ddr_archive, matcher)
800                .map_err(|_| JsValue::from_str(&format!("missing DDR file for key '{key}' in archive payload")))?;
801            files.insert(key.to_string(), text);
802        }
803        build_from_ddr_text_files(files)
804    }
805
806    fn build(
807        airports: Vec<AirportRecord>,
808        fixes: Vec<NavpointRecord>,
809        navaids: Vec<NavpointRecord>,
810        airways: Vec<AirwayRecord>,
811    ) -> Result<EurocontrolResolver, JsValue> {
812        let mut airport_index: HashMap<String, Vec<usize>> = HashMap::new();
813        for (i, a) in airports.iter().enumerate() {
814            airport_index.entry(a.code.clone()).or_default().push(i);
815            if let Some(v) = &a.iata {
816                airport_index.entry(v.clone()).or_default().push(i);
817            }
818            if let Some(v) = &a.icao {
819                airport_index.entry(v.clone()).or_default().push(i);
820            }
821        }
822
823        let mut fix_index: HashMap<String, Vec<usize>> = HashMap::new();
824        for (i, n) in fixes.iter().enumerate() {
825            fix_index.entry(n.code.clone()).or_default().push(i);
826        }
827
828        let mut navaid_index: HashMap<String, Vec<usize>> = HashMap::new();
829        for (i, n) in navaids.iter().enumerate() {
830            navaid_index.entry(n.code.clone()).or_default().push(i);
831        }
832
833        let mut airway_index: HashMap<String, Vec<usize>> = HashMap::new();
834        for (i, a) in airways.iter().enumerate() {
835            airway_index.entry(normalize_airway_name(&a.name)).or_default().push(i);
836            airway_index.entry(a.name.to_uppercase()).or_default().push(i);
837        }
838
839        Ok(EurocontrolResolver {
840            airports,
841            fixes,
842            navaids,
843            airways,
844            airport_index,
845            fix_index,
846            navaid_index,
847            airway_index,
848        })
849    }
850
851    pub fn airports(&self) -> Result<JsValue, JsValue> {
852        serde_wasm_bindgen::to_value(&self.airports).map_err(|e| JsValue::from_str(&e.to_string()))
853    }
854
855    pub fn fixes(&self) -> Result<JsValue, JsValue> {
856        serde_wasm_bindgen::to_value(&self.fixes).map_err(|e| JsValue::from_str(&e.to_string()))
857    }
858
859    pub fn navaids(&self) -> Result<JsValue, JsValue> {
860        serde_wasm_bindgen::to_value(&self.navaids).map_err(|e| JsValue::from_str(&e.to_string()))
861    }
862
863    pub fn airways(&self) -> Result<JsValue, JsValue> {
864        serde_wasm_bindgen::to_value(&self.airways).map_err(|e| JsValue::from_str(&e.to_string()))
865    }
866
867    pub fn resolve_airport(&self, code: String) -> Result<JsValue, JsValue> {
868        let key = code.to_uppercase();
869        let item = self
870            .airport_index
871            .get(&key)
872            .and_then(|idx| idx.first().copied())
873            .and_then(|i| self.airports.get(i))
874            .cloned();
875        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
876    }
877
878    pub fn resolve_fix(&self, code: String) -> Result<JsValue, JsValue> {
879        let key = code.to_uppercase();
880        let item = self
881            .fix_index
882            .get(&key)
883            .and_then(|idx| idx.first().copied())
884            .and_then(|i| self.fixes.get(i))
885            .cloned();
886        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
887    }
888
889    pub fn resolve_navaid(&self, code: String) -> Result<JsValue, JsValue> {
890        let key = code.to_uppercase();
891        let item = self
892            .navaid_index
893            .get(&key)
894            .and_then(|idx| idx.first().copied())
895            .and_then(|i| self.navaids.get(i))
896            .cloned();
897        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
898    }
899
900    pub fn resolve_airway(&self, name: String) -> Result<JsValue, JsValue> {
901        let key = normalize_airway_name(&name);
902        let item = self
903            .airway_index
904            .get(&key)
905            .and_then(|idx| idx.first().copied())
906            .and_then(|i| self.airways.get(i))
907            .cloned();
908        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
909    }
910}