use crate::model::RouteInfo; use anyhow::{Context, Result}; use geo::{ Distance, Euclidean, Geodesic, InterpolateLine, Length, LineLocatePoint, LineString, Point, Simplify, }; use std::collections::{HashMap, HashSet}; pub type Polyline = Vec<(f32, f32)>; pub type ShapesByLine = HashMap>; pub type ShapeIndex = HashMap<(String, String), Polyline>; pub fn parse(zip_bytes: &[u8], routes: &HashMap, RouteInfo>) -> Result { let mut route_id_to_line: HashMap = routes.values().map(|r| (r.route_id.clone(), r.line.clone())).collect(); let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).context("gtfs zip")?; if let Ok(file) = archive.by_name("routes.txt") { let mut reader = csv::Reader::from_reader(file); let headers = reader.headers()?.clone(); let col = |name: &str| headers.iter().position(|h| h == name); if let (Some(id_col), Some(short_col)) = (col("route_id"), col("route_short_name")) { for record in reader.records() { let record = record?; let short = &record[short_col]; if routes.contains_key(short) { route_id_to_line.insert(record[id_col].to_string(), short.to_string()); } } } } let mut shape_ids_by_line: HashMap> = HashMap::new(); { let file = archive.by_name("trips.txt").context("trips.txt missing")?; let mut reader = csv::Reader::from_reader(file); let headers = reader.headers()?.clone(); let col = |name: &str| headers.iter().position(|h| h == name); let (Some(route_col), Some(shape_col)) = (col("route_id"), col("shape_id")) else { anyhow::bail!("trips.txt header mismatch"); }; for record in reader.records() { let record = record?; let route_id = &record[route_col]; let shape_id = &record[shape_col]; if shape_id.is_empty() { continue; } let Some(line) = route_id_to_line.get(route_id) else { continue; }; shape_ids_by_line.entry(line.clone()).or_default().insert(shape_id.to_string()); } } let known_shape_ids: HashSet<&str> = shape_ids_by_line.values().flatten().map(|s| s.as_str()).collect(); let mut points_by_shape: HashMap> = HashMap::new(); { let file = archive.by_name("shapes.txt").context("shapes.txt missing")?; let mut reader = csv::Reader::from_reader(file); let headers = reader.headers()?.clone(); let col = |name: &str| headers.iter().position(|h| h == name); let (Some(id_col), Some(lat_col), Some(lon_col), Some(seq_col)) = ( col("shape_id"), col("shape_pt_lat"), col("shape_pt_lon"), col("shape_pt_sequence"), ) else { anyhow::bail!("shapes.txt header mismatch"); }; for record in reader.records() { let record = record?; let shape_id = &record[id_col]; if !known_shape_ids.contains(shape_id) { continue; } let (Ok(lat), Ok(lon), Ok(seq)) = ( record[lat_col].parse::(), record[lon_col].parse::(), record[seq_col].parse::(), ) else { continue; }; points_by_shape.entry(shape_id.to_string()).or_default().push((seq, lat, lon)); } } for points in points_by_shape.values_mut() { points.sort_by_key(|p| p.0); } let mut result: ShapesByLine = HashMap::new(); for (line, shape_ids) in shape_ids_by_line { let mut shapes: Vec<(String, Polyline)> = shape_ids .into_iter() .filter_map(|sid| { let points = points_by_shape.get(&sid)?; Some((sid, points.iter().map(|(_, lat, lon)| (*lat, *lon)).collect())) }) .collect(); shapes.sort_by(|a, b| a.0.cmp(&b.0)); if !shapes.is_empty() { result.insert(line, shapes); } } Ok(result) } fn line_string(points: &[(f32, f32)]) -> LineString { points .iter() .map(|&(lat, lon)| (f64::from(lon), f64::from(lat))) .collect() } fn polyline(line: LineString) -> Polyline { line.0 .into_iter() .map(|point| (point.y as f32, point.x as f32)) .collect() } pub fn simplify(points: &[(f32, f32)], max_points: usize) -> Polyline { if max_points < 2 || points.len() <= max_points { return points.to_vec(); } let line = line_string(points); let mut lower = 0.0; let mut upper = Euclidean.length(&line).max(f64::EPSILON); let mut best = line.simplify(upper); for _ in 0..32 { let epsilon = (lower + upper) / 2.0; let candidate = line.simplify(epsilon); if candidate.0.len() <= max_points { upper = epsilon; best = candidate; } else { lower = epsilon; } } let mut result = polyline(best); result[0] = points[0]; let last = result.len() - 1; result[last] = *points.last().unwrap(); result } pub fn section( points: &[(f32, f32)], first: (f64, f64), last: (f64, f64), ) -> Option { if points.len() < 2 { return None; } let line = line_string(points); let first = line.line_locate_point(&Point::new(first.1, first.0))?; let last = line.line_locate_point(&Point::new(last.1, last.0))?; let (start, end) = if first <= last { (first, last) } else { (last, first) }; if start == end { return Some(points.to_vec()); } let length = Euclidean.length(&line); if length == 0.0 { return Some(points.to_vec()); } let start_point = Euclidean.point_at_ratio_from_start(&line, start)?; let end_point = Euclidean.point_at_ratio_from_start(&line, end)?; let mut result = vec![(start_point.y() as f32, start_point.x() as f32)]; let mut traversed = 0.0; for (index, segment) in line.lines().enumerate() { traversed += Euclidean.length(&segment); let ratio = traversed / length; if ratio > start && ratio < end { result.push(points[index + 1]); } } let end_point = (end_point.y() as f32, end_point.x() as f32); if result.last() != Some(&end_point) { result.push(end_point); } if result.len() < 2 || result.first() == result.last() { Some(points.to_vec()) } else { Some(result) } } pub fn for_terminus( candidates: &[(String, Polyline)], origin: Option<(f64, f64)>, terminus_lat: f64, terminus_lon: f64, ) -> Option<&Polyline> { let score = |points: &Polyline| { let &(last_lat, last_lon) = points.last().unwrap(); let end = Geodesic.distance( Point::new(terminus_lon, terminus_lat), Point::new(f64::from(last_lon), f64::from(last_lat)), ); let start = origin.map_or(0.0, |(origin_lat, origin_lon)| { let (first_lat, first_lon) = points[0]; Geodesic.distance( Point::new(origin_lon, origin_lat), Point::new(f64::from(first_lon), f64::from(first_lat)), ) }); end + start }; candidates .iter() .filter(|(_, points)| points.len() >= 2) .min_by(|left, right| score(&left.1).partial_cmp(&score(&right.1)).unwrap()) .map(|(_, points)| points) } #[cfg(test)] mod tests { use super::*; use crate::model::VehicleKind; use std::io::Write; fn route(line: &str, route_id: &str) -> RouteInfo { RouteInfo { line: line.to_string(), route_id: route_id.to_string(), kind: VehicleKind::Bus, color: "000000".into(), text_color: "FFFFFF".into(), } } fn gtfs_zip(trips: &str, shapes: &str) -> Vec { let mut buf = std::io::Cursor::new(Vec::new()); { let mut z = zip::ZipWriter::new(&mut buf); z.start_file::<_, ()>("trips.txt", zip::write::FileOptions::default()).unwrap(); z.write_all(trips.as_bytes()).unwrap(); z.start_file::<_, ()>("shapes.txt", zip::write::FileOptions::default()).unwrap(); z.write_all(shapes.as_bytes()).unwrap(); z.finish().unwrap(); } buf.into_inner() } const TRIPS: &str = "route_id,service_id,trip_id,shape_id\n\ R62,1,t1,S1\n\ R62,1,t2,S1\n\ R62,1,t3,S2\n\ RX,1,t4,S9\n"; const SHAPES: &str = "shape_id,shape_pt_lat,shape_pt_lon,shape_pt_sequence\n\ S1,50.80,4.30,2\n\ S1,50.81,4.31,1\n\ S1,50.82,4.32,3\n\ S2,50.90,4.40,1\n\ S2,50.91,4.41,2\n\ S9,51.00,4.50,1\n"; #[test] fn parse_groups_by_line_orders_by_sequence_and_filters_unknown_routes() { let mut routes = HashMap::new(); routes.insert("62".into(), route("62", "R62")); let zip = gtfs_zip(TRIPS, SHAPES); let by_line = parse(&zip, &routes).unwrap(); assert_eq!(by_line.len(), 1); let shapes = &by_line["62"]; assert_eq!(shapes.len(), 2); let s1 = shapes.iter().find(|(id, _)| id == "S1").unwrap(); assert_eq!( s1.1, vec![(50.81_f32, 4.31_f32), (50.80_f32, 4.30_f32), (50.82_f32, 4.32_f32)] ); let s2 = shapes.iter().find(|(id, _)| id == "S2").unwrap(); assert_eq!(s2.1, vec![(50.90_f32, 4.40_f32), (50.91_f32, 4.41_f32)]); } #[test] fn parse_dedupes_repeated_shape_ids_within_a_route() { let mut routes = HashMap::new(); routes.insert("62".into(), route("62", "R62")); let zip = gtfs_zip(TRIPS, SHAPES); let by_line = parse(&zip, &routes).unwrap(); let shapes = &by_line["62"]; assert_eq!(shapes.iter().filter(|(id, _)| id == "S1").count(), 1); } #[test] fn simplify_returns_all_points_within_budget() { let points = vec![(0.0, 0.0), (1.0, 1.0), (2.0, 0.0)]; let out = simplify(&points, 5); assert_eq!(out, points); } #[test] fn simplify_keeps_endpoints_and_respects_budget() { let points: Vec<(f32, f32)> = (0..20).map(|i| (i as f32, if i == 10 { 5.0 } else { 0.0 })).collect(); let out = simplify(&points, 4); assert!(out.len() <= 4); assert_eq!(out.first(), points.first()); assert_eq!(out.last(), points.last()); assert!(out.contains(&(10.0, 5.0))); } #[test] fn for_terminus_picks_the_nearer_ending_shape() { let candidates = vec![ ("near".to_string(), vec![(50.0, 4.0), (50.1, 4.1)]), ("far".to_string(), vec![(50.0, 4.0), (52.0, 6.0)]), ]; let picked = for_terminus(&candidates, None, 50.1, 4.1).unwrap(); assert_eq!(picked, &vec![(50.0, 4.0), (50.1, 4.1)]); } #[test] fn for_terminus_none_for_empty_candidates() { let candidates: Vec<(String, Vec<(f32, f32)>)> = Vec::new(); assert!(for_terminus(&candidates, None, 50.0, 4.0).is_none()); } #[test] fn section_locates_route_span_and_keeps_both_ends() { let points = vec![ (50.0, 4.0), (50.1, 4.1), (50.2, 4.2), (50.3, 4.3), ]; let section = section(&points, (50.1, 4.1), (50.2, 4.2)).unwrap(); assert_eq!(section.len(), 2); assert!((section[0].0 - 50.1).abs() < 1e-5); assert!((section[0].1 - 4.1).abs() < 1e-5); assert!((section[1].0 - 50.2).abs() < 1e-5); assert!((section[1].1 - 4.2).abs() < 1e-5); } } #[cfg(test)] mod repro { use super::*; use crate::model::VehicleKind; use std::io::Write; fn route(line: &str, route_id: &str) -> RouteInfo { RouteInfo { line: line.to_string(), route_id: route_id.to_string(), kind: VehicleKind::Bus, color: "000000".into(), text_color: "FFFFFF".into(), } } fn zip_with(files: &[(&str, &str)]) -> Vec { let mut buf = std::io::Cursor::new(Vec::new()); { let mut z = zip::ZipWriter::new(&mut buf); for (name, body) in files { z.start_file::<_, ()>(*name, zip::write::FileOptions::default()).unwrap(); z.write_all(body.as_bytes()).unwrap(); } z.finish().unwrap(); } buf.into_inner() } fn interp(a: (f32, f32), b: (f32, f32), n: usize) -> Vec<(f32, f32)> { (0..n) .map(|i| { let t = i as f32 / (n - 1) as f32; (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t) }) .collect() } fn shapes_csv(entries: &[(&str, &[(f32, f32)])]) -> String { let mut s = String::from("shape_id,shape_pt_lat,shape_pt_lon,shape_pt_sequence\n"); for (id, pts) in entries { for (i, (lat, lon)) in pts.iter().enumerate() { s.push_str(&format!("{id},{lat},{lon},{i}\n")); } } s } fn api_slice(poly: &[(f32, f32)], first: (f64, f64), last: (f64, f64)) -> Vec<(f32, f32)> { section(poly, first, last) .map(|section| simplify(§ion, 24)) .unwrap_or_default() } const ORIGIN_34: (f32, f32) = (50.8380, 4.3670); const SAINTE_ANNE: (f32, f32) = (50.8070, 4.4310); #[test] fn short_variant_must_not_collapse_the_strip_slice_to_one_point() { let stops = interp(ORIGIN_34, SAINTE_ANNE, 10); let full: Vec<(f32, f32)> = { let mut p = interp(ORIGIN_34, SAINTE_ANNE, 37); let l = p.last_mut().unwrap(); l.0 += 0.0003; p }; let short: Vec<(f32, f32)> = interp(stops[7], (SAINTE_ANNE.0 + 0.00005, SAINTE_ANNE.1), 9); let mut routes = std::collections::HashMap::new(); routes.insert("34".into(), route("34", "R34")); let zip = zip_with(&[ ("trips.txt", "route_id,service_id,trip_id,shape_id\nR34,1,t1,FULL\nR34,1,t2,SHORT\n"), ("shapes.txt", &shapes_csv(&[("FULL", &full), ("SHORT", &short)])), ]); let by_line = parse(&zip, &routes).unwrap(); let candidates = &by_line["34"]; let origin = Some((ORIGIN_34.0 as f64, ORIGIN_34.1 as f64)); let picked = for_terminus(candidates, origin, SAINTE_ANNE.0 as f64, SAINTE_ANNE.1 as f64).unwrap(); let stored = simplify(picked, 48); let strip_first = (stops[0].0 as f64, stops[0].1 as f64); let strip_last = (stops[5].0 as f64, stops[5].1 as f64); let slice = api_slice(&stored, strip_first, strip_last); assert!(slice.len() >= 2, "line 34 class: slice collapsed to {:?}", slice); } const ERASME: (f32, f32) = (50.8130, 4.2350); const HERRMANN: (f32, f32) = (50.9010, 4.4260); #[test] fn depot_spur_must_not_win_either_metro_terminus() { let stations = interp(ERASME, HERRMANN, 8); let dir_a: Vec<(f32, f32)> = { let mut p = interp(HERRMANN, ERASME, 30); p.last_mut().unwrap().0 += 0.00035; p }; let dir_b: Vec<(f32, f32)> = { let mut p = interp(ERASME, HERRMANN, 30); p.last_mut().unwrap().0 += 0.00035; p }; let spur_a = vec![ (ERASME.0 - 0.0035, ERASME.1 - 0.0075), (ERASME.0 - 0.0020, ERASME.1 - 0.0040), (ERASME.0 + 0.00007, ERASME.1), ]; let spur_b = vec![ (HERRMANN.0 + 0.0035, HERRMANN.1 + 0.0075), (HERRMANN.0 + 0.0020, HERRMANN.1 + 0.0040), (HERRMANN.0 + 0.00007, HERRMANN.1), ]; let mut routes = std::collections::HashMap::new(); routes.insert("5".into(), route("5", "R5")); let zip = zip_with(&[ ("trips.txt", "route_id,service_id,trip_id,shape_id\nR5,1,t1,DIRA\nR5,1,t2,DIRB\nR5,1,t3,SPURA\nR5,1,t4,SPURB\n"), ("shapes.txt", &shapes_csv(&[("DIRA", &dir_a), ("DIRB", &dir_b), ("SPURA", &spur_a), ("SPURB", &spur_b)])), ]); let by_line = parse(&zip, &routes).unwrap(); let candidates = &by_line["5"]; for (terminus, origin, strip_hi) in [(ERASME, HERRMANN, 2usize), (HERRMANN, ERASME, 5usize)] { let o = Some((origin.0 as f64, origin.1 as f64)); let picked = for_terminus(candidates, o, terminus.0 as f64, terminus.1 as f64).unwrap(); let stored = simplify(picked, 48); let f = stations[strip_hi.saturating_sub(2)]; let l = stations[strip_hi]; let slice = api_slice(&stored, (f.0 as f64, f.1 as f64), (l.0 as f64, l.1 as f64)); assert!( slice.len() >= 2, "metro 5 class at terminus {terminus:?}: slice collapsed to {:?}", slice ); } } #[test] fn line_with_duplicate_route_rows_still_gets_shapes() { let pts = interp((50.80, 4.30), (50.84, 4.36), 12); let mut routes = std::collections::HashMap::new(); routes.insert("81".into(), route("81", "R81_SEASONAL")); let zip = zip_with(&[ ( "routes.txt", "route_id,route_short_name,route_type\nR81,81,0\nR81_SEASONAL,81,0\n", ), ("trips.txt", "route_id,service_id,trip_id,shape_id\nR81,1,t1,SH81\n"), ("shapes.txt", &shapes_csv(&[("SH81", &pts)])), ]); let by_line = parse(&zip, &routes).unwrap(); assert!( by_line.contains_key("81"), "line 81 class: shapes lost to route_id dedup, got {:?}", by_line.keys().collect::>() ); } #[test] fn for_terminus_has_no_distance_bound_so_absence_is_not_a_sanity_rejection() { let candidates = vec![("far".to_string(), vec![(40.0_f32, 0.0_f32), (40.1, 0.1)])]; assert!(for_terminus(&candidates, None, 50.8, 4.4).is_some()); } }