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
28const 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(), §ors_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(§or_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 #[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 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 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 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, };
942
943 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 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 let expanded = expand_airway(&airway_name, &entry, &exit, &mut segments);
1034 if !expanded {
1035 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 if let Some(entry) = last_point.take() {
1071 pending_airway = Some((name.clone(), entry));
1072 current_segment_type = None;
1073 } else {
1074 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}