// Naturreservat — Leaflet tile map with real Naturvårdsverket data. const { useState, useRef, useEffect, useCallback } = React; // ---------- THEME ---------- const THEME = { font: "'Inter', system-ui, sans-serif", displayWeight: 600, bodyWeight: 400, letterSpacing: "-0.01em", radius: 18, sheetRadius: 28, bg: "#f3efe9", surface: "#ffffff", surfaceAlt: "#f8f5f0", text: "#1f1d1a", textMuted: "#6b6760", border: "rgba(31,29,26,0.08)", borderStrong: "rgba(31,29,26,0.14)", accent: "#3a4a32", accentText: "#ffffff", pin: "#3a4a32", pinPark: "#a85a2a", chip: { bg: "#ffffff", border: "rgba(31,29,26,0.10)", text: "#1f1d1a" }, }; // Build SVG pin HTML for a Leaflet divIcon function pinSvg(color, isNationalpark, scale = 1) { const w = Math.round(32 * scale), h = Math.round(42 * scale); const parkPath = isNationalpark ? `` : ""; return ` ${parkPath} `; } // ---------- MAP VIEW ---------- function MapView({ reserves, selectedId, userPos, insideReserve, reservesLoaded, onPinClick, mapApiRef }) { const containerRef = useRef(null); const mapRef = useRef(null); const layersRef = useRef(new Map()); // id → { marker, circle } const userMarkerRef = useRef(null); const initialPanRef = useRef(false); const [mapReady, setMapReady] = useState(false); // Initialize Leaflet map once on mount useEffect(() => { if (mapRef.current) return; const map = L.map(containerRef.current, { zoomControl: false, attributionControl: false, center: [59.33, 18.06], zoom: 15, }); L.tileLayer( "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", { subdomains: "abcd", maxZoom: 19 } ).addTo(map); mapRef.current = map; if (mapApiRef) mapApiRef.current = map; // whenReady fires after the map container is sized and the first render is done map.whenReady(() => setMapReady(true)); }, []); // Pan to user once on the first position fix — never again so manual zoom is preserved useEffect(() => { if (!mapReady || !userPos || initialPanRef.current) return; initialPanRef.current = true; mapRef.current.setView([userPos.lat, userPos.lon], 15, { animate: false }); }, [mapReady, userPos?.lat, userPos?.lon]); // User location dot + 1 km diameter circle useEffect(() => { if (!mapReady || !userPos) return; userMarkerRef.current?.remove(); const group = L.layerGroup().addTo(mapRef.current); // 1 km diameter proximity circle (500 m radius) L.circle([userPos.lat, userPos.lon], { radius: 500, color: THEME.accent, weight: 2, dashArray: "8 6", opacity: 0.8, fillColor: THEME.accent, fillOpacity: 0.07, interactive: false, }).addTo(group); // User dot const icon = L.divIcon({ className: "naturreservat-pin", html: `
`, iconSize: [18, 18], iconAnchor: [9, 9], }); L.marker([userPos.lat, userPos.lon], { icon, zIndexOffset: 2000, interactive: false }).addTo(group); userMarkerRef.current = group; }, [mapReady, userPos?.lat, userPos?.lon]); // "Du är i ett naturreservat!" label — separate effect so it updates when insideReserve changes const insideLabelRef = useRef(null); useEffect(() => { insideLabelRef.current?.remove(); insideLabelRef.current = null; if (!mapReady || !userPos || !reservesLoaded) return; const text = insideReserve ? "Du är i ett naturreservat!" : "Du är inte i ett naturreservat!"; const bg = insideReserve ? THEME.accent : THEME.textMuted; const labelIcon = L.divIcon({ className: "naturreservat-pin", html: `
${text}
`, iconSize: [0, 0], iconAnchor: [0, 0], }); insideLabelRef.current = L.marker([userPos.lat, userPos.lon], { icon: labelIcon, zIndexOffset: 1999, interactive: false, }).addTo(mapRef.current); }, [mapReady, userPos?.lat, userPos?.lon, insideReserve, reservesLoaded]); // Reserve markers & boundary circles — only run once map is confirmed ready useEffect(() => { if (!mapReady) return; const map = mapRef.current; // Remove previous layers layersRef.current.forEach(({ marker, circle }) => { marker.remove(); circle.remove(); }); layersRef.current.clear(); reserves.forEach((r) => { const isSelected = r.id === selectedId; const color = r.typ === "Nationalpark" ? THEME.pinPark : THEME.pin; const scale = isSelected ? 1.3 : 1; const w = Math.round(32 * scale), h = Math.round(42 * scale); const icon = L.divIcon({ className: "naturreservat-pin", html: pinSvg(color, r.typ === "Nationalpark", scale), iconSize: [w, h], iconAnchor: [Math.round(w / 2), Math.round(h * 0.33)], }); const marker = L.marker([r.lat, r.lon], { icon, zIndexOffset: isSelected ? 1000 : 0 }) .addTo(map) .on("click", () => onPinClick(r)); // Actual polygon boundary from WFS geometry const boundary = L.geoJSON(r.geojson, { style: { color, weight: 2, dashArray: "4 7", fillColor: color, fillOpacity: isSelected ? 0.12 : 0.05, interactive: false, }, }).addTo(map); layersRef.current.set(r.id, { marker, circle: boundary }); }); }, [mapReady, reserves, selectedId]); return
; } // ---------- BOTTOM SHEET ---------- function BottomSheet({ reserve, onClose }) { const [drag, setDrag] = useState(null); const [offset, setOffset] = useState(0); const [desc, setDesc] = useState(null); const [loadingDesc, setLoadingDesc] = useState(false); useEffect(() => { setOffset(0); setDesc(null); if (!reserve) return; setLoadingDesc(true); window.fetchReserveDetail(reserve.id).then(d => { setDesc(d); setLoadingDesc(false); }); }, [reserve?.id]); if (!reserve) return null; const onDown = (e) => { e.currentTarget.setPointerCapture?.(e.pointerId); setDrag({ id: e.pointerId, y: e.clientY, off: offset }); }; const onMove = (e) => { if (drag?.id === e.pointerId) setOffset(Math.max(0, e.clientY - drag.y + drag.off)); }; const onUp = () => { if (offset > 120) onClose(); else setOffset(0); setDrag(null); }; const t = THEME; return (
{reserve.typ}

{reserve.namn}

{reserve.storlek > 0 && } {reserve.ar && }
{loadingDesc ?

Hämtar beskrivning…

: desc ?

{desc}

:

Ingen beskrivning tillgänglig.

}
); } function Stat({ label, value }) { const t = THEME; return (
{label}
{value}
); } // ---------- ZOOM + LOCATE ---------- function ZoomControls({ onZoomIn, onZoomOut, onLocate }) { const t = THEME; const btn = { width: 44, height: 44, background: t.surface, border: `1px solid ${t.border}`, color: t.text, fontSize: 20, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }; return (
); } // ---------- LEGEND ---------- function Legend() { const t = THEME; return (
Naturreservat
Naturreservat
Nationalpark
); } // ---------- OVERLAYS ---------- function LoadingOverlay({ message }) { const t = THEME; return (
{message}
); } function ErrorOverlay({ message, onRetry }) { const t = THEME; return (

{message}

{onRetry && }
); } // ---------- APP ---------- window.NaturreservatApp = function NaturreservatApp() { const mapApiRef = useRef(null); const [selected, setSelected] = useState(null); const [userPos, setUserPos] = useState(null); const [reserves, setReserves] = useState([]); const [status, setStatus] = useState("locating"); const [insideReserve, setInsideReserve] = useState(false); const fetchAnchorRef = useRef(null); // lat/lon of last reserve fetch const reservesRef = useRef([]); // mirror of reserves for use in callbacks const load = useCallback((lat, lon) => { fetchAnchorRef.current = { lat, lon }; setStatus("loading"); window.fetchReservesNearby(lat, lon, 0.27) // ~30 km radius .then(data => { reservesRef.current = data; setReserves(data); setStatus("ready"); setInsideReserve(!!window.findReserveContaining(lat, lon, data)); }) .catch(() => setStatus("fetch-error")); }, []); const startWatching = useCallback(() => { setStatus("locating"); if (!navigator.geolocation) { setStatus("geo-error"); return; } const watchId = navigator.geolocation.watchPosition( ({ coords }) => { const { latitude: lat, longitude: lon } = coords; setUserPos({ lat, lon }); // Re-check inside/outside on every position update setInsideReserve(!!window.findReserveContaining(lat, lon, reservesRef.current)); // Re-fetch reserves if we've moved more than ~5 km from the last fetch anchor const anchor = fetchAnchorRef.current; if (!anchor) { load(lat, lon); return; } const dlat = lat - anchor.lat, dlon = lon - anchor.lon; const distDeg = Math.sqrt(dlat * dlat + dlon * dlon); if (distDeg > 0.045) load(lat, lon); // ~5 km }, () => setStatus("geo-error"), { enableHighAccuracy: true, timeout: 10000, maximumAge: 5000 } ); return watchId; }, [load]); useEffect(() => { const id = startWatching(); return () => { if (id != null) navigator.geolocation.clearWatch(id); }; }, []); const onLocate = () => { if (!userPos || !mapApiRef.current) return; mapApiRef.current.setView([userPos.lat, userPos.lon], 15, { animate: true }); }; return (
mapApiRef.current?.zoomIn()} onZoomOut={() => mapApiRef.current?.zoomOut()} onLocate={onLocate} /> setSelected(null)} /> {status === "locating" && } {status === "loading" && } {status === "geo-error" && } {status === "fetch-error" && userPos && load(userPos.lat, userPos.lon)} />}
Naturvårdsverket · © CartoDB
); };