Skip to main content

egui_map_view/layers/
svg.rs

1//! A layer for placing SVG elements on the map.
2
3use crate::layers::Layer;
4use crate::projection::{GeoPos, MapProjection};
5use egui::{Color32, Painter, PointerButton, Pos2, Response};
6use serde::{Deserialize, Serialize};
7use std::any::Any;
8use std::collections::hash_map::DefaultHasher;
9use std::hash::{Hash, Hasher};
10
11/// An SVG element on the map.
12#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
13pub struct SvgElement {
14    /// The geographical position (longitude, latitude) of the SVG element.
15    pub pos: GeoPos,
16
17    /// The SVG content string.
18    pub text: String,
19
20    /// Arbitrary metadata string, not rendered.
21    pub metadata: String,
22
23    /// Whether the SVG element should scale with the zoom level.
24    /// If false, the image size stays the same in screen pixels.
25    /// If true, the image size scales with the map.
26    pub scalable: bool,
27
28    /// Whether the SVG element is clickable.
29    /// If true, click events will be emitted for this element.
30    /// If false, no click events will be emitted.
31    #[serde(default = "default_true")]
32    pub clickable: bool,
33
34    /// Whether the SVG element is draggable.
35    /// If true, the element can be moved on the map by dragging it with the mouse.
36    #[serde(default)]
37    pub draggable: bool,
38
39    /// The anchor point of the SVG element, relative to its size.
40    /// (0.5, 0.5) is the center (default).
41    /// (0.0, 0.0) is the top-left.
42    /// (1.0, 1.0) is the bottom-right.
43    #[serde(default = "default_anchor")]
44    pub anchor: Pos2,
45}
46
47fn default_anchor() -> Pos2 {
48    Pos2::new(0.5, 0.5)
49}
50
51fn default_true() -> bool {
52    true
53}
54
55impl SvgElement {
56    /// Creates a new SVG element.
57    pub fn new(pos: GeoPos, text: impl Into<String>, metadata: impl Into<String>) -> Self {
58        Self {
59            pos,
60            text: text.into(),
61            metadata: metadata.into(),
62            scalable: false,
63            clickable: true,
64            draggable: false,
65            anchor: default_anchor(),
66        }
67    }
68
69    /// Creates a new SVG element from x (longitude) and y (latitude) coordinates.
70    pub fn from_xy(
71        lon: f64,
72        lat: f64,
73        text: impl Into<String>,
74        metadata: impl Into<String>,
75    ) -> Self {
76        Self {
77            pos: GeoPos { lon, lat },
78            text: text.into(),
79            metadata: metadata.into(),
80            scalable: false,
81            clickable: true,
82            draggable: false,
83            anchor: default_anchor(),
84        }
85    }
86
87    /// Sets whether the SVG element is scalable.
88    #[must_use]
89    pub fn with_scalable(mut self, scalable: bool) -> Self {
90        self.scalable = scalable;
91        self
92    }
93
94    /// Sets whether the SVG element is clickable.
95    #[must_use]
96    pub fn with_clickable(mut self, clickable: bool) -> Self {
97        self.clickable = clickable;
98        self
99    }
100
101    /// Sets whether the SVG element is draggable.
102    #[must_use]
103    pub fn with_draggable(mut self, draggable: bool) -> Self {
104        self.draggable = draggable;
105        self
106    }
107
108    /// Sets the anchor point of the SVG element.
109    #[must_use]
110    pub fn with_anchor(mut self, anchor: Pos2) -> Self {
111        self.anchor = anchor;
112        self
113    }
114}
115
116/// Information about a click on an SVG element.
117#[derive(Clone, Debug)]
118pub struct SvgClickEvent {
119    /// The button that was clicked.
120    pub button: PointerButton,
121    /// The metadata of the clicked SVG element.
122    pub metadata: String,
123    /// The geographical position where the click occurred.
124    pub world_pos: GeoPos,
125    /// The screen position where the click occurred.
126    pub screen_pos: Pos2,
127}
128
129/// Layer implementation that allows placing multiple SVG elements on the map.
130#[derive(Clone, Default, Serialize, Deserialize)]
131pub struct SvgLayer {
132    /// The list of SVG elements.
133    pub elements: Vec<SvgElement>,
134
135    /// Click events that have occurred on the SVG elements.
136    #[serde(skip)]
137    pub events: Vec<SvgClickEvent>,
138
139    /// The index of the element currently being dragged.
140    #[serde(skip)]
141    pub dragging_index: Option<usize>,
142}
143
144impl SvgLayer {
145    /// Adds an SVG element to the layer.
146    pub fn add_element(&mut self, element: SvgElement) {
147        self.elements.push(element);
148    }
149
150    /// Clears all SVG elements from the layer.
151    pub fn clear(&mut self) {
152        self.elements.clear();
153    }
154
155    /// Takes all click events from the layer, leaving it empty.
156    pub fn take_events(&mut self) -> Vec<SvgClickEvent> {
157        std::mem::take(&mut self.events)
158    }
159}
160
161impl Layer for SvgLayer {
162    fn as_any(&self) -> &dyn Any {
163        self
164    }
165
166    fn as_any_mut(&mut self) -> &mut dyn Any {
167        self
168    }
169
170    fn handle_input(&mut self, response: &Response, projection: &MapProjection) -> bool {
171        // Ensure image loaders are installed
172        egui_extras::install_image_loaders(&response.ctx);
173
174        for element in &self.elements {
175            let uri = format!("bytes://{}.svg", rust_hash(&element.text));
176            // include_bytes ensures the data is available for the loaders
177            response
178                .ctx
179                .include_bytes(uri, element.text.as_bytes().to_vec());
180        }
181
182        let mut handled = false;
183
184        // Handle active dragging
185        if let Some(index) = self.dragging_index {
186            if response.dragged() {
187                if let Some(pointer_pos) = response.interact_pointer_pos()
188                    && let Some(element) = self.elements.get_mut(index)
189                {
190                    element.pos = projection.unproject(pointer_pos);
191                    handled = true;
192                    response.ctx.request_repaint();
193                }
194            } else {
195                self.dragging_index = None;
196            }
197        }
198
199        // Detect drag start or click
200        if let Some(pointer_pos) = response.interact_pointer_pos() {
201            for (index, element) in self.elements.iter_mut().enumerate() {
202                if !element.clickable && !element.draggable {
203                    continue;
204                }
205
206                let screen_pos = projection.project(element.pos);
207                let uri = format!("bytes://{}.svg", rust_hash(&element.text));
208
209                if let Ok(egui::load::TexturePoll::Ready { texture }) = response
210                    .ctx
211                    .try_load_texture(&uri, egui::TextureOptions::default(), Default::default())
212                {
213                    let mut size = texture.size;
214
215                    if element.scalable {
216                        // Scale the size based on the zoom level.
217                        let scale = 2.0_f32.powi(i32::from(projection.zoom) - 10);
218                        size *= scale;
219                    }
220
221                    let rect = egui::Rect::from_min_size(
222                        screen_pos - size * element.anchor.to_vec2(),
223                        size,
224                    );
225                    if rect.contains(pointer_pos) {
226                        // Check for drag start
227                        if element.draggable && response.drag_started() {
228                            self.dragging_index = Some(index);
229                            handled = true;
230                        }
231
232                        // Check for clicks
233                        if element.clickable && (response.clicked() || response.secondary_clicked())
234                        {
235                            let button = if response.secondary_clicked() {
236                                PointerButton::Secondary
237                            } else {
238                                PointerButton::Primary
239                            };
240
241                            self.events.push(SvgClickEvent {
242                                button,
243                                metadata: element.metadata.clone(),
244                                world_pos: projection.unproject(pointer_pos),
245                                screen_pos: pointer_pos,
246                            });
247                            handled = true;
248                        }
249                    }
250                }
251            }
252        }
253
254        handled
255    }
256
257    fn draw(&self, painter: &Painter, projection: &MapProjection) {
258        for element in &self.elements {
259            let screen_pos = projection.project(element.pos);
260            let uri = format!("bytes://{}.svg", rust_hash(&element.text));
261
262            match painter.ctx().try_load_texture(
263                &uri,
264                egui::TextureOptions::default(),
265                Default::default(),
266            ) {
267                Ok(egui::load::TexturePoll::Ready { texture }) => {
268                    let mut size = texture.size;
269
270                    if element.scalable {
271                        // Scale the size based on the zoom level.
272                        // We use zoom level 10 as a reference where scale is 1.0.
273                        let scale = 2.0_f32.powi(i32::from(projection.zoom) - 10);
274                        size *= scale;
275                    }
276
277                    let rect = egui::Rect::from_min_size(
278                        screen_pos - size * element.anchor.to_vec2(),
279                        size,
280                    );
281                    painter.image(
282                        texture.id,
283                        rect,
284                        egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
285                        Color32::WHITE,
286                    );
287                }
288                _ => {
289                    // Still loading or failed.
290                    // We could draw a placeholder here if desired.
291                    painter.ctx().request_repaint();
292                }
293            }
294        }
295    }
296}
297
298fn rust_hash(s: &str) -> u64 {
299    let mut hasher = DefaultHasher::new();
300    s.hash(&mut hasher);
301    hasher.finish()
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn svg_layer_serde() {
310        let mut layer = SvgLayer::default();
311        layer.add_element(SvgElement {
312            pos: GeoPos { lon: 1.0, lat: 2.0 },
313            text: "<svg></svg>".to_string(),
314            metadata: "test metadata".to_string(),
315            scalable: false,
316            clickable: true,
317            draggable: false,
318            anchor: Pos2::new(0.5, 0.5),
319        });
320
321        let json = serde_json::to_string(&layer).unwrap();
322        let deserialized: SvgLayer = serde_json::from_str(&json).unwrap();
323
324        assert_eq!(deserialized.elements.len(), 1);
325        assert_eq!(deserialized.elements[0].text, "<svg></svg>");
326        assert_eq!(deserialized.elements[0].metadata, "test metadata");
327        assert_eq!(deserialized.elements[0].pos, GeoPos { lon: 1.0, lat: 2.0 });
328        assert!(deserialized.elements[0].clickable);
329        assert!(!deserialized.elements[0].draggable);
330    }
331
332    #[test]
333    fn svg_layer_serde_backward_compatibility() {
334        let json = r#"{
335            "elements": [
336                {
337                    "pos": {"lon": 1.0, "lat": 2.0},
338                    "text": "<svg></svg>",
339                    "metadata": "test metadata",
340                    "scalable": false
341                }
342            ]
343        }"#;
344        let deserialized: SvgLayer = serde_json::from_str(json).unwrap();
345        assert!(deserialized.elements[0].clickable);
346        assert!(!deserialized.elements[0].draggable);
347    }
348
349    #[test]
350    fn svg_layer_clickable_false() {
351        let mut layer = SvgLayer::default();
352        layer.add_element(SvgElement {
353            pos: GeoPos { lon: 1.0, lat: 2.0 },
354            text: "<svg></svg>".to_string(),
355            metadata: "test metadata".to_string(),
356            scalable: false,
357            clickable: false,
358            draggable: false,
359            anchor: default_anchor(),
360        });
361
362        let json = serde_json::to_string(&layer).unwrap();
363        let deserialized: SvgLayer = serde_json::from_str(&json).unwrap();
364        assert!(!deserialized.elements[0].clickable);
365    }
366
367    #[test]
368    fn svg_layer_draggable_true() {
369        let mut layer = SvgLayer::default();
370        layer.add_element(SvgElement {
371            pos: GeoPos { lon: 1.0, lat: 2.0 },
372            text: "<svg></svg>".to_string(),
373            metadata: "test metadata".to_string(),
374            scalable: false,
375            clickable: false,
376            draggable: true,
377            anchor: default_anchor(),
378        });
379
380        let json = serde_json::to_string(&layer).unwrap();
381        let deserialized: SvgLayer = serde_json::from_str(&json).unwrap();
382        assert!(deserialized.elements[0].draggable);
383    }
384}