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
75static MAP_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
77
78const 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
82const LEAFLET_CSS_SRI: &str = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
84const LEAFLET_JS_SRI: &str = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
85
86pub 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 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
239const 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 || '© <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 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 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 }
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 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}