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/// Framework-served, self-hosted Leaflet (see `/_ferro/leaflet/*` in the server).
76const DEFAULT_LEAFLET_BASE: &str = "/_ferro/leaflet";
77
78/// Whether to opt back into the unpkg CDN via `FERRO_LEAFLET_CDN`.
79fn cdn_opt_in() -> bool {
80    matches!(
81        std::env::var("FERRO_LEAFLET_CDN").ok().as_deref(),
82        Some("1") | Some("true") | Some("yes")
83    )
84}
85
86/// Resolve the Leaflet asset base.
87///
88/// - `Some(base)` → self-hosted `{base}/leaflet.{css,js}` (no SRI/crossorigin).
89/// - `None` → the SRI-pinned unpkg CDN.
90///
91/// Precedence: an explicit `FERRO_LEAFLET_BASE` wins; else `FERRO_LEAFLET_CDN=1`
92/// selects the CDN; else the **default** is the framework-served
93/// `/_ferro/leaflet`, so the Map plugin works offline / behind TLS-terminating
94/// proxies with zero configuration.
95fn leaflet_base() -> Option<String> {
96    if let Some(b) = std::env::var("FERRO_LEAFLET_BASE")
97        .ok()
98        .map(|s| s.trim_end_matches('/').to_string())
99        .filter(|s| !s.is_empty())
100    {
101        return Some(b);
102    }
103    if cdn_opt_in() {
104        return None;
105    }
106    Some(DEFAULT_LEAFLET_BASE.to_string())
107}
108
109/// Build a Leaflet asset: self-hosted `{base}/{file}` (no SRI) when a base is
110/// configured, else the SRI-pinned CDN URL. Pure so the branch is unit-testable
111/// without mutating the process environment.
112fn leaflet_asset(base: Option<&str>, file: &str, cdn_url: &str, cdn_sri: &str) -> Asset {
113    match base {
114        Some(b) => Asset::new(format!("{b}/{file}")),
115        None => Asset::new(cdn_url).integrity(cdn_sri).crossorigin(""),
116    }
117}
118
119/// Global counter for unique map container IDs.
120static MAP_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
121
122/// Leaflet 1.9.4 CDN base URL.
123const LEAFLET_CSS_URL: &str = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
124const LEAFLET_JS_URL: &str = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js";
125
126/// SRI hashes for Leaflet 1.9.4.
127const LEAFLET_CSS_SRI: &str = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
128const LEAFLET_JS_SRI: &str = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
129
130/// Map plugin using Leaflet 1.9.4.
131///
132/// Renders interactive maps from JSON props. Configuration is stored in
133/// `data-ferro-map` attributes on container elements; a single init script
134/// initializes all maps on the page.
135pub struct MapPlugin;
136
137impl JsonUiPlugin for MapPlugin {
138    fn component_type(&self) -> &str {
139        "Map"
140    }
141
142    fn props_schema(&self) -> Value {
143        serde_json::json!({
144            "type": "object",
145            "description": "Interactive map component using Leaflet. Renders a map with configurable center, zoom, markers, and tile layer.",
146            "required": [],
147            "properties": {
148                "center": {
149                    "type": "array",
150                    "description": "Map center as [latitude, longitude]. Optional when fit_bounds is true.",
151                    "items": { "type": "number" },
152                    "minItems": 2,
153                    "maxItems": 2,
154                    "examples": [[51.505, -0.09]]
155                },
156                "zoom": {
157                    "type": "integer",
158                    "description": "Initial zoom level (0-18)",
159                    "default": 13,
160                    "minimum": 0,
161                    "maximum": 18
162                },
163                "height": {
164                    "type": "string",
165                    "description": "CSS height of the map container",
166                    "default": "400px",
167                    "examples": ["400px", "100vh", "600px"]
168                },
169                "fit_bounds": {
170                    "type": "boolean",
171                    "description": "Auto-zoom to fit all markers. When true, center/zoom are ignored if markers exist.",
172                    "default": false
173                },
174                "markers": {
175                    "type": "array",
176                    "description": "Markers to display on the map",
177                    "items": {
178                        "type": "object",
179                        "required": ["lat", "lng"],
180                        "properties": {
181                            "lat": {
182                                "type": "number",
183                                "description": "Marker latitude"
184                            },
185                            "lng": {
186                                "type": "number",
187                                "description": "Marker longitude"
188                            },
189                            "popup": {
190                                "type": "string",
191                                "description": "Optional popup text shown on marker click"
192                            },
193                            "color": {
194                                "type": "string",
195                                "description": "Hex color for DivIcon pin (e.g., '#3B82F6'). When set, renders as colored CSS pin instead of default marker."
196                            },
197                            "popup_html": {
198                                "type": "string",
199                                "description": "HTML content for popup. Takes priority over plain text popup."
200                            },
201                            "href": {
202                                "type": "string",
203                                "description": "URL to navigate to on marker click."
204                            }
205                        }
206                    }
207                },
208                "tile_url": {
209                    "type": "string",
210                    "description": "Custom tile layer URL template. Defaults to OpenStreetMap.",
211                    "examples": ["https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"]
212                },
213                "attribution": {
214                    "type": "string",
215                    "description": "Tile layer attribution text"
216                },
217                "max_zoom": {
218                    "type": "integer",
219                    "description": "Maximum zoom level for the tile layer",
220                    "minimum": 0,
221                    "maximum": 22
222                }
223            }
224        })
225    }
226
227    fn render(&self, props: &Value, _data: &Value) -> String {
228        let map_props: MapProps = match serde_json::from_value(props.clone()) {
229            Ok(p) => p,
230            Err(e) => {
231                return format!(
232                    "<div class=\"p-4 bg-red-50 text-red-600 rounded\">Map error: {}</div>",
233                    html_escape(&e.to_string())
234                );
235            }
236        };
237
238        // Build the config JSON stored in the data attribute.
239        let mut config = serde_json::json!({
240            "zoom": map_props.zoom,
241            "markers": map_props.markers,
242            "tile_url": map_props.tile_url,
243            "attribution": map_props.attribution,
244            "max_zoom": map_props.max_zoom,
245        });
246
247        if let Some(center) = &map_props.center {
248            config["center"] = serde_json::json!(center);
249        }
250
251        if let Some(true) = map_props.fit_bounds {
252            config["fit_bounds"] = serde_json::json!(true);
253        }
254
255        let config_json = serde_json::to_string(&config).unwrap_or_default();
256        let id = MAP_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
257
258        format!(
259            "<div id=\"ferro-map-{}\" data-ferro-map='{}' style=\"height: {}; width: 100%;\"></div>",
260            id,
261            html_escape(&config_json),
262            html_escape(&map_props.height),
263        )
264    }
265
266    fn css_assets(&self) -> Vec<Asset> {
267        vec![leaflet_asset(
268            leaflet_base().as_deref(),
269            "leaflet.css",
270            LEAFLET_CSS_URL,
271            LEAFLET_CSS_SRI,
272        )]
273    }
274
275    fn js_assets(&self) -> Vec<Asset> {
276        vec![leaflet_asset(
277            leaflet_base().as_deref(),
278            "leaflet.js",
279            LEAFLET_JS_URL,
280            LEAFLET_JS_SRI,
281        )]
282    }
283
284    fn init_script(&self) -> Option<String> {
285        Some(INIT_SCRIPT.to_string())
286    }
287}
288
289/// Leaflet initialization script.
290///
291/// Discovers all `[data-ferro-map]` elements, parses their JSON config,
292/// and creates Leaflet maps. Uses `IntersectionObserver` to handle maps
293/// inside hidden containers (tabs, modals).
294const INIT_SCRIPT: &str = r#"
295(function() {
296  if (!document.getElementById('ferro-map-pin-css')) {
297    var s = document.createElement('style');
298    s.id = 'ferro-map-pin-css';
299    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%;}';
300    document.head.appendChild(s);
301  }
302})();
303document.addEventListener('DOMContentLoaded', function() {
304  document.querySelectorAll('[data-ferro-map]').forEach(function(el) {
305    try {
306      var cfg = JSON.parse(el.getAttribute('data-ferro-map'));
307      var map = L.map(el);
308      el._leaflet_map = map;
309
310      var tileUrl = cfg.tile_url || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
311      var attribution = cfg.attribution || '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
312      var maxZoom = cfg.max_zoom || 19;
313
314      L.tileLayer(tileUrl, {
315        attribution: attribution,
316        maxZoom: maxZoom
317      }).addTo(map);
318
319      var allMarkers = [];
320      if (cfg.markers) {
321        cfg.markers.forEach(function(m) {
322          var opts = {};
323          if (m.color) {
324            opts.icon = L.divIcon({
325              className: 'poi-marker',
326              html: '<div class="marker-pin" style="background:' + m.color + '"></div>',
327              iconSize: [30, 42],
328              iconAnchor: [15, 42],
329              popupAnchor: [0, -42]
330            });
331          }
332          var marker = L.marker([m.lat, m.lng], opts).addTo(map);
333          if (m.popup_html) {
334            marker.bindPopup(m.popup_html);
335          } else if (m.popup) {
336            marker.bindPopup(m.popup);
337          }
338          if (m.href) {
339            marker.on('click', function(e) {
340              L.DomEvent.stopPropagation(e);
341              window.location.href = m.href;
342            });
343          }
344          allMarkers.push(marker);
345        });
346      }
347
348      if (cfg.fit_bounds && allMarkers.length > 0) {
349        map.fitBounds(L.featureGroup(allMarkers).getBounds(), { padding: [50, 50] });
350      } else if (cfg.center) {
351        map.setView(cfg.center, cfg.zoom || 13);
352      } else {
353        map.setView([0, 0], 2);
354      }
355
356      if (typeof IntersectionObserver !== 'undefined') {
357        var observer = new IntersectionObserver(function(entries) {
358          entries.forEach(function(entry) {
359            if (entry.isIntersecting) {
360              map.invalidateSize();
361            }
362          });
363        });
364        observer.observe(el);
365      }
366    } catch (e) {
367      console.error('Ferro Map init error:', e);
368    }
369  });
370});
371"#;
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    fn empty_data() -> Value {
378        serde_json::json!({})
379    }
380
381    fn basic_props() -> Value {
382        serde_json::json!({
383            "center": [51.505, -0.09],
384            "zoom": 13,
385            "markers": [
386                {"lat": 51.5, "lng": -0.09, "popup": "Hello"},
387                {"lat": 51.51, "lng": -0.1}
388            ]
389        })
390    }
391
392    #[test]
393    fn test_map_renders_container_with_data_attribute() {
394        let plugin = MapPlugin;
395        let html = plugin.render(&basic_props(), &empty_data());
396
397        assert!(
398            html.contains("data-ferro-map"),
399            "output should contain data-ferro-map attribute"
400        );
401        assert!(
402            html.contains("51.505"),
403            "output should contain center latitude"
404        );
405        assert!(
406            html.contains("-0.09"),
407            "output should contain center longitude"
408        );
409        assert!(
410            html.contains("style=\"height: 400px"),
411            "output should use default 400px height"
412        );
413    }
414
415    #[test]
416    fn test_map_custom_height() {
417        let plugin = MapPlugin;
418        let props = serde_json::json!({
419            "center": [40.7128, -74.0060],
420            "height": "600px"
421        });
422        let html = plugin.render(&props, &empty_data());
423
424        assert!(
425            html.contains("style=\"height: 600px"),
426            "output should use custom 600px height"
427        );
428    }
429
430    #[test]
431    fn test_map_with_markers() {
432        let plugin = MapPlugin;
433        let html = plugin.render(&basic_props(), &empty_data());
434
435        // The data attribute should contain marker coordinates and popup.
436        assert!(html.contains("51.5"), "should contain marker lat");
437        assert!(html.contains("-0.09"), "should contain marker lng");
438        assert!(html.contains("Hello"), "should contain popup text");
439    }
440
441    #[test]
442    fn test_map_invalid_props_shows_error() {
443        let plugin = MapPlugin;
444        // center must be an array of numbers, not a string.
445        let props = serde_json::json!({"center": "not-an-array"});
446        let html = plugin.render(&props, &empty_data());
447
448        assert!(
449            html.contains("Map error:"),
450            "should show error message for invalid props"
451        );
452        assert!(html.contains("bg-red-50"), "should use error styling");
453        // Must not panic.
454    }
455
456    #[test]
457    fn test_map_props_schema_valid() {
458        let plugin = MapPlugin;
459        let schema = plugin.props_schema();
460
461        assert_eq!(schema["type"], "object", "schema type should be 'object'");
462        assert!(
463            schema["properties"].is_object(),
464            "schema should have 'properties'"
465        );
466        assert!(
467            schema["properties"]["center"].is_object(),
468            "schema should describe 'center' property"
469        );
470        assert!(
471            schema["properties"]["fit_bounds"].is_object(),
472            "schema should describe 'fit_bounds' property"
473        );
474        assert_eq!(
475            schema["required"],
476            serde_json::json!([]),
477            "no properties should be required"
478        );
479    }
480
481    #[test]
482    fn default_assets_are_self_hosted() {
483        // By default the plugin serves Leaflet from the framework (no CDN, no
484        // SRI). Skip on a machine that overrides the base/CDN via env so the
485        // test never flakes.
486        if std::env::var_os("FERRO_LEAFLET_BASE").is_some()
487            || std::env::var_os("FERRO_LEAFLET_CDN").is_some()
488        {
489            return;
490        }
491        let plugin = MapPlugin;
492
493        let css = plugin.css_assets();
494        assert_eq!(css.len(), 1);
495        assert_eq!(css[0].url, "/_ferro/leaflet/leaflet.css");
496        assert!(
497            css[0].integrity.is_none(),
498            "self-hosted default must not carry an SRI hash"
499        );
500
501        let js = plugin.js_assets();
502        assert_eq!(js.len(), 1);
503        assert_eq!(js[0].url, "/_ferro/leaflet/leaflet.js");
504        assert!(js[0].integrity.is_none());
505    }
506
507    #[test]
508    fn cdn_opt_in_still_available() {
509        // The pure builder still produces the SRI-pinned CDN asset when no base
510        // is supplied — the path taken when FERRO_LEAFLET_CDN=1.
511        let a = leaflet_asset(None, "leaflet.js", LEAFLET_JS_URL, LEAFLET_JS_SRI);
512        assert_eq!(a.url, LEAFLET_JS_URL);
513        assert!(a.integrity.as_deref().unwrap_or("").starts_with("sha256-"));
514    }
515
516    #[test]
517    fn test_map_init_script_present() {
518        let plugin = MapPlugin;
519        let script = plugin.init_script();
520
521        assert!(script.is_some(), "init_script should return Some");
522        let script = script.unwrap();
523        assert!(
524            script.contains("DOMContentLoaded"),
525            "script should listen for DOMContentLoaded"
526        );
527        assert!(
528            script.contains("data-ferro-map"),
529            "script should query data-ferro-map elements"
530        );
531        assert!(
532            script.contains("IntersectionObserver"),
533            "script should use IntersectionObserver"
534        );
535        assert!(
536            script.contains("fitBounds"),
537            "script should support fitBounds auto-zoom"
538        );
539        assert!(
540            script.contains("featureGroup"),
541            "script should use featureGroup for bounds calculation"
542        );
543    }
544
545    #[test]
546    fn test_map_component_type() {
547        let plugin = MapPlugin;
548        assert_eq!(plugin.component_type(), "Map");
549    }
550
551    #[test]
552    fn leaflet_asset_defaults_to_sri_cdn() {
553        let a = leaflet_asset(None, "leaflet.css", LEAFLET_CSS_URL, LEAFLET_CSS_SRI);
554        assert_eq!(a.url, LEAFLET_CSS_URL);
555        assert_eq!(a.integrity.as_deref(), Some(LEAFLET_CSS_SRI));
556    }
557
558    #[test]
559    fn leaflet_asset_self_hosted_drops_sri() {
560        let a = leaflet_asset(
561            Some("/vendor/leaflet"),
562            "leaflet.js",
563            LEAFLET_JS_URL,
564            LEAFLET_JS_SRI,
565        );
566        assert_eq!(a.url, "/vendor/leaflet/leaflet.js");
567        assert!(
568            a.integrity.is_none(),
569            "self-hosted asset must not carry an SRI hash"
570        );
571    }
572
573    #[test]
574    fn test_map_unique_ids() {
575        let plugin = MapPlugin;
576        let props = serde_json::json!({"center": [0.0, 0.0]});
577
578        let html1 = plugin.render(&props, &empty_data());
579        let html2 = plugin.render(&props, &empty_data());
580
581        // Extract IDs: they should differ.
582        assert_ne!(html1, html2, "two renders should produce different IDs");
583        assert!(
584            html1.contains("ferro-map-"),
585            "should have ferro-map- prefix"
586        );
587        assert!(
588            html2.contains("ferro-map-"),
589            "should have ferro-map- prefix"
590        );
591    }
592
593    #[test]
594    fn test_map_renders_without_center() {
595        let plugin = MapPlugin;
596        let props = serde_json::json!({
597            "fit_bounds": true,
598            "markers": [
599                {"lat": 51.5, "lng": -0.09, "popup": "A"},
600                {"lat": 51.51, "lng": -0.1, "popup": "B"}
601            ]
602        });
603        let html = plugin.render(&props, &empty_data());
604
605        assert!(
606            html.contains("data-ferro-map"),
607            "should render map container without center"
608        );
609        assert!(
610            !html.contains("Map error:"),
611            "should not show error when center is omitted with fit_bounds"
612        );
613        assert!(
614            html.contains("fit_bounds"),
615            "config should contain fit_bounds"
616        );
617    }
618}