Steps towards clicking cells in the problem matrix to explore trips with differences. #600

- Add optional clickable labels to DrawWithTooltips
- Wire up problem_matrix to remember the list of trips associated with
  each cell
- When clicking a cell, just open one arbitrary example trip
This commit is contained in:
Dustin Carlino 2021-07-17 13:55:01 -07:00
parent db435cd56d
commit 942f2292fc
8 changed files with 172 additions and 97 deletions

View File

@ -200,7 +200,7 @@ fn current_demand_body(ctx: &mut EventCtx, app: &App, id: IntersectionID) -> Wid
polygon.translate(-bounds.min_x, -bounds.min_y).scale(zoom),
);
let mut tooltips: Vec<(Polygon, Text)> = Vec::new();
let mut tooltips: Vec<(Polygon, Text, Option<String>)> = Vec::new();
let mut outlines = Vec::new();
for (pl, demand) in demand_per_movement {
let percent = (demand as f64) / (total_demand as f64);
@ -212,7 +212,7 @@ fn current_demand_body(ctx: &mut EventCtx, app: &App, id: IntersectionID) -> Wid
outlines.push(p);
}
batch.push(Color::hex("#A3A3A3"), arrow.clone());
tooltips.push((arrow, Text::from(prettyprint_usize(demand))));
tooltips.push((arrow, Text::from(prettyprint_usize(demand)), None));
}
batch.extend(Color::WHITE, outlines);

View File

