1use geom::{Angle, Circle, Distance, FindClosest, PolyLine, Pt2D, UnitFmt};
2
3use crate::widgets::plots::{make_legend, thick_lineseries, Axis, PlotOptions, Series};
4use crate::{
5 Color, Drawable, EdgeInsets, EventCtx, GeomBatch, GfxCtx, ScreenDims, ScreenPt,
6 ScreenRectangle, Text, TextExt, Widget, WidgetImpl, WidgetOutput,
7};
8
9pub struct LinePlot<X: Axis<X>, Y: Axis<Y>> {
10 draw: Drawable,
11
12 max_x: X,
14 max_y: Y,
15 closest: FindClosest<String>,
16
17 hovering: Option<Hovering<X, Y>>,
18
19 top_left: ScreenPt,
20 dims: ScreenDims,
21 unit_fmt: UnitFmt,
22}
23
24impl<X: Axis<X>, Y: Axis<Y>> LinePlot<X, Y> {
25 pub fn new_widget(
28 ctx: &EventCtx,
29 label: &str,
30 mut series: Vec<Series<X, Y>>,
31 opts: PlotOptions<X, Y>,
32 unit_fmt: UnitFmt,
33 ) -> Widget {
34 let legend = make_legend(ctx, &series, &opts);
35 series.retain(|s| !opts.disabled.contains(&s.label));
36
37 let max_x = opts.max_x.unwrap_or_else(|| {
39 series
40 .iter()
41 .map(|s| s.pts.iter().map(|(x, _)| *x).max().unwrap_or_else(X::zero))
42 .max()
43 .unwrap_or_else(X::zero)
44 });
45 let max_y = opts.max_y.unwrap_or_else(|| {
46 series
47 .iter()
48 .map(|s| {
49 s.pts
50 .iter()
51 .map(|(_, value)| *value)
52 .max()
53 .unwrap_or_else(Y::zero)
54 })
55 .max()
56 .unwrap_or_else(Y::zero)
57 });
58
59 let default_dims = {
61 let width = 0.23 * ctx.canvas.window_width;
62 let height = 0.2 * ctx.canvas.window_height;
63 ScreenDims { width, height }
64 };
65
66 let dims = opts.dims.unwrap_or(default_dims);
67 let width = dims.width;
68 let height = dims.height;
69
70 let mut batch = GeomBatch::new();
71 {
76 let order_of_mag = 10.0_f64.powf(max_y.to_f64().log10().ceil());
77 for i in 0..10 {
78 let y = max_y.from_f64(order_of_mag / 10.0 * (i as f64));
79 let pct = y.to_percent(max_y);
80 if pct > 1.0 {
81 break;
82 }
83 batch.push(
84 Color::hex("#7C7C7C"),
85 PolyLine::must_new(vec![
86 Pt2D::new(0.0, (1.0 - pct) * height),
87 Pt2D::new(width, (1.0 - pct) * height),
88 ])
89 .make_polygons(Distance::meters(1.0)),
90 );
91 }
92 }
93 if max_x != X::zero() {
95 let order_of_mag = 10.0_f64.powf(max_x.to_f64().log10().ceil());
96 for i in 0..10 {
97 let x = max_x.from_f64(order_of_mag / 10.0 * (i as f64));
98 let pct = x.to_percent(max_x);
99 if pct > 1.0 {
100 break;
101 }
102 batch.push(
103 Color::hex("#7C7C7C"),
104 PolyLine::must_new(vec![
105 Pt2D::new(pct * width, 0.0),
106 Pt2D::new(pct * width, height),
107 ])
108 .make_polygons(Distance::meters(1.0)),
109 );
110 }
111 }
112
113 let mut closest = FindClosest::new();
114 for s in series {
115 if max_x == X::zero() {
116 continue;
117 }
118
119 let mut pts = Vec::new();
120 for (t, y) in s.pts {
121 let percent_x = t.to_percent(max_x);
122 let percent_y = y.to_percent(max_y);
123 pts.push(Pt2D::new(
124 percent_x * width,
125 (1.0 - percent_y) * height,
127 ));
128 }
129 pts = Pt2D::approx_dedupe(pts, Distance::meters(1.0));
132 if pts.len() >= 2 {
133 closest.add(s.label.clone(), &pts);
134 batch.push(s.color, thick_lineseries(pts, Distance::meters(5.0)));
135 }
136 }
137
138 let num_x_labels = 3;
139 let mut row = Vec::new();
140 for i in 0..num_x_labels {
141 let percent_x = (i as f64) / ((num_x_labels - 1) as f64);
142 let x = max_x.from_percent(percent_x);
143 let batch = Text::from(x.prettyprint(&unit_fmt))
145 .render(ctx)
146 .rotate(Angle::degrees(-15.0))
147 .autocrop();
148 row.push(batch.into_widget(ctx));
149 }
150 let x_axis = Widget::custom_row(row)
151 .padding(EdgeInsets {
152 top: 10.0,
153 left: 60.0,
154 right: 10.0,
155 bottom: 10.0,
156 })
157 .evenly_spaced();
158
159 let num_y_labels = 3;
160 let mut col = Vec::new();
161 for i in 0..num_y_labels {
162 let percent_y = (i as f64) / ((num_y_labels - 1) as f64);
163 col.push(
164 max_y
165 .from_percent(percent_y)
166 .prettyprint(&unit_fmt)
167 .text_widget(ctx),
168 );
169 }
170 col.reverse();
171 let y_axis = Widget::custom_col(col).padding(10).evenly_spaced();
172
173 let plot = LinePlot {
174 draw: ctx.upload(batch),
175 closest,
176 max_x,
177 max_y,
178 hovering: None,
179
180 top_left: ScreenPt::new(0.0, 0.0),
181 dims: ScreenDims::new(width, height),
182 unit_fmt,
183 };
184
185 Widget::custom_col(vec![
187 legend.margin_below(10),
188 Widget::custom_row(vec![y_axis, Widget::new(Box::new(plot)).named(label)]),
189 x_axis,
190 ])
191 .container()
192 }
193
194 pub fn get_hovering(&self) -> Vec<(X, Y)> {
195 if let Some(ref h) = self.hovering {
196 h.hits.clone()
197 } else {
198 Vec::new()
199 }
200 }
201
202 pub fn set_hovering(&mut self, ctx: &mut EventCtx, label: &str, x: X, y: Y) {
207 let txt = Text::from(crate::Line(format!(
209 "{}: at {}, {}",
210 label,
211 x.prettyprint(&self.unit_fmt),
212 y.prettyprint(&self.unit_fmt)
213 )))
214 .bg(Color::BLACK)
215 .change_fg(ctx.style().text_tooltip_color);
216
217 let pt = Pt2D::new(
219 self.top_left.x + x.to_percent(self.max_x) * self.dims.width,
220 self.top_left.y + (1.0 - y.to_percent(self.max_y)) * self.dims.height,
221 );
222
223 self.hovering = Some(Hovering {
224 hits: Vec::new(),
225 tooltip: Text::new(),
226 draw_cursor: txt.render(ctx).centered_on(pt).upload(ctx),
227 });
228 }
229}
230
231impl<X: Axis<X>, Y: Axis<Y>> WidgetImpl for LinePlot<X, Y> {
232 fn get_dims(&self) -> ScreenDims {
233 self.dims
234 }
235
236 fn set_pos(&mut self, top_left: ScreenPt) {
237 self.top_left = top_left;
238 }
239
240 fn event(&mut self, ctx: &mut EventCtx, _: &mut WidgetOutput) {
241 if ctx.redo_mouseover() {
242 self.hovering = None;
243 if let Some(cursor) = ctx.canvas.get_cursor_in_screen_space() {
244 if ScreenRectangle::top_left(self.top_left, self.dims).contains(cursor) {
245 let radius = Distance::meters(15.0);
246 let mut txt = Text::new();
247 let mut hits = Vec::new();
248 for (label, pt, _) in self.closest.all_close_pts(
249 Pt2D::new(cursor.x - self.top_left.x, cursor.y - self.top_left.y),
250 radius,
251 ) {
252 let x = self.max_x.from_percent(pt.x() / self.dims.width);
254 let y_percent = 1.0 - (pt.y() / self.dims.height);
255 let y = self.max_y.from_percent(y_percent);
256
257 txt.add_line(format!(
259 "{}: at {}, {}",
260 label,
261 x.prettyprint(&self.unit_fmt),
262 y.prettyprint(&self.unit_fmt)
263 ));
264 hits.push((x, y));
265 }
266 if !hits.is_empty() {
267 self.hovering = Some(Hovering {
268 hits,
269 tooltip: txt,
270 draw_cursor: GeomBatch::from(vec![(
271 Color::RED,
272 Circle::new(cursor.to_pt(), radius).to_polygon(),
273 )])
274 .upload(ctx),
275 });
276 }
277 }
278 }
279 }
280 }
281
282 fn draw(&self, g: &mut GfxCtx) {
283 g.redraw_at(self.top_left, &self.draw);
284
285 if let Some(ref hovering) = self.hovering {
286 g.fork_screenspace();
287 g.redraw(&hovering.draw_cursor);
288 g.draw_mouse_tooltip(hovering.tooltip.clone());
289 g.unfork();
290 }
291 }
292}
293
294struct Hovering<X: Axis<X>, Y: Axis<Y>> {
295 hits: Vec<(X, Y)>,
296 tooltip: Text,
297 draw_cursor: Drawable,
298}