Skip to main content

walkers/
map.rs

1use egui::{
2    DragPanButtons, InnerResponse, PointerButton, Response, Sense, Ui, UiBuilder, Vec2, Widget,
3};
4
5use crate::{
6    MapMemory, Position, Projector, Tiles, center::Center, position::AdjustedPosition,
7    tiles::draw_tiles,
8};
9
10/// Plugins allow drawing custom shapes on the map. After implementing this trait for your type,
11/// you can add it to the map with [`Map::with_plugin`]
12pub trait Plugin {
13    /// Function called at each frame.
14    ///
15    /// The provided [`Ui`] has its [`Ui::max_rect`] set to the full rect that was allocated
16    /// by the map widget. Implementations should typically use the provided [`Projector`] to
17    /// compute target screen coordinates and use one of the various egui methods to draw at these
18    /// coordinates instead of relying on [`Ui`] layout system.
19    ///
20    /// The provided [`Response`] is the response of the map widget itself and can be used to test
21    /// if the mouse is hovering or clicking on the map.
22    fn run(
23        self: Box<Self>,
24        ui: &mut Ui,
25        response: &Response,
26        projector: &Projector,
27        map_memory: &MapMemory,
28    );
29}
30
31struct Layer<'a> {
32    tiles: &'a mut dyn Tiles,
33    transparency: f32,
34}
35
36struct Options {
37    zoom_gesture_enabled: bool,
38    drag_pan_buttons: DragPanButtons,
39    zoom_speed: f64,
40    double_click_to_zoom: bool,
41    double_click_to_zoom_out: bool,
42    zoom_with_ctrl: bool,
43    panning: bool,
44    pull_to_my_position_threshold: f32,
45}
46
47impl Default for Options {
48    fn default() -> Self {
49        Self {
50            zoom_gesture_enabled: true,
51            drag_pan_buttons: DragPanButtons::PRIMARY,
52            zoom_speed: 2.0,
53            double_click_to_zoom: false,
54            double_click_to_zoom_out: false,
55            zoom_with_ctrl: true,
56            panning: true,
57            pull_to_my_position_threshold: 0.0,
58        }
59    }
60}
61
62/// The actual map widget. Instances are to be created on each frame, as all necessary state is
63/// stored in [`Tiles`] and [`MapMemory`].
64///
65/// # Examples
66///
67/// ```
68/// # use walkers::{Map, Tiles, MapMemory, Position, lon_lat};
69///
70/// fn update(ui: &mut egui::Ui, tiles: &mut dyn Tiles, map_memory: &mut MapMemory) {
71///     ui.add(Map::new(
72///         Some(tiles), // `None`, if you don't want to show any tiles.
73///         map_memory,
74///         lon_lat(17.03664, 51.09916)
75///     ));
76/// }
77/// ```
78///
79/// Initially, the map follows `my_position` argument which is typically fed by a GPS sensor or
80/// other geo-localization method. If user drags the map, it enters a "detached state". You can use
81/// [`MapMemory`]'s methods to change the state programmatically.
82pub struct Map<'a, 'b, 'c> {
83    tiles: Option<&'b mut dyn Tiles>,
84    layers: Vec<Layer<'b>>,
85    memory: &'a mut MapMemory,
86    my_position: Position,
87    plugins: Vec<Box<dyn Plugin + 'c>>,
88    options: Options,
89}
90
91impl<'a, 'b, 'c> Map<'a, 'b, 'c> {
92    pub fn new(
93        tiles: Option<&'b mut dyn Tiles>,
94        memory: &'a mut MapMemory,
95        my_position: Position,
96    ) -> Self {
97        Self {
98            tiles,
99            layers: Vec::default(),
100            memory,
101            my_position,
102            plugins: Vec::default(),
103            options: Options::default(),
104        }
105    }
106
107    /// Add plugin to the drawing pipeline. Plugins allow drawing custom shapes on the map.
108    pub fn with_plugin(mut self, plugin: impl Plugin + 'c) -> Self {
109        self.plugins.push(Box::new(plugin));
110        self
111    }
112
113    /// Add a tile layer. All layers are drawn on top of each other with given transparency.
114    pub fn with_layer(mut self, tiles: &'b mut dyn Tiles, transparency: f32) -> Self {
115        self.layers.push(Layer {
116            tiles,
117            transparency,
118        });
119        self
120    }
121
122    /// Set whether map should perform zoom gesture.
123    ///
124    /// Zoom is typically triggered by the mouse wheel while holding <kbd>ctrl</kbd> key on native
125    /// and web, and by pinch gesture on Android.
126    pub fn zoom_gesture(mut self, enabled: bool) -> Self {
127        self.options.zoom_gesture_enabled = enabled;
128        self
129    }
130
131    /// Specify which pointer buttons can be used to pan by clicking and dragging.
132    pub fn drag_pan_buttons(mut self, buttons: DragPanButtons) -> Self {
133        self.options.drag_pan_buttons = buttons;
134        self
135    }
136
137    /// Change how far to zoom in/out.
138    /// Default value is 2.0
139    pub fn zoom_speed(mut self, speed: f64) -> Self {
140        self.options.zoom_speed = speed;
141        self
142    }
143
144    /// Set whether to enable double click primary mouse button to zoom
145    pub fn double_click_to_zoom(mut self, enabled: bool) -> Self {
146        self.options.double_click_to_zoom = enabled;
147        self
148    }
149
150    /// Set whether to enable double click secondary mouse button to zoom out
151    pub fn double_click_to_zoom_out(mut self, enabled: bool) -> Self {
152        self.options.double_click_to_zoom_out = enabled;
153        self
154    }
155
156    /// Sets the zoom behaviour
157    ///
158    /// When enabled zoom is done with mouse wheel while holding <kbd>ctrl</kbd> key on native
159    /// and web. Panning is done with mouse wheel without <kbd>ctrl</kbd> key
160    ///
161    /// When disabled, zooming can be done without holding <kbd>ctrl</kbd> key
162    /// but panning with mouse wheel is disabled
163    ///
164    /// Has no effect on Android
165    pub fn zoom_with_ctrl(mut self, enabled: bool) -> Self {
166        self.options.zoom_with_ctrl = enabled;
167        self
168    }
169
170    /// Set if we can pan with mouse wheel.
171    /// By default, panning is disabled when zooming with ctrl is disabled.
172    /// Allow to disable panning even when zooming with ctrl is enabled.
173    pub fn panning(mut self, enabled: bool) -> Self {
174        self.options.panning = enabled;
175        self
176    }
177
178    /// Set the threshold for pulling the map back to `my_position` when dragged.
179    ///
180    /// It can be used to prevent the map from being accidentally detached when the user clicks on
181    /// something causing a small drag.
182    pub fn pull_to_my_position_threshold(mut self, threshold: f32) -> Self {
183        self.options.pull_to_my_position_threshold = threshold;
184        self
185    }
186
187    /// Show the map widget inside a [`egui::Ui`].
188    pub fn show<R>(
189        mut self,
190        ui: &mut Ui,
191        add_contents: impl FnOnce(&mut Ui, &Response, &Projector, &MapMemory) -> R,
192    ) -> InnerResponse<R> {
193        let (rect, mut response) =
194            ui.allocate_exact_size(ui.available_size(), Sense::click_and_drag());
195
196        let mut changed = self.handle_gestures(ui, &response);
197        let delta_time = ui.input(|reader| reader.stable_dt);
198        let zoom = self.memory.zoom;
199        changed |= self
200            .memory
201            .center_mode
202            .update_movement(delta_time, zoom.into());
203
204        if changed {
205            response.mark_changed();
206            ui.request_repaint();
207        }
208
209        let map_center = self.position();
210        let painter = ui.painter().with_clip_rect(rect);
211
212        if let Some(tiles) = self.tiles {
213            draw_tiles(&painter, map_center, zoom, tiles, 1.0);
214        }
215
216        for layer in self.layers {
217            draw_tiles(&painter, map_center, zoom, layer.tiles, layer.transparency);
218        }
219
220        // Run plugins.
221        let projector = Projector::new(response.rect, self.memory, self.my_position);
222        for (idx, plugin) in self.plugins.into_iter().enumerate() {
223            let mut child_ui = ui.new_child(UiBuilder::new().max_rect(rect).id_salt(idx));
224            plugin.run(&mut child_ui, &response, &projector, self.memory);
225        }
226
227        let mut child_ui = ui.new_child(UiBuilder::new().max_rect(rect).id_salt("inner"));
228        let inner = add_contents(&mut child_ui, &response, &projector, self.memory);
229
230        InnerResponse { inner, response }
231    }
232}
233
234impl Map<'_, '_, '_> {
235    /// Handle user inputs and recalculate everything accordingly. Returns whether something changed.
236    fn handle_gestures(&mut self, ui: &mut Ui, response: &Response) -> bool {
237        let zoom_delta = self.zoom_delta(ui, response);
238
239        // Zooming and dragging need to be exclusive, otherwise the map will get dragged when
240        // pinch gesture is used.
241        let changed = if (zoom_delta - 1.0).abs() > 0.001
242            && ui.ui_contains_pointer()
243            && self.options.zoom_gesture_enabled
244        {
245            // Displacement of mouse pointer relative to widget center
246            let offset = input_offset(ui, response);
247
248            // While zooming, we want to keep the location under the mouse pointer fixed on the
249            // screen. To achieve this, we first move the location to the widget's center,
250            // then adjust zoom level, finally move the location back to the original screen
251            // position.
252            if let Some(offset) = offset {
253                // If map is tracking `my_position` and the input offset is close, just let it be.
254                if self.memory.detached().is_some()
255                    || offset.length() > self.options.pull_to_my_position_threshold
256                {
257                    self.memory.center_mode = Center::Exact(
258                        AdjustedPosition::new(self.position()).shift(-offset, self.memory.zoom()),
259                    );
260                }
261            }
262
263            // Shift by 1 because of the values given by zoom_delta(). Multiple by zoom_speed(defaults to 2.0),
264            // because then it felt right with both mouse wheel, and an Android phone.
265            self.memory
266                .zoom
267                .zoom_by((zoom_delta - 1.) * self.options.zoom_speed);
268
269            if let Some(offset) = offset {
270                self.memory.center_mode = self
271                    .memory
272                    .center_mode
273                    .clone()
274                    .shift(offset, self.memory.zoom());
275            }
276
277            true
278        } else {
279            self.memory.center_mode.handle_gestures(
280                response,
281                self.my_position,
282                self.options.pull_to_my_position_threshold,
283                self.options.drag_pan_buttons,
284            )
285        };
286
287        // Only enable panning with mouse_wheel if we are zooming with ctrl. But always allow touch devices to pan
288        let panning_enabled =
289            self.options.panning && (ui.input(|i| i.any_touches()) || self.options.zoom_with_ctrl);
290
291        if ui.ui_contains_pointer() && panning_enabled {
292            // Panning by scrolling, e.g. two-finger drag on a touchpad:
293            let scroll_delta = ui.input(|i| i.smooth_scroll_delta);
294            if scroll_delta != Vec2::ZERO {
295                self.memory.center_mode = Center::Exact(
296                    AdjustedPosition::new(self.position()).shift(scroll_delta, self.memory.zoom()),
297                );
298            }
299        }
300
301        changed
302    }
303
304    /// Calculate the zoom delta based on the input.
305    fn zoom_delta(&self, ui: &mut Ui, response: &Response) -> f64 {
306        let mut zoom_delta = ui.input(|input| input.zoom_delta()) as f64;
307
308        if self.options.double_click_to_zoom
309            && ui.ui_contains_pointer()
310            && response.double_clicked_by(PointerButton::Primary)
311        {
312            zoom_delta = 2.0;
313        }
314
315        if self.options.double_click_to_zoom_out
316            && ui.ui_contains_pointer()
317            && response.double_clicked_by(PointerButton::Secondary)
318        {
319            zoom_delta = 0.0;
320        }
321
322        if !self.options.zoom_with_ctrl && zoom_delta == 1.0 {
323            // We only use the raw scroll values, if we are zooming without ctrl,
324            // and zoom_delta is not already over/under 1.0 (eg. a ctrl + scroll event or a pinch zoom)
325            // These values seem to correspond to the same values as one would get in `zoom_delta()`
326            zoom_delta = 1f64
327                + ui.input(|input| {
328                    input.smooth_scroll_delta.y * input.stable_dt.max(input.predicted_dt * 1.5)
329                }) as f64
330                    / 4.0;
331        };
332
333        zoom_delta
334    }
335
336    /// Get the real position at the map's center.
337    fn position(&self) -> Position {
338        self.memory.center_mode.position(self.my_position)
339    }
340}
341
342impl Widget for Map<'_, '_, '_> {
343    fn ui(self, ui: &mut Ui) -> Response {
344        self.show(ui, |_, _, _, _| ()).response
345    }
346}
347
348/// Get the offset of the input (either mouse or touch) relative to the center.
349fn input_offset(ui: &mut Ui, response: &Response) -> Option<Vec2> {
350    let mouse_offset = response.hover_pos();
351    let touch_offset = ui
352        .input(|input| input.multi_touch())
353        .map(|multi_touch| multi_touch.center_pos);
354
355    // On touch we get both, so make touch the priority.
356    touch_offset
357        .or(mouse_offset)
358        .map(|pos| pos - response.rect.center())
359}