Files
rfcp/frontend/src/components/map/CoverageBoundary.tsx

182 lines
5.1 KiB
TypeScript

/**
* 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<L.LayerGroup | null>(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<string, CoveragePoint[]>();
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 [];
}
}