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::{
87        parse_ddr_airports, parse_ddr_airspaces, parse_ddr_airways, procedure_lookup_keys, EurocontrolResolver,
88    };
89    use crate::models::{AirwayPointRecord, AirwayRecord};
90
91    #[test]
92    fn parse_lfbo_coordinates_from_ddr_arp() {
93        let airports = parse_ddr_airports("LFBO 2618.100000 82.066667\n").expect("DDR airport parsing failed");
94        let lfbo = airports.iter().find(|a| a.code == "LFBO").expect("LFBO not found");
95
96        assert!((lfbo.latitude - 43.635).abs() < 1e-9);
97        assert!((lfbo.longitude - 1.3677777833333334).abs() < 1e-9);
98    }
99
100    #[test]
101    fn split_ddr_airway_on_very_large_gap() {
102        let text = [
103            "L;A10;AR;999999999999;000000000000;YJQ;SP;1",
104            "L;A10;AR;999999999999;000000000000;MITEK;SP;2",
105            "L;A10;AR;999999999999;000000000000;*PR13;DBP;3",
106            "L;A10;AR;999999999999;000000000000;SIT;SP;4",
107            "L;A10;AR;999999999999;000000000000;PAXIS;SP;5",
108        ]
109        .join("\n");
110
111        let navpoints = [
112            "YJQ;FIX;10;10;_",
113            "MITEK;FIX;10;11;_",
114            "*PR13;DBP;10;12;_",
115            "SIT;FIX;55;120;_",
116            "PAXIS;FIX;55;121;_",
117        ]
118        .join("\n");
119
120        let airways = parse_ddr_airways(&text, &navpoints).expect("DDR airway parsing failed");
121        assert_eq!(airways.len(), 2);
122        assert_eq!(airways[0].name, "A10");
123        assert_eq!(airways[1].name, "A10");
124        assert_eq!(airways[0].points.len(), 3);
125        assert_eq!(airways[1].points.len(), 2);
126        assert_eq!(airways[0].points[0].code, "YJQ");
127        assert_eq!(airways[0].points[2].code, "*PR13");
128        assert_eq!(airways[1].points[0].code, "SIT");
129        assert_eq!(airways[1].points[1].code, "PAXIS");
130    }
131
132    #[test]
133    fn keep_ddr_airway_when_gaps_are_reasonable() {
134        let text = [
135            "L;UM605;AR;999999999999;000000000000;A;SP;1",
136            "L;UM605;AR;999999999999;000000000000;B;SP;2",
137            "L;UM605;AR;999999999999;000000000000;C;SP;3",
138        ]
139        .join("\n");
140
141        let navpoints = ["A;FIX;43.6;1.4;_", "B;FIX;44.0;2.0;_", "C;FIX;44.5;3.0;_"].join("\n");
142        let airways = parse_ddr_airways(&text, &navpoints).expect("DDR airway parsing failed");
143        assert_eq!(airways.len(), 1);
144        assert_eq!(airways[0].name, "UM605");
145        assert_eq!(airways[0].points.len(), 3);
146    }
147
148    #[test]
149    fn parse_ddr_airspaces_from_are_and_sls_text() {
150        let mut files = HashMap::new();
151        files.insert(
152            "sectors.are".to_string(),
153            ["3 SEC1_POLY", "0 0", "0 60", "60 60"].join("\n"),
154        );
155        files.insert("sectors.sls".to_string(), ["SEC1 X SEC1_POLY 100 200"].join("\n"));
156        files.insert(
157            "free_route.are".to_string(),
158            ["3 FRA1_POLY", "120 0", "120 60", "180 60"].join("\n"),
159        );
160        files.insert("free_route.sls".to_string(), ["FRA1 X FRA1_POLY 245 660"].join("\n"));
161
162        let airspaces = parse_ddr_airspaces(&files).expect("DDR airspace parsing should succeed");
163        assert_eq!(airspaces.len(), 2);
164        assert_eq!(airspaces[0].designator, "SEC1");
165        assert_eq!(airspaces[0].type_.as_deref(), Some("SECTOR"));
166        assert_eq!(airspaces[1].designator, "FRA1");
167        assert_eq!(airspaces[1].type_.as_deref(), Some("FRA"));
168    }
169
170    #[test]
171    fn parse_ddr_airspaces_enriches_with_spc_collapsed_designators() {
172        let mut files = HashMap::new();
173        files.insert("sectors.are".to_string(), ["3 P1", "0 0", "0 60", "60 60"].join("\n"));
174        files.insert("sectors.sls".to_string(), ["LFBBN1 X P1 195 295"].join("\n"));
175        files.insert(
176            "sectors.spc".to_string(),
177            ["A;LFBBCTA;BORDEAUX U/ACC;AUA;42;_", "S;LFBBN1;ES"].join("\n"),
178        );
179        files.insert(
180            "free_route.are".to_string(),
181            ["3 FRA1_POLY", "120 0", "120 60", "180 60"].join("\n"),
182        );
183        files.insert("free_route.sls".to_string(), ["FRA1 X FRA1_POLY 245 660"].join("\n"));
184
185        let airspaces = parse_ddr_airspaces(&files).expect("DDR airspace parsing should succeed");
186        assert!(airspaces.iter().any(|a| a.designator == "LFBBN1"));
187        let collapsed = airspaces
188            .iter()
189            .find(|a| a.designator == "LFBBCTA")
190            .expect("Collapsed LFBBCTA should be present");
191        assert_eq!(collapsed.name.as_deref(), Some("BORDEAUX U/ACC"));
192        assert_eq!(collapsed.type_.as_deref(), Some("AUA"));
193        assert_eq!(collapsed.lower, Some(195.0));
194        assert_eq!(collapsed.upper, Some(295.0));
195    }
196
197    #[test]
198    fn procedure_lookup_keys_extracts_base_designator() {
199        let keys = procedure_lookup_keys("FISTO5ALFBO");
200        assert!(keys.contains(&"FISTO5ALFBO".to_string()));
201        assert!(keys.contains(&"FISTO5A".to_string()));
202    }
203
204    #[test]
205    fn resolve_star_uses_route_class_ap_airway_record() {
206        let resolver = EurocontrolResolver::build(
207            Vec::new(),
208            Vec::new(),
209            vec![AirwayRecord {
210                name: "KEPER9ELFBO".to_string(),
211                source: "eurocontrol_ddr".to_string(),
212                route_class: Some("AP".to_string()),
213                points: vec![
214                    AirwayPointRecord {
215                        code: "KEPER".to_string(),
216                        raw_code: "KEPER".to_string(),
217                        kind: "fix".to_string(),
218                        latitude: 44.0,
219                        longitude: 2.0,
220                    },
221                    AirwayPointRecord {
222                        code: "LFBO".to_string(),
223                        raw_code: "LFBO".to_string(),
224                        kind: "airport".to_string(),
225                        latitude: 43.6,
226                        longitude: 1.4,
227                    },
228                ],
229            }],
230            Vec::new(),
231        )
232        .expect("resolver build failed");
233
234        let star = resolver
235            .resolve_procedure_airway_by_kind("STAR", "KEPER9E")
236            .expect("missing STAR KEPER9E");
237        assert_eq!(star.route_class.as_deref(), Some("AP"));
238        assert_eq!(star.name, "KEPER9ELFBO");
239    }
240}
241
242fn parse_ddr_airways(routes_text: &str, navpoints_text: &str) -> Result<Vec<AirwayRecord>, JsValue> {
243    let navpoints = parse_navpoints_bytes(navpoints_text.as_bytes()).map_err(|e| JsValue::from_str(&e.to_string()))?;
244    let route_points =
245        parse_routes_bytes(routes_text.as_bytes(), &navpoints).map_err(|e| JsValue::from_str(&e.to_string()))?;
246
247    let mut grouped: HashMap<String, Vec<(i32, AirwayPointRecord, bool)>> = HashMap::new();
248    let mut route_class_by_name: HashMap<String, String> = HashMap::new();
249    for point in route_points {
250        let route = point.route.to_uppercase();
251        let route_class = point.route_class.to_uppercase();
252        let navaid = point.navaid.to_uppercase();
253        let seq = point.seq;
254        let (lat, lon, kind, has_coords) = match (point.latitude, point.longitude) {
255            (Some(lat), Some(lon)) => {
256                let point_type = point.point_type.to_uppercase();
257                let kind = if point_type.contains("FIX") || point_type == "WPT" || point_type == "WP" {
258                    "fix"
259                } else {
260                    "navaid"
261                }
262                .to_string();
263                (lat, lon, kind, true)
264            }
265            _ => (0.0, 0.0, "point".to_string(), false),
266        };
267
268        route_class_by_name.entry(route.clone()).or_insert(route_class);
269
270        grouped.entry(route).or_default().push((
271            seq,
272            AirwayPointRecord {
273                code: navaid.clone(),
274                raw_code: navaid,
275                kind,
276                latitude: lat,
277                longitude: lon,
278            },
279            has_coords,
280        ));
281    }
282
283    let mut out = Vec::new();
284    for (name, mut points) in grouped {
285        points.sort_by_key(|(seq, _, _)| *seq);
286        let deduped = points.into_iter().map(|(_, p, has_coords)| (p, has_coords)).fold(
287            Vec::<(AirwayPointRecord, bool)>::new(),
288            |mut acc, (p, has_coords)| {
289                if acc.last().map(|(x, _)| x.code.as_str()) != Some(p.code.as_str()) {
290                    acc.push((p, has_coords));
291                }
292                acc
293            },
294        );
295
296        if deduped.is_empty() {
297            continue;
298        }
299
300        let mut variants: Vec<Vec<AirwayPointRecord>> = vec![vec![deduped[0].0.clone()]];
301        for idx in 1..deduped.len() {
302            let (prev, prev_has_coords) = &deduped[idx - 1];
303            let (point, has_coords) = &deduped[idx];
304            let split_here = *prev_has_coords
305                && *has_coords
306                && great_circle_distance_nm(prev.latitude, prev.longitude, point.latitude, point.longitude)
307                    >= DDR_AIRWAY_SPLIT_GAP_NM;
308
309            if split_here {
310                variants.push(vec![point.clone()]);
311            } else if let Some(current) = variants.last_mut() {
312                current.push(point.clone());
313            }
314        }
315
316        let route_class = route_class_by_name.get(&name).cloned();
317        for points in variants.into_iter().filter(|points| points.len() >= 2) {
318            out.push(AirwayRecord {
319                route_class: route_class.clone(),
320                name: name.clone(),
321                source: "eurocontrol_ddr".to_string(),
322                points,
323            });
324        }
325    }
326    Ok(out)
327}
328
329fn ddr_layers_to_airspaces(layers: Vec<DdrSectorLayer>, type_name: &str) -> Vec<AirspaceRecord> {
330    layers
331        .into_iter()
332        .map(|layer| AirspaceRecord {
333            designator: layer.designator,
334            name: Some(layer.polygon_name),
335            type_: Some(type_name.to_string()),
336            lower: Some(layer.lower),
337            upper: Some(layer.upper),
338            coordinates: layer.coordinates,
339            source: "eurocontrol_ddr".to_string(),
340        })
341        .collect()
342}
343
344fn parse_ddr_collapsed_sectors(text: &str) -> Vec<(String, String, Option<String>, Option<String>)> {
345    let mut out = Vec::new();
346    let mut current_designator = String::new();
347    let mut current_name: Option<String> = None;
348    let mut current_type: Option<String> = None;
349
350    for line in text.lines() {
351        let line = line.trim();
352        if line.is_empty() || line.starts_with('#') {
353            continue;
354        }
355        let fields: Vec<&str> = line.split(';').collect();
356        if fields.is_empty() {
357            continue;
358        }
359
360        match fields[0] {
361            "A" if fields.len() >= 4 => {
362                current_designator = fields[1].trim().to_uppercase();
363                current_name = Some(fields[2].trim().to_string()).filter(|v| !v.is_empty() && v != "_");
364                current_type = Some(fields[3].trim().to_string()).filter(|v| !v.is_empty() && v != "_");
365            }
366            "S" if fields.len() >= 2 && !current_designator.is_empty() => {
367                out.push((
368                    current_designator.clone(),
369                    fields[1].trim().to_uppercase(),
370                    current_name.clone(),
371                    current_type.clone(),
372                ));
373            }
374            _ => {}
375        }
376    }
377
378    out
379}
380
381fn enrich_sector_airspaces_with_spc(sector_airspaces: &[AirspaceRecord], spc_text: &str) -> Vec<AirspaceRecord> {
382    let mappings = parse_ddr_collapsed_sectors(spc_text);
383    if mappings.is_empty() {
384        return Vec::new();
385    }
386
387    let mut by_component: HashMap<String, Vec<&AirspaceRecord>> = HashMap::new();
388    for layer in sector_airspaces {
389        by_component
390            .entry(layer.designator.to_uppercase())
391            .or_default()
392            .push(layer);
393    }
394
395    let mut out = Vec::new();
396    let mut seen: HashSet<String> = HashSet::new();
397    for (designator, component, name, type_name) in mappings {
398        let Some(component_layers) = by_component.get(&component) else {
399            continue;
400        };
401
402        for layer in component_layers {
403            let record = AirspaceRecord {
404                designator: designator.clone(),
405                name: name.clone().or_else(|| layer.name.clone()),
406                type_: type_name.clone().or_else(|| layer.type_.clone()),
407                lower: layer.lower,
408                upper: layer.upper,
409                coordinates: layer.coordinates.clone(),
410                source: "eurocontrol_ddr".to_string(),
411            };
412
413            let first = record.coordinates.first().copied().unwrap_or((0.0, 0.0));
414            let sig = format!(
415                "{}|{}|{}|{}|{}|{}|{}|{}",
416                record.designator,
417                record.name.as_deref().unwrap_or(""),
418                record.type_.as_deref().unwrap_or(""),
419                record.lower.unwrap_or(-1.0),
420                record.upper.unwrap_or(-1.0),
421                record.coordinates.len(),
422                first.0,
423                first.1
424            );
425            if seen.insert(sig) {
426                out.push(record);
427            }
428        }
429    }
430
431    out
432}
433
434fn parse_ddr_airspaces(files: &HashMap<String, String>) -> Result<Vec<AirspaceRecord>, JsValue> {
435    let sectors_are = files
436        .get("sectors.are")
437        .ok_or_else(|| JsValue::from_str("missing sectors.are"))?;
438    let sectors_sls = files
439        .get("sectors.sls")
440        .ok_or_else(|| JsValue::from_str("missing sectors.sls"))?;
441    let sectors_polygons = parse_are_bytes(sectors_are.as_bytes()).map_err(|e| JsValue::from_str(&e.to_string()))?;
442    let sector_layers =
443        parse_sls_bytes(sectors_sls.as_bytes(), &sectors_polygons).map_err(|e| JsValue::from_str(&e.to_string()))?;
444
445    let free_route_are = files
446        .get("free_route.are")
447        .ok_or_else(|| JsValue::from_str("missing free_route.are"))?;
448    let free_route_sls = files
449        .get("free_route.sls")
450        .ok_or_else(|| JsValue::from_str("missing free_route.sls"))?;
451    let free_route_polygons =
452        parse_are_bytes(free_route_are.as_bytes()).map_err(|e| JsValue::from_str(&e.to_string()))?;
453    let free_route_layers = parse_sls_bytes(free_route_sls.as_bytes(), &free_route_polygons)
454        .map_err(|e| JsValue::from_str(&e.to_string()))?;
455
456    let sector_airspaces = ddr_layers_to_airspaces(sector_layers, "SECTOR");
457    let mut out = sector_airspaces.clone();
458    if let Some(sectors_spc) = files.get("sectors.spc") {
459        out.extend(enrich_sector_airspaces_with_spc(&sector_airspaces, sectors_spc));
460    }
461    out.extend(ddr_layers_to_airspaces(free_route_layers, "FRA"));
462    Ok(out)
463}
464
465fn compose_airspace(records: Vec<AirspaceRecord>) -> Option<AirspaceCompositeRecord> {
466    let first = records.first()?;
467    let designator = first.designator.clone();
468    let source = first.source.clone();
469    let name = records.iter().find_map(|r| r.name.clone());
470    let type_ = records.iter().find_map(|r| r.type_.clone());
471    let layers = records
472        .into_iter()
473        .map(|r| AirspaceLayerRecord {
474            lower: r.lower,
475            upper: r.upper,
476            coordinates: r.coordinates,
477        })
478        .collect();
479
480    Some(AirspaceCompositeRecord {
481        designator,
482        name,
483        type_,
484        layers,
485        source,
486    })
487}
488
489fn great_circle_distance_nm(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
490    let radius_nm = 3440.065_f64;
491    let phi1 = lat1.to_radians();
492    let phi2 = lat2.to_radians();
493    let dphi = (lat2 - lat1).to_radians();
494    let dlambda = (lon2 - lon1).to_radians();
495    let a = (dphi / 2.0).sin() * (dphi / 2.0).sin()
496        + phi1.cos() * phi2.cos() * (dlambda / 2.0).sin() * (dlambda / 2.0).sin();
497    2.0 * radius_nm * a.sqrt().asin()
498}
499
500fn file_basename(name: &str) -> &str {
501    name.rsplit('/').next().unwrap_or(name)
502}
503
504fn find_zip_text_entry(ddr_archive: &[u8], predicate: impl Fn(&str) -> bool) -> Result<String, JsValue> {
505    let mut archive = ZipArchive::new(Cursor::new(ddr_archive))
506        .map_err(|e| JsValue::from_str(&format!("invalid DDR zip archive: {e}")))?;
507    for idx in 0..archive.len() {
508        let mut entry = archive
509            .by_index(idx)
510            .map_err(|e| JsValue::from_str(&format!("unable to read DDR zip entry: {e}")))?;
511        if entry.is_dir() {
512            continue;
513        }
514        let name = file_basename(entry.name()).to_string();
515        if !predicate(&name) {
516            continue;
517        }
518        let mut text = String::new();
519        entry
520            .read_to_string(&mut text)
521            .map_err(|e| JsValue::from_str(&format!("unable to decode DDR entry '{name}' as UTF-8 text: {e}")))?;
522        return Ok(text);
523    }
524    Err(JsValue::from_str("matching DDR file not found in archive"))
525}
526
527type DdrEntryMatcher = (&'static str, fn(&str) -> bool);
528
529fn ddr_file_key_and_matchers() -> [DdrEntryMatcher; 8] {
530    [
531        ("navpoints.nnpt", |name: &str| {
532            let lower = name.to_ascii_lowercase();
533            lower.starts_with("airac_") && lower.ends_with(".nnpt")
534        }),
535        ("routes.routes", |name: &str| {
536            let lower = name.to_ascii_lowercase();
537            lower.starts_with("airac_") && lower.ends_with(".routes")
538        }),
539        ("airports.arp", |name: &str| {
540            let lower = name.to_ascii_lowercase();
541            lower.starts_with("vst_") && lower.ends_with("_airports.arp")
542        }),
543        ("sectors.are", |name: &str| {
544            let lower = name.to_ascii_lowercase();
545            lower.starts_with("sectors_") && lower.ends_with(".are")
546        }),
547        ("sectors.sls", |name: &str| {
548            let lower = name.to_ascii_lowercase();
549            lower.starts_with("sectors_") && lower.ends_with(".sls")
550        }),
551        ("free_route.are", |name: &str| {
552            let lower = name.to_ascii_lowercase();
553            lower.starts_with("free_route_") && lower.ends_with(".are")
554        }),
555        ("free_route.sls", |name: &str| {
556            let lower = name.to_ascii_lowercase();
557            lower.starts_with("free_route_") && lower.ends_with(".sls")
558        }),
559        ("free_route.frp", |name: &str| {
560            let lower = name.to_ascii_lowercase();
561            lower.starts_with("free_route_") && lower.ends_with(".frp")
562        }),
563    ]
564}
565
566fn sectors_spc_matcher(name: &str) -> bool {
567    let lower = name.to_ascii_lowercase();
568    lower.starts_with("sectors_") && lower.ends_with(".spc")
569}
570
571fn build_from_ddr_text_files(files: HashMap<String, String>) -> Result<EurocontrolResolver, JsValue> {
572    for name in DDR_EXPECTED_FILES {
573        if !files.contains_key(name) {
574            return Err(JsValue::from_str(&format!(
575                "missing DDR file '{name}' in dataset payload"
576            )));
577        }
578    }
579
580    let navaids = parse_ddr_navpoints(
581        files
582            .get("navpoints.nnpt")
583            .ok_or_else(|| JsValue::from_str("missing navpoints.nnpt"))?,
584    )?;
585
586    let airports = parse_ddr_airports(
587        files
588            .get("airports.arp")
589            .ok_or_else(|| JsValue::from_str("missing airports.arp"))?,
590    )?;
591    let airways = parse_ddr_airways(
592        files
593            .get("routes.routes")
594            .ok_or_else(|| JsValue::from_str("missing routes.routes"))?,
595        files
596            .get("navpoints.nnpt")
597            .ok_or_else(|| JsValue::from_str("missing navpoints.nnpt"))?,
598    )?;
599    let airspaces = parse_ddr_airspaces(&files)?;
600
601    EurocontrolResolver::build(airports, navaids, airways, airspaces)
602}
603
604#[wasm_bindgen]
605pub struct EurocontrolResolver {
606    airports: Vec<AirportRecord>,
607    navaids: Vec<NavpointRecord>,
608    airways: Vec<AirwayRecord>,
609    airspaces: Vec<AirspaceRecord>,
610    airport_index: HashMap<String, Vec<usize>>,
611    navaid_index: HashMap<String, Vec<usize>>,
612    airway_index: HashMap<String, Vec<usize>>,
613    sid_index: HashMap<String, Vec<usize>>,
614    star_index: HashMap<String, Vec<usize>>,
615    airspace_index: HashMap<String, Vec<usize>>,
616}
617
618fn procedure_lookup_keys(name: &str) -> Vec<String> {
619    let upper = name.trim().to_uppercase();
620    if upper.is_empty() {
621        return Vec::new();
622    }
623    let mut out = vec![upper.clone()];
624    let compact = upper.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>();
625    if !compact.is_empty() {
626        out.push(compact.clone());
627        if compact.len() > 4 && compact[compact.len() - 4..].chars().all(|c| c.is_ascii_alphabetic()) {
628            out.push(compact[..compact.len() - 4].to_string());
629        }
630    }
631    out.sort();
632    out.dedup();
633    out
634}
635
636#[wasm_bindgen]
637impl EurocontrolResolver {
638    #[wasm_bindgen(constructor)]
639    pub fn new(aixm_folder: JsValue) -> Result<EurocontrolResolver, JsValue> {
640        let files: HashMap<String, Vec<u8>> =
641            serde_wasm_bindgen::from_value(aixm_folder).map_err(|e| JsValue::from_str(&e.to_string()))?;
642        let dataset = parse_aixm_folder_bytes(&files).map_err(|e| JsValue::from_str(&e.to_string()))?;
643
644        Self::build(
645            dataset.airports.into_iter().map(Into::into).collect(),
646            dataset.navaids.into_iter().map(Into::into).collect(),
647            dataset.airways.into_iter().map(Into::into).collect(),
648            Vec::new(),
649        )
650    }
651
652    #[wasm_bindgen(js_name = fromDdrFolder)]
653    pub fn from_ddr_folder(ddr_folder: JsValue) -> Result<EurocontrolResolver, JsValue> {
654        let files: HashMap<String, String> =
655            serde_wasm_bindgen::from_value(ddr_folder).map_err(|e| JsValue::from_str(&e.to_string()))?;
656        build_from_ddr_text_files(files)
657    }
658
659    #[wasm_bindgen(js_name = fromDdrArchive)]
660    pub fn from_ddr_archive(ddr_archive: Vec<u8>) -> Result<EurocontrolResolver, JsValue> {
661        let mut files: HashMap<String, String> = HashMap::new();
662        for (key, matcher) in ddr_file_key_and_matchers() {
663            let text = find_zip_text_entry(&ddr_archive, matcher)
664                .map_err(|_| JsValue::from_str(&format!("missing DDR file for key '{key}' in archive payload")))?;
665            files.insert(key.to_string(), text);
666        }
667        if let Ok(text) = find_zip_text_entry(&ddr_archive, sectors_spc_matcher) {
668            files.insert("sectors.spc".to_string(), text);
669        }
670        build_from_ddr_text_files(files)
671    }
672
673    fn build(
674        airports: Vec<AirportRecord>,
675        mut navaids: Vec<NavpointRecord>,
676        airways: Vec<AirwayRecord>,
677        airspaces: Vec<AirspaceRecord>,
678    ) -> Result<EurocontrolResolver, JsValue> {
679        let mut seen = HashSet::new();
680        navaids.retain(|n| {
681            let key = format!(
682                "{}|{}|{:.8}|{:.8}",
683                n.code,
684                n.point_type.as_deref().unwrap_or(""),
685                n.latitude,
686                n.longitude
687            );
688            seen.insert(key)
689        });
690
691        let mut airport_index: HashMap<String, Vec<usize>> = HashMap::new();
692        for (i, a) in airports.iter().enumerate() {
693            airport_index.entry(a.code.clone()).or_default().push(i);
694            if let Some(v) = &a.iata {
695                airport_index.entry(v.clone()).or_default().push(i);
696            }
697            if let Some(v) = &a.icao {
698                airport_index.entry(v.clone()).or_default().push(i);
699            }
700        }
701
702        let mut navaid_index: HashMap<String, Vec<usize>> = HashMap::new();
703        for (i, n) in navaids.iter().enumerate() {
704            navaid_index.entry(n.code.clone()).or_default().push(i);
705        }
706
707        let mut airway_index: HashMap<String, Vec<usize>> = HashMap::new();
708        let mut sid_index: HashMap<String, Vec<usize>> = HashMap::new();
709        let mut star_index: HashMap<String, Vec<usize>> = HashMap::new();
710        for (i, a) in airways.iter().enumerate() {
711            airway_index.entry(normalize_airway_name(&a.name)).or_default().push(i);
712            airway_index.entry(a.name.to_uppercase()).or_default().push(i);
713            match a.route_class.as_deref().map(|s| s.to_uppercase()) {
714                Some(rc) if rc == "DP" => {
715                    for key in procedure_lookup_keys(&a.name) {
716                        sid_index.entry(key).or_default().push(i);
717                    }
718                }
719                Some(rc) if rc == "AP" => {
720                    for key in procedure_lookup_keys(&a.name) {
721                        star_index.entry(key).or_default().push(i);
722                    }
723                }
724                _ => {}
725            }
726        }
727
728        let mut airspace_index: HashMap<String, Vec<usize>> = HashMap::new();
729        for (i, a) in airspaces.iter().enumerate() {
730            airspace_index.entry(a.designator.to_uppercase()).or_default().push(i);
731        }
732
733        Ok(EurocontrolResolver {
734            airports,
735            navaids,
736            airways,
737            airspaces,
738            airport_index,
739            navaid_index,
740            airway_index,
741            sid_index,
742            star_index,
743            airspace_index,
744        })
745    }
746
747    fn resolve_procedure_airway_by_kind(&self, kind: &str, name: &str) -> Option<AirwayRecord> {
748        let index = match kind {
749            "SID" => &self.sid_index,
750            "STAR" => &self.star_index,
751            _ => return None,
752        };
753        for key in procedure_lookup_keys(name) {
754            if let Some(i) = index.get(&key).and_then(|idx| idx.first()).copied() {
755                if let Some(item) = self.airways.get(i) {
756                    return Some(item.clone());
757                }
758            }
759        }
760        None
761    }
762
763    pub fn airports(&self) -> Result<JsValue, JsValue> {
764        serde_wasm_bindgen::to_value(&self.airports).map_err(|e| JsValue::from_str(&e.to_string()))
765    }
766
767    pub fn fixes(&self) -> Result<JsValue, JsValue> {
768        serde_wasm_bindgen::to_value(&self.navaids).map_err(|e| JsValue::from_str(&e.to_string()))
769    }
770
771    pub fn navaids(&self) -> Result<JsValue, JsValue> {
772        serde_wasm_bindgen::to_value(&self.navaids).map_err(|e| JsValue::from_str(&e.to_string()))
773    }
774
775    pub fn airways(&self) -> Result<JsValue, JsValue> {
776        serde_wasm_bindgen::to_value(&self.airways).map_err(|e| JsValue::from_str(&e.to_string()))
777    }
778
779    pub fn airspaces(&self) -> Result<JsValue, JsValue> {
780        let mut keys = self.airspace_index.keys().cloned().collect::<Vec<_>>();
781        keys.sort();
782        let rows = keys
783            .into_iter()
784            .filter_map(|key| {
785                let records = self
786                    .airspace_index
787                    .get(&key)
788                    .into_iter()
789                    .flat_map(|indices| indices.iter().copied())
790                    .filter_map(|idx| self.airspaces.get(idx).cloned())
791                    .collect::<Vec<_>>();
792                compose_airspace(records)
793            })
794            .collect::<Vec<_>>();
795        serde_wasm_bindgen::to_value(&rows).map_err(|e| JsValue::from_str(&e.to_string()))
796    }
797
798    pub fn resolve_airport(&self, code: String) -> Result<JsValue, JsValue> {
799        let key = code.to_uppercase();
800        let item = self
801            .airport_index
802            .get(&key)
803            .and_then(|idx| idx.first().copied())
804            .and_then(|i| self.airports.get(i))
805            .cloned();
806        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
807    }
808
809    pub fn resolve_fix(&self, code: String) -> Result<JsValue, JsValue> {
810        let key = code.to_uppercase();
811        let item = self
812            .navaid_index
813            .get(&key)
814            .and_then(|idx| idx.first().copied())
815            .and_then(|i| self.navaids.get(i))
816            .cloned();
817        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
818    }
819
820    pub fn resolve_navaid(&self, code: String) -> Result<JsValue, JsValue> {
821        let key = code.to_uppercase();
822        let item = self
823            .navaid_index
824            .get(&key)
825            .and_then(|idx| idx.first().copied())
826            .and_then(|i| self.navaids.get(i))
827            .cloned();
828        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
829    }
830
831    pub fn resolve_airway(&self, name: String) -> Result<JsValue, JsValue> {
832        let key = normalize_airway_name(&name);
833        let item = self
834            .airway_index
835            .get(&key)
836            .and_then(|idx| idx.first().copied())
837            .and_then(|i| self.airways.get(i))
838            .cloned();
839        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
840    }
841
842    pub fn resolve_sid(&self, name: String) -> Result<JsValue, JsValue> {
843        let item = self.resolve_procedure_airway_by_kind("SID", &name);
844        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
845    }
846
847    pub fn resolve_star(&self, name: String) -> Result<JsValue, JsValue> {
848        let item = self.resolve_procedure_airway_by_kind("STAR", &name);
849        serde_wasm_bindgen::to_value(&item).map_err(|e| JsValue::from_str(&e.to_string()))
850    }
851
852    pub fn resolve_airspace(&self, designator: String) -> Result<JsValue, JsValue> {
853        let key = designator.to_uppercase();
854        let records = self
855            .airspace_index
856            .get(&key)
857            .into_iter()
858            .flat_map(|indices| indices.iter().copied())
859            .filter_map(|idx| self.airspaces.get(idx).cloned())
860            .collect::<Vec<_>>();
861        serde_wasm_bindgen::to_value(&compose_airspace(records)).map_err(|e| JsValue::from_str(&e.to_string()))
862    }
863
864    /// Parse and resolve a raw ICAO field 15 route string into a sequence of geographic segments.
865    ///
866    /// Returns a JS array of `{ start, end, name? }` objects where `start` and `end` are
867    /// `{ latitude, longitude, name?, kind? }` resolved geographic points.
868    ///
869    /// Points are resolved against the resolver's navaid/airport indices. Airways are expanded
870    /// to their constituent waypoints. Direct (DCT) segments connect the previous resolved point
871    /// to the next one. SID/STAR designators are not expanded (no procedure leg data available).
872    #[wasm_bindgen(js_name = enrichRoute)]
873    pub fn enrich_route(&self, route: String) -> Result<JsValue, JsValue> {
874        use crate::field15::ResolvedPoint as WasmPoint;
875        use crate::field15::RouteSegment;
876        use thrust::data::field15::{Connector, Field15Element, Field15Parser, Point};
877
878        let elements = Field15Parser::parse(&route);
879        let mut segments: Vec<RouteSegment> = Vec::new();
880        let mut last_point: Option<WasmPoint> = None;
881        // When we encounter an airway connector we store it here together with
882        // the entry point so we can slice correctly once the exit point is known.
883        let mut pending_airway: Option<(String, WasmPoint)> = None;
884        let mut current_connector: Option<String> = None;
885        let mut current_segment_type: Option<String> = None;
886
887        let resolve_code = |code: &str| -> Option<WasmPoint> {
888            let key = code.split('/').next().unwrap_or(code).trim().to_uppercase();
889            if let Some(idx) = self.airport_index.get(&key).and_then(|v| v.first()) {
890                if let Some(a) = self.airports.get(*idx) {
891                    return Some(WasmPoint {
892                        latitude: a.latitude,
893                        longitude: a.longitude,
894                        name: Some(a.code.clone()),
895                        kind: Some("airport".to_string()),
896                    });
897                }
898            }
899            if let Some(idx) = self.navaid_index.get(&key).and_then(|v| v.first()) {
900                if let Some(n) = self.navaids.get(*idx) {
901                    return Some(WasmPoint {
902                        latitude: n.latitude,
903                        longitude: n.longitude,
904                        name: Some(n.code.clone()),
905                        kind: Some(n.kind.clone()),
906                    });
907                }
908            }
909            None
910        };
911
912        // Expand a pending airway from `entry` to `exit` using the airway's point list.
913        // Finds entry and exit by name, then walks forward or backward between them.
914        // Returns true if expansion succeeded; false means the caller should fall back
915        // to a direct segment labelled with the airway name.
916        let expand_airway =
917            |airway_name: &str, entry: &WasmPoint, exit: &WasmPoint, segs: &mut Vec<RouteSegment>| -> bool {
918                let key = crate::models::normalize_airway_name(airway_name);
919                let airway = match self
920                    .airway_index
921                    .get(&key)
922                    .and_then(|v| v.first())
923                    .and_then(|i| self.airways.get(*i))
924                {
925                    Some(a) => a,
926                    None => return false,
927                };
928
929                let pts = &airway.points;
930
931                // Find entry and exit positions by name (case-insensitive).
932                let entry_name = entry.name.as_deref().unwrap_or("").to_uppercase();
933                let exit_name = exit.name.as_deref().unwrap_or("").to_uppercase();
934
935                let entry_pos = pts.iter().position(|p| p.code.to_uppercase() == entry_name);
936                let exit_pos = pts.iter().position(|p| p.code.to_uppercase() == exit_name);
937
938                let (from, to) = match (entry_pos, exit_pos) {
939                    (Some(f), Some(t)) => (f, t),
940                    _ => return false, // one or both endpoints not in this airway
941                };
942
943                // Build the slice going forward or backward.
944                let slice: Vec<&crate::models::AirwayPointRecord> = if from <= to {
945                    pts[from..=to].iter().collect()
946                } else {
947                    pts[to..=from].iter().rev().collect()
948                };
949
950                if slice.len() < 2 {
951                    return false;
952                }
953
954                // The first point in the slice IS the entry — use the already-resolved
955                // entry WasmPoint so coordinates are consistent with what we already emitted.
956                let mut prev = entry.clone();
957                for pt in &slice[1..] {
958                    let next = WasmPoint {
959                        latitude: pt.latitude,
960                        longitude: pt.longitude,
961                        name: Some(pt.code.clone()),
962                        kind: Some(pt.kind.clone()),
963                    };
964                    segs.push(RouteSegment {
965                        start: prev,
966                        end: next.clone(),
967                        name: Some(airway_name.to_string()),
968                        segment_type: Some("route".to_string()),
969                        connector: Some(airway_name.to_string()),
970                    });
971                    prev = next;
972                }
973                true
974            };
975
976        let expand_procedure_from_entry =
977            |kind: &str, procedure_name: &str, entry: &WasmPoint, segs: &mut Vec<RouteSegment>| -> Option<WasmPoint> {
978                let airway = self.resolve_procedure_airway_by_kind(kind, procedure_name)?;
979                let pts = &airway.points;
980                if pts.len() < 2 {
981                    return None;
982                }
983                let entry_name = entry.name.as_deref().unwrap_or("").to_uppercase();
984                let start_idx = pts.iter().position(|p| p.code.to_uppercase() == entry_name)?;
985                if start_idx >= pts.len() - 1 {
986                    return None;
987                }
988                let mut prev = entry.clone();
989                for pt in &pts[start_idx + 1..] {
990                    let next = WasmPoint {
991                        latitude: pt.latitude,
992                        longitude: pt.longitude,
993                        name: Some(pt.code.clone()),
994                        kind: Some(pt.kind.clone()),
995                    };
996                    segs.push(RouteSegment {
997                        start: prev,
998                        end: next.clone(),
999                        name: Some(procedure_name.to_string()),
1000                        segment_type: Some(kind.to_string()),
1001                        connector: Some(procedure_name.to_string()),
1002                    });
1003                    prev = next;
1004                }
1005                Some(prev)
1006            };
1007
1008        for element in &elements {
1009            match element {
1010                Field15Element::Point(point) => {
1011                    let resolved = match point {
1012                        Point::Waypoint(name) | Point::Aerodrome(name) => resolve_code(name),
1013                        Point::Coordinates((lat, lon)) => Some(WasmPoint {
1014                            latitude: *lat,
1015                            longitude: *lon,
1016                            name: None,
1017                            kind: Some("coords".to_string()),
1018                        }),
1019                        Point::BearingDistance { point, .. } => match point.as_ref() {
1020                            Point::Waypoint(name) | Point::Aerodrome(name) => resolve_code(name),
1021                            Point::Coordinates((lat, lon)) => Some(WasmPoint {
1022                                latitude: *lat,
1023                                longitude: *lon,
1024                                name: None,
1025                                kind: Some("coords".to_string()),
1026                            }),
1027                            _ => None,
1028                        },
1029                    };
1030                    if let Some(exit) = resolved {
1031                        if let Some((airway_name, entry)) = pending_airway.take() {
1032                            // Try to expand the airway between entry and exit.
1033                            let expanded = expand_airway(&airway_name, &entry, &exit, &mut segments);
1034                            if !expanded {
1035                                // Fall back: single labelled segment entry → exit.
1036                                segments.push(RouteSegment {
1037                                    start: entry,
1038                                    end: exit.clone(),
1039                                    name: Some(airway_name.clone()),
1040                                    segment_type: Some("unresolved".to_string()),
1041                                    connector: Some(airway_name),
1042                                });
1043                            }
1044                        } else if let Some(prev) = last_point.take() {
1045                            let seg_name = current_connector.take();
1046                            let seg_type = current_segment_type.take();
1047                            let seg_connector = if seg_type.as_deref() == Some("dct") {
1048                                Some("DCT".to_string())
1049                            } else {
1050                                seg_name.clone()
1051                            };
1052                            segments.push(RouteSegment {
1053                                start: prev,
1054                                end: exit.clone(),
1055                                name: seg_name,
1056                                segment_type: seg_type,
1057                                connector: seg_connector,
1058                            });
1059                        } else {
1060                            current_connector = None;
1061                            current_segment_type = None;
1062                        }
1063                        last_point = Some(exit);
1064                    }
1065                }
1066                Field15Element::Connector(connector) => match connector {
1067                    Connector::Airway(name) => {
1068                        // Stash the airway name and current last_point as entry.
1069                        // We need the exit point (next Point token) to slice correctly.
1070                        if let Some(entry) = last_point.take() {
1071                            pending_airway = Some((name.clone(), entry));
1072                            current_segment_type = None;
1073                        } else {
1074                            // No entry point yet — treat as a labelled connector.
1075                            current_connector = Some(name.clone());
1076                            current_segment_type = Some("unresolved".to_string());
1077                        }
1078                    }
1079                    Connector::Direct => {
1080                        current_connector = None;
1081                        current_segment_type = Some("dct".to_string());
1082                    }
1083                    Connector::Sid(name) => {
1084                        if let Some(entry) = last_point.clone() {
1085                            if let Some(end) = expand_procedure_from_entry("SID", name, &entry, &mut segments) {
1086                                last_point = Some(end);
1087                                current_connector = None;
1088                                pending_airway = None;
1089                                current_segment_type = None;
1090                            } else {
1091                                current_connector = Some(name.clone());
1092                                current_segment_type = Some("unresolved".to_string());
1093                            }
1094                        } else {
1095                            current_connector = Some(name.clone());
1096                            current_segment_type = Some("unresolved".to_string());
1097                        }
1098                    }
1099                    Connector::Star(name) => {
1100                        if let Some(entry) = last_point.clone() {
1101                            if let Some(end) = expand_procedure_from_entry("STAR", name, &entry, &mut segments) {
1102                                last_point = Some(end);
1103                                current_connector = None;
1104                                pending_airway = None;
1105                                current_segment_type = None;
1106                            } else {
1107                                current_connector = Some(name.clone());
1108                                current_segment_type = Some("unresolved".to_string());
1109                            }
1110                        } else {
1111                            current_connector = Some(name.clone());
1112                            current_segment_type = Some("unresolved".to_string());
1113                        }
1114                    }
1115                    Connector::Nat(name) => {
1116                        current_connector = Some(name.clone());
1117                        current_segment_type = Some("NAT".to_string());
1118                    }
1119                    Connector::Pts(name) => {
1120                        current_connector = Some(name.clone());
1121                        current_segment_type = Some("PTS".to_string());
1122                    }
1123                    _ => {}
1124                },
1125                Field15Element::Modifier(_) => {}
1126            }
1127        }
1128
1129        serde_wasm_bindgen::to_value(&segments).map_err(|e| JsValue::from_str(&e.to_string()))
1130    }
1131}