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