@mytec Phase 1 - Core UI & Manual Input, Phase 2 - RF Calculation Engine, Phase 3 - Heatmap Visualization
This commit is contained in:
67
frontend/src/components/map/Heatmap.tsx
Normal file
67
frontend/src/components/map/Heatmap.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet.heat';
|
||||
import type { CoveragePoint } from '@/types/index.ts';
|
||||
|
||||
// Extend L with heat layer type
|
||||
declare module 'leaflet' {
|
||||
function heatLayer(
|
||||
latlngs: Array<[number, number, number]>,
|
||||
options?: Record<string, unknown>
|
||||
): L.Layer;
|
||||
}
|
||||
|
||||
interface HeatmapProps {
|
||||
points: CoveragePoint[];
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize RSRP to 0-1 intensity for heatmap.
|
||||
* -120 dBm -> 0.0 (very weak)
|
||||
* -70 dBm -> 1.0 (excellent)
|
||||
*/
|
||||
function rsrpToIntensity(rsrp: number): number {
|
||||
const min = -120;
|
||||
const max = -60;
|
||||
return Math.max(0, Math.min(1, (rsrp - min) / (max - min)));
|
||||
}
|
||||
|
||||
export default function Heatmap({ points, visible }: HeatmapProps) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || points.length === 0) return;
|
||||
|
||||
const heatData: Array<[number, number, number]> = points.map((p) => [
|
||||
p.lat,
|
||||
p.lon,
|
||||
rsrpToIntensity(p.rsrp),
|
||||
]);
|
||||
|
||||
const heatLayer = L.heatLayer(heatData, {
|
||||
radius: 15,
|
||||
blur: 20,
|
||||
maxZoom: 17,
|
||||
max: 1.0,
|
||||
minOpacity: 0.3,
|
||||
gradient: {
|
||||
0.0: '#ef4444', // red (weak)
|
||||
0.2: '#f97316', // orange (poor)
|
||||
0.4: '#eab308', // yellow (fair)
|
||||
0.6: '#84cc16', // lime (good)
|
||||
0.8: '#22c55e', // green (excellent)
|
||||
1.0: '#16a34a', // dark green (very strong)
|
||||
},
|
||||
});
|
||||
|
||||
heatLayer.addTo(map);
|
||||
|
||||
return () => {
|
||||
map.removeLayer(heatLayer);
|
||||
};
|
||||
}, [map, points, visible]);
|
||||
|
||||
return null;
|
||||
}
|
||||
52
frontend/src/components/map/Legend.tsx
Normal file
52
frontend/src/components/map/Legend.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { RSRP_LEGEND } from '@/constants/rsrp-thresholds.ts';
|
||||
import { useCoverageStore } from '@/store/coverage.ts';
|
||||
|
||||
export default function Legend() {
|
||||
const result = useCoverageStore((s) => s.result);
|
||||
const heatmapVisible = useCoverageStore((s) => s.heatmapVisible);
|
||||
const toggleHeatmap = useCoverageStore((s) => s.toggleHeatmap);
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-6 right-2 z-[1000] bg-white rounded-lg shadow-lg border border-gray-200 p-3 min-w-[160px]">
|
||||
{/* Header with toggle */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xs font-semibold text-gray-700">Signal (RSRP)</h3>
|
||||
<button
|
||||
onClick={toggleHeatmap}
|
||||
className={`w-8 h-4 rounded-full transition-colors relative
|
||||
${heatmapVisible ? 'bg-blue-500' : 'bg-gray-300'}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-3 h-3 rounded-full bg-white shadow transition-transform
|
||||
${heatmapVisible ? 'left-4' : 'left-0.5'}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Legend items */}
|
||||
<div className="space-y-1">
|
||||
{RSRP_LEGEND.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-sm flex-shrink-0"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span className="text-[10px] text-gray-600">
|
||||
{item.label}: {item.range}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mt-2 pt-2 border-t border-gray-100 text-[10px] text-gray-400 space-y-0.5">
|
||||
<div>Points: {result.totalPoints.toLocaleString()}</div>
|
||||
<div>
|
||||
Time: {(result.calculationTime / 1000).toFixed(2)}s
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
frontend/src/components/map/Map.tsx
Normal file
54
frontend/src/components/map/Map.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { MapContainer, TileLayer, useMapEvents } from 'react-leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import type { Site } from '@/types/index.ts';
|
||||
import { useSitesStore } from '@/store/sites.ts';
|
||||
import SiteMarker from './SiteMarker.tsx';
|
||||
|
||||
interface MapViewProps {
|
||||
onMapClick: (lat: number, lon: number) => void;
|
||||
onEditSite: (site: Site) => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function MapClickHandler({
|
||||
onMapClick,
|
||||
}: {
|
||||
onMapClick: (lat: number, lon: number) => void;
|
||||
}) {
|
||||
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
|
||||
|
||||
useMapEvents({
|
||||
click: (e) => {
|
||||
if (isPlacingMode) {
|
||||
onMapClick(e.latlng.lat, e.latlng.lng);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function MapView({ onMapClick, onEditSite, children }: MapViewProps) {
|
||||
const sites = useSitesStore((s) => s.sites);
|
||||
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={[48.4, 35.0]}
|
||||
zoom={7}
|
||||
className={`w-full h-full ${isPlacingMode ? 'cursor-crosshair' : ''}`}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<MapClickHandler onMapClick={onMapClick} />
|
||||
{sites
|
||||
.filter((s) => s.visible)
|
||||
.map((site) => (
|
||||
<SiteMarker key={site.id} site={site} onEdit={onEditSite} />
|
||||
))}
|
||||
{children}
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
||||
88
frontend/src/components/map/SiteMarker.tsx
Normal file
88
frontend/src/components/map/SiteMarker.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Marker, Popup, useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import type { Site } from '@/types/index.ts';
|
||||
import { useSitesStore } from '@/store/sites.ts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface SiteMarkerProps {
|
||||
site: Site;
|
||||
onEdit: (site: Site) => void;
|
||||
}
|
||||
|
||||
function createSiteIcon(color: string, isSelected: boolean): L.DivIcon {
|
||||
const size = isSelected ? 16 : 12;
|
||||
const border = isSelected ? '3px solid white' : '2px solid white';
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
html: `<div style="
|
||||
width: ${size}px;
|
||||
height: ${size}px;
|
||||
border-radius: 50%;
|
||||
background: ${color};
|
||||
border: ${border};
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
||||
"></div>`,
|
||||
});
|
||||
}
|
||||
|
||||
function FlyToSelected({ site, isSelected }: { site: Site; isSelected: boolean }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (isSelected) {
|
||||
map.flyTo([site.lat, site.lon], map.getZoom(), { duration: 0.5 });
|
||||
}
|
||||
}, [isSelected, site.lat, site.lon, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function SiteMarker({ site, onEdit }: SiteMarkerProps) {
|
||||
const selectedSiteId = useSitesStore((s) => s.selectedSiteId);
|
||||
const selectSite = useSitesStore((s) => s.selectSite);
|
||||
const updateSite = useSitesStore((s) => s.updateSite);
|
||||
|
||||
const isSelected = selectedSiteId === site.id;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlyToSelected site={site} isSelected={isSelected} />
|
||||
<Marker
|
||||
position={[site.lat, site.lon]}
|
||||
icon={createSiteIcon(site.color, isSelected)}
|
||||
draggable
|
||||
eventHandlers={{
|
||||
click: () => selectSite(site.id),
|
||||
dragend: (e) => {
|
||||
const marker = e.target as L.Marker;
|
||||
const pos = marker.getLatLng();
|
||||
updateSite(site.id, { lat: pos.lat, lon: pos.lng });
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div className="text-xs space-y-1 min-w-[160px]">
|
||||
<div className="font-semibold">{site.name}</div>
|
||||
<div>
|
||||
{site.frequency} MHz · {site.power} dBm · {site.gain} dBi
|
||||
</div>
|
||||
<div>
|
||||
Height: {site.height}m · {site.antennaType}
|
||||
</div>
|
||||
{site.notes && (
|
||||
<div className="text-gray-500">{site.notes}</div>
|
||||
)}
|
||||
<div className="pt-1">
|
||||
<button
|
||||
onClick={() => onEdit(site)}
|
||||
className="text-blue-600 hover:underline text-xs"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user