scatter plot: move to ezgui, add a grid, interactive cursor

This commit is contained in:
Dustin Carlino 2020-04-14 12:50:16 -07:00
parent 1c3bab61f9
commit bde09a08e4
6 changed files with 220 additions and 98 deletions

View File

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

View File

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

View File

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

View File

@ -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<usize>) {
// 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(),
)
}

View File

@ -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<usize>) {
// 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,
)
}

View File

@ -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<Duration> {
pub fn trip_time_deltas(&self, now: Time, before: &Analytics) -> Vec<Duration> {
fn trip_times(a: &Analytics, now: Time) -> BTreeMap<TripID, Duration> {
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()