map_gui/tools/
minimap.rs

1use std::marker::PhantomData;
2
3use geom::{Distance, Pt2D, Ring, Time};
4use widgetry::{
5    ControlState, Drawable, EventCtx, Filler, GfxCtx, HorizontalAlignment, Line, Outcome, Panel,
6    ScreenDims, ScreenPt, Spinner, Transition, VerticalAlignment, Widget,
7};
8
9use crate::AppLike;
10
11static MINIMAP_WIDTH: f64 = 400.0;
12static MINIMAP_HEIGHT: f64 = 300.0;
13
14// TODO Some of the math in here might assume map bound minimums start at (0, 0).
15pub struct Minimap<A: AppLike, T: MinimapControls<A>> {
16    controls: T,
17    time: Time,
18    app_type: PhantomData<A>,
19
20    dragging: bool,
21    panel: Panel,
22    // Update panel when other things change
23    zoomed: bool,
24    layer: bool,
25
26    // [0, 3], with 0 meaning the most unzoomed
27    zoom_lvl: usize,
28    base_zoom: f64,
29    zoom: f64,
30    offset_x: f64,
31    offset_y: f64,
32}
33
34/// Customize the appearance and behavior of a minimap.
35pub trait MinimapControls<A: AppLike> {
36    /// Should the user be able to control the z-order visible? The control is only present when
37    /// zoomed in, placed beneath the zoom column.
38    fn has_zorder(&self, app: &A) -> bool;
39    /// Is there some additional layer displayed on the minimap? If this changes, the panel gets
40    /// recalculated.
41    fn has_layer(&self, _: &A) -> bool {
42        false
43    }
44
45    /// Draw extra stuff on the minimap, just pulling from the app.
46    fn draw_extra(&self, _: &mut GfxCtx, _: &A) {}
47
48    /// When unzoomed, display this panel. By default, no controls when unzoomed.
49    fn make_unzoomed_panel(&self, ctx: &mut EventCtx, _: &A) -> Panel {
50        Panel::empty(ctx)
51    }
52    /// A row beneath the minimap in the zoomed view, usually used as a legend for things on the
53    /// minimap.
54    fn make_legend(&self, _: &mut EventCtx, _: &A) -> Widget {
55        Widget::nothing()
56    }
57    /// Controls to be placed to the left to the zoomed-in panel
58    fn make_zoomed_side_panel(&self, _: &mut EventCtx, _: &A) -> Widget {
59        Widget::nothing()
60    }
61
62    /// If a button is clicked that was produced by some method in this trait, respond to it here.
63    fn panel_clicked(&self, _: &mut EventCtx, _: &mut A, _: &str) -> Option<Transition<A>> {
64        unreachable!()
65    }
66    /// Called for `Outcome::Changed` on the panel.
67    fn panel_changed(&self, _: &mut EventCtx, _: &mut A, _: &Panel) {}
68}
69
70impl<A: AppLike + 'static, T: MinimapControls<A>> Minimap<A, T> {
71    pub fn new(ctx: &mut EventCtx, app: &A, controls: T) -> Minimap<A, T> {
72        // Initially pick a zoom to fit the smaller of the entire map's width or height in the
73        // minimap. Arbitrary and probably pretty weird.
74        let bounds = app.map().get_bounds();
75        let base_zoom = 0.15 * ctx.canvas.window_width / bounds.width().min(bounds.height());
76        let layer = controls.has_layer(app);
77        let mut m = Minimap {
78            controls,
79            time: Time::START_OF_DAY,
80            app_type: PhantomData,
81
82            dragging: false,
83            panel: Panel::empty(ctx),
84            zoomed: ctx.canvas.is_zoomed(),
85            layer,
86
87            zoom_lvl: 0,
88            base_zoom,
89            zoom: base_zoom,
90            offset_x: 0.0,
91            offset_y: 0.0,
92        };
93        m.recreate_panel(ctx, app);
94        if m.zoomed {
95            m.recenter(ctx, app);
96        }
97        m
98    }
99
100    pub fn recreate_panel(&mut self, ctx: &mut EventCtx, app: &A) {
101        if ctx.canvas.is_unzoomed() {
102            self.panel = self.controls.make_unzoomed_panel(ctx, app);
103            return;
104        }
105
106        let zoom_col = {
107            let mut col = vec![ctx
108                .style()
109                .btn_plain
110                .icon("system/assets/speed/plus.svg")
111                .build_widget(ctx, "zoom in")
112                .centered_horiz()
113                .margin_below(10)];
114
115            let level_btn = ctx
116                .style()
117                .btn_plain
118                .icon("system/assets/speed/zoom_level_rect.svg")
119                .padding_top(0.0)
120                .padding_bottom(0.0);
121
122            for i in (0..=3).rev() {
123                let level_btn = if self.zoom_lvl < i {
124                    level_btn
125                        .clone()
126                        .image_color(ctx.style().btn_outline.fg_disabled, ControlState::Default)
127                } else {
128                    level_btn.clone()
129                };
130                col.push(
131                    level_btn
132                        .build_widget(ctx, format!("zoom to level {}", i + 1))
133                        .centered_horiz()
134                        .margin_below(10),
135                );
136            }
137            col.push(
138                ctx.style()
139                    .btn_plain
140                    .icon("system/assets/speed/minus.svg")
141                    .build_widget(ctx, "zoom out")
142                    .centered_horiz(),
143            );
144            // The zoom column should start below the "pan up" arrow. But if we put it on the row
145            // with <, minimap, and > then it messes up the horizontal alignment of the
146            // pan up arrow. Also, double column to avoid the background color
147            // stretching to the bottom of the row.
148            Widget::custom_col(vec![
149                Widget::custom_col(col)
150                    .padding(10)
151                    .bg(app.cs().inner_panel_bg),
152                if self.controls.has_zorder(app) {
153                    Widget::col(vec![
154                        Line("Z-order:").small().into_widget(ctx),
155                        Spinner::widget(
156                            ctx,
157                            "zorder",
158                            app.draw_map().zorder_range,
159                            app.draw_map().show_zorder,
160                            1,
161                        ),
162                    ])
163                    .margin_above(10)
164                } else {
165                    Widget::nothing()
166                },
167            ])
168            .margin_above(26)
169        };
170
171        let minimap_widget =
172            Filler::fixed_dims(ScreenDims::new(MINIMAP_WIDTH, MINIMAP_HEIGHT)).named("minimap");
173
174        let minimap_controls = {
175            let buttons = ctx.style().btn_plain.btn().padding(4);
176            Widget::col(vec![
177                buttons
178                    .clone()
179                    .image_path("system/assets/minimap/up.svg")
180                    .build_widget(ctx, "pan up")
181                    .centered_horiz(),
182                Widget::row(vec![
183                    buttons
184                        .clone()
185                        .image_path("system/assets/minimap/left.svg")
186                        .build_widget(ctx, "pan left")
187                        .centered_vert(),
188                    minimap_widget,
189                    buttons
190                        .clone()
191                        .image_path("system/assets/minimap/right.svg")
192                        .build_widget(ctx, "pan right")
193                        .centered_vert(),
194                ]),
195                buttons
196                    .clone()
197                    .image_path("system/assets/minimap/down.svg")
198                    .build_widget(ctx, "pan down")
199                    .centered_horiz(),
200            ])
201        };
202
203        let controls = if app.opts().minimal_controls {
204            minimap_controls.padding(16).bg(app.cs().panel_bg)
205        } else {
206            Widget::row(vec![
207                self.controls.make_zoomed_side_panel(ctx, app),
208                Widget::col(vec![
209                    Widget::row(vec![minimap_controls, zoom_col]),
210                    self.controls.make_legend(ctx, app),
211                ])
212                .padding(16)
213                .bg(app.cs().panel_bg),
214            ])
215        };
216
217        self.panel = Panel::new_builder(controls)
218            .aligned(
219                HorizontalAlignment::Right,
220                VerticalAlignment::BottomAboveOSD,
221            )
222            .build_custom(ctx);
223    }
224
225    fn map_to_minimap_pct(&self, pt: Pt2D) -> (f64, f64) {
226        let inner_rect = self.panel.rect_of("minimap");
227        let pct_x = (pt.x() * self.zoom - self.offset_x) / inner_rect.width();
228        let pct_y = (pt.y() * self.zoom - self.offset_y) / inner_rect.height();
229        (pct_x, pct_y)
230    }
231
232    pub fn set_zoom(&mut self, ctx: &mut EventCtx, app: &A, zoom_lvl: usize) {
233        // Make the frame wind up in the same relative position on the minimap
234        let (pct_x, pct_y) = self.map_to_minimap_pct(ctx.canvas.center_to_map_pt());
235
236        let zoom_speed: f64 = 2.0;
237        self.zoom_lvl = zoom_lvl;
238        self.zoom = self.base_zoom * zoom_speed.powi(self.zoom_lvl as i32);
239        self.recreate_panel(ctx, app);
240
241        // Find the new offset
242        let map_center = ctx.canvas.center_to_map_pt();
243        let inner_rect = self.panel.rect_of("minimap");
244        self.offset_x = map_center.x() * self.zoom - pct_x * inner_rect.width();
245        self.offset_y = map_center.y() * self.zoom - pct_y * inner_rect.height();
246    }
247
248    fn recenter(&mut self, ctx: &EventCtx, app: &A) {
249        // Recenter the minimap on the screen bounds
250        let map_center = ctx.canvas.center_to_map_pt();
251        let rect = self.panel.rect_of("minimap");
252        let off_x = map_center.x() * self.zoom - rect.width() / 2.0;
253        let off_y = map_center.y() * self.zoom - rect.height() / 2.0;
254
255        // Don't go out of bounds.
256        let bounds = app.map().get_bounds();
257        // TODO For boundaries without rectangular shapes, it'd be even nicer to clamp to the
258        // boundary.
259        // clamp crashes if min > max; if that happens, just don't do anything.
260        let max_x = bounds.max_x * self.zoom - rect.width();
261        let max_y = bounds.max_y * self.zoom - rect.height();
262        if max_x >= 0.0 && max_y >= 0.0 {
263            self.offset_x = off_x.clamp(0.0, max_x);
264            self.offset_y = off_y.clamp(0.0, max_y);
265        }
266    }
267
268    pub fn event(&mut self, ctx: &mut EventCtx, app: &mut A) -> Option<Transition<A>> {
269        if self.time != app.sim_time() {
270            self.time = app.sim_time();
271            self.recreate_panel(ctx, app);
272        }
273
274        let zoomed = ctx.canvas.is_zoomed();
275        let layer = self.controls.has_layer(app);
276        if zoomed != self.zoomed || layer != self.layer {
277            let just_zoomed_in = zoomed && !self.zoomed;
278
279            self.zoomed = zoomed;
280            self.layer = layer;
281            self.recreate_panel(ctx, app);
282
283            if just_zoomed_in {
284                self.recenter(ctx, app);
285            }
286        } else if self.zoomed && !self.dragging {
287            // If either corner of the cursor is out of bounds on the minimap, recenter.
288            // TODO This means clicking the pan buttons while along the boundary won't work.
289            let mut ok = true;
290            for pt in [
291                ScreenPt::new(0.0, 0.0),
292                ScreenPt::new(ctx.canvas.window_width, ctx.canvas.window_height),
293            ] {
294                let (pct_x, pct_y) = self.map_to_minimap_pct(ctx.canvas.screen_to_map(pt));
295                if !(0.0..=1.0).contains(&pct_x) || pct_y < 0.0 || pct_y > 1.0 {
296                    ok = false;
297                    break;
298                }
299            }
300            if !ok {
301                self.recenter(ctx, app);
302            }
303        }
304        if ctx.input.is_window_resized() {
305            // When the window is resized, just reset completely. This is important when the window
306            // size at startup is incorrect and immediately corrected by the window manager after
307            // Minimap::new happens.
308            let map_bounds = app.map().get_bounds();
309            // scale minimap to cover area
310            self.base_zoom = (MINIMAP_WIDTH / map_bounds.width())
311                .max(MINIMAP_HEIGHT / map_bounds.height())
312                .max(0.001);
313            self.zoom = self.base_zoom;
314            if self.zoomed {
315                self.recenter(ctx, app);
316            }
317        }
318
319        let pan_speed = 100.0;
320        match self.panel.event(ctx) {
321            Outcome::Clicked(x) => match x {
322                x if x == "pan up" => {
323                    self.offset_y -= pan_speed * self.zoom;
324                    return Some(Transition::KeepWithMouseover);
325                }
326                x if x == "pan down" => {
327                    self.offset_y += pan_speed * self.zoom;
328                    return Some(Transition::KeepWithMouseover);
329                }
330                x if x == "pan left" => {
331                    self.offset_x -= pan_speed * self.zoom;
332                    return Some(Transition::KeepWithMouseover);
333                }
334                x if x == "pan right" => {
335                    self.offset_x += pan_speed * self.zoom;
336                    return Some(Transition::KeepWithMouseover);
337                }
338                // TODO Make the center of the cursor still point to the same thing. Same math as
339                // Canvas.
340                x if x == "zoom in" => {
341                    if self.zoom_lvl != 3 {
342                        self.set_zoom(ctx, app, self.zoom_lvl + 1);
343                    }
344                }
345                x if x == "zoom out" => {
346                    if self.zoom_lvl != 0 {
347                        self.set_zoom(ctx, app, self.zoom_lvl - 1);
348                    }
349                }
350                x if x == "zoom to level 1" => {
351                    self.set_zoom(ctx, app, 0);
352                }
353                x if x == "zoom to level 2" => {
354                    self.set_zoom(ctx, app, 1);
355                }
356                x if x == "zoom to level 3" => {
357                    self.set_zoom(ctx, app, 2);
358                }
359                x if x == "zoom to level 4" => {
360                    self.set_zoom(ctx, app, 3);
361                }
362                x => {
363                    if let Some(transition) = self.controls.panel_clicked(ctx, app, &x) {
364                        return Some(transition);
365                    }
366                }
367            },
368            Outcome::Changed(_) => {
369                self.controls.panel_changed(ctx, app, &self.panel);
370                if self.panel.has_widget("zorder") {
371                    app.mut_draw_map().show_zorder = self.panel.spinner("zorder");
372                }
373                self.recreate_panel(ctx, app);
374            }
375            _ => {}
376        }
377
378        if self.zoomed {
379            let inner_rect = self.panel.rect_of("minimap");
380
381            // TODO Not happy about reaching in like this. The minimap logic should be an widgetry
382            // Widget eventually, a generalization of Canvas.
383            let mut pt = ctx.canvas.get_cursor();
384            if self.dragging {
385                if ctx.input.left_mouse_button_released() {
386                    self.dragging = false;
387                }
388                // Don't drag out of inner_rect
389                pt.x = pt.x.clamp(inner_rect.x1, inner_rect.x2);
390                pt.y = pt.y.clamp(inner_rect.y1, inner_rect.y2);
391            } else if inner_rect.contains(pt) && ctx.input.left_mouse_button_pressed() {
392                self.dragging = true;
393            } else {
394                return None;
395            }
396
397            let percent_x = (pt.x - inner_rect.x1) / inner_rect.width();
398            let percent_y = (pt.y - inner_rect.y1) / inner_rect.height();
399
400            let map_pt = Pt2D::new(
401                (self.offset_x + percent_x * inner_rect.width()) / self.zoom,
402                (self.offset_y + percent_y * inner_rect.height()) / self.zoom,
403            );
404            ctx.canvas.center_on_map_pt(map_pt);
405        }
406
407        None
408    }
409
410    pub fn draw(&self, g: &mut GfxCtx, app: &A) {
411        self.draw_with_extra_layers(g, app, Vec::new());
412    }
413
414    pub fn draw_with_extra_layers(&self, g: &mut GfxCtx, app: &A, extra: Vec<&Drawable>) {
415        self.panel.draw(g);
416        if !self.zoomed {
417            return;
418        }
419
420        let inner_rect = self.panel.rect_of("minimap").clone();
421
422        let mut map_bounds = *app.map().get_bounds();
423        // Adjust bounds to account for the current pan and zoom
424        map_bounds.min_x = (map_bounds.min_x + self.offset_x) / self.zoom;
425        map_bounds.min_y = (map_bounds.min_y + self.offset_y) / self.zoom;
426        map_bounds.max_x = map_bounds.min_x + inner_rect.width() / self.zoom;
427        map_bounds.max_y = map_bounds.min_y + inner_rect.height() / self.zoom;
428
429        g.fork(
430            Pt2D::new(map_bounds.min_x, map_bounds.min_y),
431            ScreenPt::new(inner_rect.x1, inner_rect.y1),
432            self.zoom,
433            None,
434        );
435        g.enable_clipping(inner_rect);
436        let draw_map = app.draw_map();
437        g.redraw(&draw_map.boundary_polygon);
438        g.redraw(&draw_map.draw_all_areas);
439        g.redraw(&draw_map.draw_all_unzoomed_parking_lots);
440        g.redraw(&draw_map.draw_all_unzoomed_roads_and_intersections);
441        if app.cs().show_buildings_in_minimap {
442            g.redraw(&draw_map.draw_all_buildings);
443        }
444        for draw in extra {
445            g.redraw(draw);
446        }
447        self.controls.draw_extra(g, app);
448
449        // The cursor
450        let (x1, y1) = {
451            let pt = g.canvas.screen_to_map(ScreenPt::new(0.0, 0.0));
452            (pt.x(), pt.y())
453        };
454        let (x2, y2) = {
455            let pt = g
456                .canvas
457                .screen_to_map(ScreenPt::new(g.canvas.window_width, g.canvas.window_height));
458            (pt.x(), pt.y())
459        };
460        // On some platforms, minimized windows wind up with 0 width/height and this rectangle
461        // collapses
462        if let Ok(rect) = Ring::new(vec![
463            Pt2D::new(x1, y1),
464            Pt2D::new(x2, y1),
465            Pt2D::new(x2, y2),
466            Pt2D::new(x1, y2),
467            Pt2D::new(x1, y1),
468        ]) {
469            if let Some(color) = app.cs().minimap_cursor_bg {
470                g.draw_polygon(color, rect.clone().into_polygon());
471            }
472            g.draw_polygon(
473                app.cs().minimap_cursor_border,
474                rect.to_outline(Distance::meters(10.0)),
475            );
476        }
477        g.disable_clipping();
478        g.unfork();
479    }
480
481    pub fn get_panel(&self) -> &Panel {
482        &self.panel
483    }
484
485    pub fn mut_panel(&mut self) -> &mut Panel {
486        &mut self.panel
487    }
488}