@ -612,7 +612,7 @@ fn make_timeline(
// icons above them.
let mut batch = GeomBatch::new();
// And associate a tooltip with each rectangle segment
let mut tooltips: Vec<(Polygon, Text)> = Vec::new();
let mut tooltips: Vec<(Polygon, Text, Option<String>)> = Vec::new();
// How far along are we from previous segments?
let mut x1 = 0.0;
let rectangle_height = 15.0;
@ -660,6 +660,7 @@ fn make_timeline(
tooltips.push((
rectangle.clone(),
Text::from_multiline(tooltip.into_iter().map(Line).collect()),
None,
));
batch.push(

View File

@ -1,19 +1,21 @@
use std::collections::BTreeSet;
use std::collections::{BTreeSet, HashMap};
use std::fs::File;
use std::io::Write;
use anyhow::Result;
use map_gui::tools::PopupMsg;
use sim::TripMode;
use sim::{TripID, TripMode};
use widgetry::{EventCtx, GfxCtx, Image, Line, Outcome, Panel, State, TextExt, Toggle, Widget};
use super::trip_problems::{problem_matrix, ProblemType, TripProblemFilter};
use crate::app::{App, Transition};
use crate::sandbox::dashboards::generic_trip_table::open_trip_transition;
use crate::sandbox::dashboards::DashTab;
pub struct RiskSummaries {
panel: Panel,
trip_lookup: HashMap<String, Vec<TripID>>,
}
impl RiskSummaries {
@ -26,12 +28,33 @@ impl RiskSummaries {
modes: maplit::btreeset! { TripMode::Bike },
include_no_changes,
};
let ped_filter = Filter {
modes: maplit::btreeset! { TripMode::Walk },
include_no_changes,
};
let (arterial_problems, mut trip_lookup) = problem_matrix(
ctx,
app,
"arterials",
ped_filter.trip_problems(app, ProblemType::ArterialIntersectionCrossing),
);
let (complex_intersection_problems, lookup2) = problem_matrix(
ctx,
app,
"complex intersections",
bike_filter.trip_problems(app, ProblemType::ComplexIntersectionCrossing),
);
let (overtaking_problems, lookup3) = problem_matrix(
ctx,
app,
"overtakings",
bike_filter.trip_problems(app, ProblemType::OvertakeDesired),
);
// The keys won't overlap, due to the unique title of each matrix.
trip_lookup.extend(lookup2);
trip_lookup.extend(lookup3);
Box::new(RiskSummaries {
panel: Panel::new_builder(Widget::col(vec![
DashTab::RiskSummaries.picker(ctx, app),
@ -66,12 +89,7 @@ impl RiskSummaries {
.small_heading()
.into_widget(ctx)
.centered_horiz(),
problem_matrix(
ctx,
app,
&ped_filter
.trip_problems(app, ProblemType::ArterialIntersectionCrossing),
),
arterial_problems,
])
.section(ctx)],
)
@ -98,12 +116,7 @@ impl RiskSummaries {
.small_heading()
.into_widget(ctx)
.centered_horiz(),
problem_matrix(
ctx,
app,
&bike_filter
.trip_problems(app, ProblemType::ComplexIntersectionCrossing),
),
complex_intersection_problems,
])
.section(ctx),
Widget::col(vec![
@ -111,11 +124,7 @@ impl RiskSummaries {
.small_heading()
.into_widget(ctx)
.centered_horiz(),
problem_matrix(
ctx,
app,
&bike_filter.trip_problems(app, ProblemType::OvertakeDesired),
),
overtaking_problems,
])
.section(ctx),
],
@ -125,6 +134,7 @@ impl RiskSummaries {
]))
.exact_size_percent(90, 90)
.build(ctx),
trip_lookup,
})
}
}
@ -146,7 +156,10 @@ impl State<App> for RiskSummaries {
}
});
}
_ => unreachable!(),
x => {
// TODO Handle browsing multiple trips
return open_trip_transition(app, self.trip_lookup[x][0].0);
}
},
Outcome::Changed(_) => {
if let Some(t) = DashTab::RiskSummaries.transition(ctx, app, &self.panel) {

View File

@ -1,4 +1,4 @@
use std::collections::BTreeSet;
use std::collections::{BTreeSet, HashMap};
use std::fs::File;
use std::io::Write;
@ -7,7 +7,7 @@ use anyhow::Result;
use abstutil::prettyprint_usize;
use geom::{Distance, Duration, Polygon, Pt2D};
use map_gui::tools::PopupMsg;
use sim::TripMode;
use sim::{TripID, TripMode};
use widgetry::{
Choice, Color, CompareTimes, DrawWithTooltips, EventCtx, GeomBatch, GfxCtx, Line, Outcome,
Panel, State, Text, TextExt, Toggle, Widget,
@ -16,20 +16,20 @@ use widgetry::{
use super::trip_problems::{problem_matrix, ProblemType, TripProblemFilter};
use crate::app::{App, Transition};
use crate::common::color_for_mode;
use crate::sandbox::dashboards::generic_trip_table::open_trip_transition;
use crate::sandbox::dashboards::DashTab;
pub struct TravelTimes {
panel: Panel,
trip_lookup: HashMap<String, Vec<TripID>>,
}
impl TravelTimes {
pub fn new_state(ctx: &mut EventCtx, app: &App, filter: Filter) -> Box<dyn State<App>> {
Box::new(TravelTimes {
panel: TravelTimes::make_panel(ctx, app, filter),
})
Box::new(TravelTimes::new(ctx, app, filter))
}
fn make_panel(ctx: &mut EventCtx, app: &App, filter: Filter) -> Panel {
fn new(ctx: &mut EventCtx, app: &App, filter: Filter) -> TravelTimes {
let mut filters = vec!["Filters".text_widget(ctx)];
for mode in TripMode::all() {
filters.push(Toggle::colored_checkbox(
@ -48,7 +48,14 @@ impl TravelTimes {
.align_bottom(),
);
Panel::new_builder(Widget::col(vec![
let (problems, trip_lookup) = problem_matrix(
ctx,
app,
"delays",
filter.trip_problems(app, ProblemType::IntersectionDelay),
);
let panel = Panel::new_builder(Widget::col(vec![
DashTab::TravelTimes.picker(ctx, app),
Widget::row(vec![
Widget::col(filters).section(ctx),
@ -92,19 +99,16 @@ impl TravelTimes {
filter.include_no_changes(),
),
]),
problem_matrix(
ctx,
app,
&filter.trip_problems(app, ProblemType::IntersectionDelay),
)
.margin_left(32),
problems.margin_left(32),
])
.section(ctx),
]),
]),
]))
.exact_size_percent(90, 90)
.build(ctx)
.build(ctx);
TravelTimes { panel, trip_lookup }
}
}
@ -125,7 +129,10 @@ impl State<App> for TravelTimes {
});
}
"close" => Transition::Pop,
_ => unreachable!(),
x => {
// TODO Handle browsing multiple trips
return open_trip_transition(app, self.trip_lookup[x][0].0);
}
},
Outcome::Changed(_) => {
if let Some(t) = DashTab::TravelTimes.transition(ctx, app, &self.panel) {
@ -142,9 +149,9 @@ impl State<App> for TravelTimes {
filter.modes.insert(m);
}
}
let mut new_panel = TravelTimes::make_panel(ctx, app, filter);
new_panel.restore(ctx, &self.panel);
self.panel = new_panel;
let mut new = TravelTimes::new(ctx, app, filter);
new.panel.restore(ctx, &self.panel);
*self = new;
Transition::Keep
}
_ => Transition::Keep,
@ -462,6 +469,7 @@ fn contingency_table(ctx: &mut EventCtx, app: &App, filter: &Filter) -> Widget {
))
.fg(Color::hex("#72CE36")),
]),
None,
));
}
if num_loss > 0 {
@ -496,6 +504,7 @@ fn contingency_table(ctx: &mut EventCtx, app: &App, filter: &Filter) -> Widget {
Line(format!("Lost {} in total", total_loss.to_rounded_string(1)))
.fg(Color::hex("#EB3223")),
]),
None,
));
}
x1 += bar_width;

