/** * Renders a dashed polyline around the coverage zone boundary. * * Prefers server-computed boundary if available (shapely concave_hull). * Falls back to client-side @turf/concave computation. * * Performance: ~20-50ms for 10k points (runs once per coverage change). */ import { useEffect, useRef, useMemo } from 'react'; import { useMap } from 'react-leaflet'; import L from 'leaflet'; import concave from '@turf/concave'; import { featureCollection, point } from '@turf/helpers'; import type { CoveragePoint, BoundaryPoint } from '@/types/index.ts'; import { logger } from '@/utils/logger.ts'; interface CoverageBoundaryProps { points: CoveragePoint[]; visible: boolean; resolution: number; // meters — controls concave hull detail color?: string; weight?: number; boundary?: BoundaryPoint[]; // server-provided boundary (preferred) } export default function CoverageBoundary({ points, visible, resolution, color = '#ffffff', // white — visible against red-to-blue gradient weight = 2, boundary, }: CoverageBoundaryProps) { const map = useMap(); const layerRef = useRef(null); // Compute boundary paths - prefer server boundary, fallback to client-side const boundaryPaths = useMemo(() => { if (!visible) return []; // Use server-provided boundary if available if (boundary && boundary.length >= 3) { const serverPath: L.LatLngExpression[] = boundary.map( (p) => [p.lat, p.lon] as L.LatLngExpression ); return [serverPath]; } // Fallback to client-side computation if (points.length === 0) return []; // Group points by siteId (fallback to 'all' when siteId not available from API) const bySite = new Map(); for (const p of points) { const key = p.siteId || 'all'; let arr = bySite.get(key); if (!arr) { arr = []; bySite.set(key, arr); } arr.push(p); } const paths: L.LatLngExpression[][] = []; for (const sitePoints of bySite.values()) { const sitePaths = computeConcaveHulls(sitePoints, resolution); for (const path of sitePaths) { if (path.length >= 3) { paths.push(path); } } } return paths; }, [points, visible, resolution, boundary]); // Render / cleanup polylines useEffect(() => { // Remove old if (layerRef.current) { map.removeLayer(layerRef.current); layerRef.current = null; } if (!visible || boundaryPaths.length === 0) return; const group = L.layerGroup(); for (const path of boundaryPaths) { L.polyline(path, { color, weight, dashArray: '8, 5', opacity: 0.7, fill: false, interactive: false, }).addTo(group); } group.addTo(map); layerRef.current = group; return () => { if (layerRef.current) { map.removeLayer(layerRef.current); layerRef.current = null; } }; }, [map, visible, boundaryPaths, color, weight]); return null; } // --------------------------------------------------------------------------- // Concave hull via Turf.js // --------------------------------------------------------------------------- /** * Compute concave hull boundary path(s) for a set of coverage points. * * Uses adaptive maxEdge based on point count and resolution: * - More points → smaller maxEdge for finer detail * - Larger resolution → larger maxEdge to avoid over-fitting * * Returns multiple paths if hull is a MultiPolygon (disjoint coverage areas). * Falls back to empty if hull computation fails (e.g., collinear points). */ function computeConcaveHulls( pts: CoveragePoint[], resolutionM: number ): L.LatLngExpression[][] { if (pts.length < 3) return []; // Convert to GeoJSON FeatureCollection of Points const features = pts.map((p) => point([p.lon, p.lat])); const fc = featureCollection(features); // Adaptive maxEdge based on point density: // - Base: resolution * 2 (tighter fit) // - For sparse grids (<100 pts): use larger edge to avoid holes // - For dense grids (>1000 pts): use smaller edge for detail let multiplier = 2.0; if (pts.length < 100) { multiplier = 4.0; // Sparse: wider tolerance } else if (pts.length > 1000) { multiplier = 1.5; // Dense: finer detail } const maxEdge = (resolutionM * multiplier) / 1000; try { const hull = concave(fc, { maxEdge, units: 'kilometers' }); if (!hull) return []; // Handle both Polygon and MultiPolygon results if (hull.geometry.type === 'Polygon') { const coords = hull.geometry.coordinates[0]; return [ coords.map( ([lon, lat]: number[]) => [lat, lon] as L.LatLngExpression ), ]; } if (hull.geometry.type === 'MultiPolygon') { // Return all polygons as separate boundary paths return hull.geometry.coordinates.map((poly) => poly[0].map( ([lon, lat]: number[]) => [lat, lon] as L.LatLngExpression ) ); } return []; } catch (error) { logger.error('Coverage hull computation error:', error); return []; } }