// Real data from Naturvårdsverket APIs. // WFS SkyddadePunkter: point centroids with bbox support, GeoJSON output. // REST /omrade/{id}/Gällande: full record including beskrivning, fetched on demand. const _WFS = "https://geodata.naturvardsverket.se/naturvardsregistret/wfs/v2"; const _REST = "https://geodata.naturvardsverket.se/naturvardsregistret/rest/v3"; // Haversine distance in km function _haversine(lat1, lon1, lat2, lon2) { const R = 6371; const dLat = (lat2 - lat1) * Math.PI / 180; const dLon = (lon2 - lon1) * Math.PI / 180; const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } // Compute centroid of a GeoJSON MultiPolygon (average of outer ring vertices). function _centroid(geometry) { const ring = geometry.coordinates[0][0]; // outer ring of first polygon let sumLon = 0, sumLat = 0; for (const [lon, lat] of ring) { sumLon += lon; sumLat += lat; } return { lat: sumLat / ring.length, lon: sumLon / ring.length }; } // Fetch reserves near a lat/lon. Uses SkyddadeOmraden (polygon layer) which // contains Naturreservat and Nationalpark. Returns array sorted by distance. window.fetchReservesNearby = async function (userLat, userLon, radiusDeg = 0.35) { const latSpan = radiusDeg; const lonSpan = radiusDeg / Math.cos(userLat * Math.PI / 180); const bbox = [ (userLat - latSpan).toFixed(6), (userLon - lonSpan).toFixed(6), (userLat + latSpan).toFixed(6), (userLon + lonSpan).toFixed(6), ].join(","); const url = `${_WFS}?service=WFS&version=2.0.0&request=GetFeature` + `&typeNames=Naturvardsregistret_WFS:SkyddadeOmraden` + `&outputFormat=GEOJSON&srsName=EPSG:4326` + `&BBOX=${bbox}&count=1000`; const resp = await fetch(url); if (!resp.ok) throw new Error(`WFS ${resp.status}`); const data = await resp.json(); const allowed = new Set(["Naturreservat", "Nationalpark", "Kommunalt naturreservat"]); return data.features .filter(f => allowed.has(f.properties.SKYDDSTYP)) .map((f, i) => { const { lat, lon } = _centroid(f.geometry); const dist = _haversine(userLat, userLon, lat, lon); const yearStr = f.properties.URSPR_BESLUTSDATUM; const ar = yearStr ? parseInt(yearStr.slice(0, 4)) : null; const typ = f.properties.SKYDDSTYP === "Kommunalt naturreservat" ? "Naturreservat" : f.properties.SKYDDSTYP; return { id: f.properties.NVRID, namn: f.properties.NAMN, typ, storlek: f.properties.AREA_HA, ar, avstand: Math.round(dist * 10) / 10, lat, lon, geojson: f.geometry, // actual polygon for boundary rendering beskrivning: null, _seed: (i + 1) * 1337, }; }) .sort((a, b) => a.avstand - b.avstand); }; // Fetch the beskrivning for one reserve (called when user opens detail sheet). window.fetchReserveDetail = async function (id) { try { const resp = await fetch(`${_REST}/omrade/${id}/G%C3%A4llande`); if (!resp.ok) return null; const data = await resp.json(); return data.beskrivning || null; } catch { return null; } }; // Project real lat/lon to SVG coordinate space (0..1000). // User location is always at SVG (500, 500). Scale: ~30 km across 1000 SVG units. window.latLonToSvg = function (lat, lon, userLat, userLon) { const SVG_PER_KM = 33.3; const kmLat = 111.32; const kmLon = 111.32 * Math.cos(userLat * Math.PI / 180); return { x: 500 + (lon - userLon) * kmLon * SVG_PER_KM, y: 500 - (lat - userLat) * kmLat * SVG_PER_KM, }; }; // Build a soft irregular boundary polygon around SVG point (cx, cy). function _seeded(seed) { let s = seed; return () => { s = (s * 9301 + 49297) % 233280; return s / 233280; }; } window.boundaryPath = function (cx, cy, areaHa, seed) { const baseR = Math.min(28 + Math.sqrt(Math.max(areaHa || 1, 1)) * 1.6, 90); const rand = _seeded(seed); const points = 14; const pts = []; for (let i = 0; i < points; i++) { const a = (i / points) * Math.PI * 2; const r = baseR * (0.72 + rand() * 0.55); pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]); } let d = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`; for (let i = 0; i < pts.length; i++) { const p0 = pts[(i - 1 + pts.length) % pts.length]; const p1 = pts[i]; const p2 = pts[(i + 1) % pts.length]; const p3 = pts[(i + 2) % pts.length]; const t = 0.18; const c1x = p1[0] + (p2[0] - p0[0]) * t; const c1y = p1[1] + (p2[1] - p0[1]) * t; const c2x = p2[0] - (p3[0] - p1[0]) * t; const c2y = p2[1] - (p3[1] - p1[1]) * t; d += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`; } return d + " Z"; }; // Ray-casting point-in-polygon for a GeoJSON ring ([lon, lat] pairs). function _pointInRing(lon, lat, ring) { let inside = false; for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { const xi = ring[i][0], yi = ring[i][1]; const xj = ring[j][0], yj = ring[j][1]; if ((yi > lat) !== (yj > lat) && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) { inside = !inside; } } return inside; } // Returns true if lat/lon is inside any polygon of a GeoJSON MultiPolygon geometry. function _pointInMultiPolygon(lon, lat, geometry) { for (const polygon of geometry.coordinates) { if (_pointInRing(lon, lat, polygon[0])) return true; // outer ring only } return false; } // Returns the first reserve whose polygon contains the given lat/lon, or null. window.findReserveContaining = function (lat, lon, reserves) { return reserves.find(r => r.geojson && _pointInMultiPolygon(lon, lat, r.geojson)) || null; }; // Decorative stylized map shapes (background only — not geographic). window.MAP_SHAPES = { land: "M -40 80 C 60 40, 180 60, 280 50 C 380 40, 480 90, 580 70 C 680 50, 780 80, 880 60 C 960 50, 1040 90, 1080 160 L 1080 940 C 1020 980, 920 960, 820 980 C 720 1000, 620 970, 520 990 C 420 1010, 320 970, 220 985 C 120 1000, 20 970, -40 920 Z", lake1: "M 700 180 C 760 150, 860 160, 900 220 C 940 280, 920 340, 850 360 C 780 380, 700 360, 670 300 C 640 240, 660 200, 700 180 Z", lake2: "M 80 700 C 140 670, 240 680, 290 740 C 320 790, 290 850, 220 870 C 140 890, 60 860, 50 800 C 40 750, 50 720, 80 700 Z", river: "M -20 280 C 80 300, 140 260, 220 290 C 300 320, 360 380, 440 400 C 520 420, 580 480, 620 560 C 660 640, 700 720, 780 780 C 860 840, 940 860, 1040 880", forest1: "M 320 480 C 400 460, 480 480, 500 540 C 520 600, 460 640, 380 630 C 300 620, 280 540, 320 480 Z", forest2: "M 580 380 C 640 360, 700 380, 710 430 C 720 480, 660 510, 600 500 C 540 490, 540 410, 580 380 Z", forest3: "M 140 420 C 200 400, 260 420, 270 470 C 280 520, 220 540, 160 530 C 100 520, 100 440, 140 420 Z", forest4: "M 800 600 C 860 580, 920 610, 920 660 C 920 710, 860 730, 800 720 C 740 710, 750 620, 800 600 Z", road1: "M -20 500 C 100 510, 240 480, 380 510 C 520 540, 660 520, 800 550 C 900 570, 980 560, 1040 580", road2: "M 500 -20 C 510 100, 480 220, 510 340 C 540 460, 520 580, 550 700 C 570 800, 540 900, 560 1040", };