/* ================================================ Map components — Leaflet wrappers ================================================ */ const TILE_URLS = { satellite: { url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", attribution: "Tiles © Esri", maxZoom: 18, }, illustrated: { // Watercolor-like / artistic style via Stamen url: "https://tiles.stadiamaps.com/tiles/stamen_watercolor/{z}/{x}/{y}.jpg", attribution: "© Stamen · Stadia", maxZoom: 16, }, }; window.TILE_URLS = TILE_URLS; /* A static Leaflet map showing all parcels as polygons */ function ParcelsMap({ parcels, mapStyle = "satellite", activeId, onSelect, center = [48.085, 7.65], zoom = 13 }) { const containerRef = React.useRef(null); const mapRef = React.useRef(null); const layersRef = React.useRef({ tile: null, polys: {} }); // init map once React.useEffect(() => { if (!containerRef.current || mapRef.current) return; const m = L.map(containerRef.current, { center, zoom, zoomControl: true, attributionControl: true, }); mapRef.current = m; return () => { m.remove(); mapRef.current = null; }; }, []); // switch tile layer React.useEffect(() => { const m = mapRef.current; if (!m) return; if (layersRef.current.tile) m.removeLayer(layersRef.current.tile); const cfg = TILE_URLS[mapStyle] || TILE_URLS.satellite; const tile = L.tileLayer(cfg.url, { maxZoom: cfg.maxZoom, attribution: cfg.attribution }); tile.addTo(m); layersRef.current.tile = tile; }, [mapStyle]); // draw polygons React.useEffect(() => { const m = mapRef.current; if (!m) return; // clear existing Object.values(layersRef.current.polys).forEach((p) => m.removeLayer(p)); layersRef.current.polys = {}; parcels.forEach((p) => { const isActive = p.id === activeId; const poly = L.polygon(p.koordinaten, { color: p.farbe || "#6B1F2E", weight: isActive ? 3 : 2, fillColor: p.farbe || "#6B1F2E", fillOpacity: isActive ? 0.55 : 0.35, opacity: 0.95, }); poly.bindTooltip(p.lagebuch, { permanent: true, direction: "center", className: "parcel-label", }); poly.on("click", () => onSelect && onSelect(p)); poly.addTo(m); layersRef.current.polys[p.id] = poly; }); // fit bounds first render if (parcels.length && !layersRef.current.didFit) { const group = L.featureGroup(Object.values(layersRef.current.polys)); m.fitBounds(group.getBounds().pad(0.2)); layersRef.current.didFit = true; } }, [parcels, activeId]); // focus active parcel React.useEffect(() => { const m = mapRef.current; if (!m || !activeId) return; const poly = layersRef.current.polys[activeId]; if (poly) m.fitBounds(poly.getBounds().pad(0.8), { animate: true }); }, [activeId]); return
; } window.ParcelsMap = ParcelsMap; /* Static single-parcel map (for detail / summary) */ function SingleParcelMap({ parcel, mapStyle = "satellite" }) { const containerRef = React.useRef(null); React.useEffect(() => { if (!containerRef.current) return; const cfg = TILE_URLS[mapStyle] || TILE_URLS.satellite; const m = L.map(containerRef.current, { zoomControl: false, attributionControl: false, dragging: false, scrollWheelZoom: false, doubleClickZoom: false, boxZoom: false, keyboard: false, touchZoom: false, tap: false, }); L.tileLayer(cfg.url, { maxZoom: cfg.maxZoom, attribution: cfg.attribution }).addTo(m); const poly = L.polygon(parcel.koordinaten, { color: parcel.farbe || "#6B1F2E", weight: 3, fillColor: parcel.farbe || "#6B1F2E", fillOpacity: 0.5, }).addTo(m); m.fitBounds(poly.getBounds().pad(0.4)); return () => m.remove(); }, [parcel, mapStyle]); return
; } window.SingleParcelMap = SingleParcelMap; /* Non-interactive thumbnail map for list cards. Uses a dedicated SVG renderer and forces an initial fitBounds AFTER the container has real size — otherwise Leaflet's overlay SVG can be 0×0 and polygons won't paint. */ function ThumbnailMap({ parcel, mapStyle = "satellite" }) { const containerRef = React.useRef(null); React.useEffect(() => { if (!containerRef.current) return; const el = containerRef.current; const cfg = TILE_URLS[mapStyle] || TILE_URLS.satellite; const color = parcel.farbe || "#6B1F2E"; // compute bounds once so we can pass them at init const bounds = L.latLngBounds(parcel.koordinaten).pad(0.5); const renderer = L.canvas({ padding: 2 }); const m = L.map(el, { zoomControl: false, attributionControl: false, dragging: false, scrollWheelZoom: false, doubleClickZoom: false, boxZoom: false, keyboard: false, touchZoom: false, tap: false, renderer, }); L.tileLayer(cfg.url, { maxZoom: cfg.maxZoom, attribution: cfg.attribution }).addTo(m); // halo (cream outline) + filled polygon + vertex dots L.polygon(parcel.koordinaten, { renderer, color: "#F5EFE3", weight: 4, opacity: 0.9, fill: false, lineJoin: "round", }).addTo(m); const poly = L.polygon(parcel.koordinaten, { renderer, color, weight: 2.5, fillColor: color, fillOpacity: 0.65, lineJoin: "round", }).addTo(m); // (no vertex dots — only polygon outline + fill) const fit = () => { if (!m._container || !m._container.isConnected) return; const r = el.getBoundingClientRect(); if (r.width < 2 || r.height < 2) return; m.invalidateSize(false); m.fitBounds(bounds, { animate: false }); }; // Initial fit on next frame (after layout) requestAnimationFrame(() => requestAnimationFrame(fit)); // Resize observer: fires when container first gets real size too const ro = new ResizeObserver(fit); ro.observe(el); return () => { ro.disconnect(); m.remove(); }; }, [parcel.id, mapStyle]); return
; } window.ThumbnailMap = ThumbnailMap; /* Interactive polygon drawer for the wizard */ function PolygonDrawer({ points, onPointsChange, mapStyle = "satellite", center = [48.085, 7.65] }) { const containerRef = React.useRef(null); const mapRef = React.useRef(null); const layersRef = React.useRef({ tile: null, poly: null, line: null, markers: [] }); const pointsRef = React.useRef(points); const prevCountRef = React.useRef(points.length); pointsRef.current = points; React.useEffect(() => { const m = mapRef.current; if (!m) return; if (points.length >= 3 && points.length - prevCountRef.current > 1) { m.fitBounds(L.polygon(points).getBounds().pad(0.2), { animate: true }); } prevCountRef.current = points.length; }, [points]); React.useEffect(() => { if (!containerRef.current || mapRef.current) return; const m = L.map(containerRef.current, { center, zoom: 16, zoomControl: true, attributionControl: true, }); mapRef.current = m; m.on("click", (e) => { const next = [...pointsRef.current, [e.latlng.lat, e.latlng.lng]]; onPointsChange(next); }); return () => { m.remove(); mapRef.current = null; }; }, []); // tile layer React.useEffect(() => { const m = mapRef.current; if (!m) return; if (layersRef.current.tile) m.removeLayer(layersRef.current.tile); const cfg = TILE_URLS[mapStyle] || TILE_URLS.satellite; const tile = L.tileLayer(cfg.url, { maxZoom: cfg.maxZoom, attribution: cfg.attribution }); tile.addTo(m); layersRef.current.tile = tile; }, [mapStyle]); // redraw polygon / markers React.useEffect(() => { const m = mapRef.current; if (!m) return; const L_ = layersRef.current; if (L_.poly) { m.removeLayer(L_.poly); L_.poly = null; } if (L_.line) { m.removeLayer(L_.line); L_.line = null; } L_.markers.forEach((mk) => m.removeLayer(mk)); L_.markers = []; if (points.length >= 3) { L_.poly = L.polygon(points, { color: "#6B1F2E", weight: 2.5, fillColor: "#6B1F2E", fillOpacity: 0.3, dashArray: "4 3", }).addTo(m); } else if (points.length === 2) { L_.line = L.polyline(points, { color: "#6B1F2E", weight: 2.5, dashArray: "4 3" }).addTo(m); } points.forEach((pt, idx) => { const icon = L.divIcon({ className: "", html: `
${idx + 1}
`, iconSize: [16, 16], iconAnchor: [8, 8], }); const mk = L.marker(pt, { icon, draggable: true }); mk.on("dragend", (e) => { const ll = e.target.getLatLng(); const next = pointsRef.current.map((p, i) => i === idx ? [ll.lat, ll.lng] : p); onPointsChange(next); }); mk.addTo(m); L_.markers.push(mk); }); }, [points]); return
; } window.PolygonDrawer = PolygonDrawer; /* ================================================ FlurstueckPicker — select ALKIS Flurstücke on the map. Fetches features for the current viewport (debounced) and lets the user toggle selection by clicking. Parents get the full Flurstück objects back via onChange. ================================================ */ function FlurstueckPicker({ selected, onChange, mapStyle = "satellite", center = [48.085, 7.65], onStatus, initialOutline, flyTo, }) { const containerRef = React.useRef(null); const mapRef = React.useRef(null); const layersRef = React.useRef({ tile: null, available: L.layerGroup(), chosen: L.layerGroup(), labels: L.layerGroup(), reference: null, }); const featureIndex = React.useRef(new Map()); // kennzeichen -> feature obj const selectedRef = React.useRef(selected); selectedRef.current = selected; const [loading, setLoading] = React.useState(false); const selectedKeys = React.useMemo( () => new Set(selected.map(f => f.kennzeichen)), [selected] ); // init map React.useEffect(() => { if (!containerRef.current || mapRef.current) return; const m = L.map(containerRef.current, { center, zoom: 17, zoomControl: true, attributionControl: true, }); mapRef.current = m; layersRef.current.available.addTo(m); layersRef.current.chosen.addTo(m); layersRef.current.labels.addTo(m); let timer = null; const reload = () => { clearTimeout(timer); timer = setTimeout(() => loadFlurstuecke(), 350); }; m.on("moveend", reload); m.on("zoomend", reload); // initial fit: if we already have selected, fit them; else if initialOutline, fit it if (selectedRef.current.length > 0) { const bounds = L.latLngBounds([]); selectedRef.current.forEach(f => f.koordinaten.forEach(p => bounds.extend(p))); if (bounds.isValid()) m.fitBounds(bounds.pad(0.3), { animate: false }); } else if (initialOutline && initialOutline.length >= 3) { const ref = L.polygon(initialOutline, { color: "#6B1F2E", weight: 2, fill: false, dashArray: "4 4", opacity: 0.7, }).addTo(m); layersRef.current.reference = ref; m.fitBounds(ref.getBounds().pad(0.3), { animate: false }); } else { loadFlurstuecke(); } // first load after initial fit setTimeout(loadFlurstuecke, 80); return () => { clearTimeout(timer); m.remove(); mapRef.current = null; }; }, []); // tile layer React.useEffect(() => { const m = mapRef.current; if (!m) return; if (layersRef.current.tile) m.removeLayer(layersRef.current.tile); const cfg = TILE_URLS[mapStyle] || TILE_URLS.satellite; const tile = L.tileLayer(cfg.url, { maxZoom: cfg.maxZoom, attribution: cfg.attribution }); tile.addTo(m); layersRef.current.tile = tile; }, [mapStyle]); async function loadFlurstuecke() { const m = mapRef.current; if (!m) return; if (m.getZoom() < 16) { onStatus?.({ loading: false, message: "Bitte weiter hereinzoomen (ab Zoom 16).", count: 0, truncated: false }); redraw([]); return; } const b = m.getBounds(); const bbox = `${b.getWest()},${b.getSouth()},${b.getEast()},${b.getNorth()}`; setLoading(true); onStatus?.({ loading: true, message: "Flurstücke werden geladen…", count: 0, truncated: false }); try { const r = await fetch(`/api/flurstuecke?bbox=${bbox}&limit=400`, { headers: { Accept: "application/json" }, }); const j = await r.json(); if (!r.ok) { onStatus?.({ loading: false, message: j.error || "Fehler beim Laden.", count: 0, truncated: false }); redraw([]); return; } redraw(j.features || []); onStatus?.({ loading: false, message: null, count: j.returned || 0, truncated: !!j.truncated, }); } catch (e) { onStatus?.({ loading: false, message: "Netzwerkfehler.", count: 0, truncated: false }); } finally { setLoading(false); } } function redraw(features) { const m = mapRef.current; if (!m) return; featureIndex.current.clear(); layersRef.current.available.clearLayers(); layersRef.current.labels.clearLayers(); const sel = new Set(selectedRef.current.map(f => f.kennzeichen)); features.forEach(f => { featureIndex.current.set(f.kennzeichen, f); if (sel.has(f.kennzeichen)) return; // drawn by chosen layer const poly = L.polygon(f.koordinaten, { color: "#F5EFE3", weight: 1, fillColor: "#F5EFE3", fillOpacity: 0.05, interactive: true, }); poly.on("mouseover", () => poly.setStyle({ fillOpacity: 0.25, weight: 1.5 })); poly.on("mouseout", () => poly.setStyle({ fillOpacity: 0.05, weight: 1 })); poly.on("click", () => toggle(f.kennzeichen)); poly.bindTooltip(`${f.gemarkung_name || ""} · ${f.flurstueckstext || f.zaehler} · ${fmtNum(f.flaeche_ha)} ha`, { sticky: true, direction: "top", className: "flur-tooltip", }); poly.addTo(layersRef.current.available); }); drawChosen(); } function drawChosen() { const m = mapRef.current; if (!m) return; layersRef.current.chosen.clearLayers(); selectedRef.current.forEach(f => { const poly = L.polygon(f.koordinaten, { color: "#6B1F2E", weight: 2.5, fillColor: "#6B1F2E", fillOpacity: 0.4, interactive: true, }); poly.on("click", () => toggle(f.kennzeichen)); poly.bindTooltip(`${f.gemarkung_name || ""} · ${f.flurstueckstext || f.zaehler} · ${fmtNum(f.flaeche_ha)} ha · klicken zum Abwählen`, { sticky: true, direction: "top", className: "flur-tooltip", }); poly.addTo(layersRef.current.chosen); }); } function toggle(kennzeichen) { const curr = selectedRef.current; const exists = curr.find(f => f.kennzeichen === kennzeichen); if (exists) { onChange(curr.filter(f => f.kennzeichen !== kennzeichen)); } else { const f = featureIndex.current.get(kennzeichen); if (f) onChange([...curr, f]); } } // re-render selected layer when selection changes React.useEffect(() => { const m = mapRef.current; if (!m) return; // re-run available filter so selected ones aren't double-drawn const feats = Array.from(featureIndex.current.values()); redraw(feats); }, [selectedKeys]); // imperative fly-to trigger (PLZ-Suche etc.) React.useEffect(() => { const m = mapRef.current; if (!m || !flyTo || typeof flyTo.lat !== "number" || typeof flyTo.lng !== "number") return; m.setView([flyTo.lat, flyTo.lng], flyTo.zoom || 17, { animate: true }); }, [flyTo && flyTo.ts]); return (
{loading &&
Lade Flurstücke…
}
); } window.FlurstueckPicker = FlurstueckPicker;