delay chart on "travel times"

This commit is contained in:
Michael Kirk 2021-05-14 16:10:00 -07:00
parent ed36776b1d
commit 4e8136d4ff
4 changed files with 469 additions and 409 deletions

View File

@ -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

View File

@ -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
}
}

View File

@ -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,
}
}

View 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])
}
}