@mytec: iter10.3 ready for testing
This commit is contained in:
@@ -9,6 +9,7 @@ import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts.ts';
|
|||||||
import { logger } from '@/utils/logger.ts';
|
import { logger } from '@/utils/logger.ts';
|
||||||
import MapView from '@/components/map/Map.tsx';
|
import MapView from '@/components/map/Map.tsx';
|
||||||
import GeographicHeatmap from '@/components/map/GeographicHeatmap.tsx';
|
import GeographicHeatmap from '@/components/map/GeographicHeatmap.tsx';
|
||||||
|
import CoverageBoundary from '@/components/map/CoverageBoundary.tsx';
|
||||||
import HeatmapLegend from '@/components/map/HeatmapLegend.tsx';
|
import HeatmapLegend from '@/components/map/HeatmapLegend.tsx';
|
||||||
import SiteList from '@/components/panels/SiteList.tsx';
|
import SiteList from '@/components/panels/SiteList.tsx';
|
||||||
import SiteForm from '@/components/panels/SiteForm.tsx';
|
import SiteForm from '@/components/panels/SiteForm.tsx';
|
||||||
@@ -373,12 +374,19 @@ export default function App() {
|
|||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<MapView onMapClick={handleMapClick} onEditSite={handleEditSite}>
|
<MapView onMapClick={handleMapClick} onEditSite={handleEditSite}>
|
||||||
{coverageResult && (
|
{coverageResult && (
|
||||||
<GeographicHeatmap
|
<>
|
||||||
points={coverageResult.points}
|
<GeographicHeatmap
|
||||||
visible={heatmapVisible}
|
points={coverageResult.points}
|
||||||
opacity={settings.heatmapOpacity}
|
visible={heatmapVisible}
|
||||||
radiusMeters={settings.heatmapRadius}
|
opacity={settings.heatmapOpacity}
|
||||||
/>
|
radiusMeters={settings.heatmapRadius}
|
||||||
|
/>
|
||||||
|
<CoverageBoundary
|
||||||
|
points={coverageResult.points}
|
||||||
|
visible={heatmapVisible}
|
||||||
|
resolution={settings.resolution}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</MapView>
|
</MapView>
|
||||||
<HeatmapLegend />
|
<HeatmapLegend />
|
||||||
|
|||||||
188
frontend/src/components/map/CoverageBoundary.tsx
Normal file
188
frontend/src/components/map/CoverageBoundary.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
* gradient pipeline as the heatmap renderer.
|
* gradient pipeline as the heatmap renderer.
|
||||||
*
|
*
|
||||||
* Renders a smooth continuous gradient bar + labeled RSRP thresholds.
|
* Renders a smooth continuous gradient bar + labeled RSRP thresholds.
|
||||||
|
* Steps below the Min Signal threshold are dimmed to indicate
|
||||||
|
* they fall outside the visible coverage zone.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { normalizeRSRP, valueToColor } from '@/utils/colorGradient.ts';
|
import { normalizeRSRP, valueToColor } from '@/utils/colorGradient.ts';
|
||||||
@@ -46,6 +48,12 @@ export default function HeatmapLegend() {
|
|||||||
const areaKm2 =
|
const areaKm2 =
|
||||||
(result.totalPoints * settings.resolution * settings.resolution) / 1e6;
|
(result.totalPoints * settings.resolution * settings.resolution) / 1e6;
|
||||||
|
|
||||||
|
const threshold = settings.rsrpThreshold;
|
||||||
|
|
||||||
|
// Split legend into above/below threshold
|
||||||
|
const aboveThreshold = LEGEND_STEPS.filter((s) => s.rsrp >= threshold);
|
||||||
|
const belowThreshold = LEGEND_STEPS.filter((s) => s.rsrp < threshold);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute bottom-6 right-2 z-[1000] bg-white dark:bg-dark-surface rounded-lg shadow-lg border border-gray-200 dark:border-dark-border p-3 min-w-[180px]">
|
<div className="absolute bottom-6 right-2 z-[1000] bg-white dark:bg-dark-surface rounded-lg shadow-lg border border-gray-200 dark:border-dark-border p-3 min-w-[180px]">
|
||||||
{/* Header with toggle */}
|
{/* Header with toggle */}
|
||||||
@@ -72,13 +80,13 @@ export default function HeatmapLegend() {
|
|||||||
className="w-3 rounded-sm flex-shrink-0"
|
className="w-3 rounded-sm flex-shrink-0"
|
||||||
style={{
|
style={{
|
||||||
background: gradientCSS,
|
background: gradientCSS,
|
||||||
minHeight: `${LEGEND_STEPS.length * 18}px`,
|
minHeight: `${aboveThreshold.length * 18}px`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Labels */}
|
{/* Labels — above threshold (visible coverage) */}
|
||||||
<div className="flex flex-col justify-between flex-1 py-0.5">
|
<div className="flex flex-col justify-between flex-1 py-0.5">
|
||||||
{[...LEGEND_STEPS].reverse().map((step) => {
|
{[...aboveThreshold].reverse().map((step) => {
|
||||||
const norm = normalizeRSRP(step.rsrp);
|
const norm = normalizeRSRP(step.rsrp);
|
||||||
const [r, g, b] = valueToColor(norm);
|
const [r, g, b] = valueToColor(norm);
|
||||||
return (
|
return (
|
||||||
@@ -96,6 +104,32 @@ export default function HeatmapLegend() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Cutoff indicator + below-threshold (dimmed) */}
|
||||||
|
{belowThreshold.length > 0 && (
|
||||||
|
<div className="mt-1.5 pt-1.5 border-t border-dashed border-purple-400 dark:border-purple-500">
|
||||||
|
<div className="flex items-center gap-1 mb-1">
|
||||||
|
<span className="text-[9px] text-purple-500 dark:text-purple-400 font-medium">
|
||||||
|
─ ─ Coverage boundary ({threshold} dBm)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{[...belowThreshold].reverse().map((step) => {
|
||||||
|
const norm = normalizeRSRP(step.rsrp);
|
||||||
|
const [r, g, b] = valueToColor(norm);
|
||||||
|
return (
|
||||||
|
<div key={step.rsrp} className="flex items-center gap-1.5 opacity-35">
|
||||||
|
<div
|
||||||
|
className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
|
||||||
|
style={{ backgroundColor: `rgb(${r},${g},${b})` }}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-gray-500 dark:text-gray-500 leading-tight">
|
||||||
|
{step.rsrp} dBm {step.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="mt-2 pt-2 border-t border-gray-100 dark:border-dark-border text-[10px] text-gray-400 dark:text-dark-muted space-y-0.5">
|
<div className="mt-2 pt-2 border-t border-gray-100 dark:border-dark-border text-[10px] text-gray-400 dark:text-dark-muted space-y-0.5">
|
||||||
<div>Points: {result.totalPoints.toLocaleString()}</div>
|
<div>Points: {result.totalPoints.toLocaleString()}</div>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const useCoverageStore = create<CoverageState>((set) => ({
|
|||||||
settings: {
|
settings: {
|
||||||
radius: 10,
|
radius: 10,
|
||||||
resolution: 200,
|
resolution: 200,
|
||||||
rsrpThreshold: -120,
|
rsrpThreshold: -100,
|
||||||
heatmapOpacity: 0.7,
|
heatmapOpacity: 0.7,
|
||||||
heatmapRadius: 400,
|
heatmapRadius: 400,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user