@mytec: iter10.3.2 ready for testing
This commit is contained in:
@@ -383,7 +383,7 @@ export default function App() {
|
||||
rsrpThreshold={settings.rsrpThreshold}
|
||||
/>
|
||||
<CoverageBoundary
|
||||
points={coverageResult.points}
|
||||
points={coverageResult.points.filter(p => p.rsrp >= settings.rsrpThreshold)}
|
||||
visible={heatmapVisible}
|
||||
resolution={settings.resolution}
|
||||
/>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
/**
|
||||
* 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
|
||||
* Uses @turf/concave to compute a concave hull (alpha shape) per site,
|
||||
* which correctly follows sector/wedge shapes — not just convex circles.
|
||||
*
|
||||
* Performance: runs once per coverage result change, O(n) where n = grid points.
|
||||
* 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 } from '@/types/index.ts';
|
||||
import { logger } from '@/utils/logger.ts';
|
||||
|
||||
interface CoverageBoundaryProps {
|
||||
points: CoveragePoint[];
|
||||
visible: boolean;
|
||||
resolution: number; // meters — used to determine grid cell size
|
||||
resolution: number; // meters — controls concave hull detail
|
||||
color?: string;
|
||||
weight?: number;
|
||||
}
|
||||
@@ -35,12 +35,8 @@ export default function CoverageBoundary({
|
||||
|
||||
// Compute boundary paths grouped by site
|
||||
const boundaryPaths = useMemo(() => {
|
||||
console.log('[CoverageBoundary] Computing:', { visible, pointsCount: points.length, resolution });
|
||||
|
||||
if (!visible || points.length === 0) {
|
||||
console.log('[CoverageBoundary] SKIP - not visible or no points');
|
||||
return [];
|
||||
}
|
||||
if (!visible || points.length === 0) return [];
|
||||
|
||||
// Group points by siteId
|
||||
const bySite = new Map<string, CoveragePoint[]>();
|
||||
for (const p of points) {
|
||||
@@ -55,14 +51,12 @@ export default function CoverageBoundary({
|
||||
const paths: L.LatLngExpression[][] = [];
|
||||
|
||||
for (const sitePoints of bySite.values()) {
|
||||
const edgePath = computeEdgePath(sitePoints, resolution);
|
||||
if (edgePath.length >= 3) {
|
||||
paths.push(edgePath);
|
||||
const path = computeConcaveHull(sitePoints, resolution);
|
||||
if (path.length >= 3) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[CoverageBoundary] Paths:', paths.length);
|
||||
|
||||
|
||||
return paths;
|
||||
}, [points, visible, resolution]);
|
||||
|
||||
@@ -76,8 +70,6 @@ export default function CoverageBoundary({
|
||||
|
||||
if (!visible || boundaryPaths.length === 0) return;
|
||||
|
||||
console.log('[CoverageBoundary] RENDERING polylines:', boundaryPaths.length);
|
||||
|
||||
const group = L.layerGroup();
|
||||
|
||||
for (const path of boundaryPaths) {
|
||||
@@ -106,91 +98,42 @@ export default function CoverageBoundary({
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge detection on the grid
|
||||
// Concave hull via Turf.js
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* For a set of coverage points (all belonging to one site), find the
|
||||
* ordered boundary polygon.
|
||||
* Compute a concave hull boundary for one site's coverage points.
|
||||
*
|
||||
* 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
|
||||
* maxEdge = resolution * 3 (in km) gives good detail without over-fitting.
|
||||
* Falls back to empty if hull computation fails (e.g., collinear points).
|
||||
*/
|
||||
function computeEdgePath(
|
||||
function computeConcaveHull(
|
||||
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));
|
||||
// Convert to GeoJSON FeatureCollection of Points
|
||||
const features = pts.map((p) => point([p.lon, p.lat]));
|
||||
const fc = featureCollection(features);
|
||||
|
||||
// Quantize helper
|
||||
const toKey = (lat: number, lon: number) => {
|
||||
const r = Math.round(lat / cellLat);
|
||||
const c = Math.round(lon / cellLon);
|
||||
return `${r},${c}`;
|
||||
};
|
||||
// maxEdge in km — resolution * 3 balances detail vs smoothness
|
||||
const maxEdge = (resolutionM * 3) / 1000;
|
||||
|
||||
// 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 });
|
||||
try {
|
||||
const hull = concave(fc, { maxEdge, units: 'kilometers' });
|
||||
|
||||
if (!hull || hull.geometry.type !== 'Polygon') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// GeoJSON coordinates are [lon, lat]; Leaflet needs [lat, lon]
|
||||
const coords = hull.geometry.coordinates[0];
|
||||
return coords.map(
|
||||
([lon, lat]: number[]) => [lat, lon] as L.LatLngExpression
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Coverage hull computation error:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user