Skip to main content

egui_map_view/layers/
text.rs

1//! A layer for placing text on the map.
2
3use crate::layers::{Layer, serde_color32};
4use crate::projection::{GeoPos, MapProjection};
5use egui::{Align2, Color32, FontId, Painter, Pos2, Rect, Response};
6use serde::{Deserialize, Serialize};
7use std::any::Any;
8
9/// The size of the text.
10#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
11pub enum TextSize {
12    /// Size is in screen points, and does not scale with zoom.
13    Static(f32),
14
15    /// Size is in meters at the equator, and scales with zoom.
16    Relative(f32),
17}
18
19impl Default for TextSize {
20    fn default() -> Self {
21        // A reasonable default.
22        Self::Static(12.0)
23    }
24}
25
26/// A piece of text on the map.
27#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
28pub struct Text {
29    /// The text to display.
30    pub text: String,
31
32    /// The geographical position of the text.
33    pub pos: GeoPos,
34
35    /// The size of the text.
36    pub size: TextSize,
37
38    /// The color of the text.
39    #[serde(with = "serde_color32")]
40    pub color: Color32,
41
42    /// The color of the background.
43    #[serde(with = "serde_color32")]
44    pub background: Color32,
45}
46
47impl Default for Text {
48    fn default() -> Self {
49        Self {
50            text: "New Text".to_string(),
51            pos: GeoPos { lon: 0.0, lat: 0.0 }, // This will be updated on click.
52            size: TextSize::default(),
53            color: Color32::BLACK,
54            background: Color32::from_rgba_unmultiplied(255, 255, 255, 180),
55        }
56    }
57}
58
59/// The state of the text currently being edited or added.
60#[derive(Clone, Debug)]
61pub struct EditingText {
62    /// The index of the text being edited, if it's an existing one.
63    pub index: Option<usize>,
64    /// The properties of the text being edited.
65    pub properties: Text,
66}
67
68/// The mode of the `TextLayer`.
69#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
70pub enum TextLayerMode {
71    /// The layer is not interactive.
72    #[default]
73    Disabled,
74    /// The user can add, remove, and modify text elements.
75    Modify,
76}
77
78/// Layer implementation that allows placing text on the map.
79#[derive(Clone, Serialize, Deserialize)]
80#[serde(default)]
81#[derive(Default)]
82pub struct TextLayer {
83    texts: Vec<Text>,
84
85    /// The current mode.
86    #[serde(skip)]
87    pub mode: TextLayerMode,
88
89    /// The properties for the next text to be added.
90    #[serde(skip)]
91    pub new_text_properties: Text,
92
93    /// The state of the text currently being edited or added.
94    #[serde(skip)]
95    pub editing: Option<EditingText>,
96
97    #[serde(skip)]
98    dragged_text_index: Option<usize>,
99}
100
101impl TextLayer {
102    /// Starts editing an existing text element.
103    pub fn start_editing(&mut self, index: usize) {
104        if let Some(text) = self.texts.get(index) {
105            self.editing = Some(EditingText {
106                index: Some(index),
107                properties: text.clone(),
108            });
109        }
110    }
111
112    /// Deletes a text element.
113    pub fn delete(&mut self, index: usize) {
114        if index < self.texts.len() {
115            self.texts.remove(index);
116        }
117    }
118
119    /// Saves the changes made in the editing dialog.
120    pub fn commit_edit(&mut self) {
121        if let Some(editing) = self.editing.take() {
122            if let Some(index) = editing.index {
123                // It's an existing text.
124                if let Some(text) = self.texts.get_mut(index) {
125                    *text = editing.properties;
126                }
127            } else {
128                // It's a new text.
129                self.texts.push(editing.properties);
130            }
131        }
132    }
133
134    /// Discards the changes made in the editing dialog.
135    pub fn cancel_edit(&mut self) {
136        self.editing = None;
137    }
138
139    /// Serializes the layer to a `GeoJSON` `FeatureCollection`.
140    #[cfg(feature = "geojson")]
141    pub fn to_geojson_str(&self) -> Result<String, serde_json::Error> {
142        let features: Vec<geojson::Feature> = self
143            .texts
144            .clone()
145            .into_iter()
146            .map(geojson::Feature::from)
147            .collect();
148        let feature_collection = geojson::FeatureCollection {
149            bbox: None,
150            features,
151            foreign_members: None,
152        };
153        serde_json::to_string(&feature_collection)
154    }
155
156    /// Deserializes a `GeoJSON` `FeatureCollection` and adds the features to the layer.
157    #[cfg(feature = "geojson")]
158    pub fn from_geojson_str(&mut self, s: &str) -> Result<(), serde_json::Error> {
159        let feature_collection: geojson::FeatureCollection = serde_json::from_str(s)?;
160        let new_texts: Vec<Text> = feature_collection
161            .features
162            .into_iter()
163            .filter_map(|f| Text::try_from(f).ok())
164            .collect();
165        self.texts.extend(new_texts);
166        Ok(())
167    }
168
169    fn handle_modify_input(&mut self, response: &Response, projection: &MapProjection) -> bool {
170        if self.editing.is_some() {
171            // While editing in a dialog, we don't want to interact with the map.
172            // We consume all hover events to prevent panning and zooming.
173            return response.hovered();
174        }
175
176        if response.drag_started()
177            && let Some(pointer_pos) = response.interact_pointer_pos()
178        {
179            self.dragged_text_index = self.find_text_at(pointer_pos, projection, &response.ctx);
180        }
181
182        if response.dragged()
183            && let Some(text_index) = self.dragged_text_index
184            && let Some(text) = self.texts.get_mut(text_index)
185            && let Some(pointer_pos) = response.interact_pointer_pos()
186        {
187            text.pos = projection.unproject(pointer_pos);
188        }
189
190        if response.drag_stopped() {
191            self.dragged_text_index = None;
192        }
193
194        // Change cursor on hover
195        if self.dragged_text_index.is_some() {
196            response.ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
197        } else if let Some(hover_pos) = response.hover_pos() {
198            if self
199                .find_text_at(hover_pos, projection, &response.ctx)
200                .is_some()
201            {
202                response.ctx.set_cursor_icon(egui::CursorIcon::PointingHand);
203            } else {
204                response.ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
205            }
206        }
207
208        if !response.dragged() && response.clicked() {
209            // Left-click to add or edit a text element
210            if let Some(pointer_pos) = response.interact_pointer_pos() {
211                if let Some(index) = self.find_text_at(pointer_pos, projection, &response.ctx) {
212                    // Clicked on an existing text, start editing it.
213                    self.start_editing(index);
214                } else {
215                    // Clicked on an empty spot, start adding a new text.
216                    let geo_pos = projection.unproject(pointer_pos);
217                    let mut properties = self.new_text_properties.clone();
218                    properties.pos = geo_pos;
219                    self.editing = Some(EditingText {
220                        index: None,
221                        properties,
222                    });
223                }
224            }
225        }
226
227        response.hovered()
228    }
229
230    /// A more robust check that considers the text's bounding box.
231    fn find_text_at(
232        &self,
233        screen_pos: Pos2,
234        projection: &MapProjection,
235        ctx: &egui::Context,
236    ) -> Option<usize> {
237        self.texts.iter().enumerate().rev().find_map(|(i, text)| {
238            let text_rect = self.get_text_rect(text, projection, ctx);
239            if text_rect.expand(5.0).contains(screen_pos) {
240                // Add some tolerance
241                Some(i)
242            } else {
243                None
244            }
245        })
246    }
247}
248
249impl Layer for TextLayer {
250    fn as_any(&self) -> &dyn Any {
251        self
252    }
253
254    fn as_any_mut(&mut self) -> &mut dyn Any {
255        self
256    }
257
258    fn handle_input(&mut self, response: &Response, projection: &MapProjection) -> bool {
259        match self.mode {
260            TextLayerMode::Disabled => false,
261            TextLayerMode::Modify => self.handle_modify_input(response, projection),
262        }
263    }
264
265    fn draw(&self, painter: &Painter, projection: &MapProjection) {
266        for text in &self.texts {
267            let screen_pos = projection.project(text.pos);
268
269            let galley = painter.layout_no_wrap(
270                // We use the painter's layout function here for drawing.
271                text.text.clone(),
272                FontId::proportional(self.get_font_size(text, projection)),
273                text.color,
274            );
275
276            let rect =
277                Align2::CENTER_CENTER.anchor_rect(Rect::from_min_size(screen_pos, galley.size()));
278
279            painter.rect_filled(rect.expand(2.0), 3.0, text.background);
280            painter.galley(rect.min, galley, Color32::TRANSPARENT);
281        }
282    }
283}
284
285impl TextLayer {
286    fn get_font_size(&self, text: &Text, projection: &MapProjection) -> f32 {
287        match text.size {
288            TextSize::Static(size) => size,
289            TextSize::Relative(size_in_meters) => {
290                let p2 = projection.project(GeoPos {
291                    lon: text.pos.lon
292                        + (f64::from(size_in_meters)
293                            / (111_320.0 * text.pos.lat.to_radians().cos())),
294                    lat: text.pos.lat,
295                });
296                (p2.x - projection.project(text.pos).x).abs()
297            }
298        }
299    }
300
301    fn get_text_rect(&self, text: &Text, projection: &MapProjection, ctx: &egui::Context) -> Rect {
302        let font_size = self.get_font_size(text, projection);
303        let galley = ctx
304            .debug_painter()
305            .layout_job(egui::text::LayoutJob::simple(
306                text.text.clone(),
307                FontId::proportional(font_size),
308                text.color,
309                f32::INFINITY,
310            ));
311        let screen_pos = projection.project(text.pos);
312        Align2::CENTER_CENTER.anchor_rect(Rect::from_min_size(screen_pos, galley.size()))
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn text_layer_serde() {
322        let mut layer = TextLayer::default();
323        layer.mode = TextLayerMode::Modify; // This should not be serialized.
324        layer.texts.push(Text {
325            text: "Hello".to_string(),
326            pos: GeoPos { lon: 1.0, lat: 2.0 },
327            size: TextSize::Static(14.0),
328            color: Color32::from_rgb(0, 0, 255),
329            background: Color32::from_rgba_unmultiplied(255, 0, 0, 128),
330        });
331
332        let json = serde_json::to_string(&layer).unwrap();
333
334        // The serialized string should only contain texts.
335        assert!(json.contains(r##""texts":[{"text":"Hello","pos":{"lon":1.0,"lat":2.0},"size":{"Static":14.0},"color":"#0000ffff","background":"#ff000080""##));
336
337        // it should not contain skipped fields
338        assert!(!json.contains("mode"));
339        assert!(!json.contains("new_text_properties"));
340        assert!(!json.contains("editing"));
341        assert!(!json.contains("dragged_text_index"));
342
343        let deserialized: TextLayer = serde_json::from_str(&json).unwrap();
344
345        // Check that texts are restored correctly.
346        assert_eq!(deserialized.texts.len(), 1);
347        assert_eq!(deserialized.texts[0].text, "Hello");
348        assert_eq!(deserialized.texts[0].pos, GeoPos { lon: 1.0, lat: 2.0 });
349        assert_eq!(deserialized.texts[0].size, TextSize::Static(14.0));
350        assert_eq!(deserialized.texts[0].color, Color32::from_rgb(0, 0, 255));
351        assert_eq!(
352            deserialized.texts[0].background,
353            Color32::from_rgba_unmultiplied(255, 0, 0, 128)
354        );
355
356        // Check that skipped fields have their values from the `default()` implementation.
357        let default_layer = TextLayer::default();
358        assert_eq!(deserialized.mode, default_layer.mode);
359        assert_eq!(
360            deserialized.new_text_properties,
361            default_layer.new_text_properties
362        );
363        assert!(deserialized.editing.is_none());
364        assert!(deserialized.dragged_text_index.is_none());
365    }
366
367    #[cfg(feature = "geojson")]
368    mod geojson_tests {
369        use super::*;
370
371        #[test]
372        fn text_layer_geojson() {
373            let mut layer = TextLayer::default();
374            layer.texts.push(Text {
375                text: "Hello".to_string(),
376                pos: (10.0, 20.0).into(),
377                size: TextSize::Static(14.0),
378                color: Color32::from_rgb(0, 0, 255),
379                background: Color32::from_rgba_unmultiplied(255, 0, 0, 128),
380            });
381
382            let geojson_str = layer.to_geojson_str().unwrap();
383            let mut new_layer = TextLayer::default();
384            new_layer.from_geojson_str(&geojson_str).unwrap();
385
386            assert_eq!(new_layer.texts.len(), 1);
387            assert_eq!(layer.texts[0], new_layer.texts[0]);
388        }
389    }
390}