From bde09a08e4c8574adb7cccc51dc94b2d76f39e1b Mon Sep 17 00:00:00 2001 From: Dustin Carlino Date: Tue, 14 Apr 2020 12:50:16 -0700 Subject: [PATCH] scatter plot: move to ezgui, add a grid, interactive cursor --- ezgui/src/lib.rs | 2 + ezgui/src/screen_geom.rs | 14 +++ ezgui/src/widgets/mod.rs | 1 + ezgui/src/widgets/scatter_plot.rs | 187 ++++++++++++++++++++++++++++++ game/src/sandbox/dashboards.rs | 98 ++-------------- sim/src/analytics.rs | 16 +-- 6 files changed, 220 insertions(+), 98 deletions(-) create mode 100644 ezgui/src/widgets/scatter_plot.rs diff --git a/ezgui/src/lib.rs b/ezgui/src/lib.rs index 07dfaa1223..f325216d38 100644 --- a/ezgui/src/lib.rs +++ b/ezgui/src/lib.rs @@ -13,6 +13,7 @@ //! * [`LinePlot`] - visualize 2 variables with a line plot //! * [`Menu`] - select something from a menu, with keybindings //! * [`PersistentSplit`] - a button with a dropdown to change its state +//! * [`ScatterPlot`] - visualize 2 variables with a scatter plot //! * [`Slider`] - horizontal and vertical sliders //! * [`Spinner`] - numeric input with up/down buttons //! * [`TexBox`] - single line text entry @@ -65,6 +66,7 @@ pub(crate) use crate::widgets::just_draw::JustDraw; pub use crate::widgets::line_plot::{LinePlot, PlotOptions, Series}; pub(crate) use crate::widgets::menu::Menu; pub use crate::widgets::persistent_split::PersistentSplit; +pub use crate::widgets::scatter_plot::ScatterPlot; pub use crate::widgets::slider::Slider; pub use crate::widgets::spinner::Spinner; pub(crate) use crate::widgets::text_box::TextBox; diff --git a/ezgui/src/screen_geom.rs b/ezgui/src/screen_geom.rs index 76c15da439..bcb00a761b 100644 --- a/ezgui/src/screen_geom.rs +++ b/ezgui/src/screen_geom.rs @@ -51,6 +51,20 @@ impl ScreenRectangle { pt.x >= self.x1 && pt.x <= self.x2 && pt.y >= self.y1 && pt.y <= self.y2 } + pub fn pt_to_percent(&self, pt: ScreenPt) -> Option<(f64, f64)> { + if self.contains(pt) { + Some(( + (pt.x - self.x1) / self.width(), + (pt.y - self.y1) / self.height(), + )) + } else { + None + } + } + pub fn percent_to_pt(&self, x: f64, y: f64) -> ScreenPt { + ScreenPt::new(self.x1 + x * self.width(), self.y1 + y * self.height()) + } + // TODO Remove these in favor of dims() pub fn width(&self) -> f64 { self.x2 - self.x1 diff --git a/ezgui/src/widgets/mod.rs b/ezgui/src/widgets/mod.rs index b21dcdfaaf..a0b7af2f6d 100644 --- a/ezgui/src/widgets/mod.rs +++ b/ezgui/src/widgets/mod.rs @@ -8,6 +8,7 @@ pub mod just_draw; pub mod line_plot; pub mod menu; pub mod persistent_split; +pub mod scatter_plot; pub mod slider; pub mod spinner; pub mod text_box; diff --git a/ezgui/src/widgets/scatter_plot.rs b/ezgui/src/widgets/scatter_plot.rs new file mode 100644 index 0000000000..819028ac14 --- /dev/null +++ b/ezgui/src/widgets/scatter_plot.rs @@ -0,0 +1,187 @@ +use crate::{ + Color, Drawable, EventCtx, GeomBatch, GfxCtx, Line, ScreenDims, ScreenPt, ScreenRectangle, + Text, TextExt, Widget, WidgetImpl, WidgetOutput, +}; +use geom::{Angle, Circle, Distance, Duration, Pt2D}; + +// TODO This is tuned for the trip time comparison right now. +// - Generic types for x and y axis +// - number of labels +// - rounding behavior +// - forcing the x and y axis to be on the same scale, be drawn as a square +// - coloring the better/worse + +pub struct ScatterPlot { + draw: Drawable, + + max: Duration, + x_name: String, + y_name: String, + + top_left: ScreenPt, + dims: ScreenDims, +} + +impl ScatterPlot { + pub fn new( + ctx: &mut EventCtx, + x_name: &str, + y_name: &str, + points: Vec<(Duration, Duration)>, + ) -> Widget { + if points.is_empty() { + return Widget::nothing(); + } + + let actual_max = *points.iter().map(|(b, a)| a.max(b)).max().unwrap(); + // Excluding 0 + let num_labels = 5; + let (max, labels) = make_intervals(actual_max, num_labels); + + // We want a nice square so the scales match up. + let width = 500.0; + let height = width; + + let mut batch = GeomBatch::new(); + batch.autocrop_dims = false; + + // Grid lines + let thickness = Distance::meters(2.0); + for i in 1..num_labels { + let x = (i as f64) / (num_labels as f64) * width; + let y = (i as f64) / (num_labels as f64) * height; + // Horizontal + batch.push( + Color::grey(0.5), + geom::Line::new(Pt2D::new(0.0, y), Pt2D::new(width, y)).make_polygons(thickness), + ); + // Vertical + batch.push( + Color::grey(0.5), + geom::Line::new(Pt2D::new(x, 0.0), Pt2D::new(x, height)).make_polygons(thickness), + ); + } + + let circle = Circle::new(Pt2D::new(0.0, 0.0), Distance::meters(4.0)).to_polygon(); + for (b, a) in points { + let pt = Pt2D::new((b / max) * width, (1.0 - (a / max)) * height); + // TODO Could color circles by mode + let color = if a == b { + Color::YELLOW.alpha(0.5) + } else if a < b { + Color::GREEN.alpha(0.9) + } else { + Color::RED.alpha(0.9) + }; + batch.push(color, circle.translate(pt.x(), pt.y())); + } + let plot = Widget::new(Box::new(ScatterPlot { + dims: batch.get_dims(), + draw: ctx.upload(batch), + max, + x_name: x_name.to_string(), + y_name: y_name.to_string(), + top_left: ScreenPt::new(0.0, 0.0), + })); + + let y_axis = Widget::col( + labels + .iter() + .rev() + .map(|x| Line(x.to_string()).small().draw(ctx)) + .collect(), + ) + .evenly_spaced(); + let y_label = { + let mut label = GeomBatch::new(); + for (color, poly) in Text::from(Line(format!("{} (minutes)", y_name))) + .render_ctx(ctx) + .consume() + { + label.fancy_push(color, poly.rotate(Angle::new_degs(90.0))); + } + Widget::draw_batch(ctx, label.autocrop()).centered_vert() + }; + + let x_axis = Widget::row( + labels + .iter() + .map(|x| Line(x.to_string()).small().draw(ctx)) + .collect(), + ) + .evenly_spaced(); + let x_label = format!("{} (minutes)", x_name) + .draw_text(ctx) + .centered_horiz(); + + // It's a bit of work to make both the x and y axis line up with the plot. :) + let plot_width = plot.get_width_for_forcing(); + Widget::row(vec![Widget::col(vec![ + Widget::row(vec![y_label, y_axis, plot]), + Widget::col(vec![x_axis, x_label]) + .force_width(plot_width) + .align_right(), + ])]) + } +} + +impl WidgetImpl for ScatterPlot { + fn get_dims(&self) -> ScreenDims { + self.dims + } + + fn set_pos(&mut self, top_left: ScreenPt) { + self.top_left = top_left; + } + + fn event(&mut self, _ctx: &mut EventCtx, _output: &mut WidgetOutput) {} + + 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() { + let rect = ScreenRectangle::top_left(self.top_left, self.dims); + if let Some((pct_x, pct_y)) = rect.pt_to_percent(cursor) { + let thickness = Distance::meters(2.0); + let mut batch = GeomBatch::new(); + // Horizontal + batch.push( + Color::WHITE, + geom::Line::new(Pt2D::new(rect.x1, cursor.y), Pt2D::new(cursor.x, cursor.y)) + .make_polygons(thickness), + ); + // Vertical + batch.push( + Color::WHITE, + geom::Line::new(Pt2D::new(cursor.x, rect.y2), Pt2D::new(cursor.x, cursor.y)) + .make_polygons(thickness), + ); + + g.fork_screenspace(); + let draw = g.upload(batch); + g.redraw(&draw); + g.draw_mouse_tooltip(Text::from_multiline(vec![ + Line(format!("{}: {}", self.x_name, pct_x * self.max)), + Line(format!("{}: {}", self.y_name, (1.0 - pct_y) * self.max)), + ])); + g.unfork(); + } + } + } +} + +// TODO Do something fancier? http://vis.stanford.edu/papers/tick-labels +fn make_intervals(actual_max: Duration, num_labels: usize) -> (Duration, Vec) { + // Example: 43 minutes, max 5 labels... raw_mins_per_interval is 8.6 + let raw_mins_per_interval = + (actual_max.num_minutes_rounded_down() as f64) / (num_labels as f64); + // So then this rounded up to 10 minutes + let mins_per_interval = Duration::seconds(60.0 * raw_mins_per_interval) + .round_up(Duration::minutes(5)) + .num_minutes_rounded_down(); + + ( + actual_max.round_up(Duration::minutes(mins_per_interval)), + (0..=num_labels).map(|i| i * mins_per_interval).collect(), + ) +} diff --git a/game/src/sandbox/dashboards.rs b/game/src/sandbox/dashboards.rs index 83f86e0585..1d3edbea32 100644 --- a/game/src/sandbox/dashboards.rs +++ b/game/src/sandbox/dashboards.rs @@ -6,10 +6,10 @@ use crate::sandbox::trip_table::TripTable; use crate::sandbox::SandboxMode; use abstutil::prettyprint_usize; use ezgui::{ - hotkey, Btn, Color, Composite, EventCtx, GeomBatch, GfxCtx, Key, Line, LinePlot, Outcome, - PlotOptions, Series, Text, TextExt, Widget, + hotkey, Btn, Color, Composite, EventCtx, GfxCtx, Key, Line, LinePlot, Outcome, PlotOptions, + ScatterPlot, Series, Text, TextExt, Widget, }; -use geom::{Angle, Circle, Distance, Duration, Polygon, Pt2D, Time}; +use geom::{Duration, Time}; // Oh the dashboards melted, but we still had the radio #[derive(PartialEq)] @@ -190,7 +190,7 @@ fn summary_absolute(ctx: &mut EventCtx, app: &App) -> Widget { let mut slower = Vec::new(); let mut sum_faster = Duration::ZERO; let mut sum_slower = Duration::ZERO; - for (a, b) in app + for (b, a) in app .primary .sim .get_analytics() @@ -268,7 +268,7 @@ fn summary_normalized(ctx: &mut EventCtx, app: &App) -> Widget { let mut num_same = 0; let mut faster = Vec::new(); let mut slower = Vec::new(); - for (a, b) in app + for (b, a) in app .primary .sim .get_analytics() @@ -326,88 +326,10 @@ fn scatter_plot(ctx: &mut EventCtx, app: &App) -> Widget { .sim .get_analytics() .both_finished_trips(app.primary.sim.time(), app.prebaked()); - if points.is_empty() { - return Widget::nothing(); - } - - let actual_max = *points.iter().map(|(a, b)| a.max(b)).max().unwrap(); - let (max, labels) = make_intervals(actual_max, 5); - - // We want a nice square so the scales match up. - let width = 500.0; - let height = width; - - let mut batch = GeomBatch::new(); - batch.autocrop_dims = false; - batch.push(Color::BLACK, Polygon::rectangle(width, width)); - - let circle = Circle::new(Pt2D::new(0.0, 0.0), Distance::meters(4.0)).to_polygon(); - for (a, b) in points { - let pt = Pt2D::new((a / max) * width, (1.0 - (b / max)) * height); - // TODO Could color circles by mode - let color = if a == b { - Color::YELLOW.alpha(0.5) - } else if a < b { - Color::GREEN.alpha(0.9) - } else { - Color::RED.alpha(0.9) - }; - batch.push(color, circle.translate(pt.x(), pt.y())); - } - let plot = Widget::draw_batch(ctx, batch); - - let y_axis = Widget::col( - labels - .iter() - .rev() - .map(|x| Line(x.to_string()).small().draw(ctx)) - .collect(), - ) - .evenly_spaced(); - let y_label = { - let mut label = GeomBatch::new(); - for (color, poly) in Text::from(Line("Current trip time (minutes)")) - .render_ctx(ctx) - .consume() - { - label.fancy_push(color, poly.rotate(Angle::new_degs(90.0))); - } - Widget::draw_batch(ctx, label.autocrop()).centered_vert() - }; - - let x_axis = Widget::row( - labels - .iter() - .map(|x| Line(x.to_string()).small().draw(ctx)) - .collect(), - ) - .evenly_spaced(); - let x_label = Line("Original trip time (minutes)") - .draw(ctx) - .centered_horiz(); - - // It's a bit of work to make both the x and y axis line up with the plot. :) - let plot_width = plot.get_width_for_forcing(); - Widget::row(vec![Widget::col(vec![ - Widget::row(vec![y_label, y_axis, plot]), - Widget::col(vec![x_axis, x_label]) - .force_width(plot_width) - .align_right(), - ])]) -} - -// TODO Do something fancier? http://vis.stanford.edu/papers/tick-labels -fn make_intervals(actual_max: Duration, num_labels: usize) -> (Duration, Vec) { - // Example: 43 minutes, max 5 labels... raw_mins_per_interval is 8.6 - let raw_mins_per_interval = - (actual_max.num_minutes_rounded_down() as f64) / (num_labels as f64); - // So then this rounded up to 10 minutes - let mins_per_interval = Duration::seconds(60.0 * raw_mins_per_interval) - .round_up(Duration::minutes(5)) - .num_minutes_rounded_down(); - - ( - actual_max.round_up(Duration::minutes(mins_per_interval)), - (0..=num_labels).map(|i| i * mins_per_interval).collect(), + ScatterPlot::new( + ctx, + "Trip time before changes", + "Trip time after changes", + points, ) } diff --git a/sim/src/analytics.rs b/sim/src/analytics.rs index 5eea546249..3aa70e0d16 100644 --- a/sim/src/analytics.rs +++ b/sim/src/analytics.rs @@ -236,12 +236,8 @@ impl Analytics { None } - // Returns pairs of trip times for finished trips in both worlds. - pub fn both_finished_trips( - &self, - now: Time, - baseline: &Analytics, - ) -> Vec<(Duration, Duration)> { + // Returns pairs of trip times for finished trips in both worlds. (before, after) + pub fn both_finished_trips(&self, now: Time, before: &Analytics) -> Vec<(Duration, Duration)> { let mut a = BTreeMap::new(); for (t, id, maybe_mode, dt) in &self.finished_trips { if *t > now { @@ -253,13 +249,13 @@ impl Analytics { } let mut results = Vec::new(); - for (t, id, maybe_mode, dt) in &baseline.finished_trips { + for (t, id, maybe_mode, dt) in &before.finished_trips { if *t > now { break; } if maybe_mode.is_some() { if let Some(dt1) = a.remove(id) { - results.push((dt1, *dt)); + results.push((*dt, dt1)); } } } @@ -269,7 +265,7 @@ impl Analytics { // Returns unsorted list of deltas, one for each trip finished or ongoing in both worlds. // Positive dt means faster. // TODO Now unused - pub fn trip_time_deltas(&self, now: Time, baseline: &Analytics) -> Vec { + pub fn trip_time_deltas(&self, now: Time, before: &Analytics) -> Vec { fn trip_times(a: &Analytics, now: Time) -> BTreeMap { let mut ongoing = a.started_trips.clone(); let mut trips = BTreeMap::new(); @@ -291,7 +287,7 @@ impl Analytics { } let a = trip_times(&self, now); - let b = trip_times(baseline, now); + let b = trip_times(before, now); // TODO Think through what missing (aborted) in one but not the other means a.into_iter()