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::{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(), §ors_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(§or_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 #[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 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 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 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, };
831
832 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 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 let expanded = expand_airway(&airway_name, &entry, &exit, &mut segments);
889 if !expanded {
890 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 if let Some(entry) = last_point.take() {
914 pending_airway = Some((name.clone(), entry));
915 } else {
916 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}