@mytec: iter6 ready for test
This commit is contained in:
@@ -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 />
|
||||
|
||||
|
||||
97
frontend/src/components/map/CoordinateGrid.tsx
Normal file
97
frontend/src/components/map/CoordinateGrid.tsx
Normal 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;
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
50
frontend/src/components/map/MapExtras.tsx
Normal file
50
frontend/src/components/map/MapExtras.tsx
Normal 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;
|
||||
}
|
||||
119
frontend/src/components/map/MeasurementTool.tsx
Normal file
119
frontend/src/components/map/MeasurementTool.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user