Skip to main content

egui_map_view/layers/
text.rs

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