Skip to main content

thrust_wasm/
eurocontrol.rs

1use std::collections::{HashMap, HashSet};
2use std::io::{Cursor, Read};
3
4use thrust::data::eurocontrol::aixm::dataset::parse_aixm_folder_bytes;
5use thrust::data::eurocontrol::ddr::airports::parse_airports_bytes;
6use thrust::data::eurocontrol::ddr::airspaces::{parse_are_bytes, parse_sls_bytes, DdrSectorLayer};
7use thrust::data::eurocontrol::ddr::navpoints::parse_navpoints_bytes;
8use thrust::data::eurocontrol::ddr::routes::parse_routes_bytes;
9use wasm_bindgen::prelude::*;
10use zip::read::ZipArchive;
11
12use crate::models::{
13    normalize_airway_name, AirportRecord, AirspaceCompositeRecord, AirspaceLayerRecord, AirspaceRecord,
14    AirwayPointRecord, AirwayRecord, NavpointRecord,
15};
16
17const DDR_EXPECTED_FILES: [&str; 8] = [
18    "navpoints.nnpt",
19    "routes.routes",
20    "airports.arp",
21    "sectors.are",
22    "sectors.sls",
23    "free_route.are",
24    "free_route.sls",
25    "free_route.frp",
26];
27
28// DDR routes are grouped by airway name, which can merge distinct route variants
29// into a single sequence. We split a chain only when a consecutive leg is an
30// extreme geodesic jump, using a conservative fixed cutoff. Empirical analysis
31// on AIRAC_484E showed >=1000 NM gives near-zero same-name AIXM segment matches
32// while still catching obvious merges (for example A10: *PR13 -> SIT).
33const DDR_AIRWAY_SPLIT_GAP_NM: f64 = 1_000.0;
34
35fn parse_ddr_navpoints(text: &str) -> Result<Vec<NavpointRecord>, JsValue> {
36    let points = parse_navpoints_bytes(text.as_bytes()).map_err(|e| JsValue::from_str(&e.to_string()))?;
37    Ok(points
38        .into_iter()
39        .map(|point| {
40            let point_type = point.point_type.to_uppercase();
41            let kind = if point_type.contains("FIX") || point_type == "WPT" || point_type == "WP" {
42                "fix"
43            } else {
44                "navaid"
45            }
46            .to_string();
47
48            NavpointRecord {
49                code: point.name.to_uppercase(),
50                identifier: point.name.to_uppercase(),
51                kind,
52                name: point.description.clone(),
53                latitude: point.latitude,
54                longitude: point.longitude,
55                description: point.description,
56                frequency: None,
57                point_type: Some(point_type),
58                region: None,
59                source: "eurocontrol_ddr".to_string(),
60            }
61        })
62        .collect())
63}
64
65fn parse_ddr_airports(text: &str) -> Result<Vec<AirportRecord>, JsValue> {
66    let airports = parse_airports_bytes(text.as_bytes()).map_err(|e| JsValue::from_str(&e.to_string()))?;
67    Ok(airports
68        .into_iter()
69        .map(|airport| AirportRecord {
70            code: airport.code.clone(),
71            iata: None,
72            icao: Some(airport.code),
73            name: None,
74            latitude: airport.latitude,
75            longitude: airport.longitude,
76            region: None,
77            source: "eurocontrol_ddr".to_string(),
78        })
79        .collect())
80}
81
82#[cfg(test)]
83mod tests {
84    use std::collections::HashMap;
85
86    use super::{parse_ddr_airports, parse_ddr_airspaces, parse_ddr_airways};
87
88    #[test]
89    fn parse_lfbo_coordinates_from_ddr_arp() {
90        let airports = parse_ddr_airports("LFBO 2618.100000 82.066667\n").expect("DDR airport parsing failed");
91        let lfbo = airports.iter().find(|a| a.code == "LFBO").expect("LFBO not found");
92
93        assert!((lfbo.latitude - 43.635).abs() < 1e-9);
94        assert!((lfbo.longitude - 1.3677777833333334).abs() < 1e-9);
95    }
96
97    #[test]
98    fn split_ddr_airway_on_very_large_gap() {
99        let text = [
100            "L;A10;AR;999999999999;000000000000;YJQ;SP;1",
101            "L;A10;AR;999999999999;000000000000;MITEK;SP;2",
102            "L;A10;AR;999999999999;000000000000;*PR13;DBP;3",
103            "L;A10;AR;999999999999;000000000000;SIT;SP;4",
104            "L;A10;AR;999999999999;000000000000;PAXIS;SP;5",
105        ]
106        .join("\n");
107
108        let navpoints = [
109            "YJQ;FIX;10;10;_",
110            "MITEK;FIX;10;11;_",
111            "*PR13;DBP;10;12;_",
112            "SIT;FIX;55;120;_",
113            "PAXIS;FIX;55;121;_",
114        ]
115        .join("\n");
116
117        let airways = parse_ddr_airways(&text, &navpoints).expect("DDR airway parsing failed");
118        assert_eq!(airways.len(), 2);
119        assert_eq!(airways[0].name, "A10");
120        assert_eq!(airways[1].name, "A10");
121        assert_eq!(airways[0].points.len(), 3);
122        assert_eq!(airways[1].points.len(), 2);
123        assert_eq!(airways[0].points[0].code, "YJQ");
124        assert_eq!(airways[0].points[2].code, "*PR13");
125        assert_eq!(airways[1].points[0].code, "SIT");
126        assert_eq!(airways[1].points[1].code, "PAXIS");
127    }
128
129    #[test]
130    fn keep_ddr_airway_when_gaps_are_reasonable() {
131        let text = [
132            "L;UM605;AR;999999999999;000000000000;A;SP;1",
133            "L;UM605;AR;999999999999;000000000000;B;SP;2",
134            "L;UM605;AR;999999999999;000000000000;C;SP;3",
135        ]
136        .join("\n");
137
138        let navpoints = ["A;FIX;43.6;1.4;_", "B;FIX;44.0;2.0;_", "C;FIX;44.5;3.0;_"].join("\n");
139        let airways = parse_ddr_airways(&text, &navpoints).expect("DDR airway parsing failed");
140        assert_eq!(airways.len(), 1);
141        assert_eq!(airways[0].name, "UM605");
142        assert_eq!(airways[0].points.len(), 3);
143    }
144
145    #[test]
146    fn parse_ddr_airspaces_from_are_and_sls_text() {
147        let mut files = HashMap::new();
148        files.insert(
149            "sectors.are".to_string(),
150            ["3 SEC1_POLY", "0 0", "0 60", "60 60"].join("\n"),
151        );
152        files.insert("sectors.sls".to_string(), ["SEC1 X SEC1_POLY 100 200"].join("\n"));
153        files.insert(
154            "free_route.are".to_string(),
155            ["3 FRA1_POLY", "120 0", "120 60", "180 60"].join("\n"),
156        );
157        files.insert("free_route.sls".to_string(), ["FRA1 X FRA1_POLY 245 660"].join("\n"));
158
159        let airspaces = parse_ddr_airspaces(&files).expect("DDR airspace parsing should succeed");
160        assert_eq!(airspaces.len(), 2);
161        assert_eq!(airspaces[0].designator, "SEC1");
162        assert_eq!(airspaces[0].type_.as_deref(), Some("SECTOR"));
163        assert_eq!(airspaces[1].designator, "FRA1");
164        assert_eq!(airspaces[1].type_.as_deref(), Some("FRA"));
165    }
166
167    #[test]
168    fn parse_ddr_airspaces_enriches_with_spc_collapsed_designators() {
169        let mut files = HashMap::new();
170        files.insert("sectors.are".to_string(), ["3 P1", "0 0", "0 60", "60 60"].join("\n"));
171        files.insert("sectors.sls".to_string(), ["LFBBN1 X P1 195 295"].join("\n"));
172        files.insert(
173            "sectors.spc".to_string(),
174            ["A;LFBBCTA;BORDEAUX U/ACC;AUA;42;_", "S;LFBBN1;ES"].join("\n"),
175        );
176        files.insert(
177            "free_route.are".to_string(),
178            ["3 FRA1_POLY", "120 0", "120 60", "180 60"].join("\n"),
179        );
180        files.insert("free_route.sls".to_string(), ["FRA1 X FRA1_POLY 245 660"].join("\n"));
181
182        let airspaces = parse_ddr_airspaces(&files).expect("DDR airspace parsing should succeed");
183        assert!(airspaces.iter().any(|a| a.designator == "LFBBN1"));
184        let collapsed = airspaces
185            .iter()
186            .find(|a| a.designator == "LFBBCTA")
187            .expect("Collapsed LFBBCTA should be present");
188        assert_eq!(collapsed.name.as_deref(), Some("BORDEAUX U/ACC"));
189        assert_eq!(collapsed.type_.as_deref(), Some("AUA"));
190        assert_eq!(collapsed.lower, Some(195.0));
191        assert_eq!(collapsed.upper, Some(295.0));
192    }
193}
194
195fn parse_ddr_airways(routes_text: &str, navpoints_text: &str) -> Result<Vec<AirwayRecord>, JsValue> {
196    let navpoints = parse_navpoints_bytes(navpoints_text.as_bytes()).map_err(|e| JsValue::from_str(&e.to_string()))?;
197    let route_points =
198        parse_routes_bytes(routes_text.as_bytes(), &navpoints).map_err(|e| JsValue::from_str(&e.to_string()))?;
199
200    let mut grouped: HashMap<String, Vec<(i32, AirwayPointRecord, bool)>> = HashMap::new();
201    let mut route_class_by_name: HashMap<String, String> = HashMap::new();
202    for point in route_points {
203        let route = point.route.to_uppercase();
204        let route_class = point.route_class.to_uppercase();
205        let navaid = point.navaid.to_uppercase();
206        let seq = point.seq;
207        let (lat, lon, kind, has_coords) = match (point.latitude, point.longitude) {
208            (Some(lat), Some(lon)) => {
209                let point_type = point.point_type.to_uppercase();
210                let kind = if point_type.contains("FIX") || point_type == "WPT" || point_type == "WP" {
211                    "fix"
212                } else {
213                    "navaid"
214                }
215                .to_string();
216                (lat, lon, kind, true)
217            }
218            _ => (0.0, 0.0, "point".to_string(), false),
219        };
220
221        route_class_by_name.entry(route.clone()).or_insert(route_class);
222
223        grouped.entry(route).or_default().push((
224            seq,
225            AirwayPointRecord {
226                code: navaid.clone(),
227                raw_code: navaid,
228                kind,
229                latitude: lat,
230                longitude: lon,
231            },
232            has_coords,
233        ));
234    }
235
236    let mut out = Vec::new();
237    for (name, mut points) in grouped {
238        points.sort_by_key(|(seq, _, _)| *seq);
239        let deduped = points.into_iter().map(|(_, p, has_coords)| (p, has_coords)).fold(
240            Vec::<(AirwayPointRecord, bool)>::new(),
241            |mut acc, (p, has_coords)| {
242                if acc.last().map(|(x, _)| x.code.as_str()) != Some(p.code.as_str()) {
243                    acc.push((p, has_coords));
244                }
245                acc
246            },
247        );
248
249        if deduped.is_empty() {
250            continue;
251        }
252
253        let mut variants: Vec<Vec<AirwayPointRecord>> = vec![vec![deduped[0].0.clone()]];
254        for idx in 1..deduped.len() {
255            let (prev, prev_has_coords) = &deduped[idx - 1];
256            let (point, has_coords) = &deduped[idx];
257            let split_here = *prev_has_coords
258                && *has_coords
259                && great_circle_distance_nm(prev.latitude, prev.longitude, point.latitude, point.longitude)
260                    >= DDR_AIRWAY_SPLIT_GAP_NM;
261
262            if split_here {
263                variants.push(vec![point.clone()]);
264            } else if let Some(current) = variants.last_mut() {
265                current.push(point.clone());
266            }
267        }
268
269        let route_class = route_class_by_name.get(&name).cloned();
270        for points in variants.into_iter().filter(|points| points.len() >= 2) {
271            out.push(AirwayRecord {
272                route_class: route_class.clone(),
273                name: name.clone(),
274                source: "eurocontrol_ddr".to_string(),
275                points,
276            });
277        }
278    }
279    Ok(out)
280}
281
282fn ddr_layers_to_airspaces(layers: Vec<DdrSectorLayer>, type_name: &str) -> Vec<AirspaceRecord> {
283    layers
284        .into_iter()
285        .map(|layer| AirspaceRecord {
286            designator: layer.designator,
287            name: Some(layer.polygon_name),
288            type_: Some(type_name.to_string()),
289            lower: Some(layer.lower),
290            upper: Some(layer.upper),
291            coordinates: layer.coordinates,
292            source: "eurocontrol_ddr".to_string(),
293        })
294        .collect()
295}
296
297fn parse_ddr_collapsed_sectors(text: &str) -> Vec<(String, String, Option<String>, Option<String>)> {
298    let mut out = Vec::new();
299    let mut current_designator = String::new();
300    let mut current_name: Option<String> = None;
301    let mut current_type: Option<String> = None;
302
303    for line in text.lines() {
304        let line = line.trim();
305        if line.is_empty() || line.starts_with('#') {
306            continue;
307        }
308        let fields: Vec<&str> = line.split(';').collect();
309        if fields.is_empty() {
310            continue;
311        }
312
313        match fields[0] {
314            "A" if fields.len() >= 4 => {
315                current_designator = fields[1].trim().to_uppercase();
316                current_name = Some(fields[2].trim().to_string()).filter(|v| !v.is_empty() && v != "_");
317                current_type = Some(fields[3].trim().to_string()).filter(|v| !v.is_empty() && v != "_");
318            }
319            "S" if fields.len() >= 2 && !current_designator.is_empty() => {
320                out.push((
321                    current_designator.clone(),
322                    fields[1].trim().to_uppercase(),
323                    current_name.clone(),
324                    current_type.clone(),
325                ));
326            }
327            _ => {}
328        }
329    }
330
331    out
332}
333
334fn enrich_sector_airspaces_with_spc(sector_airspaces: &[AirspaceRecord], spc_text: &str) -> Vec<AirspaceRecord> {
335    let mappings = parse_ddr_collapsed_sectors(spc_text);
336    if mappings.is_empty() {
337        return Vec::new();
338    }
339
340    let mut by_component: HashMap<String, Vec<&AirspaceRecord>> = HashMap::new();
341    for layer in sector_airspaces {
342        by_component
343            .entry(layer.designator.to_uppercase())
344            .or_default()
345            .push(layer);
346    }
347
348    let mut out = Vec::new();
349    let mut seen: HashSet<String> = HashSet::new();
350    for (designator, component, name, type_name) in mappings {
351        let Some(component_layers) = by_component.get(&component) else {
352            continue;
353        };
354
355        for layer in component_layers {
356            let record = AirspaceRecord {
357                designator: designator.clone(),
358                name: name.clone().or_else(|| layer.name.clone()),
359                type_: type_name.clone().or_else(|| layer.type_.clone()),
360                lower: layer.lower,
361                upper: layer.upper,
362                coordinates: layer.coordinates.clone(),
363                source: "eurocontrol_ddr".to_string(),
364            };
365
366            let first = record.coordinates.first().copied().unwrap_or((0.0, 0.0));
367            let sig = format!(
368                "{}|{}|{}|{}|{}|{}|{}|{}",
369                record.designator,
370                record.name.as_deref().unwrap_or(""),
371                record.type_.as_deref().unwrap_or(""),
372                record.lower.unwrap_or(-1.0),
373                record.upper.unwrap_or(-1.0),
374                record.coordinates.len(),
375                first.0,
376                first.1
377            );
378            if seen.insert(sig) {
379                out.push(record);
380            }
381        }
382    }
383
384    out
385}
386
387fn parse_ddr_airspaces(files: &HashMap<String, String>) -> Result<Vec<AirspaceRecord>, JsValue> {
388    let sectors_are = files
389        .get("sectors.are")
390        .ok_or_else(|| JsValue::from_str("missing sectors.are"))?;
391    let sectors_sls = files
392        .get("sectors.sls")
393        .ok_or_else(|| JsValue::from_str("missing sectors.sls"))?;
394    let sectors_polygons = parse_are_bytes(sectors_are.as_bytes()).map_err(|e| JsValue::from_str(&e.to_string()))?;
395    let sector_layers =
396        parse_sls_bytes(sectors_sls.as_bytes(), &sectors_polygons).map_err(|e| JsValue::from_str(&e.to_string()))?;
397
398    let free_route_are = files
399        .get("free_route.are")
400        .ok_or_else(|| JsValue::from_str("missing free_route.are"))?;
401    let free_route_sls = files
402        .get("free_route.sls")
403        .ok_or_else(|| JsValue::from_str("missing free_route.sls"))?;
404    let free_route_polygons =
405        parse_are_bytes(free_route_are.as_bytes()).map_err(|e| JsValue::from_str(&e.to_string()))?;
406    let free_route_layers = parse_sls_bytes(free_route_sls.as_bytes(), &free_route_polygons)
407        .map_err(|e| JsValue::from_str(&e.to_string()))?;
408
409    let sector_airspaces = ddr_layers_to_airspaces(sector_layers, "SECTOR");
410    let mut out = sector_airspaces.clone();
411    if let Some(sectors_spc) = files.get("sectors.spc") {
412        out.extend(enrich_sector_airspaces_with_spc(&sector_airspaces, sectors_spc));
413    }
414    out.extend(ddr_layers_to_airspaces(free_route_layers, "FRA"));
415    Ok(out)
416}
417
418fn compose_airspace(records: Vec<AirspaceRecord>) -> Option<AirspaceCompositeRecord> {
419    let first = records.first()?;
420    let designator = first.designator.clone();
421    let source = first.source.clone();
422    let name = records.iter().find_map(|r| r.name.clone());
423    let type_ = records.iter().find_map(|r| r.type_.clone());
424    let layers = records
425        .into_iter()
426        .map(|r| AirspaceLayerRecord {
427            lower: r.lower,
428            upper: r.upper,
429            coordinates: r.coordinates,
430        })
431        .collect();
432
433    Some(AirspaceCompositeRecord {
434        designator,
435        name,
436        type_,
437        layers,
438        source,
439    })
440}
441
442fn great_circle_distance_nm(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
443    let radius_nm = 3440.065_f64;
444    let phi1 = lat1.to_radians();
445    let phi2 = lat2.to_radians();
446    let dphi = (lat2 - lat1).to_radians();
447    let dlambda = (lon2 - lon1).to_radians();
448    let a = (dphi / 2.0).sin() * (dphi / 2.0).sin()
449        + phi1.cos() * phi2.cos() * (dlambda / 2.0).sin() * (dlambda / 2.0).sin();
450    2.0 * radius_nm * a.sqrt().asin()
451}
452
453fn file_basename(name: &str) -> &str {
454    name.rsplit('/').next().unwrap_or(name)
455}
456
457fn find_zip_text_entry(ddr_archive: &[u8], predicate: impl Fn(&str) -> bool) -> Result<String, JsValue> {
458    let mut archive = ZipArchive::new(Cursor::new(ddr_archive))
459        .map_err(|e| JsValue::from_str(&format!("invalid DDR zip archive: {e}")))?;
460    for idx in 0..archive.len() {
461        let mut entry = archive
462            .by_index(idx)
463            .map_err(|e| JsValue::from_str(&format!("unable to read DDR zip entry: {e}")))?;
464        if entry.is_dir() {
465            continue;
466        }
467        let name = file_basename(entry.name()).to_string();
468        if !predicate(&name) {
469            continue;
470        }
471        let mut text = String::new();
472        entry
473            .read_to_string(&mut text)
474            .map_err(|e| JsValue::from_str(&format!("unable to decode DDR entry '{name}' as UTF-8 text: {e}")))?;
475        return Ok(text);
476    }
477    Err(JsValue::from_str("matching DDR file not found in archive"))
478}
479
480type DdrEntryMatcher = (&'static str, fn(&str) -> bool);
481
482fn ddr_file_key_and_matchers() -> [DdrEntryMatcher; 8] {
483    [
484        ("navpoints.nnpt", |name: &str| {
485            let lower = name.to_ascii_lowercase();
486            lower.starts_with("airac_") && lower.ends_with(".nnpt")
487        }),
488        ("routes.routes", |name: &str| {
489            let lower = name.to_ascii_lowercase();
490            lower.starts_with("airac_") && lower.ends_with(".routes")
491        }),
492        ("airports.arp", |name: &str| {
493            let lower = name.to_ascii_lowercase();
494            lower.starts_with("vst_") && lower.ends_with("_airports.arp")
495        }),
496        ("sectors.are", |name: &str| {
497            let lower = name.to_ascii_lowercase();
498            lower.starts_with("sectors_") && lower.ends_with(".are")
499        }),
500        ("sectors.sls", |name: &str| {
501            let lower = name.to_ascii_lowercase();
502            lower.starts_with("sectors_") && lower.ends_with(".sls")
503        }),
504        ("free_route.are", |name: &str| {
505            let lower = name.to_ascii_lowercase();
506            lower.starts_with("free_route_") && lower.ends_with(".are")
507        }),
508        ("free_route.sls", |name: &str| {
509            let lower = name.to_ascii_lowercase();
510            lower.starts_with("free_route_") && lower.ends_with(".sls")
511        }),
512        ("free_route.frp", |name: &str| {
513            let lower = name.to_ascii_lowercase();
514            lower.starts_with("free_route_") && lower.ends_with(".frp")
515        }),
516    ]
517}
518
519fn sectors_spc_matcher(name: &str) -> bool {
520    let lower = name.to_ascii_lowercase();
521    lower.starts_with("sectors_") && lower.ends_with(".spc")
522}
523
524fn build_from_ddr_text_files(files: HashMap<String, String>) -> Result<EurocontrolResolver, JsValue> {
525    for name in DDR_EXPECTED_FILES {
526        if !files.contains_key(name) {
527            return Err(JsValue::from_str(&format!(
528                "missing DDR file '{name}' in dataset payload"
529            )));
530        }
531    }
532
533    let navaids = parse_ddr_navpoints(
534        files
535            .get("navpoints.nnpt")
536            .ok_or_else(|| JsValue::from_str("missing navpoints.nnpt"))?,
537    )?;
538
539    let airports = parse_ddr_airports(
540        files
541            .get("airports.arp")
542            .ok_or_else(|| JsValue::from_str("missing airports.arp"))?,
543    )?;
544    let airways = parse_ddr_airways(
545        files
546            .get("routes.routes")
547            .ok_or_else(|| JsValue::from_str("missing routes.routes"))?,
548        files
549            .get("navpoints.nnpt")
550            .ok_or_else(|| JsValue::from_str("missing navpoints.nnpt"))?,
551    )?;
552    let airspaces = parse_ddr_airspaces(&files)?;
553
554    EurocontrolResolver::build(airports, navaids, airways, airspaces)
555}
556
557#[wasm_bindgen]
558pub struct EurocontrolResolver {
559    airports: Vec<AirportRecord>,
560    navaids: Vec<NavpointRecord>,
561    airways: Vec<AirwayRecord>,
562    airspaces: Vec<AirspaceRecord>,
563    airport_index: HashMap<String, Vec<usize>>,
564    navaid_index: HashMap<String, Vec<usize>>,
565    airway_index: HashMap<String, Vec<usize>>,
566    airspace_index: HashMap<String, Vec<usize>>,
567}
568
569#[wasm_bindgen]
570impl EurocontrolResolver {
571    #[wasm_bindgen(constructor)]
572    pub fn new(aixm_folder: JsValue) -> Result<EurocontrolResolver, JsValue> {
573        let files: HashMap<String, Vec<u8>> =
574            serde_wasm_bindgen::from_value(aixm_folder).map_err(|e| JsValue::from_str(&e.to_string()))?;
575        let dataset = parse_aixm_folder_bytes(&files).map_err(|e| JsValue::from_str(&e.to_string()))?;
576
577        Self::build(
578            dataset.airports.into_iter().map(Into::into).collect(),
579            dataset.navaids.into_iter().map(Into::into).collect(),
580            dataset.airways.into_iter().map(Into::into).collect(),
581            Vec::new(),
582        )
583    }
584
585    #[wasm_bindgen(js_name = fromDdrFolder)]
586    pub fn from_ddr_folder(ddr_folder: JsValue) -> Result<EurocontrolResolver, JsValue> {
587        let files: HashMap<String, String> =
588            serde_wasm_bindgen::from_value(ddr_folder).map_err(|e| JsValue::from_str(&e.to_string()))?;
589        build_from_ddr_text_files(files)
590    }
591
592    #[wasm_bindgen(js_name = fromDdrArchive)]
593    pub fn from_ddr_archive(ddr_archive: Vec<u8>) -> Result<EurocontrolResolver, JsValue> {
594        let mut files: HashMap<String, String> = HashMap::new();
595        for (key, matcher) in ddr_file_key_and_matchers() {
596            let text = find_zip_text_entry(&ddr_archive, matcher)
597                .map_err(|_| JsValue::from_str(&format!("missing DDR file for key '{key}' in archive payload")))?;
598            files.insert(key.to_string(), text);
599        }
600        if let Ok(text) = find_zip_text_entry(&ddr_archive, sectors_spc_matcher) {
601            files.insert("sectors.spc".to_string(), text);
602        }
603        build_from_ddr_text_files(files)
604    }
605
606    fn build(
607        airports: Vec<AirportRecord>,
608        mut navaids: Vec<NavpointRecord>,
609        airways: Vec<AirwayRecord>,
610        airspaces: Vec<AirspaceRecord>,
611    ) -> Result<EurocontrolResolver, JsValue> {
612        let mut seen = HashSet::new();
613        navaids.retain(|n| {
614            let key = format!(
615                "{}|{}|{:.8}|{:.8}",
616                n.code,
617                n.point_type.as_deref().unwrap_or(""),
618                n.latitude,
619                n.longitude
620            );
621            seen.insert(key)
622        });
623
624        let mut airport_index: HashMap<String, Vec<usize>> = HashMap::new();
625        for (i, a) in airports.iter().enumerate() {
626            airport_index.entry(a.code.clone()).or_default().push(i);
627            if let Some(v) = &a.iata {
628                airport_index.entry(v.clone()).or_default().push(i);
629            }
630            if let Some(v) = &a.icao {
631                airport_index.entry(v.clone()).or_default().push(i);
632            }
633        }
634
635        let mut navaid_index: HashMap<String, Vec<usize>> = HashMap::new();
636        for (i, n) in navaids.iter().enumerate() {
637            navaid_index.entry(n.code.clone()).or_default().push(i);
638        }
639
640        let mut airway_index: HashMap<String, Vec<usize>> = HashMap::new();
641        for (i, a) in airways.iter().enumerate() {
642            airway_index.entry(normalize_airway_name(&a.name)).or_default().push(i);
643            airway_index.entry(a.name.to_uppercase()).or_default().push(i);
644        }
645
646        let mut airspace_index: HashMap<String, Vec<usize>> = HashMap::new();
647        for (i, a) in airspaces.iter().enumerate() {
648            airspace_index.entry(a.designator.to_uppercase()).or_default().push(i);
649        }
650
651        Ok(EurocontrolResolver {
652            airports,
653            navaids,
654            airways,
655            airspaces,
656            airport_index,
657            navaid_index,
658            airway_index,
659            airspace_index,
660        })
661    }
662
663    pub fn airports(&self) -> Result<JsValue, JsValue> {
664        serde_wasm_bindgen::to_value(&self.airports).map_err(|e| JsValue::from_str(&e.to_string()))
665    }
666
667    pub fn fixes(&self) -> Result<JsValue, JsValue> {
668        serde_wasm_bindgen::to_value(&self.navaids).map_err(|e| JsValue::from_str(&e.to_string()))
669    }
670
671    pub fn navaids(&self) -> Result<JsValue, JsValue> {
672        serde_wasm_bindgen::to_value(&self.navaids).map_err(|e| JsValue::from_str(&e.to_string()))
673    }
674
675    pub fn airways(&self) -> Result<JsValue, JsValue> {
676        serde_wasm_bindgen::to_value(&self.airways).map_err(|e| JsValue::from_str(&e.to_string()))
677    }
678
679    pub fn airspaces(&self) -> Result<JsValue, JsValue> {
680        let mut keys = self.airspace_index.keys().cloned().collect::<Vec<_>>();
681        keys.sort();
682        let rows = keys
683            .into_iter()
684            .filter_map(|key| {
685                let records = self
686                    .airspace_index
687                    .get(&key)
688                    .into_iter()
689                    .flat_map(|indices| indices.iter().copied())
690                    .filter_map(|idx| self.airspaces.get(idx).cloned())
691                    .collect::<Vec<_>>();
692                compose_airspace(records)
693            })
694            .collect::<Vec<_>>();
695        serde_wasm_bindgen::to_value(&rows).map_err(|e| JsValue::from_str(&e.to_string()))
696    }
697
698    pub fn resolve_airport(&self, code: String) -> Result<JsValue, JsValue> {
699        let key = code.to_uppercase();
700        let item = self
701            .airport_index
702            .get(&key)
703            .and_then(|idx| idx.first().copied())
704            .and_then(|i| self.airports.get(i))
705            .cloned();
706        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
707    }
708
709    pub fn resolve_fix(&self, code: String) -> Result<JsValue, JsValue> {
710        let key = code.to_uppercase();
711        let item = self
712            .navaid_index
713            .get(&key)
714            .and_then(|idx| idx.first().copied())
715            .and_then(|i| self.navaids.get(i))
716            .cloned();
717        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
718    }
719
720    pub fn resolve_navaid(&self, code: String) -> Result<JsValue, JsValue> {
721        let key = code.to_uppercase();
722        let item = self
723            .navaid_index
724            .get(&key)
725            .and_then(|idx| idx.first().copied())
726            .and_then(|i| self.navaids.get(i))
727            .cloned();
728        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
729    }
730
731    pub fn resolve_airway(&self, name: String) -> Result<JsValue, JsValue> {
732        let key = normalize_airway_name(&name);
733        let item = self
734            .airway_index
735            .get(&key)
736            .and_then(|idx| idx.first().copied())
737            .and_then(|i| self.airways.get(i))
738            .cloned();
739        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
740    }
741
742    pub fn resolve_airspace(&self, designator: String) -> Result<JsValue, JsValue> {
743        let key = designator.to_uppercase();
744        let records = self
745            .airspace_index
746            .get(&key)
747            .into_iter()
748            .flat_map(|indices| indices.iter().copied())
749            .filter_map(|idx| self.airspaces.get(idx).cloned())
750            .collect::<Vec<_>>();
751        serde_wasm_bindgen::to_value(&compose_airspace(records)).map_err(|e| JsValue::from_str(&e.to_string()))
752    }
753
754    /// Parse and resolve a raw ICAO field 15 route string into a sequence of geographic segments.
755    ///
756    /// Returns a JS array of `{ start, end, name? }` objects where `start` and `end` are
757    /// `{ latitude, longitude, name?, kind? }` resolved geographic points.
758    ///
759    /// Points are resolved against the resolver's navaid/airport indices. Airways are expanded
760    /// to their constituent waypoints. Direct (DCT) segments connect the previous resolved point
761    /// to the next one. SID/STAR designators are not expanded (no procedure leg data available).
762    #[wasm_bindgen(js_name = enrichRoute)]
763    pub fn enrich_route(&self, route: String) -> Result<JsValue, JsValue> {
764        use crate::field15::ResolvedPoint as WasmPoint;
765        use crate::field15::RouteSegment;
766        use thrust::data::field15::{Connector, Field15Element, Field15Parser, Point};
767
768        let elements = Field15Parser::parse(&route);
769        let mut segments: Vec<RouteSegment> = Vec::new();
770        let mut last_point: Option<WasmPoint> = None;
771        // When we encounter an airway connector we store it here together with
772        // the entry point so we can slice correctly once the exit point is known.
773        let mut pending_airway: Option<(String, WasmPoint)> = None;
774        let mut current_connector: Option<String> = None;
775
776        let resolve_code = |code: &str| -> Option<WasmPoint> {
777            let key = code.to_uppercase();
778            if let Some(idx) = self.airport_index.get(&key).and_then(|v| v.first()) {
779                if let Some(a) = self.airports.get(*idx) {
780                    return Some(WasmPoint {
781                        latitude: a.latitude,
782                        longitude: a.longitude,
783                        name: Some(a.code.clone()),
784                        kind: Some("airport".to_string()),
785                    });
786                }
787            }
788            if let Some(idx) = self.navaid_index.get(&key).and_then(|v| v.first()) {
789                if let Some(n) = self.navaids.get(*idx) {
790                    return Some(WasmPoint {
791                        latitude: n.latitude,
792                        longitude: n.longitude,
793                        name: Some(n.code.clone()),
794                        kind: Some(n.kind.clone()),
795                    });
796                }
797            }
798            None
799        };
800
801        // Expand a pending airway from `entry` to `exit` using the airway's point list.
802        // Finds entry and exit by name, then walks forward or backward between them.
803        // Returns true if expansion succeeded; false means the caller should fall back
804        // to a direct segment labelled with the airway name.
805        let expand_airway =
806            |airway_name: &str, entry: &WasmPoint, exit: &WasmPoint, segs: &mut Vec<RouteSegment>| -> bool {
807                let key = crate::models::normalize_airway_name(airway_name);
808                let airway = match self
809                    .airway_index
810                    .get(&key)
811                    .and_then(|v| v.first())
812                    .and_then(|i| self.airways.get(*i))
813                {
814                    Some(a) => a,
815                    None => return false,
816                };
817
818                let pts = &airway.points;
819
820                // Find entry and exit positions by name (case-insensitive).
821                let entry_name = entry.name.as_deref().unwrap_or("").to_uppercase();
822                let exit_name = exit.name.as_deref().unwrap_or("").to_uppercase();
823
824                let entry_pos = pts.iter().position(|p| p.code.to_uppercase() == entry_name);
825                let exit_pos = pts.iter().position(|p| p.code.to_uppercase() == exit_name);
826
827                let (from, to) = match (entry_pos, exit_pos) {
828                    (Some(f), Some(t)) => (f, t),
829                    _ => return false, // one or both endpoints not in this airway
830                };
831
832                // Build the slice going forward or backward.
833                let slice: Vec<&crate::models::AirwayPointRecord> = if from <= to {
834                    pts[from..=to].iter().collect()
835                } else {
836                    pts[to..=from].iter().rev().collect()
837                };
838
839                if slice.len() < 2 {
840                    return false;
841                }
842
843                // The first point in the slice IS the entry — use the already-resolved
844                // entry WasmPoint so coordinates are consistent with what we already emitted.
845                let mut prev = entry.clone();
846                for pt in &slice[1..] {
847                    let next = WasmPoint {
848                        latitude: pt.latitude,
849                        longitude: pt.longitude,
850                        name: Some(pt.code.clone()),
851                        kind: Some(pt.kind.clone()),
852                    };
853                    segs.push(RouteSegment {
854                        start: prev,
855                        end: next.clone(),
856                        name: Some(airway_name.to_string()),
857                    });
858                    prev = next;
859                }
860                true
861            };
862
863        for element in &elements {
864            match element {
865                Field15Element::Point(point) => {
866                    let resolved = match point {
867                        Point::Waypoint(name) | Point::Aerodrome(name) => resolve_code(name),
868                        Point::Coordinates((lat, lon)) => Some(WasmPoint {
869                            latitude: *lat,
870                            longitude: *lon,
871                            name: None,
872                            kind: Some("coords".to_string()),
873                        }),
874                        Point::BearingDistance { point, .. } => match point.as_ref() {
875                            Point::Waypoint(name) | Point::Aerodrome(name) => resolve_code(name),
876                            Point::Coordinates((lat, lon)) => Some(WasmPoint {
877                                latitude: *lat,
878                                longitude: *lon,
879                                name: None,
880                                kind: Some("coords".to_string()),
881                            }),
882                            _ => None,
883                        },
884                    };
885                    if let Some(exit) = resolved {
886                        if let Some((airway_name, entry)) = pending_airway.take() {
887                            // Try to expand the airway between entry and exit.
888                            let expanded = expand_airway(&airway_name, &entry, &exit, &mut segments);
889                            if !expanded {
890                                // Fall back: single labelled segment entry → exit.
891                                segments.push(RouteSegment {
892                                    start: entry,
893                                    end: exit.clone(),
894                                    name: Some(airway_name),
895                                });
896                            }
897                        } else if let Some(prev) = last_point.take() {
898                            segments.push(RouteSegment {
899                                start: prev,
900                                end: exit.clone(),
901                                name: current_connector.take(),
902                            });
903                        } else {
904                            current_connector = None;
905                        }
906                        last_point = Some(exit);
907                    }
908                }
909                Field15Element::Connector(connector) => match connector {
910                    Connector::Airway(name) => {
911                        // Stash the airway name and current last_point as entry.
912                        // We need the exit point (next Point token) to slice correctly.
913                        if let Some(entry) = last_point.take() {
914                            pending_airway = Some((name.clone(), entry));
915                        } else {
916                            // No entry point yet — treat as a labelled connector.
917                            current_connector = Some(name.clone());
918                        }
919                    }
920                    Connector::Direct => {
921                        current_connector = None;
922                    }
923                    Connector::Sid(name) | Connector::Star(name) => {
924                        current_connector = Some(name.clone());
925                    }
926                    _ => {}
927                },
928                Field15Element::Modifier(_) => {}
929            }
930        }
931
932        serde_wasm_bindgen::to_value(&segments).map_err(|e| JsValue::from_str(&e.to_string()))
933    }
934}