// 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 ``;
}
// ---------- 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 (
);
}
// ---------- 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 (
);
}
// ---------- OVERLAYS ----------
function LoadingOverlay({ message }) {
const t = THEME;
return (
);
}
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
);
};