@mytec: iter7 ready for test
This commit is contained in:
@@ -13,6 +13,8 @@ import SiteList from '@/components/panels/SiteList.tsx';
|
|||||||
import SiteForm from '@/components/panels/SiteForm.tsx';
|
import SiteForm from '@/components/panels/SiteForm.tsx';
|
||||||
import ExportPanel from '@/components/panels/ExportPanel.tsx';
|
import ExportPanel from '@/components/panels/ExportPanel.tsx';
|
||||||
import ProjectPanel from '@/components/panels/ProjectPanel.tsx';
|
import ProjectPanel from '@/components/panels/ProjectPanel.tsx';
|
||||||
|
import CoverageStats from '@/components/panels/CoverageStats.tsx';
|
||||||
|
import SiteImportExport from '@/components/panels/SiteImportExport.tsx';
|
||||||
import ToastContainer from '@/components/ui/Toast.tsx';
|
import ToastContainer from '@/components/ui/Toast.tsx';
|
||||||
import ThemeToggle from '@/components/ui/ThemeToggle.tsx';
|
import ThemeToggle from '@/components/ui/ThemeToggle.tsx';
|
||||||
import Button from '@/components/ui/Button.tsx';
|
import Button from '@/components/ui/Button.tsx';
|
||||||
@@ -40,6 +42,10 @@ export default function App() {
|
|||||||
const setShowGrid = useSettingsStore((s) => s.setShowGrid);
|
const setShowGrid = useSettingsStore((s) => s.setShowGrid);
|
||||||
const measurementMode = useSettingsStore((s) => s.measurementMode);
|
const measurementMode = useSettingsStore((s) => s.measurementMode);
|
||||||
const setMeasurementMode = useSettingsStore((s) => s.setMeasurementMode);
|
const setMeasurementMode = useSettingsStore((s) => s.setMeasurementMode);
|
||||||
|
const showElevationInfo = useSettingsStore((s) => s.showElevationInfo);
|
||||||
|
const setShowElevationInfo = useSettingsStore((s) => s.setShowElevationInfo);
|
||||||
|
const showElevationOverlay = useSettingsStore((s) => s.showElevationOverlay);
|
||||||
|
const setShowElevationOverlay = useSettingsStore((s) => s.setShowElevationOverlay);
|
||||||
|
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editSite, setEditSite] = useState<Site | null>(null);
|
const [editSite, setEditSite] = useState<Site | null>(null);
|
||||||
@@ -428,12 +434,39 @@ export default function App() {
|
|||||||
Click to add points. Right-click to finish.
|
Click to add points. Right-click to finish.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-dark-text">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showElevationInfo}
|
||||||
|
onChange={(e) => setShowElevationInfo(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-gray-300 dark:border-dark-border accent-amber-600"
|
||||||
|
/>
|
||||||
|
Cursor Elevation
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-dark-text">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showElevationOverlay}
|
||||||
|
onChange={(e) => setShowElevationOverlay(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-gray-300 dark:border-dark-border accent-amber-600"
|
||||||
|
/>
|
||||||
|
Elevation Colors
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Coverage statistics */}
|
||||||
|
<CoverageStats
|
||||||
|
points={coverageResult?.points ?? []}
|
||||||
|
resolution={settings.resolution}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Export coverage data */}
|
{/* Export coverage data */}
|
||||||
<ExportPanel />
|
<ExportPanel />
|
||||||
|
|
||||||
|
{/* Site import/export */}
|
||||||
|
<SiteImportExport />
|
||||||
|
|
||||||
{/* Projects save/load */}
|
{/* Projects save/load */}
|
||||||
<ProjectPanel />
|
<ProjectPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
41
frontend/src/components/map/ElevationDisplay.tsx
Normal file
41
frontend/src/components/map/ElevationDisplay.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useElevation } from '@/hooks/useElevation.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows cursor coordinates + elevation (meters ASL) at the bottom-left of the map.
|
||||||
|
* Uses the Open-Elevation API with debounced requests.
|
||||||
|
*/
|
||||||
|
export default function ElevationDisplay() {
|
||||||
|
const { elevation, position, loading } = useElevation();
|
||||||
|
|
||||||
|
if (!position) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '40px',
|
||||||
|
left: '10px',
|
||||||
|
background: 'rgba(0,0,0,0.8)',
|
||||||
|
color: 'white',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
zIndex: 1000,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{position.lat.toFixed(5)}, {position.lon.toFixed(5)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{loading
|
||||||
|
? 'Elev: ...'
|
||||||
|
: elevation !== null
|
||||||
|
? `Elev: ${elevation}m ASL`
|
||||||
|
: 'Elev: N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -84,18 +84,23 @@ export default function Heatmap({ points, visible, opacity = 0.7 }: HeatmapProps
|
|||||||
|
|
||||||
const { radius, blur, maxIntensity } = getHeatmapParams(mapZoom);
|
const { radius, blur, maxIntensity } = getHeatmapParams(mapZoom);
|
||||||
|
|
||||||
// Debug: log RSRP stats and heatmap params
|
// Debug: log RSRP stats and heatmap params (detailed per spec)
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV && heatData.length > 0) {
|
||||||
const rsrpValues = points.map((p) => p.rsrp);
|
const rsrpValues = points.map((p) => p.rsrp);
|
||||||
const intensityValues = heatData.map((d) => d[2]);
|
const intensityValues = heatData.map((d) => d[2]);
|
||||||
console.log('Heatmap Debug:', {
|
const normalizedSample = points.slice(0, 5).map((p) => ({
|
||||||
pointCount: points.length,
|
rsrp: p.rsrp,
|
||||||
|
normalized: rsrpToIntensity(p.rsrp),
|
||||||
|
}));
|
||||||
|
console.log('🔍 Heatmap Debug:', {
|
||||||
|
zoom: mapZoom,
|
||||||
|
totalPoints: points.length,
|
||||||
rsrpRange: `${Math.min(...rsrpValues).toFixed(1)} to ${Math.max(...rsrpValues).toFixed(1)} dBm`,
|
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)}`,
|
intensityRange: `${Math.min(...intensityValues).toFixed(3)} to ${Math.max(...intensityValues).toFixed(3)}`,
|
||||||
mapZoom,
|
|
||||||
radius,
|
radius,
|
||||||
blur,
|
blur,
|
||||||
maxIntensity,
|
maxIntensity, // Should ALWAYS be 0.75
|
||||||
|
sample: normalizedSample,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import SiteMarker from './SiteMarker.tsx';
|
|||||||
import MapExtras from './MapExtras.tsx';
|
import MapExtras from './MapExtras.tsx';
|
||||||
import CoordinateGrid from './CoordinateGrid.tsx';
|
import CoordinateGrid from './CoordinateGrid.tsx';
|
||||||
import MeasurementTool from './MeasurementTool.tsx';
|
import MeasurementTool from './MeasurementTool.tsx';
|
||||||
|
import ElevationDisplay from './ElevationDisplay.tsx';
|
||||||
|
|
||||||
interface MapViewProps {
|
interface MapViewProps {
|
||||||
onMapClick: (lat: number, lon: number) => void;
|
onMapClick: (lat: number, lon: number) => void;
|
||||||
@@ -54,6 +55,9 @@ export default function MapView({ onMapClick, onEditSite, children }: MapViewPro
|
|||||||
const setShowGrid = useSettingsStore((s) => s.setShowGrid);
|
const setShowGrid = useSettingsStore((s) => s.setShowGrid);
|
||||||
const measurementMode = useSettingsStore((s) => s.measurementMode);
|
const measurementMode = useSettingsStore((s) => s.measurementMode);
|
||||||
const setMeasurementMode = useSettingsStore((s) => s.setMeasurementMode);
|
const setMeasurementMode = useSettingsStore((s) => s.setMeasurementMode);
|
||||||
|
const showElevationInfo = useSettingsStore((s) => s.showElevationInfo);
|
||||||
|
const showElevationOverlay = useSettingsStore((s) => s.showElevationOverlay);
|
||||||
|
const setShowElevationOverlay = useSettingsStore((s) => s.setShowElevationOverlay);
|
||||||
const addToast = useToastStore((s) => s.addToast);
|
const addToast = useToastStore((s) => s.addToast);
|
||||||
const mapRef = useRef<LeafletMap | null>(null);
|
const mapRef = useRef<LeafletMap | null>(null);
|
||||||
|
|
||||||
@@ -89,8 +93,18 @@ export default function MapView({ onMapClick, onEditSite, children }: MapViewPro
|
|||||||
zIndex={100}
|
zIndex={100}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Elevation color overlay (Stamen Terrain via Stadia Maps) */}
|
||||||
|
{showElevationOverlay && (
|
||||||
|
<TileLayer
|
||||||
|
attribution='© Stamen Design'
|
||||||
|
url="https://tiles.stadiamaps.com/tiles/stamen_terrain/{z}/{x}/{y}.png"
|
||||||
|
opacity={0.5}
|
||||||
|
zIndex={97}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<MapClickHandler onMapClick={onMapClick} />
|
<MapClickHandler onMapClick={onMapClick} />
|
||||||
<MapExtras />
|
<MapExtras />
|
||||||
|
{showElevationInfo && <ElevationDisplay />}
|
||||||
<CoordinateGrid visible={showGrid} />
|
<CoordinateGrid visible={showGrid} />
|
||||||
<MeasurementTool
|
<MeasurementTool
|
||||||
enabled={measurementMode}
|
enabled={measurementMode}
|
||||||
@@ -159,6 +173,16 @@ export default function MapView({ onMapClick, onEditSite, children }: MapViewPro
|
|||||||
>
|
>
|
||||||
Ruler
|
Ruler
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowElevationOverlay(!showElevationOverlay)}
|
||||||
|
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]
|
||||||
|
${showElevationOverlay ? 'ring-2 ring-amber-500' : ''}`}
|
||||||
|
title={showElevationOverlay ? 'Hide elevation colors' : 'Show elevation color overlay'}
|
||||||
|
>
|
||||||
|
Elev
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export default function SiteMarker({ site, onEdit }: SiteMarkerProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
{site.antennaType === 'sector' && (
|
{site.antennaType === 'sector' && (site.beamwidth ?? 65) < 360 && (
|
||||||
<Polygon
|
<Polygon
|
||||||
positions={generateSectorWedge(site)}
|
positions={generateSectorWedge(site)}
|
||||||
pathOptions={{
|
pathOptions={{
|
||||||
|
|||||||
138
frontend/src/components/panels/CoverageStats.tsx
Normal file
138
frontend/src/components/panels/CoverageStats.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import type { CoveragePoint } from '@/types/index.ts';
|
||||||
|
|
||||||
|
interface CoverageStatsProps {
|
||||||
|
points: CoveragePoint[];
|
||||||
|
resolution: number; // meters
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate total coverage area from grid points.
|
||||||
|
* Each point represents a resolution × resolution cell.
|
||||||
|
*/
|
||||||
|
function estimateAreaKm2(pointCount: number, resolutionM: number): number {
|
||||||
|
const cellAreaM2 = resolutionM * resolutionM;
|
||||||
|
return (pointCount * cellAreaM2) / 1_000_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVELS = [
|
||||||
|
{ label: 'Excellent', threshold: -70, color: 'bg-green-500' },
|
||||||
|
{ label: 'Good', threshold: -85, color: 'bg-lime-500' },
|
||||||
|
{ label: 'Fair', threshold: -100, color: 'bg-yellow-500' },
|
||||||
|
{ label: 'Weak', threshold: -Infinity, color: 'bg-red-500' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function classifyPoints(points: CoveragePoint[]) {
|
||||||
|
const counts = { excellent: 0, good: 0, fair: 0, weak: 0 };
|
||||||
|
for (const p of points) {
|
||||||
|
if (p.rsrp > -70) counts.excellent++;
|
||||||
|
else if (p.rsrp > -85) counts.good++;
|
||||||
|
else if (p.rsrp > -100) counts.fair++;
|
||||||
|
else counts.weak++;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CoverageStats({ points, resolution }: CoverageStatsProps) {
|
||||||
|
if (points.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-800 dark:text-dark-text mb-2">
|
||||||
|
Coverage Analysis
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-dark-muted">
|
||||||
|
No coverage data. Calculate coverage first.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = classifyPoints(points);
|
||||||
|
const totalArea = estimateAreaKm2(points.length, resolution);
|
||||||
|
const total = points.length;
|
||||||
|
|
||||||
|
const rsrpValues = points.map((p) => p.rsrp);
|
||||||
|
const minRSRP = Math.min(...rsrpValues);
|
||||||
|
const maxRSRP = Math.max(...rsrpValues);
|
||||||
|
const avgRSRP = rsrpValues.reduce((a, b) => a + b, 0) / total;
|
||||||
|
|
||||||
|
// Unique sites contributing to coverage
|
||||||
|
const uniqueSites = new Set(points.map((p) => p.siteId)).size;
|
||||||
|
|
||||||
|
const levels = [
|
||||||
|
{ ...LEVELS[0], count: counts.excellent },
|
||||||
|
{ ...LEVELS[1], count: counts.good },
|
||||||
|
{ ...LEVELS[2], count: counts.fair },
|
||||||
|
{ ...LEVELS[3], count: counts.weak },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
Coverage Analysis
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Summary stats */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="bg-gray-50 dark:bg-dark-bg rounded p-2">
|
||||||
|
<div className="text-gray-500 dark:text-dark-muted">Total Area</div>
|
||||||
|
<div className="font-semibold text-gray-800 dark:text-dark-text">
|
||||||
|
{totalArea.toFixed(1)} km²
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 dark:bg-dark-bg rounded p-2">
|
||||||
|
<div className="text-gray-500 dark:text-dark-muted">Grid Points</div>
|
||||||
|
<div className="font-semibold text-gray-800 dark:text-dark-text">
|
||||||
|
{total.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 dark:bg-dark-bg rounded p-2">
|
||||||
|
<div className="text-gray-500 dark:text-dark-muted">Avg RSRP</div>
|
||||||
|
<div className="font-semibold text-gray-800 dark:text-dark-text">
|
||||||
|
{avgRSRP.toFixed(1)} dBm
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 dark:bg-dark-bg rounded p-2">
|
||||||
|
<div className="text-gray-500 dark:text-dark-muted">Sites</div>
|
||||||
|
<div className="font-semibold text-gray-800 dark:text-dark-text">
|
||||||
|
{uniqueSites}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RSRP range */}
|
||||||
|
<div className="text-xs text-gray-500 dark:text-dark-muted">
|
||||||
|
Range: {minRSRP.toFixed(1)} to {maxRSRP.toFixed(1)} dBm
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Signal quality breakdown */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{levels.map((level) => {
|
||||||
|
const pct = total > 0 ? (level.count / total) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={level.label} className="space-y-0.5">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-700 dark:text-dark-text">
|
||||||
|
{level.label}
|
||||||
|
<span className="text-gray-400 dark:text-dark-muted ml-1">
|
||||||
|
({level.threshold === -Infinity
|
||||||
|
? '< -100'
|
||||||
|
: `> ${level.threshold}`} dBm)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-gray-800 dark:text-dark-text">
|
||||||
|
{pct.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1.5 bg-gray-200 dark:bg-dark-border rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full ${level.color} rounded-full transition-all`}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ interface SiteFormProps {
|
|||||||
|
|
||||||
const TEMPLATES = {
|
const TEMPLATES = {
|
||||||
limesdr: {
|
limesdr: {
|
||||||
|
label: 'LimeSDR',
|
||||||
|
style: 'purple',
|
||||||
name: 'LimeSDR Mini',
|
name: 'LimeSDR Mini',
|
||||||
power: 20,
|
power: 20,
|
||||||
gain: 2,
|
gain: 2,
|
||||||
@@ -25,6 +27,8 @@ const TEMPLATES = {
|
|||||||
antennaType: 'omni' as const,
|
antennaType: 'omni' as const,
|
||||||
},
|
},
|
||||||
lowBBU: {
|
lowBBU: {
|
||||||
|
label: 'Low BBU',
|
||||||
|
style: 'green',
|
||||||
name: 'Low Power BBU',
|
name: 'Low Power BBU',
|
||||||
power: 40,
|
power: 40,
|
||||||
gain: 8,
|
gain: 8,
|
||||||
@@ -33,6 +37,8 @@ const TEMPLATES = {
|
|||||||
antennaType: 'omni' as const,
|
antennaType: 'omni' as const,
|
||||||
},
|
},
|
||||||
highBBU: {
|
highBBU: {
|
||||||
|
label: 'High BBU',
|
||||||
|
style: 'orange',
|
||||||
name: 'High Power BBU',
|
name: 'High Power BBU',
|
||||||
power: 43,
|
power: 43,
|
||||||
gain: 15,
|
gain: 15,
|
||||||
@@ -42,6 +48,60 @@ const TEMPLATES = {
|
|||||||
azimuth: 0,
|
azimuth: 0,
|
||||||
beamwidth: 65,
|
beamwidth: 65,
|
||||||
},
|
},
|
||||||
|
urbanMacro: {
|
||||||
|
label: 'Urban Macro',
|
||||||
|
style: 'blue',
|
||||||
|
name: 'Urban Macro Site',
|
||||||
|
power: 43,
|
||||||
|
gain: 18,
|
||||||
|
frequency: 1800,
|
||||||
|
height: 30,
|
||||||
|
antennaType: 'sector' as const,
|
||||||
|
azimuth: 0,
|
||||||
|
beamwidth: 65,
|
||||||
|
},
|
||||||
|
ruralTower: {
|
||||||
|
label: 'Rural Tower',
|
||||||
|
style: 'emerald',
|
||||||
|
name: 'Rural Tower',
|
||||||
|
power: 46,
|
||||||
|
gain: 8,
|
||||||
|
frequency: 800,
|
||||||
|
height: 50,
|
||||||
|
antennaType: 'omni' as const,
|
||||||
|
},
|
||||||
|
smallCell: {
|
||||||
|
label: 'Small Cell',
|
||||||
|
style: 'cyan',
|
||||||
|
name: 'Small Cell',
|
||||||
|
power: 30,
|
||||||
|
gain: 12,
|
||||||
|
frequency: 2600,
|
||||||
|
height: 6,
|
||||||
|
antennaType: 'sector' as const,
|
||||||
|
azimuth: 0,
|
||||||
|
beamwidth: 90,
|
||||||
|
},
|
||||||
|
indoorDAS: {
|
||||||
|
label: 'Indoor DAS',
|
||||||
|
style: 'rose',
|
||||||
|
name: 'Indoor DAS',
|
||||||
|
power: 23,
|
||||||
|
gain: 2,
|
||||||
|
frequency: 2100,
|
||||||
|
height: 3,
|
||||||
|
antennaType: 'omni' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEMPLATE_COLORS: Record<string, string> = {
|
||||||
|
purple: 'bg-purple-100 hover:bg-purple-200 text-purple-700 dark:bg-purple-900/30 dark:hover:bg-purple-900/50 dark:text-purple-300',
|
||||||
|
green: 'bg-green-100 hover:bg-green-200 text-green-700 dark:bg-green-900/30 dark:hover:bg-green-900/50 dark:text-green-300',
|
||||||
|
orange: 'bg-orange-100 hover:bg-orange-200 text-orange-700 dark:bg-orange-900/30 dark:hover:bg-orange-900/50 dark:text-orange-300',
|
||||||
|
blue: 'bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 dark:text-blue-300',
|
||||||
|
emerald: 'bg-emerald-100 hover:bg-emerald-200 text-emerald-700 dark:bg-emerald-900/30 dark:hover:bg-emerald-900/50 dark:text-emerald-300',
|
||||||
|
cyan: 'bg-cyan-100 hover:bg-cyan-200 text-cyan-700 dark:bg-cyan-900/30 dark:hover:bg-cyan-900/50 dark:text-cyan-300',
|
||||||
|
rose: 'bg-rose-100 hover:bg-rose-200 text-rose-700 dark:bg-rose-900/30 dark:hover:bg-rose-900/50 dark:text-rose-300',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SiteForm({
|
export default function SiteForm({
|
||||||
@@ -346,33 +406,17 @@ export default function SiteForm({
|
|||||||
Quick Templates
|
Quick Templates
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{Object.entries(TEMPLATES).map(([key, t]) => (
|
||||||
<button
|
<button
|
||||||
|
key={key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => applyTemplate('limesdr')}
|
onClick={() => applyTemplate(key as keyof typeof TEMPLATES)}
|
||||||
className="px-3 py-1.5 bg-purple-100 hover:bg-purple-200 text-purple-700
|
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors min-h-[32px]
|
||||||
dark:bg-purple-900/30 dark:hover:bg-purple-900/50 dark:text-purple-300
|
${TEMPLATE_COLORS[t.style] ?? TEMPLATE_COLORS.blue}`}
|
||||||
rounded text-xs font-medium transition-colors min-h-[32px]"
|
|
||||||
>
|
>
|
||||||
LimeSDR
|
{t.label}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => applyTemplate('lowBBU')}
|
|
||||||
className="px-3 py-1.5 bg-green-100 hover:bg-green-200 text-green-700
|
|
||||||
dark:bg-green-900/30 dark:hover:bg-green-900/50 dark:text-green-300
|
|
||||||
rounded text-xs font-medium transition-colors min-h-[32px]"
|
|
||||||
>
|
|
||||||
Low BBU
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => applyTemplate('highBBU')}
|
|
||||||
className="px-3 py-1.5 bg-orange-100 hover:bg-orange-200 text-orange-700
|
|
||||||
dark:bg-orange-900/30 dark:hover:bg-orange-900/50 dark:text-orange-300
|
|
||||||
rounded text-xs font-medium transition-colors min-h-[32px]"
|
|
||||||
>
|
|
||||||
High BBU
|
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
146
frontend/src/components/panels/SiteImportExport.tsx
Normal file
146
frontend/src/components/panels/SiteImportExport.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { useSitesStore } from '@/store/sites.ts';
|
||||||
|
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||||
|
import Button from '@/components/ui/Button.tsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import/Export site configurations as JSON.
|
||||||
|
* Export downloads the current site list; Import merges (appends) sites.
|
||||||
|
*/
|
||||||
|
export default function SiteImportExport() {
|
||||||
|
const sites = useSitesStore((s) => s.sites);
|
||||||
|
const importSites = useSitesStore((s) => s.importSites);
|
||||||
|
const addToast = useToastStore((s) => s.addToast);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
if (sites.length === 0) {
|
||||||
|
addToast('No sites to export', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip internal fields (id, createdAt, updatedAt) so import can reassign them
|
||||||
|
const exportData = sites.map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
lat: s.lat,
|
||||||
|
lon: s.lon,
|
||||||
|
height: s.height,
|
||||||
|
power: s.power,
|
||||||
|
gain: s.gain,
|
||||||
|
frequency: s.frequency,
|
||||||
|
antennaType: s.antennaType,
|
||||||
|
azimuth: s.azimuth,
|
||||||
|
beamwidth: s.beamwidth,
|
||||||
|
color: s.color,
|
||||||
|
visible: s.visible,
|
||||||
|
notes: s.notes,
|
||||||
|
equipment: s.equipment,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const json = JSON.stringify(exportData, null, 2);
|
||||||
|
const blob = new Blob([json], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `rfcp-sites-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
addToast(`Exported ${sites.length} site(s) as JSON`, 'success');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async (file: File) => {
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
addToast('Invalid file format: expected an array of sites', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
const valid = parsed.filter(
|
||||||
|
(s: Record<string, unknown>) =>
|
||||||
|
typeof s.name === 'string' &&
|
||||||
|
typeof s.lat === 'number' &&
|
||||||
|
typeof s.lon === 'number'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (valid.length === 0) {
|
||||||
|
addToast('No valid sites found in file', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map to SiteFormData shape
|
||||||
|
const sitesData = valid.map((s: Record<string, unknown>) => ({
|
||||||
|
name: s.name as string,
|
||||||
|
lat: s.lat as number,
|
||||||
|
lon: s.lon as number,
|
||||||
|
height: (s.height as number) ?? 30,
|
||||||
|
power: (s.power as number) ?? 43,
|
||||||
|
gain: (s.gain as number) ?? 8,
|
||||||
|
frequency: (s.frequency as number) ?? 1800,
|
||||||
|
antennaType: ((s.antennaType as string) === 'sector' ? 'sector' : 'omni') as 'omni' | 'sector',
|
||||||
|
azimuth: s.azimuth as number | undefined,
|
||||||
|
beamwidth: s.beamwidth as number | undefined,
|
||||||
|
color: (s.color as string) ?? '',
|
||||||
|
visible: (s.visible as boolean) ?? true,
|
||||||
|
notes: s.notes as string | undefined,
|
||||||
|
equipment: s.equipment as string | undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const count = await importSites(sitesData);
|
||||||
|
addToast(`Imported ${count} site(s)`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Import failed:', error);
|
||||||
|
addToast('Invalid JSON file', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset file input so same file can be re-imported
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
Site Import / Export
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={handleExport} size="sm" variant="secondary">
|
||||||
|
Export JSON
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
Import JSON
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sites.length > 0 && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-dark-muted">
|
||||||
|
{sites.length} site(s) configured
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hidden file input */}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleImport(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
|||||||
const toggleSiteSelection = useSitesStore((s) => s.toggleSiteSelection);
|
const toggleSiteSelection = useSitesStore((s) => s.toggleSiteSelection);
|
||||||
const selectAllSites = useSitesStore((s) => s.selectAllSites);
|
const selectAllSites = useSitesStore((s) => s.selectAllSites);
|
||||||
const clearSelection = useSitesStore((s) => s.clearSelection);
|
const clearSelection = useSitesStore((s) => s.clearSelection);
|
||||||
const cloneSiteAsSectors = useSitesStore((s) => s.cloneSiteAsSectors);
|
const cloneSector = useSitesStore((s) => s.cloneSector);
|
||||||
const addToast = useToastStore((s) => s.addToast);
|
const addToast = useToastStore((s) => s.addToast);
|
||||||
|
|
||||||
// Track recently batch-updated site IDs for flash animation
|
// Track recently batch-updated site IDs for flash animation
|
||||||
@@ -153,13 +153,13 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
await cloneSiteAsSectors(site.id, 3);
|
await cloneSector(site.id);
|
||||||
addToast(`Created 3 sectors from "${site.name}"`, 'success');
|
addToast(`Cloned "${site.name}" (+30° azimuth)`, '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"
|
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)"
|
title="Clone sector (+30° azimuth offset)"
|
||||||
>
|
>
|
||||||
3S
|
Clone
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
74
frontend/src/hooks/useElevation.ts
Normal file
74
frontend/src/hooks/useElevation.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useMap } from 'react-leaflet';
|
||||||
|
import L from 'leaflet';
|
||||||
|
|
||||||
|
interface ElevationState {
|
||||||
|
elevation: number | null;
|
||||||
|
position: { lat: number; lon: number } | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useElevation() {
|
||||||
|
const map = useMap();
|
||||||
|
const [state, setState] = useState<ElevationState>({
|
||||||
|
elevation: null,
|
||||||
|
position: null,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timeoutId: number;
|
||||||
|
let abortController: AbortController | null = null;
|
||||||
|
|
||||||
|
const handleMouseMove = (e: L.LeafletMouseEvent) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
position: { lat: e.latlng.lat, lon: e.latlng.lng },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Debounce API calls (300ms)
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (abortController) abortController.abort();
|
||||||
|
|
||||||
|
timeoutId = window.setTimeout(async () => {
|
||||||
|
setState((prev) => ({ ...prev, loading: true }));
|
||||||
|
abortController = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.open-elevation.com/api/v1/lookup?locations=${e.latlng.lat},${e.latlng.lng}`,
|
||||||
|
{ signal: abortController.signal }
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
const elev = data?.results?.[0]?.elevation ?? null;
|
||||||
|
setState((prev) => ({ ...prev, elevation: elev, loading: false }));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
|
// Intentional abort, ignore
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('Elevation fetch failed:', error);
|
||||||
|
setState((prev) => ({ ...prev, elevation: null, loading: false }));
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseOut = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (abortController) abortController.abort();
|
||||||
|
setState({ elevation: null, position: null, loading: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on('mousemove', handleMouseMove);
|
||||||
|
map.on('mouseout', handleMouseOut);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off('mousemove', handleMouseMove);
|
||||||
|
map.off('mouseout', handleMouseOut);
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (abortController) abortController.abort();
|
||||||
|
};
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -9,11 +9,15 @@ interface SettingsState {
|
|||||||
terrainOpacity: number;
|
terrainOpacity: number;
|
||||||
showGrid: boolean;
|
showGrid: boolean;
|
||||||
measurementMode: boolean;
|
measurementMode: boolean;
|
||||||
|
showElevationInfo: boolean;
|
||||||
|
showElevationOverlay: boolean;
|
||||||
setTheme: (theme: Theme) => void;
|
setTheme: (theme: Theme) => void;
|
||||||
setShowTerrain: (show: boolean) => void;
|
setShowTerrain: (show: boolean) => void;
|
||||||
setTerrainOpacity: (opacity: number) => void;
|
setTerrainOpacity: (opacity: number) => void;
|
||||||
setShowGrid: (show: boolean) => void;
|
setShowGrid: (show: boolean) => void;
|
||||||
setMeasurementMode: (mode: boolean) => void;
|
setMeasurementMode: (mode: boolean) => void;
|
||||||
|
setShowElevationInfo: (show: boolean) => void;
|
||||||
|
setShowElevationOverlay: (show: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTheme(theme: Theme) {
|
function applyTheme(theme: Theme) {
|
||||||
@@ -35,6 +39,8 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
terrainOpacity: 0.5,
|
terrainOpacity: 0.5,
|
||||||
showGrid: false,
|
showGrid: false,
|
||||||
measurementMode: false,
|
measurementMode: false,
|
||||||
|
showElevationInfo: false,
|
||||||
|
showElevationOverlay: false,
|
||||||
setTheme: (theme: Theme) => {
|
setTheme: (theme: Theme) => {
|
||||||
set({ theme });
|
set({ theme });
|
||||||
applyTheme(theme);
|
applyTheme(theme);
|
||||||
@@ -43,6 +49,8 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
setTerrainOpacity: (opacity: number) => set({ terrainOpacity: opacity }),
|
setTerrainOpacity: (opacity: number) => set({ terrainOpacity: opacity }),
|
||||||
setShowGrid: (show: boolean) => set({ showGrid: show }),
|
setShowGrid: (show: boolean) => set({ showGrid: show }),
|
||||||
setMeasurementMode: (mode: boolean) => set({ measurementMode: mode }),
|
setMeasurementMode: (mode: boolean) => set({ measurementMode: mode }),
|
||||||
|
setShowElevationInfo: (show: boolean) => set({ showElevationInfo: show }),
|
||||||
|
setShowElevationOverlay: (show: boolean) => set({ showElevationOverlay: show }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'rfcp-settings',
|
name: 'rfcp-settings',
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ interface SitesState {
|
|||||||
|
|
||||||
// Multi-sector
|
// Multi-sector
|
||||||
cloneSiteAsSectors: (siteId: string, sectorCount: 2 | 3) => Promise<void>;
|
cloneSiteAsSectors: (siteId: string, sectorCount: 2 | 3) => Promise<void>;
|
||||||
|
cloneSector: (siteId: string) => Promise<void>;
|
||||||
|
|
||||||
|
// Import/export
|
||||||
|
importSites: (sitesData: SiteFormData[]) => Promise<number>;
|
||||||
|
|
||||||
// Batch operations
|
// Batch operations
|
||||||
toggleSiteSelection: (siteId: string) => void;
|
toggleSiteSelection: (siteId: string) => void;
|
||||||
@@ -141,6 +145,42 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Clone a single sector: duplicate site with 30° azimuth offset
|
||||||
|
cloneSector: async (siteId: string) => {
|
||||||
|
const source = get().sites.find((s) => s.id === siteId);
|
||||||
|
if (!source) return;
|
||||||
|
|
||||||
|
const addSite = get().addSite;
|
||||||
|
const newAzimuth = ((source.azimuth ?? 0) + 30) % 360;
|
||||||
|
|
||||||
|
await addSite({
|
||||||
|
name: `${source.name}-clone`,
|
||||||
|
lat: source.lat,
|
||||||
|
lon: source.lon,
|
||||||
|
height: source.height,
|
||||||
|
power: source.power,
|
||||||
|
gain: source.gain,
|
||||||
|
frequency: source.frequency,
|
||||||
|
antennaType: source.antennaType,
|
||||||
|
azimuth: newAzimuth,
|
||||||
|
beamwidth: source.beamwidth,
|
||||||
|
color: '',
|
||||||
|
visible: true,
|
||||||
|
notes: source.notes ? `Clone of: ${source.notes}` : `Clone of ${source.name}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Import sites from parsed data
|
||||||
|
importSites: async (sitesData: SiteFormData[]) => {
|
||||||
|
const addSite = get().addSite;
|
||||||
|
let count = 0;
|
||||||
|
for (const data of sitesData) {
|
||||||
|
await addSite(data);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
},
|
||||||
|
|
||||||
// Batch operations
|
// Batch operations
|
||||||
toggleSiteSelection: (siteId: string) => {
|
toggleSiteSelection: (siteId: string) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user