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, default_opacity};
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, 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    /// The opacity of the layer.
144    #[serde(default = "default_opacity")]
145    pub opacity: f32,
146}
147
148impl Default for SvgLayer {
149    fn default() -> Self {
150        Self {
151            elements: Vec::new(),
152            events: Vec::new(),
153            dragging_index: None,
154            opacity: 1.0,
155        }
156    }
157}
158
159impl SvgLayer {
160    /// Adds an SVG element to the layer.
161    pub fn add_element(&mut self, element: SvgElement) {
162        self.elements.push(element);
163    }
164
165    /// Clears all SVG elements from the layer.
166    pub fn clear(&mut self) {
167        self.elements.clear();
168    }
169
170    /// Takes all click events from the layer, leaving it empty.
171    pub fn take_events(&mut self) -> Vec<SvgClickEvent> {
172        std::mem::take(&mut self.events)
173    }
174}
175
176impl Layer for SvgLayer {
177    fn as_any(&self) -> &dyn Any {
178        self
179    }
180
181    fn as_any_mut(&mut self) -> &mut dyn Any {
182        self
183    }
184
185    fn opacity(&self) -> f32 {
186        self.opacity
187    }
188
189    fn set_opacity(&mut self, opacity: f32) {
190        self.opacity = opacity;
191    }
192
193    fn handle_input(&mut self, response: &Response, projection: &MapProjection) -> bool {
194        // Ensure image loaders are installed
195        egui_extras::install_image_loaders(&response.ctx);
196
197        for element in &self.elements {
198            let uri = format!("bytes://{}.svg", rust_hash(&element.text));
199            // include_bytes ensures the data is available for the loaders
200            response
201                .ctx
202                .include_bytes(uri, element.text.as_bytes().to_vec());
203        }
204
205        let mut handled = false;
206
207        // Handle active dragging
208        if let Some(index) = self.dragging_index {
209            if response.dragged() {
210                if let Some(pointer_pos) = response.interact_pointer_pos()
211                    && let Some(element) = self.elements.get_mut(index)
212                {
213                    element.pos = projection.unproject(pointer_pos);
214                    handled = true;
215                    response.ctx.request_repaint();
216                }
217            } else {
218                self.dragging_index = None;
219            }
220        }
221
222        // Detect drag start or click
223        if let Some(pointer_pos) = response.interact_pointer_pos() {
224            for (index, element) in self.elements.iter_mut().enumerate() {
225                if !element.clickable && !element.draggable {
226                    continue;
227                }
228
229                let screen_pos = projection.project(element.pos);
230                let uri = format!("bytes://{}.svg", rust_hash(&element.text));
231
232                if let Ok(egui::load::TexturePoll::Ready { texture }) = response
233                    .ctx
234                    .try_load_texture(&uri, egui::TextureOptions::default(), Default::default())
235                {
236                    let mut size = texture.size;
237
238                    if element.scalable {
239                        // Scale the size based on the zoom level.
240                        let scale = 2.0_f32.powi(i32::from(projection.zoom) - 10);
241                        size *= scale;
242                    }
243
244                    let rect = egui::Rect::from_min_size(
245                        screen_pos - size * element.anchor.to_vec2(),
246                        size,
247                    );
248                    if rect.contains(pointer_pos) {
249                        // Check for drag start
250                        if element.draggable && response.drag_started() {
251                            self.dragging_index = Some(index);
252                            handled = true;
253                        }
254
255                        // Check for clicks
256                        if element.clickable && (response.clicked() || response.secondary_clicked())
257                        {
258                            let button = if response.secondary_clicked() {
259                                PointerButton::Secondary
260                            } else {
261                                PointerButton::Primary
262                            };
263
264                            self.events.push(SvgClickEvent {
265                                button,
266                                metadata: element.metadata.clone(),
267                                world_pos: projection.unproject(pointer_pos),
268                                screen_pos: pointer_pos,
269                            });
270                            handled = true;
271                        }
272                    }
273                }
274            }
275        }
276
277        handled
278    }
279
280    fn draw(&self, painter: &Painter, projection: &MapProjection) {
281        for element in &self.elements {
282            let screen_pos = projection.project(element.pos);
283            let uri = format!("bytes://{}.svg", rust_hash(&element.text));
284
285            match painter.ctx().try_load_texture(
286                &uri,
287                egui::TextureOptions::default(),
288                Default::default(),
289            ) {
290                Ok(egui::load::TexturePoll::Ready { texture }) => {
291                    let mut size = texture.size;
292
293                    if element.scalable {
294                        // Scale the size based on the zoom level.
295                        // We use zoom level 10 as a reference where scale is 1.0.
296                        let scale = 2.0_f32.powi(i32::from(projection.zoom) - 10);
297                        size *= scale;
298                    }
299
300                    let rect = egui::Rect::from_min_size(
301                        screen_pos - size * element.anchor.to_vec2(),
302                        size,
303                    );
304                    painter.image(
305                        texture.id,
306                        rect,
307                        egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
308                        Color32::WHITE.gamma_multiply(self.opacity),
309                    );
310                }
311                _ => {
312                    // Still loading or failed.
313                    // We could draw a placeholder here if desired.
314                    painter.ctx().request_repaint();
315                }
316            }
317        }
318    }
319}
320
321fn rust_hash(s: &str) -> u64 {
322    let mut hasher = DefaultHasher::new();
323    s.hash(&mut hasher);
324    hasher.finish()
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn svg_layer_serde() {
333        let mut layer = SvgLayer::default();
334        layer.add_element(SvgElement {
335            pos: GeoPos { lon: 1.0, lat: 2.0 },
336            text: "<svg></svg>".to_string(),
337            metadata: "test metadata".to_string(),
338            scalable: false,
339            clickable: true,
340            draggable: false,
341            anchor: Pos2::new(0.5, 0.5),
342        });
343
344        let json = serde_json::to_string(&layer).unwrap();
345        let deserialized: SvgLayer = serde_json::from_str(&json).unwrap();
346
347        assert_eq!(deserialized.elements.len(), 1);
348        assert_eq!(deserialized.elements[0].text, "<svg></svg>");
349        assert_eq!(deserialized.elements[0].metadata, "test metadata");
350        assert_eq!(deserialized.elements[0].pos, GeoPos { lon: 1.0, lat: 2.0 });
351        assert!(deserialized.elements[0].clickable);
352        assert!(!deserialized.elements[0].draggable);
353    }
354
355    #[test]
356    fn svg_layer_serde_backward_compatibility() {
357        let json = r#"{
358            "elements": [
359                {
360                    "pos": {"lon": 1.0, "lat": 2.0},
361                    "text": "<svg></svg>",
362                    "metadata": "test metadata",
363                    "scalable": false
364                }
365            ]
366        }"#;
367        let deserialized: SvgLayer = serde_json::from_str(json).unwrap();
368        assert!(deserialized.elements[0].clickable);
369        assert!(!deserialized.elements[0].draggable);
370    }
371
372    #[test]
373    fn svg_layer_clickable_false() {
374        let mut layer = SvgLayer::default();
375        layer.add_element(SvgElement {
376            pos: GeoPos { lon: 1.0, lat: 2.0 },
377            text: "<svg></svg>".to_string(),
378            metadata: "test metadata".to_string(),
379            scalable: false,
380            clickable: false,
381            draggable: false,
382            anchor: default_anchor(),
383        });
384
385        let json = serde_json::to_string(&layer).unwrap();
386        let deserialized: SvgLayer = serde_json::from_str(&json).unwrap();
387        assert!(!deserialized.elements[0].clickable);
388    }
389
390    #[test]
391    fn svg_layer_draggable_true() {
392        let mut layer = SvgLayer::default();
393        layer.add_element(SvgElement {
394            pos: GeoPos { lon: 1.0, lat: 2.0 },
395            text: "<svg></svg>".to_string(),
396            metadata: "test metadata".to_string(),
397            scalable: false,
398            clickable: false,
399            draggable: true,
400            anchor: default_anchor(),
401        });
402
403        let json = serde_json::to_string(&layer).unwrap();
404        let deserialized: SvgLayer = serde_json::from_str(&json).unwrap();
405        assert!(deserialized.elements[0].draggable);
406    }
407}