@mytec: iter10.3 ready for testing

This commit is contained in:
2026-01-30 17:18:42 +02:00
parent aae0bc4b12
commit fbaf619047
4 changed files with 240 additions and 10 deletions

View File

@@ -0,0 +1,188 @@
/**
* Renders a dashed polyline around the coverage zone boundary.
*
* Algorithm:
* 1. Bucket all coverage points into grid cells (resolution-based)
* 2. Find "edge" cells — cells that have ≥1 empty neighbour
* 3. Compute a concave boundary by ordering edge points angularly per site
* 4. Render as dashed Leaflet polylines
*
* Performance: runs once per coverage result change, O(n) where n = grid points.
*/
import { useEffect, useRef, useMemo } from 'react';
import { useMap } from 'react-leaflet';
import L from 'leaflet';
import type { CoveragePoint } from '@/types/index.ts';
interface CoverageBoundaryProps {
points: CoveragePoint[];
visible: boolean;
resolution: number; // meters — used to determine grid cell size
color?: string;
weight?: number;
}
export default function CoverageBoundary({
points,
visible,
resolution,
color = '#7c3aed', // purple-600 — visible against both map and orange gradient
weight = 2,
}: CoverageBoundaryProps) {
const map = useMap();
const layerRef = useRef<L.LayerGroup | null>(null);
// Compute boundary paths grouped by site
const boundaryPaths = useMemo(() => {
if (!visible || points.length === 0) return [];
// Group points by siteId
const bySite = new Map<string, CoveragePoint[]>();
for (const p of points) {
let arr = bySite.get(p.siteId);
if (!arr) {
arr = [];
bySite.set(p.siteId, arr);
}
arr.push(p);
}
const paths: L.LatLngExpression[][] = [];
for (const sitePoints of bySite.values()) {
const edgePath = computeEdgePath(sitePoints, resolution);
if (edgePath.length >= 3) {
paths.push(edgePath);
}
}
return paths;
}, [points, visible, resolution]);
// 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;
}
// ---------------------------------------------------------------------------
// Edge detection on the grid
// ---------------------------------------------------------------------------
/**
* For a set of coverage points (all belonging to one site), find the
* ordered boundary polygon.
*
* Steps:
* 1. Hash every point into a grid cell
* 2. Find edge cells (≥1 of 8 neighbours missing)
* 3. Order edge points by angle from centroid → closed polygon
*/
function computeEdgePath(
pts: CoveragePoint[],
resolutionM: number
): L.LatLngExpression[] {
if (pts.length < 3) return [];
// Grid cell size in degrees (approximate)
const cellLat = resolutionM / 111_000;
const avgLat = pts.reduce((s, p) => s + p.lat, 0) / pts.length;
const cellLon = resolutionM / (111_000 * Math.cos((avgLat * Math.PI) / 180));
// Quantize helper
const toKey = (lat: number, lon: number) => {
const r = Math.round(lat / cellLat);
const c = Math.round(lon / cellLon);
return `${r},${c}`;
};
// Build occupied set
const occupied = new Set<string>();
// Keep one representative point per cell for coordinates
const cellCoords = new Map<string, { lat: number; lon: number }>();
for (const p of pts) {
const key = toKey(p.lat, p.lon);
occupied.add(key);
if (!cellCoords.has(key)) {
cellCoords.set(key, { lat: p.lat, lon: p.lon });
}
}
// 8-connected neighbour offsets
const offsets = [
[-1, -1], [-1, 0], [-1, 1],
[0, -1], [0, 1],
[1, -1], [1, 0], [1, 1],
];
// Find edge cells: occupied cells with at least one missing neighbour
const edgePoints: { lat: number; lon: number }[] = [];
for (const [key, coord] of cellCoords) {
const [rStr, cStr] = key.split(',');
const r = Number(rStr);
const c = Number(cStr);
let isEdge = false;
for (const [dr, dc] of offsets) {
if (!occupied.has(`${r + dr},${c + dc}`)) {
isEdge = true;
break;
}
}
if (isEdge) {
edgePoints.push(coord);
}
}
if (edgePoints.length < 3) return [];
// Order by angle from centroid to form a closed polygon
const cx = edgePoints.reduce((s, p) => s + p.lat, 0) / edgePoints.length;
const cy = edgePoints.reduce((s, p) => s + p.lon, 0) / edgePoints.length;
edgePoints.sort(
(a, b) =>
Math.atan2(a.lon - cy, a.lat - cx) - Math.atan2(b.lon - cy, b.lat - cx)
);
// Close the polygon
const result: L.LatLngExpression[] = edgePoints.map(
(p) => [p.lat, p.lon] as L.LatLngExpression
);
result.push(result[0]); // close
return result;
}