mirror of
https://github.com/a-b-street/abstreet.git
synced 2024-12-25 23:43:25 +03:00
scatter plot: move to ezgui, add a grid, interactive cursor
This commit is contained in:
parent
1c3bab61f9
commit
bde09a08e4
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
187
ezgui/src/widgets/scatter_plot.rs
Normal file
187
ezgui/src/widgets/scatter_plot.rs
Normal 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(),
|
||||
)
|
||||
}
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
@ -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()
|
||||
|
Loading…
Reference in New Issue
Block a user