Skip to main content

ferro_json_ui/plugins/
map.rs

1//! Map plugin for JSON-UI using Leaflet 1.9.4.
2//!
3//! Renders interactive maps from JSON props. Each map container stores its
4//! configuration in a `data-ferro-map` attribute; a single init script
5//! discovers all containers on the page and initializes Leaflet maps.
6
7use std::sync::atomic::{AtomicU64, Ordering};
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12use crate::plugin::{Asset, JsonUiPlugin};
13use crate::render::html_escape;
14
15/// Default zoom level for maps.
16fn default_zoom() -> u8 {
17    13
18}
19
20/// Default height for the map container.
21fn default_height() -> String {
22    "400px".to_string()
23}
24
25/// Typed props for the Map component.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct MapProps {
28    /// Map center as `[lat, lng]`. Optional when using `fit_bounds`.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub center: Option<[f64; 2]>,
31    /// Zoom level (default: 13).
32    #[serde(default = "default_zoom")]
33    pub zoom: u8,
34    /// CSS height of the container (default: "400px").
35    #[serde(default = "default_height")]
36    pub height: String,
37    /// Auto-zoom to fit all markers. When true, center/zoom are ignored if markers exist.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub fit_bounds: Option<bool>,
40    /// Markers to place on the map.
41    #[serde(default)]
42    pub markers: Vec<MapMarker>,
43    /// Custom tile layer URL template.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub tile_url: Option<String>,
46    /// Tile layer attribution string.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub attribution: Option<String>,
49    /// Maximum zoom level for the tile layer.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub max_zoom: Option<u8>,
52}
53
54/// A marker on the map.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct MapMarker {
57    /// Latitude.
58    pub lat: f64,
59    /// Longitude.
60    pub lng: f64,
61    /// Optional popup content (plain text).
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub popup: Option<String>,
64    /// Hex color for DivIcon pin (e.g., "#3B82F6"). When set, renders as colored CSS pin.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub color: Option<String>,
67    /// HTML content for popup (alternative to plain text popup).
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub popup_html: Option<String>,
70    /// URL to navigate to on marker click.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub href: Option<String>,
73}
74
75/// Global counter for unique map container IDs.
76static MAP_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
77
78/// Leaflet 1.9.4 CDN base URL.
79const LEAFLET_CSS_URL: &str = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
80const LEAFLET_JS_URL: &str = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js";
81
82/// SRI hashes for Leaflet 1.9.4.
83const LEAFLET_CSS_SRI: &str = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
84const LEAFLET_JS_SRI: &str = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
85
86/// Map plugin using Leaflet 1.9.4.
87///
88/// Renders interactive maps from JSON props. Configuration is stored in
89/// `data-ferro-map` attributes on container elements; a single init script
90/// initializes all maps on the page.
91pub struct MapPlugin;
92
93impl JsonUiPlugin for MapPlugin {
94    fn component_type(&self) -> &str {
95        "Map"
96    }
97
98    fn props_schema(&self) -> Value {
99        serde_json::json!({
100            "type": "object",
101            "description": "Interactive map component using Leaflet. Renders a map with configurable center, zoom, markers, and tile layer.",
102            "required": [],
103            "properties": {
104                "center": {
105                    "type": "array",
106                    "description": "Map center as [latitude, longitude]. Optional when fit_bounds is true.",
107                    "items": { "type": "number" },
108                    "minItems": 2,
109                    "maxItems": 2,
110                    "examples": [[51.505, -0.09]]
111                },
112                "zoom": {
113                    "type": "integer",
114                    "description": "Initial zoom level (0-18)",
115                    "default": 13,
116                    "minimum": 0,
117                    "maximum": 18
118                },
119                "height": {
120                    "type": "string",
121                    "description": "CSS height of the map container",
122                    "default": "400px",
123                    "examples": ["400px", "100vh", "600px"]
124                },
125                "fit_bounds": {
126                    "type": "boolean",
127                    "description": "Auto-zoom to fit all markers. When true, center/zoom are ignored if markers exist.",
128                    "default": false
129                },
130                "markers": {
131                    "type": "array",
132                    "description": "Markers to display on the map",
133                    "items": {
134                        "type": "object",
135                        "required": ["lat", "lng"],
136                        "properties": {
137                            "lat": {
138                                "type": "number",
139                                "description": "Marker latitude"
140                            },
141                            "lng": {
142                                "type": "number",
143                                "description": "Marker longitude"
144                            },
145                            "popup": {
146                                "type": "string",
147                                "description": "Optional popup text shown on marker click"
148                            },
149                            "color": {
150                                "type": "string",
151                                "description": "Hex color for DivIcon pin (e.g., '#3B82F6'). When set, renders as colored CSS pin instead of default marker."
152                            },
153                            "popup_html": {
154                                "type": "string",
155                                "description": "HTML content for popup. Takes priority over plain text popup."
156                            },
157                            "href": {
158                                "type": "string",
159                                "description": "URL to navigate to on marker click."
160                            }
161                        }
162                    }
163                },
164                "tile_url": {
165                    "type": "string",
166                    "description": "Custom tile layer URL template. Defaults to OpenStreetMap.",
167                    "examples": ["https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"]
168                },
169                "attribution": {
170                    "type": "string",
171                    "description": "Tile layer attribution text"
172                },
173                "max_zoom": {
174                    "type": "integer",
175                    "description": "Maximum zoom level for the tile layer",
176                    "minimum": 0,
177                    "maximum": 22
178                }
179            }
180        })
181    }
182
183    fn render(&self, props: &Value, _data: &Value) -> String {
184        let map_props: MapProps = match serde_json::from_value(props.clone()) {
185            Ok(p) => p,
186            Err(e) => {
187                return format!(
188                    "<div class=\"p-4 bg-red-50 text-red-600 rounded\">Map error: {}</div>",
189                    html_escape(&e.to_string())
190                );
191            }
192        };
193
194        // Build the config JSON stored in the data attribute.
195        let mut config = serde_json::json!({
196            "zoom": map_props.zoom,
197            "markers": map_props.markers,
198            "tile_url": map_props.tile_url,
199            "attribution": map_props.attribution,
200            "max_zoom": map_props.max_zoom,
201        });
202
203        if let Some(center) = &map_props.center {
204            config["center"] = serde_json::json!(center);
205        }
206
207        if let Some(true) = map_props.fit_bounds {
208            config["fit_bounds"] = serde_json::json!(true);
209        }
210
211        let config_json = serde_json::to_string(&config).unwrap_or_default();
212        let id = MAP_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
213
214        format!(
215            "<div id=\"ferro-map-{}\" data-ferro-map='{}' style=\"height: {}; width: 100%;\"></div>",
216            id,
217            html_escape(&config_json),
218            html_escape(&map_props.height),
219        )
220    }
221
222    fn css_assets(&self) -> Vec<Asset> {
223        vec![Asset::new(LEAFLET_CSS_URL)
224            .integrity(LEAFLET_CSS_SRI)
225            .crossorigin("")]
226    }
227
228    fn js_assets(&self) -> Vec<Asset> {
229        vec![Asset::new(LEAFLET_JS_URL)
230            .integrity(LEAFLET_JS_SRI)
231            .crossorigin("")]
232    }
233
234    fn init_script(&self) -> Option<String> {
235        Some(INIT_SCRIPT.to_string())
236    }
237}
238
239/// Leaflet initialization script.
240///
241/// Discovers all `[data-ferro-map]` elements, parses their JSON config,
242/// and creates Leaflet maps. Uses `IntersectionObserver` to handle maps
243/// inside hidden containers (tabs, modals).
244const INIT_SCRIPT: &str = r#"
245(function() {
246  if (!document.getElementById('ferro-map-pin-css')) {
247    var s = document.createElement('style');
248    s.id = 'ferro-map-pin-css';
249    s.textContent = '.poi-marker{background:transparent;border:none;}.marker-pin{width:30px;height:30px;border-radius:50% 50% 50% 0;position:absolute;transform:rotate(-45deg);left:50%;top:50%;margin:-15px 0 0 -15px;}.marker-pin::after{content:"";width:18px;height:18px;margin:6px 0 0 6px;background:rgba(255,255,255,0.4);position:absolute;border-radius:50%;}';
250    document.head.appendChild(s);
251  }
252})();
253document.addEventListener('DOMContentLoaded', function() {
254  document.querySelectorAll('[data-ferro-map]').forEach(function(el) {
255    try {
256      var cfg = JSON.parse(el.getAttribute('data-ferro-map'));
257      var map = L.map(el);
258      el._leaflet_map = map;
259
260      var tileUrl = cfg.tile_url || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
261      var attribution = cfg.attribution || '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
262      var maxZoom = cfg.max_zoom || 19;
263
264      L.tileLayer(tileUrl, {
265        attribution: attribution,
266        maxZoom: maxZoom
267      }).addTo(map);
268
269      var allMarkers = [];
270      if (cfg.markers) {
271        cfg.markers.forEach(function(m) {
272          var opts = {};
273          if (m.color) {
274            opts.icon = L.divIcon({
275              className: 'poi-marker',
276              html: '<div class="marker-pin" style="background:' + m.color + '"></div>',
277              iconSize: [30, 42],
278              iconAnchor: [15, 42],
279              popupAnchor: [0, -42]
280            });
281          }
282          var marker = L.marker([m.lat, m.lng], opts).addTo(map);
283          if (m.popup_html) {
284            marker.bindPopup(m.popup_html);
285          } else if (m.popup) {
286            marker.bindPopup(m.popup);
287          }
288          if (m.href) {
289            marker.on('click', function(e) {
290              L.DomEvent.stopPropagation(e);
291              window.location.href = m.href;
292            });
293          }
294          allMarkers.push(marker);
295        });
296      }
297
298      if (cfg.fit_bounds && allMarkers.length > 0) {
299        map.fitBounds(L.featureGroup(allMarkers).getBounds(), { padding: [50, 50] });
300      } else if (cfg.center) {
301        map.setView(cfg.center, cfg.zoom || 13);
302      } else {
303        map.setView([0, 0], 2);
304      }
305
306      if (typeof IntersectionObserver !== 'undefined') {
307        var observer = new IntersectionObserver(function(entries) {
308          entries.forEach(function(entry) {
309            if (entry.isIntersecting) {
310              map.invalidateSize();
311            }
312          });
313        });
314        observer.observe(el);
315      }
316    } catch (e) {
317      console.error('Ferro Map init error:', e);
318    }
319  });
320});
321"#;
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    fn empty_data() -> Value {
328        serde_json::json!({})
329    }
330
331    fn basic_props() -> Value {
332        serde_json::json!({
333            "center": [51.505, -0.09],
334            "zoom": 13,
335            "markers": [
336                {"lat": 51.5, "lng": -0.09, "popup": "Hello"},
337                {"lat": 51.51, "lng": -0.1}
338            ]
339        })
340    }
341
342    #[test]
343    fn test_map_renders_container_with_data_attribute() {
344        let plugin = MapPlugin;
345        let html = plugin.render(&basic_props(), &empty_data());
346
347        assert!(
348            html.contains("data-ferro-map"),
349            "output should contain data-ferro-map attribute"
350        );
351        assert!(
352            html.contains("51.505"),
353            "output should contain center latitude"
354        );
355        assert!(
356            html.contains("-0.09"),
357            "output should contain center longitude"
358        );
359        assert!(
360            html.contains("style=\"height: 400px"),
361            "output should use default 400px height"
362        );
363    }
364
365    #[test]
366    fn test_map_custom_height() {
367        let plugin = MapPlugin;
368        let props = serde_json::json!({
369            "center": [40.7128, -74.0060],
370            "height": "600px"
371        });
372        let html = plugin.render(&props, &empty_data());
373
374        assert!(
375            html.contains("style=\"height: 600px"),
376            "output should use custom 600px height"
377        );
378    }
379
380    #[test]
381    fn test_map_with_markers() {
382        let plugin = MapPlugin;
383        let html = plugin.render(&basic_props(), &empty_data());
384
385        // The data attribute should contain marker coordinates and popup.
386        assert!(html.contains("51.5"), "should contain marker lat");
387        assert!(html.contains("-0.09"), "should contain marker lng");
388        assert!(html.contains("Hello"), "should contain popup text");
389    }
390
391    #[test]
392    fn test_map_invalid_props_shows_error() {
393        let plugin = MapPlugin;
394        // center must be an array of numbers, not a string.
395        let props = serde_json::json!({"center": "not-an-array"});
396        let html = plugin.render(&props, &empty_data());
397
398        assert!(
399            html.contains("Map error:"),
400            "should show error message for invalid props"
401        );
402        assert!(html.contains("bg-red-50"), "should use error styling");
403        // Must not panic.
404    }
405
406    #[test]
407    fn test_map_props_schema_valid() {
408        let plugin = MapPlugin;
409        let schema = plugin.props_schema();
410
411        assert_eq!(schema["type"], "object", "schema type should be 'object'");
412        assert!(
413            schema["properties"].is_object(),
414            "schema should have 'properties'"
415        );
416        assert!(
417            schema["properties"]["center"].is_object(),
418            "schema should describe 'center' property"
419        );
420        assert!(
421            schema["properties"]["fit_bounds"].is_object(),
422            "schema should describe 'fit_bounds' property"
423        );
424        assert_eq!(
425            schema["required"],
426            serde_json::json!([]),
427            "no properties should be required"
428        );
429    }
430
431    #[test]
432    fn test_map_assets_have_sri() {
433        let plugin = MapPlugin;
434
435        let css = plugin.css_assets();
436        assert_eq!(css.len(), 1);
437        assert!(
438            css[0].integrity.is_some(),
439            "CSS asset should have integrity hash"
440        );
441        assert!(
442            css[0].integrity.as_ref().unwrap().starts_with("sha256-"),
443            "integrity should be sha256"
444        );
445
446        let js = plugin.js_assets();
447        assert_eq!(js.len(), 1);
448        assert!(
449            js[0].integrity.is_some(),
450            "JS asset should have integrity hash"
451        );
452        assert!(
453            js[0].integrity.as_ref().unwrap().starts_with("sha256-"),
454            "integrity should be sha256"
455        );
456    }
457
458    #[test]
459    fn test_map_init_script_present() {
460        let plugin = MapPlugin;
461        let script = plugin.init_script();
462
463        assert!(script.is_some(), "init_script should return Some");
464        let script = script.unwrap();
465        assert!(
466            script.contains("DOMContentLoaded"),
467            "script should listen for DOMContentLoaded"
468        );
469        assert!(
470            script.contains("data-ferro-map"),
471            "script should query data-ferro-map elements"
472        );
473        assert!(
474            script.contains("IntersectionObserver"),
475            "script should use IntersectionObserver"
476        );
477        assert!(
478            script.contains("fitBounds"),
479            "script should support fitBounds auto-zoom"
480        );
481        assert!(
482            script.contains("featureGroup"),
483            "script should use featureGroup for bounds calculation"
484        );
485    }
486
487    #[test]
488    fn test_map_component_type() {
489        let plugin = MapPlugin;
490        assert_eq!(plugin.component_type(), "Map");
491    }
492
493    #[test]
494    fn test_map_unique_ids() {
495        let plugin = MapPlugin;
496        let props = serde_json::json!({"center": [0.0, 0.0]});
497
498        let html1 = plugin.render(&props, &empty_data());
499        let html2 = plugin.render(&props, &empty_data());
500
501        // Extract IDs: they should differ.
502        assert_ne!(html1, html2, "two renders should produce different IDs");
503        assert!(
504            html1.contains("ferro-map-"),
505            "should have ferro-map- prefix"
506        );
507        assert!(
508            html2.contains("ferro-map-"),
509            "should have ferro-map- prefix"
510        );
511    }
512
513    #[test]
514    fn test_map_renders_without_center() {
515        let plugin = MapPlugin;
516        let props = serde_json::json!({
517            "fit_bounds": true,
518            "markers": [
519                {"lat": 51.5, "lng": -0.09, "popup": "A"},
520                {"lat": 51.51, "lng": -0.1, "popup": "B"}
521            ]
522        });
523        let html = plugin.render(&props, &empty_data());
524
525        assert!(
526            html.contains("data-ferro-map"),
527            "should render map container without center"
528        );
529        assert!(
530            !html.contains("Map error:"),
531            "should not show error when center is omitted with fit_bounds"
532        );
533        assert!(
534            html.contains("fit_bounds"),
535            "config should contain fit_bounds"
536        );
537    }
538}