@mytec: iter6 ready for test

This commit is contained in:
2026-01-30 12:44:39 +02:00
parent b18e297eee
commit ed30d886be
10 changed files with 435 additions and 21 deletions

View File

@@ -36,6 +36,10 @@ export default function App() {
const showTerrain = useSettingsStore((s) => s.showTerrain);
const terrainOpacity = useSettingsStore((s) => s.terrainOpacity);
const setTerrainOpacity = useSettingsStore((s) => s.setTerrainOpacity);
const showGrid = useSettingsStore((s) => s.showGrid);
const setShowGrid = useSettingsStore((s) => s.setShowGrid);
const measurementMode = useSettingsStore((s) => s.measurementMode);
const setMeasurementMode = useSettingsStore((s) => s.setMeasurementMode);
const [showForm, setShowForm] = useState(false);
const [editSite, setEditSite] = useState<Site | null>(null);
@@ -395,6 +399,38 @@ export default function App() {
</div>
</div>
{/* Map Tools */}
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm p-4 space-y-3">
<h3 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
Map Tools
</h3>
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-dark-text">
<input
type="checkbox"
checked={showGrid}
onChange={(e) => setShowGrid(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 dark:border-dark-border accent-green-600"
/>
Coordinate Grid
</label>
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-dark-text">
<input
type="checkbox"
checked={measurementMode}
onChange={(e) => setMeasurementMode(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 dark:border-dark-border accent-orange-600"
/>
Distance Measurement
</label>
{measurementMode && (
<p className="text-xs text-gray-400 dark:text-dark-muted pl-6">
Click to add points. Right-click to finish.
</p>
)}
</div>
</div>
{/* Export coverage data */}
<ExportPanel />

View File

@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { useMap } from 'react-leaflet';
import L from 'leaflet';
interface CoordinateGridProps {
visible: boolean;
}
/**
* Coordinate grid overlay with adaptive spacing based on zoom level.
* Drawn directly with Leaflet polylines — no extra dependencies.
*/
export default function CoordinateGrid({ visible }: CoordinateGridProps) {
const map = useMap();
const [zoom, setZoom] = useState(map.getZoom());
useEffect(() => {
const handleZoom = () => setZoom(map.getZoom());
map.on('zoomend', handleZoom);
return () => {
map.off('zoomend', handleZoom);
};
}, [map]);
useEffect(() => {
if (!visible) return;
// Adaptive grid spacing based on zoom
let interval: number;
if (zoom <= 7) interval = 1;
else if (zoom <= 10) interval = 0.5;
else if (zoom <= 13) interval = 0.1;
else interval = 0.01;
const bounds = map.getBounds();
const minLat = Math.floor(bounds.getSouth() / interval) * interval;
const maxLat = Math.ceil(bounds.getNorth() / interval) * interval;
const minLon = Math.floor(bounds.getWest() / interval) * interval;
const maxLon = Math.ceil(bounds.getEast() / interval) * interval;
const layerGroup = L.layerGroup();
// Latitude lines (horizontal)
for (let lat = minLat; lat <= maxLat; lat += interval) {
const line = L.polyline(
[
[lat, minLon - 1],
[lat, maxLon + 1],
],
{ color: '#666', weight: 0.7, opacity: 0.4, dashArray: '3,3' }
);
layerGroup.addLayer(line);
// Label
const label = L.marker([lat, bounds.getWest() + 0.002], {
icon: L.divIcon({
className: '',
html: `<span style="font:10px monospace;color:#444;background:rgba(255,255,255,0.7);padding:0 2px;white-space:nowrap;">${lat.toFixed(interval < 0.1 ? 2 : 1)}°</span>`,
iconAnchor: [0, 6],
}),
interactive: false,
});
layerGroup.addLayer(label);
}
// Longitude lines (vertical)
for (let lon = minLon; lon <= maxLon; lon += interval) {
const line = L.polyline(
[
[minLat - 1, lon],
[maxLat + 1, lon],
],
{ color: '#666', weight: 0.7, opacity: 0.4, dashArray: '3,3' }
);
layerGroup.addLayer(line);
// Label
const label = L.marker([bounds.getNorth() - 0.002, lon], {
icon: L.divIcon({
className: '',
html: `<span style="font:10px monospace;color:#444;background:rgba(255,255,255,0.7);padding:0 2px;white-space:nowrap;">${lon.toFixed(interval < 0.1 ? 2 : 1)}°</span>`,
iconAnchor: [12, 12],
}),
interactive: false,
});
layerGroup.addLayer(label);
}
layerGroup.addTo(map);
return () => {
map.removeLayer(layerGroup);
};
}, [map, visible, zoom]);
return null;
}

View File

@@ -20,12 +20,17 @@ interface HeatmapProps {
/**
* Normalize RSRP to 0-1 intensity for heatmap.
* -120 dBm -> 0.0 (very weak / blue)
* -70 dBm -> 1.0 (excellent / red)
*
* Wide range -130 to -50 dBm ensures the full gradient is used
* and close-in strong signals don't all saturate to one color.
*
* -130 dBm → 0.0 (deep blue, no service)
* -90 dBm → 0.5 (green, fair)
* -50 dBm → 1.0 (red, very strong)
*/
function rsrpToIntensity(rsrp: number): number {
const minRSRP = -120;
const maxRSRP = -70;
const minRSRP = -130;
const maxRSRP = -50;
return Math.max(0, Math.min(1, (rsrp - minRSRP) / (maxRSRP - minRSRP)));
}
@@ -42,12 +47,12 @@ function rsrpToIntensity(rsrp: number): number {
* Zoom 18 (street): radius=8, blur=6
*/
function getHeatmapParams(zoom: number) {
const radius = Math.max(8, Math.min(40, 50 - zoom * 2.5));
const blur = Math.max(6, Math.min(20, 30 - zoom * 1.5));
const radius = Math.max(10, Math.min(40, 60 - zoom * 3));
const blur = Math.max(8, Math.min(25, 35 - zoom * 1.5));
return {
radius: Math.round(radius),
blur: Math.round(blur),
maxIntensity: 1.0, // CONSTANT — zoom-independent colors
maxIntensity: 0.75, // FIXED at 0.75 — zoom-independent colors
};
}
@@ -85,16 +90,12 @@ export default function Heatmap({ points, visible, opacity = 0.7 }: HeatmapProps
const intensityValues = heatData.map((d) => d[2]);
console.log('Heatmap Debug:', {
pointCount: points.length,
rsrpMin: Math.min(...rsrpValues).toFixed(1),
rsrpMax: Math.max(...rsrpValues).toFixed(1),
rsrpSample: rsrpValues.slice(0, 5).map((v) => v.toFixed(1)),
intensityMin: Math.min(...intensityValues).toFixed(3),
intensityMax: Math.max(...intensityValues).toFixed(3),
rsrpRange: `${Math.min(...rsrpValues).toFixed(1)} to ${Math.max(...rsrpValues).toFixed(1)} dBm`,
intensityRange: `${Math.min(...intensityValues).toFixed(3)} to ${Math.max(...intensityValues).toFixed(3)}`,
mapZoom,
radius,
blur,
maxIntensity: maxIntensity.toFixed(2),
opacity,
maxIntensity,
});
}
@@ -105,12 +106,17 @@ export default function Heatmap({ points, visible, opacity = 0.7 }: HeatmapProps
max: maxIntensity,
minOpacity: 0.3,
gradient: {
0.0: '#0d47a1', // Dark Blue (very weak, -120 dBm)
0.2: '#00bcd4', // Cyan (weak, -110 dBm)
0.4: '#4caf50', // Green (fair, -100 dBm)
0.6: '#ffeb3b', // Yellow (good, -85 dBm)
0.8: '#ff9800', // Orange (strong, -70 dBm)
1.0: '#f44336', // Red (excellent, > -70 dBm)
0.0: '#1a237e', // Deep blue (-130 dBm, no service)
0.1: '#0d47a1', // Dark blue (-122 dBm)
0.2: '#2196f3', // Blue (-114 dBm)
0.3: '#00bcd4', // Cyan (-106 dBm, weak)
0.4: '#00897b', // Teal ( -98 dBm)
0.5: '#4caf50', // Green ( -90 dBm, fair)
0.6: '#8bc34a', // Light green ( -82 dBm)
0.7: '#ffeb3b', // Yellow ( -74 dBm, good)
0.8: '#ffc107', // Amber ( -66 dBm)
0.9: '#ff9800', // Orange ( -58 dBm, excellent)
1.0: '#f44336', // Red ( -50 dBm, very strong)
},
});

View File

@@ -5,7 +5,11 @@ import type { Map as LeafletMap } from 'leaflet';
import type { Site } from '@/types/index.ts';
import { useSitesStore } from '@/store/sites.ts';
import { useSettingsStore } from '@/store/settings.ts';
import { useToastStore } from '@/components/ui/Toast.tsx';
import SiteMarker from './SiteMarker.tsx';
import MapExtras from './MapExtras.tsx';
import CoordinateGrid from './CoordinateGrid.tsx';
import MeasurementTool from './MeasurementTool.tsx';
interface MapViewProps {
onMapClick: (lat: number, lon: number) => void;
@@ -46,6 +50,11 @@ export default function MapView({ onMapClick, onEditSite, children }: MapViewPro
const showTerrain = useSettingsStore((s) => s.showTerrain);
const terrainOpacity = useSettingsStore((s) => s.terrainOpacity);
const setShowTerrain = useSettingsStore((s) => s.setShowTerrain);
const showGrid = useSettingsStore((s) => s.showGrid);
const setShowGrid = useSettingsStore((s) => s.setShowGrid);
const measurementMode = useSettingsStore((s) => s.measurementMode);
const setMeasurementMode = useSettingsStore((s) => s.setMeasurementMode);
const addToast = useToastStore((s) => s.addToast);
const mapRef = useRef<LeafletMap | null>(null);
const handleFitToSites = useCallback(() => {
@@ -81,6 +90,15 @@ export default function MapView({ onMapClick, onEditSite, children }: MapViewPro
/>
)}
<MapClickHandler onMapClick={onMapClick} />
<MapExtras />
<CoordinateGrid visible={showGrid} />
<MeasurementTool
enabled={measurementMode}
onComplete={(distKm) => {
addToast(`Distance: ${distKm.toFixed(2)} km (${(distKm * 1000).toFixed(0)} m)`, 'info');
setMeasurementMode(false);
}}
/>
{sites
.filter((s) => s.visible)
.map((site) => (
@@ -121,6 +139,26 @@ export default function MapView({ onMapClick, onEditSite, children }: MapViewPro
>
Topo
</button>
<button
onClick={() => setShowGrid(!showGrid)}
className={`bg-white dark:bg-dark-surface shadow-lg rounded px-3 py-2 text-sm
hover:bg-gray-50 dark:hover:bg-dark-border transition-colors
text-gray-700 dark:text-dark-text min-h-[36px]
${showGrid ? 'ring-2 ring-green-500' : ''}`}
title={showGrid ? 'Hide coordinate grid' : 'Show coordinate grid'}
>
Grid
</button>
<button
onClick={() => setMeasurementMode(!measurementMode)}
className={`bg-white dark:bg-dark-surface shadow-lg rounded px-3 py-2 text-sm
hover:bg-gray-50 dark:hover:bg-dark-border transition-colors
text-gray-700 dark:text-dark-text min-h-[36px]
${measurementMode ? 'ring-2 ring-orange-500' : ''}`}
title={measurementMode ? 'Exit measurement mode' : 'Measure distance (click points, right-click to finish)'}
>
Ruler
</button>
</div>
</>
);

View File

@@ -0,0 +1,50 @@
import { useEffect } from 'react';
import { useMap } from 'react-leaflet';
import L from 'leaflet';
/**
* Adds metric scale bar (bottom-left) and a compass rose (top-right, below zoom buttons).
*/
export default function MapExtras() {
const map = useMap();
useEffect(() => {
// Scale bar
const scale = L.control
.scale({
position: 'bottomleft',
metric: true,
imperial: false,
maxWidth: 200,
})
.addTo(map);
// Compass rose
const CompassControl = L.Control.extend({
options: { position: 'topright' as L.ControlPosition },
onAdd() {
const div = L.DomUtil.create('div', 'compass-rose');
div.innerHTML = `
<svg width="40" height="40" viewBox="0 0 40 40" style="display:block;">
<circle cx="20" cy="20" r="18" fill="rgba(255,255,255,0.9)" stroke="#555" stroke-width="1.5"/>
<path d="M 20 5 L 23 16 L 20 20 L 17 16 Z" fill="#dc2626"/>
<path d="M 20 35 L 23 24 L 20 20 L 17 24 Z" fill="#ccc" stroke="#999" stroke-width="0.5"/>
<text x="20" y="14" text-anchor="middle" font-size="8" font-weight="bold" fill="#333">N</text>
</svg>`;
div.style.cssText =
'border-radius: 50%; box-shadow: 0 2px 5px rgba(0,0,0,0.3); margin-top: 4px; pointer-events: none;';
return div;
},
});
const compass = new CompassControl();
compass.addTo(map);
return () => {
map.removeControl(scale);
map.removeControl(compass);
};
}, [map]);
return null;
}

View File

@@ -0,0 +1,119 @@
import { useEffect, useRef, useState } from 'react';
import { useMap, Polyline, Marker } from 'react-leaflet';
import L from 'leaflet';
interface MeasurementToolProps {
enabled: boolean;
onComplete?: (distanceKm: number) => void;
}
function haversineKm(
lat1: number,
lon1: number,
lat2: number,
lon2: number
): number {
const R = 6371;
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function totalDistance(pts: [number, number][]): number {
let total = 0;
for (let i = 1; i < pts.length; i++) {
total += haversineKm(pts[i - 1][0], pts[i - 1][1], pts[i][0], pts[i][1]);
}
return total;
}
const dotIcon = L.divIcon({
className: '',
iconSize: [10, 10],
iconAnchor: [5, 5],
html: '<div style="width:10px;height:10px;background:white;border:2px solid #333;border-radius:50%;"></div>',
});
export default function MeasurementTool({ enabled, onComplete }: MeasurementToolProps) {
const map = useMap();
const [points, setPoints] = useState<[number, number][]>([]);
const pointsRef = useRef(points);
pointsRef.current = points;
// Clear on disable
useEffect(() => {
if (!enabled) {
setPoints([]);
}
}, [enabled]);
// Click handler: add measurement point
useEffect(() => {
if (!enabled) return;
const handleClick = (e: L.LeafletMouseEvent) => {
setPoints((prev) => [...prev, [e.latlng.lat, e.latlng.lng]]);
};
const handleRightClick = (e: L.LeafletMouseEvent) => {
L.DomEvent.preventDefault(e.originalEvent);
const pts = pointsRef.current;
if (pts.length >= 2 && onComplete) {
onComplete(totalDistance(pts));
}
setPoints([]);
};
map.on('click', handleClick);
map.on('contextmenu', handleRightClick);
return () => {
map.off('click', handleClick);
map.off('contextmenu', handleRightClick);
};
}, [map, enabled, onComplete]);
if (!enabled || points.length === 0) return null;
const dist = totalDistance(points);
return (
<>
{points.length >= 2 && (
<Polyline
positions={points}
pathOptions={{ color: '#00ff00', weight: 3, dashArray: '10, 5' }}
/>
)}
{points.map((pos, idx) => (
<Marker key={idx} position={pos} icon={dotIcon} />
))}
{dist > 0 && (
<div
style={{
position: 'absolute',
top: '10px',
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(0,0,0,0.8)',
color: 'white',
padding: '6px 14px',
borderRadius: '6px',
zIndex: 2000,
pointerEvents: 'none',
fontSize: '13px',
fontWeight: 600,
letterSpacing: '0.3px',
}}
>
Distance: {dist.toFixed(2)} km ({(dist * 1000).toFixed(0)} m)
</div>
)}
</>
);
}

View File

@@ -21,6 +21,7 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
const toggleSiteSelection = useSitesStore((s) => s.toggleSiteSelection);
const selectAllSites = useSitesStore((s) => s.selectAllSites);
const clearSelection = useSitesStore((s) => s.clearSelection);
const cloneSiteAsSectors = useSitesStore((s) => s.cloneSiteAsSectors);
const addToast = useToastStore((s) => s.addToast);
// Track recently batch-updated site IDs for flash animation
@@ -145,15 +146,28 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
onEditSite(site);
}}
className="px-2 py-1 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded min-w-[44px] min-h-[32px] flex items-center justify-center"
title="Edit site"
>
Edit
</button>
<button
onClick={async (e) => {
e.stopPropagation();
await cloneSiteAsSectors(site.id, 3);
addToast(`Created 3 sectors from "${site.name}"`, 'success');
}}
className="px-2 py-1 text-xs text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded min-h-[32px] flex items-center justify-center"
title="Clone as 3-sector site (120° spacing)"
>
3S
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(site.id, site.name);
}}
className="px-2 py-1 text-xs text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded min-w-[32px] min-h-[32px] flex items-center justify-center"
title="Delete site"
>
×
</button>

View File

@@ -34,6 +34,17 @@ export const COMMON_FREQUENCIES: FrequencyBand[] = [
typical: 'North America, some military equipment',
},
},
{
value: 2100,
name: 'Band 1',
range: '1920-2170 MHz',
type: 'LTE',
characteristics: {
range: 'medium',
penetration: 'good',
typical: 'Most deployed LTE band globally (IMT 2100)',
},
},
{
value: 2600,
name: 'Band 7',
@@ -80,7 +91,7 @@ export const COMMON_FREQUENCIES: FrequencyBand[] = [
},
];
export const QUICK_FREQUENCIES = [800, 1800, 1900, 2600];
export const QUICK_FREQUENCIES = [800, 1800, 1900, 2100, 2600];
export function getFrequencyInfo(frequency: number): FrequencyBand | null {
return (

View File

@@ -7,9 +7,13 @@ interface SettingsState {
theme: Theme;
showTerrain: boolean;
terrainOpacity: number;
showGrid: boolean;
measurementMode: boolean;
setTheme: (theme: Theme) => void;
setShowTerrain: (show: boolean) => void;
setTerrainOpacity: (opacity: number) => void;
setShowGrid: (show: boolean) => void;
setMeasurementMode: (mode: boolean) => void;
}
function applyTheme(theme: Theme) {
@@ -29,12 +33,16 @@ export const useSettingsStore = create<SettingsState>()(
theme: 'system' as Theme,
showTerrain: false,
terrainOpacity: 0.5,
showGrid: false,
measurementMode: false,
setTheme: (theme: Theme) => {
set({ theme });
applyTheme(theme);
},
setShowTerrain: (show: boolean) => set({ showTerrain: show }),
setTerrainOpacity: (opacity: number) => set({ terrainOpacity: opacity }),
setShowGrid: (show: boolean) => set({ showGrid: show }),
setMeasurementMode: (mode: boolean) => set({ measurementMode: mode }),
}),
{
name: 'rfcp-settings',

View File

@@ -24,6 +24,9 @@ interface SitesState {
togglePlacingMode: () => void;
setPlacingMode: (val: boolean) => void;
// Multi-sector
cloneSiteAsSectors: (siteId: string, sectorCount: 2 | 3) => Promise<void>;
// Batch operations
toggleSiteSelection: (siteId: string) => void;
selectAllSites: () => void;
@@ -106,6 +109,38 @@ export const useSitesStore = create<SitesState>((set, get) => ({
togglePlacingMode: () => set((s) => ({ isPlacingMode: !s.isPlacingMode })),
setPlacingMode: (val: boolean) => set({ isPlacingMode: val }),
// Multi-sector: clone a site into 2 or 3 co-located sector sites
cloneSiteAsSectors: async (siteId: string, sectorCount: 2 | 3) => {
const source = get().sites.find((s) => s.id === siteId);
if (!source) return;
const spacing = 360 / sectorCount;
const addSite = get().addSite;
for (let i = 0; i < sectorCount; i++) {
const azimuth = Math.round(i * spacing) % 360;
const label = sectorCount === 2
? ['Alpha', 'Beta'][i]
: ['Alpha', 'Beta', 'Gamma'][i];
await addSite({
name: `${source.name}-${label}`,
lat: source.lat,
lon: source.lon,
height: source.height,
power: source.power,
gain: source.gain >= 15 ? source.gain : 18, // sector gain default
frequency: source.frequency,
antennaType: 'sector',
azimuth,
beamwidth: sectorCount === 2 ? 90 : 65,
color: '',
visible: true,
notes: `Sector ${i + 1} (${label}) of ${source.name}`,
});
}
},
// Batch operations
toggleSiteSelection: (siteId: string) => {
set((state) => {