diff --git a/collisions/src/lib.rs b/collisions/src/lib.rs index 765ed57697..3b5fae1c4c 100644 --- a/collisions/src/lib.rs +++ b/collisions/src/lib.rs @@ -34,7 +34,7 @@ pub struct Collision { /// A simple ranking for how severe the collision was. Different agencies use different /// classification systems, each of which likely has their own nuance and bias. This is /// deliberately simplified. -#[derive(Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum Severity { Slight, Serious, diff --git a/game/src/devtools/collisions.rs b/game/src/devtools/collisions.rs index 7444e4dea7..566198c7e4 100644 --- a/game/src/devtools/collisions.rs +++ b/game/src/devtools/collisions.rs @@ -1,9 +1,10 @@ use abstutil::{prettyprint_usize, Counter}; -use collisions::CollisionDataset; -use geom::{Distance, FindClosest, Pt2D}; +use collisions::{CollisionDataset, Severity}; +use geom::{Circle, Distance, Duration, FindClosest, Pt2D}; +use map_model::{IntersectionID, RoadID}; use widgetry::{ - Btn, Drawable, EventCtx, GfxCtx, HorizontalAlignment, Line, Outcome, Panel, State, - VerticalAlignment, Widget, + Btn, Checkbox, Choice, Color, Drawable, EventCtx, GeomBatch, GfxCtx, HorizontalAlignment, Line, + Outcome, Panel, Slider, State, TextExt, VerticalAlignment, Widget, }; use crate::app::App; @@ -12,21 +13,150 @@ use crate::game::Transition; use crate::helpers::ID; pub struct CollisionsViewer { + data: CollisionDataset, + dataviz: Dataviz, panel: Panel, - unzoomed: Drawable, - zoomed: Drawable, } impl CollisionsViewer { pub fn new(ctx: &mut EventCtx, app: &App) -> Box> { let map = &app.primary.map; - let dataset: CollisionDataset = - ctx.loading_screen("load collision data", |_, mut timer| { - abstutil::read_binary( - abstutil::path(format!("input/{}/collisions.bin", map.get_city_name())), - &mut timer, - ) + let data = ctx.loading_screen("load collision data", |_, mut timer| { + let mut all: CollisionDataset = abstutil::read_binary( + abstutil::path(format!("input/{}/collisions.bin", map.get_city_name())), + &mut timer, + ); + all.collisions.retain(|c| { + map.get_boundary_polygon() + .contains_pt(Pt2D::from_gps(c.location, map.get_gps_bounds())) }); + all + }); + + let filters = Filters::new(); + let indices = filters.apply(&data); + let count = indices.len(); + let dataviz = Dataviz::aggregated(ctx, app, &data, indices); + + Box::new(CollisionsViewer { + panel: Panel::new(Widget::col(vec![ + Widget::row(vec![ + Line("Collisions viewer").small_heading().draw(ctx), + Btn::close(ctx), + ]), + format!("{} collisions", prettyprint_usize(count)) + .draw_text(ctx) + .named("count"), + Filters::to_controls(ctx).named("controls"), + ])) + .aligned(HorizontalAlignment::Right, VerticalAlignment::Top) + .build(ctx), + data, + dataviz, + }) + } +} + +#[derive(PartialEq)] +struct Filters { + show_individual: bool, + time_range: (Duration, Duration), + severity: Option, +} + +impl Filters { + fn new() -> Filters { + Filters { + show_individual: false, + time_range: (Duration::ZERO, Duration::hours(24)), + severity: None, + } + } + + /// Returns the indices of all matching collisions + fn apply(&self, data: &CollisionDataset) -> Vec { + let mut indices = Vec::new(); + for (idx, c) in data.collisions.iter().enumerate() { + if c.time < self.time_range.0 || c.time > self.time_range.1 { + continue; + } + if self.severity.map(|s| s != c.severity).unwrap_or(false) { + continue; + } + indices.push(idx); + } + indices + } + + fn to_controls(ctx: &mut EventCtx) -> Widget { + Widget::col(vec![ + Checkbox::toggle( + ctx, + "individual / aggregated", + "individual", + "aggregated", + None, + false, + ), + Widget::row(vec![ + "Between:".draw_text(ctx).margin_right(20), + Slider::area(ctx, 0.1 * ctx.canvas.window_width, 0.0).named("time1"), + ]), + Widget::row(vec![ + "and:".draw_text(ctx).margin_right(20), + Slider::area(ctx, 0.1 * ctx.canvas.window_width, 1.0).named("time2"), + ]), + Widget::row(vec![ + "Severity:".draw_text(ctx).margin_right(20), + Widget::dropdown( + ctx, + "severity", + None, + vec![ + Choice::new("any", None), + Choice::new("slight", Some(Severity::Slight)), + Choice::new("serious", Some(Severity::Serious)), + Choice::new("fatal", Some(Severity::Fatal)), + ], + ), + ]), + ]) + } + + fn from_controls(panel: &Panel) -> Filters { + let end_of_day = Duration::hours(24); + Filters { + show_individual: panel.is_checked("individual / aggregated"), + time_range: ( + end_of_day * panel.slider("time1").get_percent(), + end_of_day * panel.slider("time2").get_percent(), + ), + severity: panel.dropdown_value("severity"), + } + } +} + +enum Dataviz { + Individual { + draw_all_circles: Drawable, + hitboxes: Vec<(Circle, usize)>, + }, + Aggregated { + unzoomed: Drawable, + zoomed: Drawable, + per_road: Counter, + per_intersection: Counter, + }, +} + +impl Dataviz { + fn aggregated( + ctx: &mut EventCtx, + app: &App, + data: &CollisionDataset, + indices: Vec, + ) -> Dataviz { + let map = &app.primary.map; // Match each collision to the nearest road and intersection let mut closest: FindClosest = FindClosest::new(map.get_bounds()); @@ -41,7 +171,8 @@ impl CollisionsViewer { let mut per_road = Counter::new(); let mut per_intersection = Counter::new(); let mut unsnapped = 0; - for collision in dataset.collisions { + for idx in indices { + let collision = &data.collisions[idx]; // Search up to 10m away if let Some((id, _)) = closest.closest_pt( Pt2D::from_gps(collision.location, map.get_gps_bounds()), @@ -70,27 +201,47 @@ impl CollisionsViewer { // Color roads and intersections using the counts let mut colorer = ColorNetwork::new(app); // TODO We should use some scale for both! - colorer.pct_roads(per_road, &app.cs.good_to_bad_red); - colorer.pct_intersections(per_intersection, &app.cs.good_to_bad_red); + colorer.pct_roads(per_road.clone(), &app.cs.good_to_bad_red); + colorer.pct_intersections(per_intersection.clone(), &app.cs.good_to_bad_red); let (unzoomed, zoomed) = colorer.build(ctx); - Box::new(CollisionsViewer { + Dataviz::Aggregated { unzoomed, zoomed, - panel: Panel::new(Widget::col(vec![Widget::row(vec![ - Line("Collisions viewer").small_heading().draw(ctx), - Btn::close(ctx), - ])])) - .aligned(HorizontalAlignment::Right, VerticalAlignment::Top) - .build(ctx), - }) + per_road, + per_intersection, + } + } + + fn individual( + ctx: &mut EventCtx, + app: &App, + data: &CollisionDataset, + indices: Vec, + ) -> Dataviz { + let mut hitboxes = Vec::new(); + let mut batch = GeomBatch::new(); + for idx in indices { + let collision = &data.collisions[idx]; + let circle = Circle::new( + Pt2D::from_gps(collision.location, app.primary.map.get_gps_bounds()), + Distance::meters(5.0), + ); + batch.push(Color::RED, circle.to_polygon()); + hitboxes.push((circle, idx)); + } + Dataviz::Individual { + hitboxes, + draw_all_circles: ctx.upload(batch), + } } } impl State for CollisionsViewer { - fn event(&mut self, ctx: &mut EventCtx, _: &mut App) -> Transition { + fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Transition { ctx.canvas_movement(); + let old_filters = Filters::from_controls(&self.panel); match self.panel.event(ctx) { Outcome::Clicked(x) => match x.as_ref() { "close" => { @@ -100,15 +251,44 @@ impl State for CollisionsViewer { }, _ => {} } + // TODO Should fiddling with sliders produce Outcome::Changed? + let filters = Filters::from_controls(&self.panel); + if filters != old_filters { + let indices = filters.apply(&self.data); + let count = indices.len(); + self.dataviz = if filters.show_individual { + Dataviz::individual(ctx, app, &self.data, indices) + } else { + Dataviz::aggregated(ctx, app, &self.data, indices) + }; + let count = format!("{} collisions", prettyprint_usize(count)) + .draw_text(ctx) + .named("count"); + self.panel.replace(ctx, "count", count); + } Transition::Keep } fn draw(&self, g: &mut GfxCtx, app: &App) { - if g.canvas.cam_zoom < app.opts.min_zoom_for_detail { - g.redraw(&self.unzoomed); - } else { - g.redraw(&self.zoomed); + match self.dataviz { + Dataviz::Aggregated { + ref unzoomed, + ref zoomed, + .. + } => { + if g.canvas.cam_zoom < app.opts.min_zoom_for_detail { + g.redraw(unzoomed); + } else { + g.redraw(zoomed); + } + } + Dataviz::Individual { + ref draw_all_circles, + .. + } => { + g.redraw(draw_all_circles); + } } self.panel.draw(g); }