1use std::cmp::Ordering;
2use std::fmt::Display;
3
4use abstutil::{abbreviated_format, prettyprint_usize, CloneableAny};
5use geom::{Angle, Distance, Duration, Line, Polygon, Pt2D};
6use sim::{ProblemType, TripID};
7use synthpop::TripMode;
8use widgetry::tools::ColorScale;
9use widgetry::{
10 ClickOutcome, Color, DrawWithTooltips, GeomBatch, GeomBatchStack, StackAlignment, Text, Widget,
11};
12
13use crate::{App, EventCtx};
14
15pub trait TripProblemFilter {
16 fn includes_mode(&self, mode: &TripMode) -> bool;
17 fn include_no_changes(&self) -> bool;
18
19 fn trip_problems(
25 &self,
26 app: &App,
27 problem_type: ProblemType,
28 ) -> Vec<(TripID, Duration, isize)> {
29 let before = app.prebaked();
30 let after = app.primary.sim.get_analytics();
31 let empty = Vec::new();
32
33 let mut points = Vec::new();
34 for (id, _, time_after, mode) in after.both_finished_trips(app.primary.sim.time(), before) {
35 if self.includes_mode(&mode) {
36 let count_before = problem_type
37 .count(before.problems_per_trip.get(&id).unwrap_or(&empty))
38 as isize;
39 let count_after =
40 problem_type.count(after.problems_per_trip.get(&id).unwrap_or(&empty)) as isize;
41 if !self.include_no_changes() && count_after == count_before {
42 continue;
43 }
44 points.push((id, time_after, count_after - count_before));
45 }
46 }
47 points
48 }
49
50 fn finished_trip_count(&self, app: &App) -> usize {
51 let before = app.prebaked();
52 let after = app.primary.sim.get_analytics();
53
54 let mut count = 0;
55 for (_, _, _, mode) in after.both_finished_trips(app.primary.sim.time(), before) {
56 if self.includes_mode(&mode) {
57 count += 1;
58 }
59 }
60 count
61 }
62}
63
64lazy_static::lazy_static! {
65 static ref CLEAR_COLOR_SCALE: ColorScale = ColorScale(vec![Color::CLEAR, Color::CLEAR]);
66}
67
68pub fn problem_matrix(
70 ctx: &mut EventCtx,
71 app: &App,
72 trips: Vec<(TripID, Duration, isize)>,
73) -> Widget {
74 let duration_buckets = vec![
75 Duration::ZERO,
76 Duration::minutes(5),
77 Duration::minutes(15),
78 Duration::minutes(30),
79 Duration::hours(1),
80 Duration::hours(2),
81 ];
82
83 let num_buckets = 7;
84 let mut matrix = Matrix::new(duration_buckets, bucketize_isizes(num_buckets, &trips));
85 for (id, x, y) in trips {
86 matrix.add_pt(id, x, y);
87 }
88 matrix.draw(
89 ctx,
90 app,
91 MatrixOptions {
92 total_width: 600.0,
93 total_height: 600.0,
94 color_scale_for_bucket: Box::new(|app, _, n| match n.cmp(&0) {
95 std::cmp::Ordering::Equal => &CLEAR_COLOR_SCALE,
96 std::cmp::Ordering::Less => &app.cs.good_to_bad_green,
97 std::cmp::Ordering::Greater => &app.cs.good_to_bad_red,
98 }),
99 fmt_y_axis: Box::new(|lower_bound: isize, upper_bound: isize| -> Text {
100 if lower_bound + 1 == upper_bound {
101 Text::from(lower_bound.abs().to_string())
102 } else if lower_bound.is_negative() {
103 Text::from(format!("{}-{}", upper_bound.abs() + 1, lower_bound.abs()))
104 } else {
105 Text::from(format!("{}-{}", lower_bound.abs(), upper_bound.abs() - 1))
106 }
107 }),
108 tooltip_for_bucket: Box::new(|(t1, t2), (problems1, problems2), count| {
109 let trip_string = if count == 1 {
110 "1 trip".to_string()
111 } else {
112 format!("{} trips", prettyprint_usize(count))
113 };
114 let duration_string = match (t1, t2) {
115 (None, Some(end)) => format!("shorter than {}", end),
116 (Some(start), None) => format!("longer than {}", start),
117 (Some(start), Some(end)) => format!("between {} and {}", start, end),
118 (None, None) => {
119 unreachable!("at least one end of the duration range must be specified")
120 }
121 };
122 let mut txt = Text::from(format!("{} {}", trip_string, duration_string));
123 txt.add_line(match problems1.cmp(&0) {
124 std::cmp::Ordering::Equal => {
125 "had no change in the number of problems encountered.".to_string()
126 }
127 std::cmp::Ordering::Less => {
128 if problems1.abs() == problems2.abs() + 1 {
129 if problems1.abs() == 1 {
130 "encountered 1 fewer problem.".to_string()
131 } else {
132 format!("encountered {} fewer problems.", problems1.abs())
133 }
134 } else {
135 format!(
136 "encountered {}-{} fewer problems.",
137 problems2.abs() + 1,
138 problems1.abs()
139 )
140 }
141 }
142 std::cmp::Ordering::Greater => {
143 if problems1 == problems2 - 1 {
144 if problems1 == 1 {
145 "encountered 1 more problems.".to_string()
146 } else {
147 format!("encountered {} more problems.", problems1,)
148 }
149 } else {
150 format!("encountered {}-{} more problems.", problems1, problems2 - 1)
151 }
152 }
153 });
154 txt
155 }),
156 },
157 )
158}
159
160struct Matrix<ID, X, Y> {
162 entries: Vec<Vec<ID>>,
163 buckets_x: Vec<X>,
164 buckets_y: Vec<Y>,
165}
166
167impl<
168 ID: 'static + CloneableAny + Clone,
169 X: Copy + PartialOrd + Display,
170 Y: Copy + PartialOrd + Display,
171 > Matrix<ID, X, Y>
172{
173 fn new(buckets_x: Vec<X>, buckets_y: Vec<Y>) -> Matrix<ID, X, Y> {
174 Matrix {
175 entries: std::iter::repeat_with(Vec::new)
176 .take(buckets_x.len() * buckets_y.len())
177 .collect(),
178 buckets_x,
179 buckets_y,
180 }
181 }
182
183 fn add_pt(&mut self, id: ID, x: X, y: Y) {
184 let x_idx = self
187 .buckets_x
188 .iter()
189 .position(|min| *min > x)
190 .unwrap_or(self.buckets_x.len())
191 - 1;
192 let y_idx = self
193 .buckets_y
194 .iter()
195 .position(|min| *min > y)
196 .unwrap_or(self.buckets_y.len())
197 - 1;
198 let idx = self.idx(x_idx, y_idx);
199 self.entries[idx].push(id);
200 }
201
202 fn idx(&self, x: usize, y: usize) -> usize {
203 y * self.buckets_x.len() + x
205 }
206
207 fn draw(mut self, ctx: &mut EventCtx, app: &App, opts: MatrixOptions<X, Y>) -> Widget {
208 let mut grid_batch = GeomBatch::new();
209 let mut tooltips = Vec::new();
210 let cell_width = opts.total_width / (self.buckets_x.len() as f64);
211 let cell_height = opts.total_height / (self.buckets_y.len() as f64);
212 let cell = Polygon::rectangle(cell_width, cell_height);
213
214 let max_count = self.entries.iter().map(|list| list.len()).max().unwrap() as f64;
215
216 for x in 0..self.buckets_x.len() - 1 {
217 for y in 0..self.buckets_y.len() - 1 {
218 let is_first_xbucket = x == 0;
219 let is_last_xbucket = x == self.buckets_x.len() - 2;
220 let is_middle_ybucket = y + 1 == self.buckets_y.len() / 2;
221 let idx = self.idx(x, y);
222 let count = self.entries[idx].len();
223 let color = if count == 0 {
224 widgetry::Color::CLEAR
225 } else {
226 let density_pct = (count as f64) / max_count;
227 (opts.color_scale_for_bucket)(app, self.buckets_x[x], self.buckets_y[y])
228 .eval(density_pct)
229 };
230 let x1 = cell_width * (x as f64);
231 let y1 = cell_height * (y as f64);
232 let rect = cell.clone().translate(x1, y1);
233 grid_batch.push(color, rect.clone());
234 grid_batch.append(
235 Text::from(if count == 0 && is_middle_ybucket {
236 "-".to_string()
237 } else {
238 abbreviated_format(count)
239 })
240 .change_fg(if count == 0 || is_middle_ybucket {
241 ctx.style().text_primary_color
242 } else {
243 Color::WHITE
244 })
245 .render(ctx)
246 .centered_on(Pt2D::new(x1 + cell_width / 2.0, y1 + cell_height / 2.0)),
247 );
248
249 if count != 0 || !is_middle_ybucket {
250 tooltips.push((
251 rect,
252 (opts.tooltip_for_bucket)(
253 (
254 if is_first_xbucket {
255 None
256 } else {
257 Some(self.buckets_x[x])
258 },
259 if is_last_xbucket {
260 None
261 } else {
262 Some(self.buckets_x[x + 1])
263 },
264 ),
265 (self.buckets_y[y], self.buckets_y[y + 1]),
266 count,
267 ),
268 if count != 0 {
269 Some(ClickOutcome::Custom(Box::new(std::mem::take(
270 &mut self.entries[idx],
271 ))))
272 } else {
273 None
274 },
275 ));
276 }
277 }
278 }
279 {
280 let bottom = cell_height * (self.buckets_y.len() - 1) as f64;
281 let right = cell_width * (self.buckets_x.len() - 1) as f64;
282
283 let border_lines = vec![
284 Line::must_new(Pt2D::zero(), Pt2D::new(right, 0.0)),
285 Line::must_new(Pt2D::new(right, 0.0), Pt2D::new(right, bottom)),
286 Line::must_new(Pt2D::new(right, bottom), Pt2D::new(0.0, bottom)),
287 Line::must_new(Pt2D::new(0.0, bottom), Pt2D::zero()),
288 ];
289 for line in border_lines {
290 let border_poly = line.make_polygons(Distance::meters(3.0));
291 grid_batch.push(ctx.style().text_secondary_color, border_poly);
292 }
293 }
294
295 let y_axis_batch = {
297 let mut y_axis_scale = GeomBatch::new();
298 for y in 0..self.buckets_y.len() - 1 {
299 let x1 = 0.0;
300 let mut y1 = cell_height * y as f64;
301
302 let middle_bucket = self.buckets_y.len() / 2 - 1;
303 let y_offset = match y.cmp(&middle_bucket) {
304 Ordering::Less => cell_height,
305 Ordering::Greater => 0.0,
306 Ordering::Equal => cell_height / 2.0,
307 };
308
309 let y_label = (opts.fmt_y_axis)(self.buckets_y[y], self.buckets_y[y + 1])
310 .change_fg(ctx.style().text_secondary_color)
311 .render(ctx)
312 .centered_on(Pt2D::new(x1 + cell_width / 2.0, y1 + 0.5 * cell_height));
313 y_axis_scale.append(y_label);
314
315 if y != middle_bucket {
316 y1 += y_offset;
317 let tick_length = 8.0;
318 let tick_thickness = 2.0;
319 let start = Pt2D::new(x1 + cell_width - tick_length, y1 - tick_thickness / 2.0);
320 let line = Line::must_new(start, start.offset(tick_length, 0.0))
321 .make_polygons(Distance::meters(tick_thickness));
322 y_axis_scale.push(ctx.style().text_secondary_color, line);
323 }
324 }
325 let mut y_axis_label = Text::from("More Problems <--------> Fewer Problems")
326 .change_fg(ctx.style().text_secondary_color)
327 .render(ctx)
328 .rotate(Angle::degrees(-90.0));
329
330 y_axis_label.autocrop_dims = true;
331 y_axis_label = y_axis_label.autocrop();
332
333 y_axis_label = y_axis_label.centered_on(Pt2D::new(
334 8.0,
335 cell_height * (self.buckets_y.len() as f64 / 2.0 - 1.0),
336 ));
337
338 GeomBatchStack::horizontal(vec![y_axis_label, y_axis_scale]).batch()
339 };
340
341 let x_axis_batch = {
342 let mut x_axis_scale = GeomBatch::new();
343 for x in 1..self.buckets_x.len() - 1 {
344 let x1 = cell_width * x as f64;
345 let y1 = 0.0;
346
347 x_axis_scale.append(
348 Text::from(format!("{}", self.buckets_x[x]))
349 .change_fg(ctx.style().text_secondary_color)
350 .render(ctx)
351 .centered_on(Pt2D::new(x1, y1 + cell_height / 2.0)),
352 );
353 let tick_length = 8.0;
354 let tick_thickness = 2.0;
355 let start = Pt2D::new(x1, y1 - 2.0);
356 let line = Line::must_new(start, start.offset(0.0, tick_length))
357 .make_polygons(Distance::meters(tick_thickness));
358 x_axis_scale.push(ctx.style().text_secondary_color, line);
359 }
360 let x_axis_label = Text::from("Short Trips <--------> Long Trips")
361 .change_fg(ctx.style().text_secondary_color)
362 .render(ctx)
363 .centered_on(Pt2D::new(
364 cell_width * ((self.buckets_x.len() as f64) / 2.0 - 0.5),
365 cell_height,
366 ));
367
368 x_axis_scale.append(x_axis_label);
369
370 x_axis_scale
371 };
372
373 for (polygon, _, _) in &mut tooltips {
374 let mut translated = polygon.translate(y_axis_batch.get_bounds().width(), 0.0);
375 std::mem::swap(&mut translated, polygon);
376 }
377 let mut col = GeomBatchStack::vertical(vec![grid_batch, x_axis_batch]);
378 col.set_alignment(StackAlignment::Left);
379
380 let mut chart = GeomBatchStack::horizontal(vec![y_axis_batch, col.batch()]);
381 chart.set_alignment(StackAlignment::Top);
382
383 DrawWithTooltips::new_widget(ctx, chart.batch(), tooltips, Box::new(|_| GeomBatch::new()))
384 }
385}
386
387struct MatrixOptions<X, Y> {
388 total_width: f64,
389 total_height: f64,
390 fmt_y_axis: Box<dyn Fn(Y, Y) -> Text>,
392 color_scale_for_bucket: Box<dyn Fn(&App, X, Y) -> &ColorScale>,
393 tooltip_for_bucket: Box<dyn Fn((Option<X>, Option<X>), (Y, Y), usize) -> Text>,
394}
395
396fn bucketize_isizes(max_buckets: usize, pts: &[(TripID, Duration, isize)]) -> Vec<isize> {
397 debug_assert!(
398 max_buckets % 2 == 1,
399 "num_buckets must be odd to have a symmetrical number of buckets around axis"
400 );
401 debug_assert!(max_buckets >= 3, "num_buckets must be at least 3");
402
403 let positive_buckets = (max_buckets - 1) / 2;
404 let max = match pts.iter().max_by_key(|(_, _, cnt)| cnt.abs()) {
406 Some(t) if (t.2.abs() as usize) >= positive_buckets => t.2.abs(),
407 _ => {
408 let negative_buckets = -(positive_buckets as isize);
410 return (negative_buckets..=(positive_buckets as isize + 1)).collect();
411 }
412 };
413
414 let bucket_size = (max as f64 / positive_buckets as f64).ceil() as isize;
415
416 let mut buckets = vec![0];
418
419 for i in 0..=positive_buckets {
420 buckets.push(1 + (i as isize) * bucket_size);
422 }
423 for i in 1..=positive_buckets {
424 buckets.push(-(i as isize) * bucket_size);
425 }
426 buckets.sort_unstable();
427 debug!("buckets: {:?}", buckets);
428
429 buckets
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 const TRIP: TripID = TripID(42);
437
438 #[test]
439 fn test_bucketize_isizes() {
440 let buckets = bucketize_isizes(
441 7,
442 &vec![
443 (TRIP, Duration::minutes(3), -3),
444 (TRIP, Duration::minutes(3), -3),
445 (TRIP, Duration::minutes(3), -1),
446 (TRIP, Duration::minutes(3), 2),
447 (TRIP, Duration::minutes(3), 5),
448 ],
449 );
450 assert_eq!(buckets, vec![-6, -4, -2, 0, 1, 3, 5, 7])
457 }
458
459 #[test]
460 fn test_bucketize_empty_isizes() {
461 let buckets = bucketize_isizes(7, &vec![]);
462 assert_eq!(buckets, vec![-3, -2, -1, 0, 1, 2, 3, 4])
463 }
464
465 #[test]
466 fn test_bucketize_small_isizes() {
467 let buckets = bucketize_isizes(
468 7,
469 &vec![
470 (TRIP, Duration::minutes(3), -1),
471 (TRIP, Duration::minutes(3), -1),
472 (TRIP, Duration::minutes(3), 0),
473 (TRIP, Duration::minutes(3), -1),
474 (TRIP, Duration::minutes(3), 0),
475 ],
476 );
477 assert_eq!(buckets, vec![-3, -2, -1, 0, 1, 2, 3, 4])
478 }
479}