mirror of
https://github.com/a-b-street/abstreet.git
synced 2024-11-24 09:24:26 +03:00
delay chart on "travel times"
This commit is contained in:
parent
ed36776b1d
commit
4e8136d4ff
@ -15,6 +15,7 @@ mod risks;
|
||||
mod selector;
|
||||
mod traffic_signals;
|
||||
mod travel_times;
|
||||
mod trip_problems;
|
||||
mod trip_table;
|
||||
|
||||
// Oh the dashboards melted, but we still had the radio
|
||||
|
@ -1,15 +1,9 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::fmt::Display;
|
||||
|
||||
use abstutil::{abbreviated_format, prettyprint_usize};
|
||||
use geom::{Angle, Duration, Polygon, Pt2D, Time};
|
||||
use map_gui::tools::ColorScale;
|
||||
use sim::{Problem, TripMode};
|
||||
use widgetry::{
|
||||
Color, DrawWithTooltips, EventCtx, GeomBatch, GeomBatchStack, GfxCtx, Image, Line, Outcome,
|
||||
Panel, State, Text, TextExt, Toggle, Widget,
|
||||
};
|
||||
use sim::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::DashTab;
|
||||
|
||||
@ -63,11 +57,11 @@ impl RiskSummaries {
|
||||
.small_heading()
|
||||
.into_widget(ctx)
|
||||
.centered_horiz(),
|
||||
safety_matrix(
|
||||
problem_matrix(
|
||||
ctx,
|
||||
app,
|
||||
&bike_filter,
|
||||
ProblemType::LargeIntersectionCrossing,
|
||||
&bike_filter
|
||||
.trip_problems(app, ProblemType::LargeIntersectionCrossing),
|
||||
),
|
||||
])
|
||||
.section(ctx),
|
||||
@ -76,7 +70,11 @@ impl RiskSummaries {
|
||||
.small_heading()
|
||||
.into_widget(ctx)
|
||||
.centered_horiz(),
|
||||
safety_matrix(ctx, app, &bike_filter, ProblemType::OvertakeDesired),
|
||||
problem_matrix(
|
||||
ctx,
|
||||
app,
|
||||
&bike_filter.trip_problems(app, ProblemType::OvertakeDesired),
|
||||
),
|
||||
])
|
||||
.section(ctx),
|
||||
],
|
||||
@ -113,386 +111,16 @@ impl State<App> for RiskSummaries {
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref CLEAR_COLOR_SCALE: ColorScale = ColorScale(vec![Color::CLEAR, Color::CLEAR]);
|
||||
}
|
||||
|
||||
fn safety_matrix(
|
||||
ctx: &mut EventCtx,
|
||||
app: &App,
|
||||
filter: &Filter,
|
||||
problem_type: ProblemType,
|
||||
) -> Widget {
|
||||
let points = filter.get_trips(app, problem_type);
|
||||
|
||||
let duration_buckets = vec![
|
||||
Duration::ZERO,
|
||||
Duration::minutes(5),
|
||||
Duration::minutes(15),
|
||||
Duration::minutes(30),
|
||||
Duration::hours(1),
|
||||
Duration::hours(2),
|
||||
];
|
||||
|
||||
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);
|
||||
}
|
||||
matrix.draw(
|
||||
ctx,
|
||||
app,
|
||||
MatrixOptions {
|
||||
total_width: 600.0,
|
||||
total_height: 600.0,
|
||||
color_scale_for_bucket: Box::new(|app, _, n| match n.cmp(&0) {
|
||||
std::cmp::Ordering::Equal => &CLEAR_COLOR_SCALE,
|
||||
std::cmp::Ordering::Less => &app.cs.good_to_bad_green,
|
||||
std::cmp::Ordering::Greater => &app.cs.good_to_bad_red,
|
||||
}),
|
||||
tooltip_for_bucket: Box::new(|(t1, t2), (problems1, problems2), count| {
|
||||
let trip_string = if count == 1 {
|
||||
"1 trip".to_string()
|
||||
} else {
|
||||
format!("{} trips", prettyprint_usize(count))
|
||||
};
|
||||
let duration_string = match (t1, t2) {
|
||||
(None, Some(end)) => format!("shorter than {}", end),
|
||||
(Some(start), None) => format!("longer than {}", start),
|
||||
(Some(start), Some(end)) => format!("between {} and {}", start, end),
|
||||
(None, None) => {
|
||||
unreachable!("at least one end of the duration range must be specified")
|
||||
}
|
||||
};
|
||||
let mut txt = Text::from(format!("{} {}", trip_string, duration_string));
|
||||
txt.add_line(match problems1.cmp(&0) {
|
||||
std::cmp::Ordering::Equal => {
|
||||
"had no change in the number of problems encountered.".to_string()
|
||||
}
|
||||
std::cmp::Ordering::Less => {
|
||||
if problems1.abs() == problems2.abs() + 1 {
|
||||
if problems1.abs() == 1 {
|
||||
"encountered 1 fewer problem.".to_string()
|
||||
} else {
|
||||
format!("encountered {} fewer problems.", problems1.abs())
|
||||
}
|
||||
} else {
|
||||
format!(
|
||||
"encountered {}-{} fewer problems.",
|
||||
problems2.abs() + 1,
|
||||
problems1.abs()
|
||||
)
|
||||
}
|
||||
}
|
||||
std::cmp::Ordering::Greater => {
|
||||
if problems1 == problems2 - 1 {
|
||||
if problems1 == 1 {
|
||||
"encountered 1 more problems.".to_string()
|
||||
} else {
|
||||
format!("encountered {} more problems.", problems1,)
|
||||
}
|
||||
} else {
|
||||
format!("encountered {}-{} more problems.", problems1, problems2 - 1)
|
||||
}
|
||||
}
|
||||
});
|
||||
txt
|
||||
}),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum ProblemType {
|
||||
IntersectionDelay,
|
||||
LargeIntersectionCrossing,
|
||||
OvertakeDesired,
|
||||
}
|
||||
|
||||
impl ProblemType {
|
||||
fn count(self, problems: &[(Time, Problem)]) -> usize {
|
||||
let mut cnt = 0;
|
||||
for (_, problem) in problems {
|
||||
if match problem {
|
||||
Problem::IntersectionDelay(_, _) => self == ProblemType::IntersectionDelay,
|
||||
Problem::LargeIntersectionCrossing(_) => {
|
||||
self == ProblemType::LargeIntersectionCrossing
|
||||
}
|
||||
Problem::OvertakeDesired(_) => self == ProblemType::OvertakeDesired,
|
||||
} {
|
||||
cnt += 1;
|
||||
}
|
||||
}
|
||||
cnt
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Filter {
|
||||
modes: BTreeSet<TripMode>,
|
||||
include_no_changes: bool,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
// Returns:
|
||||
// 1) trip duration after changes
|
||||
// 2) difference in number of matching problems, where positive means MORE problems after
|
||||
// changes
|
||||
fn get_trips(&self, app: &App, problem_type: ProblemType) -> Vec<(Duration, isize)> {
|
||||
let before = app.prebaked();
|
||||
let after = app.primary.sim.get_analytics();
|
||||
let empty = Vec::new();
|
||||
|
||||
let mut points = Vec::new();
|
||||
for (id, _, time_after, mode) in after.both_finished_trips(app.primary.sim.time(), before) {
|
||||
if self.modes.contains(&mode) {
|
||||
let count_before = problem_type
|
||||
.count(before.problems_per_trip.get(&id).unwrap_or(&empty))
|
||||
as isize;
|
||||
let count_after =
|
||||
problem_type.count(after.problems_per_trip.get(&id).unwrap_or(&empty)) as isize;
|
||||
if !self.include_no_changes && count_after == count_before {
|
||||
continue;
|
||||
}
|
||||
points.push((time_after, count_after - count_before));
|
||||
}
|
||||
}
|
||||
points
|
||||
impl TripProblemFilter for Filter {
|
||||
fn includes_mode(&self, mode: &TripMode) -> bool {
|
||||
self.modes.contains(mode)
|
||||
}
|
||||
|
||||
fn finished_trip_count(&self, app: &App) -> usize {
|
||||
let before = app.prebaked();
|
||||
let after = app.primary.sim.get_analytics();
|
||||
|
||||
let mut count = 0;
|
||||
for (_, _, _, mode) in after.both_finished_trips(app.primary.sim.time(), before) {
|
||||
if self.modes.contains(&mode) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
/// Aka a 2D histogram. Counts the number of matching points in each cell.
|
||||
struct Matrix<X, Y> {
|
||||
counts: Vec<usize>,
|
||||
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> {
|
||||
Matrix {
|
||||
counts: std::iter::repeat(0)
|
||||
.take(buckets_x.len() * buckets_y.len())
|
||||
.collect(),
|
||||
buckets_x,
|
||||
buckets_y,
|
||||
}
|
||||
}
|
||||
|
||||
fn add_pt(&mut self, x: X, y: Y) {
|
||||
// Find its bucket
|
||||
// TODO Unit test this
|
||||
let x_idx = self
|
||||
.buckets_x
|
||||
.iter()
|
||||
.position(|min| *min > x)
|
||||
.unwrap_or(self.buckets_x.len())
|
||||
- 1;
|
||||
let y_idx = self
|
||||
.buckets_y
|
||||
.iter()
|
||||
.position(|min| *min > y)
|
||||
.unwrap_or(self.buckets_y.len())
|
||||
- 1;
|
||||
let idx = self.idx(x_idx, y_idx);
|
||||
self.counts[idx] += 1;
|
||||
}
|
||||
|
||||
fn idx(&self, x: usize, y: usize) -> usize {
|
||||
// Row-major
|
||||
y * self.buckets_x.len() + x
|
||||
}
|
||||
|
||||
fn draw(self, ctx: &mut EventCtx, app: &App, opts: MatrixOptions<X, Y>) -> Widget {
|
||||
let mut 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;
|
||||
|
||||
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 color = if count == 0 {
|
||||
widgetry::Color::CLEAR
|
||||
} else {
|
||||
let density_pct = (count as f64) / max_count;
|
||||
(opts.color_scale_for_bucket)(app, self.buckets_x[x], self.buckets_y[y])
|
||||
.eval(density_pct)
|
||||
};
|
||||
let x1 = cell_width * (x as f64);
|
||||
let y1 = cell_height * (y as f64);
|
||||
let rect = cell.clone().translate(x1, y1);
|
||||
batch.push(color, rect.clone());
|
||||
batch.append(
|
||||
Text::from(if count == 0 && is_middle_ybucket {
|
||||
"-".to_string()
|
||||
} else {
|
||||
abbreviated_format(count)
|
||||
})
|
||||
.change_fg(if count == 0 || is_middle_ybucket {
|
||||
ctx.style().text_primary_color
|
||||
} else {
|
||||
Color::WHITE
|
||||
})
|
||||
.render(ctx)
|
||||
.centered_on(Pt2D::new(x1 + cell_width / 2.0, y1 + cell_height / 2.0)),
|
||||
);
|
||||
|
||||
if count != 0 || !is_middle_ybucket {
|
||||
tooltips.push((
|
||||
rect,
|
||||
(opts.tooltip_for_bucket)(
|
||||
(
|
||||
if is_first_xbucket {
|
||||
None
|
||||
} else {
|
||||
Some(self.buckets_x[x])
|
||||
},
|
||||
if is_last_xbucket {
|
||||
None
|
||||
} else {
|
||||
Some(self.buckets_x[x + 1])
|
||||
},
|
||||
),
|
||||
(self.buckets_y[y], self.buckets_y[y + 1]),
|
||||
count,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Axis Labels
|
||||
let mut y_axis_label = Text::from("More Problems <--------> Fewer Problems")
|
||||
.change_fg(ctx.style().text_secondary_color)
|
||||
.render(ctx)
|
||||
.rotate(Angle::degrees(-90.0));
|
||||
y_axis_label.autocrop_dims = true;
|
||||
y_axis_label = y_axis_label.autocrop();
|
||||
|
||||
let x_axis_label = Text::from("Short Trips <--------> Long Trips")
|
||||
.change_fg(ctx.style().text_secondary_color)
|
||||
.render(ctx);
|
||||
|
||||
let vmargin = 32.0;
|
||||
for (polygon, _) in tooltips.iter_mut() {
|
||||
let mut translated =
|
||||
polygon.translate(vmargin + y_axis_label.get_bounds().width(), 0.0);
|
||||
std::mem::swap(&mut translated, polygon);
|
||||
}
|
||||
let mut row = GeomBatchStack::horizontal(vec![y_axis_label, batch]);
|
||||
row.set_spacing(vmargin);
|
||||
let mut chart = GeomBatchStack::vertical(vec![row.batch(), x_axis_label]);
|
||||
chart.set_spacing(16);
|
||||
|
||||
DrawWithTooltips::new_widget(ctx, chart.batch(), tooltips, Box::new(|_| GeomBatch::new()))
|
||||
}
|
||||
}
|
||||
|
||||
struct MatrixOptions<X, Y> {
|
||||
total_width: f64,
|
||||
total_height: f64,
|
||||
color_scale_for_bucket: Box<dyn Fn(&App, X, Y) -> &ColorScale>,
|
||||
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> {
|
||||
debug_assert!(
|
||||
max_buckets % 2 == 1,
|
||||
"num_buckets must be odd to have a symmetrical number of buckets around axis"
|
||||
);
|
||||
debug_assert!(max_buckets >= 3, "num_buckets must be at least 3");
|
||||
|
||||
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(),
|
||||
_ => {
|
||||
// Enforce a bucket width of at least 1.
|
||||
let negative_buckets = -(positive_buckets as isize);
|
||||
return (negative_buckets..=(positive_buckets as isize + 1)).collect();
|
||||
}
|
||||
};
|
||||
|
||||
let bucket_size = (max as f64 / positive_buckets as f64).ceil() as isize;
|
||||
|
||||
// we start with a 0-based bucket, and build the other buckets out from that.
|
||||
let mut buckets = vec![0];
|
||||
|
||||
for i in 0..=positive_buckets {
|
||||
// the first positive bucket starts at `1`, to ensure that the 0 bucket stands alone
|
||||
buckets.push(1 + (i as isize) * bucket_size);
|
||||
}
|
||||
for i in 1..=positive_buckets {
|
||||
buckets.push(-(i as isize) * bucket_size);
|
||||
}
|
||||
buckets.sort_unstable();
|
||||
debug!("buckets: {:?}", buckets);
|
||||
|
||||
buckets
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_bucketize_isizes() {
|
||||
let buckets = bucketize_isizes(
|
||||
7,
|
||||
&[
|
||||
(Duration::minutes(3), -3),
|
||||
(Duration::minutes(3), -3),
|
||||
(Duration::minutes(3), -1),
|
||||
(Duration::minutes(3), 2),
|
||||
(Duration::minutes(3), 5),
|
||||
],
|
||||
);
|
||||
// there should be an even number of buckets on either side of zero so as to center
|
||||
// our x-axis.
|
||||
//
|
||||
// there should always be a 0-1 bucket, ensuring that only '0' falls into the zero-bucket.
|
||||
//
|
||||
// all other buckets edges should be evenly spaced from the zero bucket
|
||||
assert_eq!(buckets, vec![-6, -4, -2, 0, 1, 3, 5, 7])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bucketize_empty_isizes() {
|
||||
let buckets = bucketize_isizes(7, &[]);
|
||||
assert_eq!(buckets, vec![-2, -1, 0, 1, 2])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bucketize_small_isizes() {
|
||||
let buckets = bucketize_isizes(
|
||||
7,
|
||||
&[
|
||||
(Duration::minutes(3), -1),
|
||||
(Duration::minutes(3), -1),
|
||||
(Duration::minutes(3), 0),
|
||||
(Duration::minutes(3), -1),
|
||||
(Duration::minutes(3), 0),
|
||||
],
|
||||
);
|
||||
assert_eq!(buckets, vec![-3, -2, -1, 0, 1, 2, 3, 4])
|
||||
fn include_no_changes(&self) -> bool {
|
||||
self.include_no_changes
|
||||
}
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ use widgetry::{
|
||||
Panel, State, Text, TextExt, Toggle, Widget,
|
||||
};
|
||||
|
||||
use super::trip_problems::{problem_matrix, ProblemType, TripProblemFilter};
|
||||
use crate::app::{App, Transition};
|
||||
use crate::common::color_for_mode;
|
||||
use crate::sandbox::dashboards::DashTab;
|
||||
@ -32,17 +33,7 @@ impl TravelTimes {
|
||||
filter.modes.contains(&mode),
|
||||
));
|
||||
}
|
||||
filters.push(Widget::dropdown(
|
||||
ctx,
|
||||
"filter",
|
||||
filter.changes_pct,
|
||||
vec![
|
||||
Choice::new("any change", None),
|
||||
Choice::new("at least 1% change", Some(0.01)),
|
||||
Choice::new("at least 10% change", Some(0.1)),
|
||||
Choice::new("at least 50% change", Some(0.5)),
|
||||
],
|
||||
));
|
||||
|
||||
filters.push(
|
||||
ctx.style()
|
||||
.btn_plain
|
||||
@ -58,11 +49,52 @@ impl TravelTimes {
|
||||
Widget::col(filters).section(ctx),
|
||||
Widget::col(vec![
|
||||
summary_boxes(ctx, app, &filter),
|
||||
Widget::row(vec![
|
||||
contingency_table(ctx, app, &filter).bg(ctx.style().section_bg),
|
||||
scatter_plot(ctx, app, &filter).bg(ctx.style().section_bg),
|
||||
Widget::col(vec![
|
||||
Text::from(Line("Travel Times").small_heading()).into_widget(ctx),
|
||||
Widget::row(vec![
|
||||
"filter:".text_widget(ctx).centered_vert(),
|
||||
Widget::dropdown(
|
||||
ctx,
|
||||
"filter",
|
||||
filter.changes_pct,
|
||||
vec![
|
||||
Choice::new("any change", None),
|
||||
Choice::new("at least 1% change", Some(0.01)),
|
||||
Choice::new("at least 10% change", Some(0.1)),
|
||||
Choice::new("at least 50% change", Some(0.5)),
|
||||
],
|
||||
),
|
||||
])
|
||||
.margin_above(8),
|
||||
Widget::horiz_separator(ctx, 1.0),
|
||||
Widget::row(vec![
|
||||
contingency_table(ctx, app, &filter).bg(ctx.style().section_bg),
|
||||
scatter_plot(ctx, app, &filter)
|
||||
.bg(ctx.style().section_bg)
|
||||
.margin_left(32),
|
||||
]),
|
||||
])
|
||||
.section(ctx)
|
||||
.evenly_spaced(),
|
||||
Widget::row(vec![
|
||||
Widget::col(vec![
|
||||
Text::from(Line("Intersection Delays").small_heading())
|
||||
.into_widget(ctx),
|
||||
Toggle::checkbox(
|
||||
ctx,
|
||||
"include trips without any changes",
|
||||
None,
|
||||
filter.include_no_changes(),
|
||||
),
|
||||
]),
|
||||
problem_matrix(
|
||||
ctx,
|
||||
app,
|
||||
&filter.trip_problems(app, ProblemType::IntersectionDelay),
|
||||
)
|
||||
.margin_left(32),
|
||||
])
|
||||
.section(ctx),
|
||||
]),
|
||||
]),
|
||||
]))
|
||||
@ -99,6 +131,7 @@ impl State<App> for TravelTimes {
|
||||
let mut filter = Filter {
|
||||
changes_pct: self.panel.dropdown_value("filter"),
|
||||
modes: BTreeSet::new(),
|
||||
include_no_changes: self.panel.is_checked("include trips without any changes"),
|
||||
};
|
||||
for m in TripMode::all() {
|
||||
if self.panel.is_checked(m.ongoing_verb()) {
|
||||
@ -236,8 +269,6 @@ fn scatter_plot(ctx: &mut EventCtx, app: &App, filter: &Filter) -> Widget {
|
||||
points,
|
||||
),
|
||||
])
|
||||
.padding(16)
|
||||
.outline(ctx.style().section_outline)
|
||||
}
|
||||
|
||||
fn contingency_table(ctx: &mut EventCtx, app: &App, filter: &Filter) -> Widget {
|
||||
@ -455,10 +486,14 @@ fn contingency_table(ctx: &mut EventCtx, app: &App, filter: &Filter) -> Widget {
|
||||
|
||||
Widget::col(vec![
|
||||
Text::from_multiline(vec![
|
||||
Line("Time difference by trip length").small_heading(),
|
||||
Line("Grouped by the length of the trip before your changes."),
|
||||
Line("Aggregate difference by trip length").small_heading(),
|
||||
Line(format!(
|
||||
"Grouped by the length of the trip before\n\"{}\" changes.",
|
||||
app.primary.map.get_edits().edits_name
|
||||
)),
|
||||
])
|
||||
.into_widget(ctx),
|
||||
.into_widget(ctx)
|
||||
.container(),
|
||||
Line("Total Time Saved (faster)")
|
||||
.secondary()
|
||||
.into_widget(ctx),
|
||||
@ -467,13 +502,22 @@ fn contingency_table(ctx: &mut EventCtx, app: &App, filter: &Filter) -> Widget {
|
||||
.secondary()
|
||||
.into_widget(ctx),
|
||||
])
|
||||
.padding(16)
|
||||
.outline(ctx.style().section_outline)
|
||||
}
|
||||
|
||||
pub struct Filter {
|
||||
changes_pct: Option<f64>,
|
||||
modes: BTreeSet<TripMode>,
|
||||
include_no_changes: bool,
|
||||
}
|
||||
|
||||
impl TripProblemFilter for Filter {
|
||||
fn includes_mode(&self, mode: &TripMode) -> bool {
|
||||
self.modes.contains(mode)
|
||||
}
|
||||
|
||||
fn include_no_changes(&self) -> bool {
|
||||
self.include_no_changes
|
||||
}
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
@ -481,6 +525,7 @@ impl Filter {
|
||||
Filter {
|
||||
changes_pct: None,
|
||||
modes: TripMode::all().into_iter().collect(),
|
||||
include_no_changes: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
386
game/src/sandbox/dashboards/trip_problems.rs
Normal file
386
game/src/sandbox/dashboards/trip_problems.rs
Normal file
@ -0,0 +1,386 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use abstutil::{abbreviated_format, prettyprint_usize};
|
||||
use geom::{Angle, Duration, Polygon, Pt2D, Time};
|
||||
use map_gui::tools::ColorScale;
|
||||
use sim::{Problem, TripMode};
|
||||
use widgetry::{Color, DrawWithTooltips, GeomBatch, GeomBatchStack, Text, Widget};
|
||||
|
||||
use crate::{App, EventCtx};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum ProblemType {
|
||||
IntersectionDelay,
|
||||
LargeIntersectionCrossing,
|
||||
OvertakeDesired,
|
||||
}
|
||||
|
||||
impl ProblemType {
|
||||
pub fn count(self, problems: &[(Time, Problem)]) -> usize {
|
||||
let mut cnt = 0;
|
||||
for (_, problem) in problems {
|
||||
if match problem {
|
||||
Problem::IntersectionDelay(_, _) => self == ProblemType::IntersectionDelay,
|
||||
Problem::LargeIntersectionCrossing(_) => {
|
||||
self == ProblemType::LargeIntersectionCrossing
|
||||
}
|
||||
Problem::OvertakeDesired(_) => self == ProblemType::OvertakeDesired,
|
||||
} {
|
||||
cnt += 1;
|
||||
}
|
||||
}
|
||||
cnt
|
||||
}
|
||||
}
|
||||
|
||||
pub trait TripProblemFilter {
|
||||
fn includes_mode(&self, mode: &TripMode) -> bool;
|
||||
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
|
||||
// changes
|
||||
fn trip_problems(&self, app: &App, problem_type: ProblemType) -> Vec<(Duration, isize)> {
|
||||
let before = app.prebaked();
|
||||
let after = app.primary.sim.get_analytics();
|
||||
let empty = Vec::new();
|
||||
|
||||
let mut points = Vec::new();
|
||||
for (id, _, time_after, mode) in after.both_finished_trips(app.primary.sim.time(), before) {
|
||||
if self.includes_mode(&mode) {
|
||||
let count_before = problem_type
|
||||
.count(before.problems_per_trip.get(&id).unwrap_or(&empty))
|
||||
as isize;
|
||||
let count_after =
|
||||
problem_type.count(after.problems_per_trip.get(&id).unwrap_or(&empty)) as isize;
|
||||
if !self.include_no_changes() && count_after == count_before {
|
||||
continue;
|
||||
}
|
||||
points.push((time_after, count_after - count_before));
|
||||
}
|
||||
}
|
||||
points
|
||||
}
|
||||
|
||||
fn finished_trip_count(&self, app: &App) -> usize {
|
||||
let before = app.prebaked();
|
||||
let after = app.primary.sim.get_analytics();
|
||||
|
||||
let mut count = 0;
|
||||
for (_, _, _, mode) in after.both_finished_trips(app.primary.sim.time(), before) {
|
||||
if self.includes_mode(&mode) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
let duration_buckets = vec![
|
||||
Duration::ZERO,
|
||||
Duration::minutes(5),
|
||||
Duration::minutes(15),
|
||||
Duration::minutes(30),
|
||||
Duration::hours(1),
|
||||
Duration::hours(2),
|
||||
];
|
||||
|
||||
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);
|
||||
}
|
||||
matrix.draw(
|
||||
ctx,
|
||||
app,
|
||||
MatrixOptions {
|
||||
total_width: 600.0,
|
||||
total_height: 600.0,
|
||||
color_scale_for_bucket: Box::new(|app, _, n| match n.cmp(&0) {
|
||||
std::cmp::Ordering::Equal => &CLEAR_COLOR_SCALE,
|
||||
std::cmp::Ordering::Less => &app.cs.good_to_bad_green,
|
||||
std::cmp::Ordering::Greater => &app.cs.good_to_bad_red,
|
||||
}),
|
||||
tooltip_for_bucket: Box::new(|(t1, t2), (problems1, problems2), count| {
|
||||
let trip_string = if count == 1 {
|
||||
"1 trip".to_string()
|
||||
} else {
|
||||
format!("{} trips", prettyprint_usize(count))
|
||||
};
|
||||
let duration_string = match (t1, t2) {
|
||||
(None, Some(end)) => format!("shorter than {}", end),
|
||||
(Some(start), None) => format!("longer than {}", start),
|
||||
(Some(start), Some(end)) => format!("between {} and {}", start, end),
|
||||
(None, None) => {
|
||||
unreachable!("at least one end of the duration range must be specified")
|
||||
}
|
||||
};
|
||||
let mut txt = Text::from(format!("{} {}", trip_string, duration_string));
|
||||
txt.add_line(match problems1.cmp(&0) {
|
||||
std::cmp::Ordering::Equal => {
|
||||
"had no change in the number of problems encountered.".to_string()
|
||||
}
|
||||
std::cmp::Ordering::Less => {
|
||||
if problems1.abs() == problems2.abs() + 1 {
|
||||
if problems1.abs() == 1 {
|
||||
"encountered 1 fewer problem.".to_string()
|
||||
} else {
|
||||
format!("encountered {} fewer problems.", problems1.abs())
|
||||
}
|
||||
} else {
|
||||
format!(
|
||||
"encountered {}-{} fewer problems.",
|
||||
problems2.abs() + 1,
|
||||
problems1.abs()
|
||||
)
|
||||
}
|
||||
}
|
||||
std::cmp::Ordering::Greater => {
|
||||
if problems1 == problems2 - 1 {
|
||||
if problems1 == 1 {
|
||||
"encountered 1 more problems.".to_string()
|
||||
} else {
|
||||
format!("encountered {} more problems.", problems1,)
|
||||
}
|
||||
} else {
|
||||
format!("encountered {}-{} more problems.", problems1, problems2 - 1)
|
||||
}
|
||||
}
|
||||
});
|
||||
txt
|
||||
}),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Aka a 2D histogram. Counts the number of matching points in each cell.
|
||||
struct Matrix<X, Y> {
|
||||
counts: Vec<usize>,
|
||||
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> {
|
||||
Matrix {
|
||||
counts: std::iter::repeat(0)
|
||||
.take(buckets_x.len() * buckets_y.len())
|
||||
.collect(),
|
||||
buckets_x,
|
||||
buckets_y,
|
||||
}
|
||||
}
|
||||
|
||||
fn add_pt(&mut self, x: X, y: Y) {
|
||||
// Find its bucket
|
||||
// TODO Unit test this
|
||||
let x_idx = self
|
||||
.buckets_x
|
||||
.iter()
|
||||
.position(|min| *min > x)
|
||||
.unwrap_or(self.buckets_x.len())
|
||||
- 1;
|
||||
let y_idx = self
|
||||
.buckets_y
|
||||
.iter()
|
||||
.position(|min| *min > y)
|
||||
.unwrap_or(self.buckets_y.len())
|
||||
- 1;
|
||||
let idx = self.idx(x_idx, y_idx);
|
||||
self.counts[idx] += 1;
|
||||
}
|
||||
|
||||
fn idx(&self, x: usize, y: usize) -> usize {
|
||||
// Row-major
|
||||
y * self.buckets_x.len() + x
|
||||
}
|
||||
|
||||
fn draw(self, ctx: &mut EventCtx, app: &App, opts: MatrixOptions<X, Y>) -> Widget {
|
||||
let mut 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;
|
||||
|
||||
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 color = if count == 0 {
|
||||
widgetry::Color::CLEAR
|
||||
} else {
|
||||
let density_pct = (count as f64) / max_count;
|
||||
(opts.color_scale_for_bucket)(app, self.buckets_x[x], self.buckets_y[y])
|
||||
.eval(density_pct)
|
||||
};
|
||||
let x1 = cell_width * (x as f64);
|
||||
let y1 = cell_height * (y as f64);
|
||||
let rect = cell.clone().translate(x1, y1);
|
||||
batch.push(color, rect.clone());
|
||||
batch.append(
|
||||
Text::from(if count == 0 && is_middle_ybucket {
|
||||
"-".to_string()
|
||||
} else {
|
||||
abbreviated_format(count)
|
||||
})
|
||||
.change_fg(if count == 0 || is_middle_ybucket {
|
||||
ctx.style().text_primary_color
|
||||
} else {
|
||||
Color::WHITE
|
||||
})
|
||||
.render(ctx)
|
||||
.centered_on(Pt2D::new(x1 + cell_width / 2.0, y1 + cell_height / 2.0)),
|
||||
);
|
||||
|
||||
if count != 0 || !is_middle_ybucket {
|
||||
tooltips.push((
|
||||
rect,
|
||||
(opts.tooltip_for_bucket)(
|
||||
(
|
||||
if is_first_xbucket {
|
||||
None
|
||||
} else {
|
||||
Some(self.buckets_x[x])
|
||||
},
|
||||
if is_last_xbucket {
|
||||
None
|
||||
} else {
|
||||
Some(self.buckets_x[x + 1])
|
||||
},
|
||||
),
|
||||
(self.buckets_y[y], self.buckets_y[y + 1]),
|
||||
count,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Axis Labels
|
||||
let mut y_axis_label = Text::from("More Problems <--------> Fewer Problems")
|
||||
.change_fg(ctx.style().text_secondary_color)
|
||||
.render(ctx)
|
||||
.rotate(Angle::degrees(-90.0));
|
||||
y_axis_label.autocrop_dims = true;
|
||||
y_axis_label = y_axis_label.autocrop();
|
||||
|
||||
let x_axis_label = Text::from(" Short Trips <--------> Long Trips")
|
||||
.change_fg(ctx.style().text_secondary_color)
|
||||
.render(ctx);
|
||||
|
||||
let vmargin = 32.0;
|
||||
for (polygon, _) in tooltips.iter_mut() {
|
||||
let mut translated =
|
||||
polygon.translate(vmargin + y_axis_label.get_bounds().width(), 0.0);
|
||||
std::mem::swap(&mut translated, polygon);
|
||||
}
|
||||
let mut row = GeomBatchStack::horizontal(vec![y_axis_label, batch]);
|
||||
row.set_spacing(vmargin);
|
||||
let mut chart = GeomBatchStack::vertical(vec![row.batch(), x_axis_label]);
|
||||
chart.set_spacing(16);
|
||||
|
||||
DrawWithTooltips::new_widget(ctx, chart.batch(), tooltips, Box::new(|_| GeomBatch::new()))
|
||||
}
|
||||
}
|
||||
|
||||
struct MatrixOptions<X, Y> {
|
||||
total_width: f64,
|
||||
total_height: f64,
|
||||
color_scale_for_bucket: Box<dyn Fn(&App, X, Y) -> &ColorScale>,
|
||||
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> {
|
||||
debug_assert!(
|
||||
max_buckets % 2 == 1,
|
||||
"num_buckets must be odd to have a symmetrical number of buckets around axis"
|
||||
);
|
||||
debug_assert!(max_buckets >= 3, "num_buckets must be at least 3");
|
||||
|
||||
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(),
|
||||
_ => {
|
||||
// Enforce a bucket width of at least 1.
|
||||
let negative_buckets = -(positive_buckets as isize);
|
||||
return (negative_buckets..=(positive_buckets as isize + 1)).collect();
|
||||
}
|
||||
};
|
||||
|
||||
let bucket_size = (max as f64 / positive_buckets as f64).ceil() as isize;
|
||||
|
||||
// we start with a 0-based bucket, and build the other buckets out from that.
|
||||
let mut buckets = vec![0];
|
||||
|
||||
for i in 0..=positive_buckets {
|
||||
// the first positive bucket starts at `1`, to ensure that the 0 bucket stands alone
|
||||
buckets.push(1 + (i as isize) * bucket_size);
|
||||
}
|
||||
for i in 1..=positive_buckets {
|
||||
buckets.push(-(i as isize) * bucket_size);
|
||||
}
|
||||
buckets.sort_unstable();
|
||||
debug!("buckets: {:?}", buckets);
|
||||
|
||||
buckets
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_bucketize_isizes() {
|
||||
let buckets = bucketize_isizes(
|
||||
7,
|
||||
&vec![
|
||||
(Duration::minutes(3), -3),
|
||||
(Duration::minutes(3), -3),
|
||||
(Duration::minutes(3), -1),
|
||||
(Duration::minutes(3), 2),
|
||||
(Duration::minutes(3), 5),
|
||||
],
|
||||
);
|
||||
// there should be an even number of buckets on either side of zero so as to center
|
||||
// our x-axis.
|
||||
//
|
||||
// there should always be a 0-1 bucket, ensuring that only '0' falls into the zero-bucket.
|
||||
//
|
||||
// all other buckets edges should be evenly spaced from the zero bucket
|
||||
assert_eq!(buckets, vec![-6, -4, -2, 0, 1, 3, 5, 7])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bucketize_empty_isizes() {
|
||||
let buckets = bucketize_isizes(7, &vec![]);
|
||||
assert_eq!(buckets, vec![-2, -1, 0, 1, 2])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bucketize_small_isizes() {
|
||||
let buckets = bucketize_isizes(
|
||||
7,
|
||||
&vec![
|
||||
(Duration::minutes(3), -1),
|
||||
(Duration::minutes(3), -1),
|
||||
(Duration::minutes(3), 0),
|
||||
(Duration::minutes(3), -1),
|
||||
(Duration::minutes(3), 0),
|
||||
],
|
||||
);
|
||||
assert_eq!(buckets, vec![-3, -2, -1, 0, 1, 2, 3, 4])
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user