1use crate::errors::ProjectionError;
4use crate::models::{GnssPosition, NetRelation, Netelement, ProjectedPosition};
5use crate::temporal::parse_timestamp_flexible;
6use geo::{Coord, LineString};
7use geojson::{Feature, GeoJson, Value};
8use std::fs;
9
10const DEFAULT_CRS: &str = "EPSG:4326";
12
13fn parse_crs_from_feature_collection(feature_collection: &geojson::FeatureCollection) -> String {
17 let Some(crs_obj) = &feature_collection.foreign_members else {
18 return DEFAULT_CRS.to_string();
19 };
20
21 let Some(crs_value) = crs_obj.get("crs") else {
22 return DEFAULT_CRS.to_string();
23 };
24
25 crs_value
27 .get("properties")
28 .and_then(|props| props.get("name"))
29 .and_then(|name| name.as_str())
30 .and_then(|name_str| {
31 if name_str.contains("EPSG") {
33 name_str
34 .split("::")
35 .last()
36 .map(|code| format!("EPSG:{}", code))
37 } else {
38 None
39 }
40 })
41 .unwrap_or_else(|| DEFAULT_CRS.to_string())
42}
43
44pub fn parse_network_geojson(
67 path: &str,
68) -> Result<(Vec<Netelement>, Vec<NetRelation>), ProjectionError> {
69 let geojson_str = fs::read_to_string(path)?;
70 parse_network_geojson_str(&geojson_str)
71}
72
73pub fn parse_network_geojson_str(
77 geojson_str: &str,
78) -> Result<(Vec<Netelement>, Vec<NetRelation>), ProjectionError> {
79 let geojson = geojson_str
81 .parse::<GeoJson>()
82 .map_err(|e| ProjectionError::InvalidGeometry(format!("Failed to parse GeoJSON: {}", e)))?;
83
84 let feature_collection = match geojson {
86 GeoJson::FeatureCollection(fc) => fc,
87 _ => {
88 return Err(ProjectionError::InvalidGeometry(
89 "GeoJSON must be a FeatureCollection".to_string(),
90 ))
91 }
92 };
93
94 let crs = parse_crs_from_feature_collection(&feature_collection);
96
97 if !crs.contains("4326") && !crs.contains("WGS84") {
99 return Err(ProjectionError::InvalidCrs(format!(
100 "GeoJSON CRS must be WGS84 (EPSG:4326) per RFC 7946, got: {}",
101 crs
102 )));
103 }
104
105 let mut netelements = Vec::new();
107 let mut netrelations = Vec::new();
108
109 for (idx, feature) in feature_collection.features.iter().enumerate() {
110 if let Some(props) = &feature.properties {
112 if let Some(feature_type) = props.get("type") {
113 if feature_type.as_str() == Some("netrelation") {
114 let netrelation = parse_netrelation_feature(feature, idx)?;
115 netrelations.push(netrelation);
116 continue;
117 }
118 }
119 }
120
121 let netelement = parse_feature(feature, &crs, idx)?;
123 netelements.push(netelement);
124 }
125
126 if netelements.is_empty() {
127 return Err(ProjectionError::EmptyNetwork);
128 }
129
130 Ok((netelements, netrelations))
131}
132
133pub fn parse_gnss_geojson(path: &str, crs: &str) -> Result<Vec<GnssPosition>, ProjectionError> {
169 let geojson_str = fs::read_to_string(path)?;
170 parse_gnss_geojson_str(&geojson_str, crs)
171}
172
173pub fn parse_gnss_geojson_str(
177 geojson_str: &str,
178 crs: &str,
179) -> Result<Vec<GnssPosition>, ProjectionError> {
180 let geojson = geojson_str
182 .parse::<GeoJson>()
183 .map_err(|e| ProjectionError::GeoJsonError(format!("Failed to parse GeoJSON: {}", e)))?;
184
185 let feature_collection = match geojson {
187 GeoJson::FeatureCollection(fc) => fc,
188 _ => {
189 return Err(ProjectionError::GeoJsonError(
190 "GeoJSON must be a FeatureCollection".to_string(),
191 ))
192 }
193 };
194
195 let mut positions = Vec::new();
197
198 for (idx, feature) in feature_collection.features.iter().enumerate() {
199 let position = parse_gnss_feature(feature, crs, idx)?;
200 positions.push(position);
201 }
202
203 if positions.is_empty() {
204 return Err(ProjectionError::GeoJsonError(
205 "GeoJSON contains no valid GNSS positions".to_string(),
206 ));
207 }
208
209 Ok(positions)
210}
211
212fn parse_gnss_feature(
214 feature: &Feature,
215 crs: &str,
216 idx: usize,
217) -> Result<GnssPosition, ProjectionError> {
218 let geometry = feature.geometry.as_ref().ok_or_else(|| {
220 ProjectionError::GeoJsonError(format!("Feature {} missing geometry", idx))
221 })?;
222
223 let (longitude, latitude) = match &geometry.value {
225 Value::Point(coords) => {
226 if coords.len() < 2 {
227 return Err(ProjectionError::InvalidCoordinate(format!(
228 "Feature {} Point must have at least 2 coordinates",
229 idx
230 )));
231 }
232 (coords[0], coords[1])
233 }
234 _ => {
235 return Err(ProjectionError::GeoJsonError(format!(
236 "Feature {} must have Point geometry for GNSS position",
237 idx
238 )))
239 }
240 };
241
242 if !(-90.0..=90.0).contains(&latitude) {
244 return Err(ProjectionError::InvalidCoordinate(format!(
245 "Feature {}: latitude {} out of range [-90, 90]",
246 idx, latitude
247 )));
248 }
249 if !(-180.0..=180.0).contains(&longitude) {
250 return Err(ProjectionError::InvalidCoordinate(format!(
251 "Feature {}: longitude {} out of range [-180, 180]",
252 idx, longitude
253 )));
254 }
255
256 let properties = feature.properties.as_ref().ok_or_else(|| {
258 ProjectionError::GeoJsonError(format!(
259 "Feature {} missing properties (timestamp required)",
260 idx
261 ))
262 })?;
263
264 let timestamp_str = properties
266 .get("timestamp")
267 .and_then(|v| v.as_str())
268 .ok_or_else(|| {
269 ProjectionError::MissingTimezone(format!(
270 "Feature {} missing 'timestamp' property",
271 idx
272 ))
273 })?;
274
275 let timestamp = parse_timestamp_flexible(timestamp_str).map_err(|e| {
278 ProjectionError::InvalidTimestamp(format!(
279 "Feature {}: invalid timestamp '{}': {}",
280 idx, timestamp_str, e
281 ))
282 })?;
283
284 let mut metadata = std::collections::HashMap::new();
286 let mut heading: Option<f64> = None;
287 let mut distance: Option<f64> = None;
288
289 for (key, value) in properties {
290 match key.as_str() {
291 "timestamp" => {} "heading" => {
293 if let Some(h) = value.as_f64() {
295 if (0.0..=360.0).contains(&h) {
296 heading = Some(h);
297 } else {
298 return Err(ProjectionError::InvalidGeometry(format!(
299 "Feature {}: heading {} not in [0, 360]",
300 idx, h
301 )));
302 }
303 }
304 }
305 "distance" => {
306 if let Some(d) = value.as_f64() {
308 if d >= 0.0 {
309 distance = Some(d);
310 } else {
311 return Err(ProjectionError::InvalidGeometry(format!(
312 "Feature {}: distance {} must be >= 0",
313 idx, d
314 )));
315 }
316 }
317 }
318 _ => {
319 if let Some(str_value) = value.as_str() {
321 metadata.insert(key.clone(), str_value.to_string());
322 } else {
323 metadata.insert(key.clone(), value.to_string());
324 }
325 }
326 }
327 }
328
329 Ok(GnssPosition {
330 latitude,
331 longitude,
332 timestamp,
333 crs: crs.to_string(),
334 metadata,
335 heading,
336 distance,
337 })
338}
339
340fn parse_feature(feature: &Feature, crs: &str, idx: usize) -> Result<Netelement, ProjectionError> {
342 let geometry = feature.geometry.as_ref().ok_or_else(|| {
344 ProjectionError::InvalidGeometry(format!("Feature {} missing geometry", idx))
345 })?;
346
347 let linestring = match &geometry.value {
349 Value::LineString(coords) => {
350 let geo_coords: Vec<Coord<f64>> = coords
351 .iter()
352 .map(|pos| Coord {
353 x: pos[0],
354 y: pos[1],
355 })
356 .collect();
357 LineString::from(geo_coords)
358 }
359 Value::MultiLineString(lines) => {
360 if lines.is_empty() {
362 return Err(ProjectionError::InvalidGeometry(format!(
363 "Feature {} has empty MultiLineString",
364 idx
365 )));
366 }
367 let geo_coords: Vec<Coord<f64>> = lines[0]
368 .iter()
369 .map(|pos| Coord {
370 x: pos[0],
371 y: pos[1],
372 })
373 .collect();
374 LineString::from(geo_coords)
375 }
376 _ => {
377 return Err(ProjectionError::InvalidGeometry(format!(
378 "Feature {} must have LineString or MultiLineString geometry",
379 idx
380 )))
381 }
382 };
383
384 let id = if let Some(props) = &feature.properties {
386 if let Some(id_value) = props.get("id") {
387 id_value
388 .as_str()
389 .map(|s| s.to_string())
390 .or_else(|| id_value.as_i64().map(|i| i.to_string()))
391 .ok_or_else(|| {
392 ProjectionError::GeoJsonError(format!(
393 "Feature {} has invalid 'id' property type",
394 idx
395 ))
396 })?
397 } else {
398 return Err(ProjectionError::GeoJsonError(format!(
399 "Feature {} missing required 'id' property",
400 idx
401 )));
402 }
403 } else {
404 return Err(ProjectionError::GeoJsonError(format!(
405 "Feature {} missing properties object",
406 idx
407 )));
408 };
409
410 Netelement::new(id, linestring, crs.to_string())
411}
412
413pub fn parse_netrelations_geojson(path: &str) -> Result<Vec<NetRelation>, ProjectionError> {
462 let geojson_str = fs::read_to_string(path)?;
464
465 let geojson = geojson_str
467 .parse::<GeoJson>()
468 .map_err(|e| ProjectionError::GeoJsonError(format!("Failed to parse GeoJSON: {}", e)))?;
469
470 let feature_collection = match geojson {
472 GeoJson::FeatureCollection(fc) => fc,
473 _ => {
474 return Err(ProjectionError::GeoJsonError(
475 "GeoJSON must be a FeatureCollection".to_string(),
476 ))
477 }
478 };
479
480 let mut netrelations = Vec::new();
482
483 for (idx, feature) in feature_collection.features.iter().enumerate() {
484 if let Some(props) = &feature.properties {
486 if let Some(feature_type) = props.get("type") {
487 if feature_type.as_str() == Some("netrelation") {
488 let netrelation = parse_netrelation_feature(feature, idx)?;
489 netrelations.push(netrelation);
490 }
491 }
492 }
493 }
494
495 Ok(netrelations)
496}
497
498fn parse_netrelation_feature(
500 feature: &Feature,
501 idx: usize,
502) -> Result<NetRelation, ProjectionError> {
503 let properties = feature.properties.as_ref().ok_or_else(|| {
505 ProjectionError::GeoJsonError(format!("Netrelation feature {} missing properties", idx))
506 })?;
507
508 let id = properties
510 .get("id")
511 .and_then(|v| v.as_str())
512 .ok_or_else(|| {
513 ProjectionError::GeoJsonError(format!(
514 "Netrelation feature {} missing 'id' property",
515 idx
516 ))
517 })?
518 .to_string();
519
520 let netelement_a = properties
522 .get("netelementA")
523 .or_else(|| properties.get("from"))
524 .and_then(|v| v.as_str())
525 .ok_or_else(|| {
526 ProjectionError::GeoJsonError(format!(
527 "Netrelation feature {} missing 'netelementA' or 'from' property",
528 idx
529 ))
530 })?
531 .to_string();
532
533 let netelement_b = properties
535 .get("netelementB")
536 .or_else(|| properties.get("to"))
537 .and_then(|v| v.as_str())
538 .ok_or_else(|| {
539 ProjectionError::GeoJsonError(format!(
540 "Netrelation feature {} missing 'netelementB' or 'to' property",
541 idx
542 ))
543 })?
544 .to_string();
545
546 let position_on_a = properties
548 .get("positionOnA")
549 .and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64)))
550 .ok_or_else(|| {
551 ProjectionError::GeoJsonError(format!(
552 "Netrelation feature {} missing or invalid 'positionOnA' property",
553 idx
554 ))
555 })? as u8;
556
557 let position_on_b = properties
559 .get("positionOnB")
560 .and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64)))
561 .ok_or_else(|| {
562 ProjectionError::GeoJsonError(format!(
563 "Netrelation feature {} missing or invalid 'positionOnB' property",
564 idx
565 ))
566 })? as u8;
567
568 let navigability_str = properties
570 .get("navigability")
571 .and_then(|v| v.as_str())
572 .ok_or_else(|| {
573 ProjectionError::GeoJsonError(format!(
574 "Netrelation feature {} missing 'navigability' property",
575 idx
576 ))
577 })?;
578
579 let (navigable_forward, navigable_backward) = match navigability_str.to_lowercase().as_str() {
580 "both" => (true, true),
581 "ab" => (true, false),
582 "ba" => (false, true),
583 "none" => (false, false),
584 _ => return Err(ProjectionError::GeoJsonError(
585 format!("Netrelation feature {}: invalid navigability value '{}' (expected: both, AB, BA, or none)", idx, navigability_str)
586 )),
587 };
588
589 let netrelation = NetRelation::new(
591 id,
592 netelement_a,
593 netelement_b,
594 position_on_a,
595 position_on_b,
596 navigable_forward,
597 navigable_backward,
598 )?;
599
600 Ok(netrelation)
601}
602
603pub fn write_network_geojson(
611 netelements: &[Netelement],
612 netrelations: &[NetRelation],
613 writer: &mut impl std::io::Write,
614) -> Result<(), ProjectionError> {
615 use geojson::{Feature, FeatureCollection, Geometry, Value};
616 use serde_json::{Map, Value as JsonValue};
617
618 let mut features: Vec<Feature> = Vec::with_capacity(netelements.len() + netrelations.len());
619
620 for ne in netelements {
621 let coords: Vec<Vec<f64>> = ne.geometry.coords().map(|c| vec![c.x, c.y]).collect();
622 let geometry = Geometry::new(Value::LineString(coords));
623
624 let mut properties = Map::new();
625 properties.insert("id".to_string(), JsonValue::from(ne.id.clone()));
626 properties.insert("crs".to_string(), JsonValue::from(ne.crs.clone()));
627
628 features.push(Feature {
629 bbox: None,
630 geometry: Some(geometry),
631 id: None,
632 properties: Some(properties),
633 foreign_members: None,
634 });
635 }
636
637 for nr in netrelations {
638 let navigability = match (nr.navigable_forward, nr.navigable_backward) {
639 (true, true) => "both",
640 (true, false) => "AB",
641 (false, true) => "BA",
642 (false, false) => "none",
643 };
644
645 let mut properties = Map::new();
646 properties.insert("type".to_string(), JsonValue::from("netrelation"));
647 properties.insert("id".to_string(), JsonValue::from(nr.id.clone()));
648 properties.insert(
649 "netelementA".to_string(),
650 JsonValue::from(nr.from_netelement_id.clone()),
651 );
652 properties.insert(
653 "netelementB".to_string(),
654 JsonValue::from(nr.to_netelement_id.clone()),
655 );
656 properties.insert(
657 "positionOnA".to_string(),
658 JsonValue::from(nr.position_on_a as u64),
659 );
660 properties.insert(
661 "positionOnB".to_string(),
662 JsonValue::from(nr.position_on_b as u64),
663 );
664 properties.insert("navigability".to_string(), JsonValue::from(navigability));
665
666 features.push(Feature {
667 bbox: None,
668 geometry: None,
669 id: None,
670 properties: Some(properties),
671 foreign_members: None,
672 });
673 }
674
675 let feature_collection = FeatureCollection {
676 bbox: None,
677 features,
678 foreign_members: None,
679 };
680
681 let json = serde_json::to_string_pretty(&feature_collection).map_err(|e| {
682 ProjectionError::GeoJsonError(format!("Failed to serialize network GeoJSON: {}", e))
683 })?;
684
685 writer.write_all(json.as_bytes())?;
686 Ok(())
687}
688
689pub fn write_geojson(
691 positions: &[ProjectedPosition],
692 writer: &mut impl std::io::Write,
693) -> Result<(), ProjectionError> {
694 use geojson::{Feature, FeatureCollection, Geometry, Value};
695 use serde_json::{Map, Value as JsonValue};
696
697 let mut features = Vec::new();
698
699 for pos in positions {
700 let geometry = Geometry::new(Value::Point(vec![
702 pos.projected_coords.x(),
703 pos.projected_coords.y(),
704 ]));
705
706 let mut properties = Map::new();
708 properties.insert(
709 "original_lat".to_string(),
710 JsonValue::from(pos.original.latitude),
711 );
712 properties.insert(
713 "original_lon".to_string(),
714 JsonValue::from(pos.original.longitude),
715 );
716 properties.insert(
717 "original_time".to_string(),
718 JsonValue::from(pos.original.timestamp.to_rfc3339()),
719 );
720 properties.insert(
721 "netelement_id".to_string(),
722 JsonValue::from(pos.netelement_id.clone()),
723 );
724 properties.insert(
725 "measure_meters".to_string(),
726 JsonValue::from(pos.measure_meters),
727 );
728 properties.insert(
729 "projection_distance_meters".to_string(),
730 JsonValue::from(pos.projection_distance_meters),
731 );
732 properties.insert("crs".to_string(), JsonValue::from(pos.crs.clone()));
733
734 for (key, value) in &pos.original.metadata {
736 properties.insert(format!("original_{}", key), JsonValue::from(value.clone()));
737 }
738
739 let feature = Feature {
740 bbox: None,
741 geometry: Some(geometry),
742 id: None,
743 properties: Some(properties),
744 foreign_members: None,
745 };
746
747 features.push(feature);
748 }
749
750 let feature_collection = FeatureCollection {
751 bbox: None,
752 features,
753 foreign_members: None,
754 };
755
756 let json = serde_json::to_string_pretty(&feature_collection).map_err(|e| {
757 ProjectionError::GeoJsonError(format!("Failed to serialize GeoJSON: {}", e))
758 })?;
759
760 writer.write_all(json.as_bytes())?;
761 Ok(())
762}
763
764pub fn parse_trainpath_geojson(path: &str) -> Result<crate::models::TrainPath, ProjectionError> {
777 use crate::models::AssociatedNetElement;
778
779 let geojson_str = fs::read_to_string(path)?;
780 let geojson = geojson_str.parse::<GeoJson>().map_err(|e| {
781 ProjectionError::GeoJsonError(format!("Failed to parse TrainPath GeoJSON: {}", e))
782 })?;
783
784 let fc = match geojson {
785 GeoJson::FeatureCollection(fc) => fc,
786 _ => {
787 return Err(ProjectionError::GeoJsonError(
788 "TrainPath GeoJSON must be a FeatureCollection".to_string(),
789 ))
790 }
791 };
792
793 let (overall_probability, calculated_at) = fc
796 .foreign_members
797 .as_ref()
798 .and_then(|fm| fm.get("properties"))
799 .and_then(|v| v.as_object())
800 .map(|props| {
801 let prob = props.get("overall_probability").and_then(|v| v.as_f64());
802 let calc_at = props
803 .get("calculated_at")
804 .and_then(|v| v.as_str())
805 .and_then(|s| parse_timestamp_flexible(s).ok())
806 .map(|dt| dt.with_timezone(&chrono::Utc));
807 (prob, calc_at)
808 })
809 .unwrap_or((None, None));
810
811 let mut segments = Vec::new();
812
813 for (idx, feature) in fc.features.iter().enumerate() {
814 let props = feature.properties.as_ref().ok_or_else(|| {
815 ProjectionError::GeoJsonError(format!("TrainPath feature {} missing properties", idx))
816 })?;
817
818 macro_rules! get_str {
819 ($key:expr) => {
820 props
821 .get($key)
822 .and_then(|v| v.as_str())
823 .map(|s| s.to_string())
824 .ok_or_else(|| {
825 ProjectionError::GeoJsonError(format!(
826 "TrainPath feature {} missing or invalid '{}' property",
827 idx, $key
828 ))
829 })
830 };
831 }
832 macro_rules! get_f64 {
833 ($key:expr) => {
834 props.get($key).and_then(|v| v.as_f64()).ok_or_else(|| {
835 ProjectionError::GeoJsonError(format!(
836 "TrainPath feature {} missing or invalid '{}' property",
837 idx, $key
838 ))
839 })
840 };
841 }
842 macro_rules! get_usize {
843 ($key:expr) => {
844 props
845 .get($key)
846 .and_then(|v| v.as_u64())
847 .map(|v| v as usize)
848 .ok_or_else(|| {
849 ProjectionError::GeoJsonError(format!(
850 "TrainPath feature {} missing or invalid '{}' property",
851 idx, $key
852 ))
853 })
854 };
855 }
856
857 let segment = AssociatedNetElement::new(
858 get_str!("netelement_id")?,
859 get_f64!("probability")?,
860 get_f64!("start_intrinsic")?,
861 get_f64!("end_intrinsic")?,
862 get_usize!("gnss_start_index")?,
863 get_usize!("gnss_end_index")?,
864 )?;
865
866 segments.push(segment);
867 }
868
869 let overall_prob = overall_probability.unwrap_or_else(|| {
870 if segments.is_empty() {
871 1.0
872 } else {
873 let sum: f64 = segments.iter().map(|s| s.probability).sum();
874 sum / segments.len() as f64
875 }
876 });
877
878 crate::models::TrainPath::new(segments, overall_prob, calculated_at, None)
879}
880
881pub fn write_trainpath_geojson(
920 train_path: &crate::models::TrainPath,
921 netelements: &std::collections::HashMap<String, Netelement>,
922 writer: &mut impl std::io::Write,
923) -> Result<(), ProjectionError> {
924 use geojson::{Feature, FeatureCollection, Geometry, Value};
925 use serde_json::{Map, Value as JsonValue};
926
927 let mut features = Vec::new();
928
929 for segment in &train_path.segments {
931 let netelement = netelements.get(&segment.netelement_id).ok_or_else(|| {
933 ProjectionError::InvalidGeometry(format!(
934 "Netelement {} not found in provided map",
935 segment.netelement_id
936 ))
937 })?;
938
939 let coords: Vec<Vec<f64>> = netelement
943 .geometry
944 .points()
945 .map(|point| vec![point.x(), point.y()])
946 .collect();
947
948 let geometry = Geometry::new(Value::LineString(coords));
949
950 let mut properties = Map::new();
952 properties.insert(
953 "netelement_id".to_string(),
954 JsonValue::from(segment.netelement_id.clone()),
955 );
956 properties.insert(
957 "probability".to_string(),
958 JsonValue::from(segment.probability),
959 );
960 properties.insert(
961 "start_intrinsic".to_string(),
962 JsonValue::from(segment.start_intrinsic),
963 );
964 properties.insert(
965 "end_intrinsic".to_string(),
966 JsonValue::from(segment.end_intrinsic),
967 );
968 properties.insert(
969 "gnss_start_index".to_string(),
970 JsonValue::from(segment.gnss_start_index as i64),
971 );
972 properties.insert(
973 "gnss_end_index".to_string(),
974 JsonValue::from(segment.gnss_end_index as i64),
975 );
976
977 let feature = Feature {
978 bbox: None,
979 geometry: Some(geometry),
980 id: None,
981 properties: Some(properties),
982 foreign_members: None,
983 };
984
985 features.push(feature);
986 }
987
988 let mut fc_properties = Map::new();
990 fc_properties.insert(
991 "overall_probability".to_string(),
992 JsonValue::from(train_path.overall_probability),
993 );
994
995 if let Some(calculated_at) = &train_path.calculated_at {
996 fc_properties.insert(
997 "calculated_at".to_string(),
998 JsonValue::from(calculated_at.to_rfc3339()),
999 );
1000 }
1001
1002 if let Some(metadata) = &train_path.metadata {
1004 fc_properties.insert(
1005 "distance_scale".to_string(),
1006 JsonValue::from(metadata.distance_scale),
1007 );
1008 fc_properties.insert(
1009 "heading_scale".to_string(),
1010 JsonValue::from(metadata.heading_scale),
1011 );
1012 fc_properties.insert(
1013 "cutoff_distance".to_string(),
1014 JsonValue::from(metadata.cutoff_distance),
1015 );
1016 fc_properties.insert(
1017 "heading_cutoff".to_string(),
1018 JsonValue::from(metadata.heading_cutoff),
1019 );
1020 fc_properties.insert(
1021 "probability_threshold".to_string(),
1022 JsonValue::from(metadata.probability_threshold),
1023 );
1024 if let Some(resampling_dist) = metadata.resampling_distance {
1025 fc_properties.insert(
1026 "resampling_distance".to_string(),
1027 JsonValue::from(resampling_dist),
1028 );
1029 }
1030 fc_properties.insert(
1031 "fallback_mode".to_string(),
1032 JsonValue::from(metadata.fallback_mode),
1033 );
1034 fc_properties.insert(
1035 "candidate_paths_evaluated".to_string(),
1036 JsonValue::from(metadata.candidate_paths_evaluated as i64),
1037 );
1038 fc_properties.insert(
1039 "bidirectional_path".to_string(),
1040 JsonValue::from(metadata.bidirectional_path),
1041 );
1042 }
1043
1044 let mut foreign_members = Map::new();
1045 foreign_members.insert("properties".to_string(), JsonValue::Object(fc_properties));
1046
1047 let feature_collection = FeatureCollection {
1048 bbox: None,
1049 features,
1050 foreign_members: Some(foreign_members),
1051 };
1052
1053 let json = serde_json::to_string_pretty(&feature_collection).map_err(|e| {
1054 ProjectionError::GeoJsonError(format!("Failed to serialize TrainPath GeoJSON: {}", e))
1055 })?;
1056
1057 writer.write_all(json.as_bytes())?;
1058 Ok(())
1059}
1060
1061#[cfg(test)]
1062mod tests;
1063
1064pub mod detections;