182 lines
5.1 KiB
TypeScript
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 [];
|
|
}
|
|
}
|