1use 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
15fn default_zoom() -> u8 {
17 13
18}
19
20fn default_height() -> String {
22 "400px".to_string()
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct MapProps {
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub center: Option<[f64; 2]>,
31 #[serde(default = "default_zoom")]
33 pub zoom: u8,
34 #[serde(default = "default_height")]
36 pub height: String,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub fit_bounds: Option<bool>,
40 #[serde(default)]
42 pub markers: Vec<MapMarker>,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub tile_url: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub attribution: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub max_zoom: Option<u8>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct MapMarker {
57 pub lat: f64,
59 pub lng: f64,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub popup: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub color: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub popup_html: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub href: Option<String>,
73}
74
75const DEFAULT_LEAFLET_BASE: &str = "/_ferro/leaflet";
77
78fn 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
86fn 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
109fn 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
119static MAP_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
121
122const 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
126const LEAFLET_CSS_SRI: &str = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
128const LEAFLET_JS_SRI: &str = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
129
130pub 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 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
289const 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 || '© <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 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 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 }
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 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 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 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}