View File

@ -1,10 +1,11 @@
use std::cmp::Ordering;
use std::collections::HashMap;
use std::fmt::Display;
use abstutil::{abbreviated_format, prettyprint_usize};
use geom::{Angle, Distance, Duration, Line, Polygon, Pt2D, Time};
use map_gui::tools::ColorScale;
use sim::{Problem, TripMode};
use sim::{Problem, TripID, TripMode};
use widgetry::{Color, DrawWithTooltips, GeomBatch, GeomBatchStack, StackAlignment, Text, Widget};
use crate::{App, EventCtx};
@ -54,10 +55,15 @@ pub trait TripProblemFilter {
fn include_no_changes(&self) -> bool;
// Returns:
// 1) trip duration after changes
// 2) difference in number of matching problems, where positive means MORE problems after
// 1) trip ID
// 2) trip duration after changes
// 3) difference in number of matching problems, where positive means MORE problems after
// changes
fn trip_problems(&self, app: &App, problem_type: ProblemType) -> Vec<(Duration, isize)> {
fn trip_problems(
&self,
app: &App,
problem_type: ProblemType,
) -> Vec<(TripID, Duration, isize)> {
let before = app.prebaked();
let after = app.primary.sim.get_analytics();
let empty = Vec::new();
@ -73,7 +79,7 @@ pub trait TripProblemFilter {
if !self.include_no_changes() && count_after == count_before {
continue;
}
points.push((time_after, count_after - count_before));
points.push((id, time_after, count_after - count_before));
}
}
points
@ -97,9 +103,14 @@ lazy_static::lazy_static! {
static ref CLEAR_COLOR_SCALE: ColorScale = ColorScale(vec![Color::CLEAR, Color::CLEAR]);
}
pub fn problem_matrix(ctx: &mut EventCtx, app: &App, trips: &[(Duration, isize)]) -> Widget {
let points = trips;
/// The `title` is just used to generate unique labels. Returns a widget and a mapping from
/// `Outcome::Clicked` labels to the list of trips matching the bucket.
pub fn problem_matrix(
ctx: &mut EventCtx,
app: &App,
title: &str,
trips: Vec<(TripID, Duration, isize)>,
) -> (Widget, HashMap<String, Vec<TripID>>) {
let duration_buckets = vec![
Duration::ZERO,
Duration::minutes(5),
@ -110,14 +121,15 @@ pub fn problem_matrix(ctx: &mut EventCtx, app: &App, trips: &[(Duration, isize)]
];
let num_buckets = 7;
let mut matrix = Matrix::new(duration_buckets, bucketize_isizes(num_buckets, points));
for (x, y) in points {
matrix.add_pt(*x, *y);
let mut matrix = Matrix::new(duration_buckets, bucketize_isizes(num_buckets, &trips));
for (id, x, y) in trips {
matrix.add_pt(id, x, y);
}
matrix.draw(
ctx,
app,
MatrixOptions {
title: title.to_string(),
total_width: 600.0,
total_height: 600.0,
color_scale_for_bucket: Box::new(|app, _, n| match n.cmp(&0) {
@ -186,17 +198,17 @@ pub fn problem_matrix(ctx: &mut EventCtx, app: &App, trips: &[(Duration, isize)]
)
}
/// Aka a 2D histogram. Counts the number of matching points in each cell.
struct Matrix<X, Y> {
counts: Vec<usize>,
/// Aka a 2D histogram. Tracks matching IDs in each cell.
struct Matrix<ID, X, Y> {
entries: Vec<Vec<ID>>,
buckets_x: Vec<X>,
buckets_y: Vec<Y>,
}
impl<X: Copy + PartialOrd + Display, Y: Copy + PartialOrd + Display> Matrix<X, Y> {
fn new(buckets_x: Vec<X>, buckets_y: Vec<Y>) -> Matrix<X, Y> {
impl<ID, X: Copy + PartialOrd + Display, Y: Copy + PartialOrd + Display> Matrix<ID, X, Y> {
fn new(buckets_x: Vec<X>, buckets_y: Vec<Y>) -> Matrix<ID, X, Y> {
Matrix {
counts: std::iter::repeat(0)
entries: std::iter::repeat_with(Vec::new)
.take(buckets_x.len() * buckets_y.len())
.collect(),
buckets_x,
@ -204,7 +216,7 @@ impl<X: Copy + PartialOrd + Display, Y: Copy + PartialOrd + Display> Matrix<X, Y
}
}
fn add_pt(&mut self, x: X, y: Y) {
fn add_pt(&mut self, id: ID, x: X, y: Y) {
// Find its bucket
// TODO Unit test this
let x_idx = self
@ -220,7 +232,7 @@ impl<X: Copy + PartialOrd + Display, Y: Copy + PartialOrd + Display> Matrix<X, Y
.unwrap_or(self.buckets_y.len())
- 1;
let idx = self.idx(x_idx, y_idx);
self.counts[idx] += 1;
self.entries[idx].push(id);
}
fn idx(&self, x: usize, y: usize) -> usize {
@ -228,21 +240,32 @@ impl<X: Copy + PartialOrd + Display, Y: Copy + PartialOrd + Display> Matrix<X, Y
y * self.buckets_x.len() + x
}
fn draw(self, ctx: &mut EventCtx, app: &App, opts: MatrixOptions<X, Y>) -> Widget {
fn draw(
mut self,
ctx: &mut EventCtx,
app: &App,
opts: MatrixOptions<X, Y>,
) -> (Widget, HashMap<String, Vec<ID>>) {
let mut grid_batch = GeomBatch::new();
let mut tooltips = Vec::new();
let cell_width = opts.total_width / (self.buckets_x.len() as f64);
let cell_height = opts.total_height / (self.buckets_y.len() as f64);
let cell = Polygon::rectangle(cell_width, cell_height);
let max_count = *self.counts.iter().max().unwrap() as f64;
let max_count = self.entries.iter().map(|list| list.len()).max().unwrap() as f64;
let mut mapping = HashMap::new();
for x in 0..self.buckets_x.len() - 1 {
for y in 0..self.buckets_y.len() - 1 {
let is_first_xbucket = x == 0;
let is_last_xbucket = x == self.buckets_x.len() - 2;
let is_middle_ybucket = y + 1 == self.buckets_y.len() / 2;
let count = self.counts[self.idx(x, y)];
let idx = self.idx(x, y);
let count = self.entries[idx].len();
let bucket_label = format!("{}/{}", opts.title, idx);
if count != 0 {
mapping.insert(bucket_label.clone(), std::mem::take(&mut self.entries[idx]));
}
let color = if count == 0 {
widgetry::Color::CLEAR
} else {
@ -288,6 +311,7 @@ impl<X: Copy + PartialOrd + Display, Y: Copy + PartialOrd + Display> Matrix<X, Y
(self.buckets_y[y], self.buckets_y[y + 1]),
count,
),
Some(bucket_label),
));
}
}
@ -388,7 +412,7 @@ impl<X: Copy + PartialOrd + Display, Y: Copy + PartialOrd + Display> Matrix<X, Y
x_axis_scale
};
for (polygon, _) in tooltips.iter_mut() {
for (polygon, _, _) in &mut tooltips {
let mut translated = polygon.translate(y_axis_batch.get_bounds().width(), 0.0);
std::mem::swap(&mut translated, polygon);
}
@ -398,11 +422,21 @@ impl<X: Copy + PartialOrd + Display, Y: Copy + PartialOrd + Display> Matrix<X, Y
let mut chart = GeomBatchStack::horizontal(vec![y_axis_batch, col.batch()]);
chart.set_alignment(StackAlignment::Top);
DrawWithTooltips::new_widget(ctx, chart.batch(), tooltips, Box::new(|_| GeomBatch::new()))
(
DrawWithTooltips::new_widget(
ctx,
chart.batch(),
tooltips,
Box::new(|_| GeomBatch::new()),
),
mapping,
)
}
}
struct MatrixOptions<X, Y> {
// To disambiguate labels
title: String,
total_width: f64,
total_height: f64,
// (lower_bound, upper_bound) -> Cell Label
@ -411,7 +445,7 @@ struct MatrixOptions<X, Y> {
tooltip_for_bucket: Box<dyn Fn((Option<X>, Option<X>), (Y, Y), usize) -> Text>,
}
fn bucketize_isizes(max_buckets: usize, pts: &[(Duration, isize)]) -> Vec<isize> {
fn bucketize_isizes(max_buckets: usize, pts: &[(TripID, Duration, isize)]) -> Vec<isize> {
debug_assert!(
max_buckets % 2 == 1,
"num_buckets must be odd to have a symmetrical number of buckets around axis"
@ -420,8 +454,8 @@ fn bucketize_isizes(max_buckets: usize, pts: &[(Duration, isize)]) -> Vec<isize>
let positive_buckets = (max_buckets - 1) / 2;
// uniformly sized integer buckets
let max = match pts.iter().max_by_key(|(_, cnt)| cnt.abs()) {
Some(t) if (t.1.abs() as usize) >= positive_buckets => t.1.abs(),
let max = match pts.iter().max_by_key(|(_, _, cnt)| cnt.abs()) {
Some(t) if (t.2.abs() as usize) >= positive_buckets => t.2.abs(),
_ => {
// Enforce a bucket width of at least 1.
let negative_buckets = -(positive_buckets as isize);

View File

@ -4,8 +4,9 @@ use map_gui::tools::PopupMsg;
use map_gui::ID;
use sim::AlertLocation;
use widgetry::{
Choice, Color, ControlState, EdgeInsets, EventCtx, GeomBatch, GfxCtx, HorizontalAlignment, Key,
Line, Outcome, Panel, PersistentSplit, ScreenDims, Text, TextExt, VerticalAlignment, Widget,
Choice, Color, ControlState, DrawWithTooltips, EdgeInsets, EventCtx, GeomBatch, GfxCtx,
HorizontalAlignment, Key, Line, Outcome, Panel, PersistentSplit, ScreenDims, Text, TextExt,
VerticalAlignment, Widget,
};
use crate::app::{App, Transition};
@ -253,7 +254,6 @@ impl TimePanel {
progress_bar.push(cursor_fg, triangle_poly);
}
use widgetry::DrawWithTooltips;
let mut tooltip_text = Text::from("Finished Trips");
tooltip_text.add_line(format!(
"{} ({}% of total)",
@ -286,7 +286,7 @@ impl TimePanel {
let bounds = progress_bar.get_bounds();
let bounding_box = Polygon::rectangle(bounds.width(), bounds.height());
let tooltip = vec![(bounding_box, tooltip_text)];
let tooltip = vec![(bounding_box, tooltip_text, None)];
DrawWithTooltips::new_widget(ctx, progress_bar, tooltip, Box::new(|_| GeomBatch::new()))
}

View File

@ -293,7 +293,7 @@ impl<'a, 'c> Image<'a, 'c> {
DrawWithTooltips::new_widget(
ctx,
batch,
vec![(bounds.get_rectangle(), tooltip)],
vec![(bounds.get_rectangle(), tooltip, None)],
Box::new(|_| GeomBatch::new()),
)
} else {

View File

@ -1,8 +1,8 @@
use geom::Polygon;
use crate::{
Drawable, EventCtx, GeomBatch, GfxCtx, ScreenDims, ScreenPt, ScreenRectangle, Text, Widget,
WidgetImpl, WidgetOutput,
Drawable, EventCtx, GeomBatch, GfxCtx, Outcome, ScreenDims, ScreenPt, ScreenRectangle, Text,
Widget, WidgetImpl, WidgetOutput,
};
// Just draw something, no interaction.
@ -41,8 +41,9 @@ impl WidgetImpl for JustDraw {
pub struct DrawWithTooltips {
draw: Drawable,
tooltips: Vec<(Polygon, Text)>,
tooltips: Vec<(Polygon, Text, Option<String>)>,
hover: Box<dyn Fn(&Polygon) -> GeomBatch>,
hovering_on_idx: Option<usize>,
top_left: ScreenPt,
dims: ScreenDims,
@ -50,20 +51,23 @@ pub struct DrawWithTooltips {
impl DrawWithTooltips {
/// `batch`: the `GeomBatch` to draw
/// `tooltips`: (hitbox, text) tuples where each `text` is shown when the user hovers over
/// the respective `hitbox`
/// `tooltips`: (hitbox, text, clickable label) tuples where each `text` is shown when the user hovers over
/// the respective `hitbox`. If a label is present and the user clicks the
/// `hitbox`, then it acts like a button click. It's assumed the hitboxes are
/// non-overlapping.
/// `hover`: returns a GeomBatch to render upon hovering. Return an `GeomBox::new()` if
/// you want hovering to be a no-op
pub fn new_widget(
ctx: &EventCtx,
batch: GeomBatch,
tooltips: Vec<(Polygon, Text)>,
tooltips: Vec<(Polygon, Text, Option<String>)>,
hover: Box<dyn Fn(&Polygon) -> GeomBatch>,
) -> Widget {
Widget::new(Box::new(DrawWithTooltips {
dims: batch.get_dims(),
top_left: ScreenPt::new(0.0, 0.0),
hover,
hovering_on_idx: None,
draw: ctx.upload(batch),
tooltips,
}))
@ -79,26 +83,40 @@ impl WidgetImpl for DrawWithTooltips {
self.top_left = top_left;
}
fn event(&mut self, _: &mut EventCtx, _: &mut WidgetOutput) {}
fn event(&mut self, ctx: &mut EventCtx, output: &mut WidgetOutput) {
if ctx.redo_mouseover() {
self.hovering_on_idx = None;
if let Some(cursor) = ctx.canvas.get_cursor_in_screen_space() {
if !ScreenRectangle::top_left(self.top_left, self.dims).contains(cursor) {
return;
}
let translated =
ScreenPt::new(cursor.x - self.top_left.x, cursor.y - self.top_left.y).to_pt();
for (idx, (hitbox, _, _)) in self.tooltips.iter().enumerate() {
if hitbox.contains_pt(translated) {
self.hovering_on_idx = Some(idx);
break;
}
}
}
}
if let Some(idx) = self.hovering_on_idx {
if ctx.normal_left_click() {
if let Some(ref label) = self.tooltips[idx].2 {
output.outcome = Outcome::Clicked(label.clone());
}
}
}
}
fn draw(&self, g: &mut GfxCtx) {
g.redraw_at(self.top_left, &self.draw);
if let Some(cursor) = g.canvas.get_cursor_in_screen_space() {
if !ScreenRectangle::top_left(self.top_left, self.dims).contains(cursor) {
return;
}
let translated =
ScreenPt::new(cursor.x - self.top_left.x, cursor.y - self.top_left.y).to_pt();
// TODO Assume regions are non-overlapping
for (region, txt) in &self.tooltips {
if region.contains_pt(translated) {
let extra = g.upload((self.hover)(region));
g.redraw_at(self.top_left, &extra);
g.draw_mouse_tooltip(txt.clone());
return;
}
}
if let Some(idx) = self.hovering_on_idx {
let (hitbox, txt, _) = &self.tooltips[idx];
let extra = g.upload((self.hover)(hitbox));
g.redraw_at(self.top_left, &extra);
g.draw_mouse_tooltip(txt.clone());
}
}
}