@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 ExportPanel from '@/components/panels/ExportPanel.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 ThemeToggle from '@/components/ui/ThemeToggle.tsx';
|
||||
import Button from '@/components/ui/Button.tsx';
|
||||
@@ -40,6 +42,10 @@ export default function App() {
|
||||
const setShowGrid = useSettingsStore((s) => s.setShowGrid);
|
||||
const measurementMode = useSettingsStore((s) => s.measurementMode);
|
||||
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 [editSite, setEditSite] = useState<Site | null>(null);
|
||||
@@ -428,12 +434,39 @@ export default function App() {
|
||||
Click to add points. Right-click to finish.
|
||||
</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>
|
||||
|
||||
{/* Coverage statistics */}
|
||||
<CoverageStats
|
||||
points={coverageResult?.points ?? []}
|
||||
resolution={settings.resolution}
|
||||
/>
|
||||
|
||||
{/* Export coverage data */}
|
||||
<ExportPanel />
|
||||
|
||||
{/* Site import/export */}
|
||||
<SiteImportExport />
|
||||
|
||||
{/* Projects save/load */}
|
||||
<ProjectPanel />
|
||||
</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);
|
||||
|
||||
// Debug: log RSRP stats and heatmap params
|
||||
if (import.meta.env.DEV) {
|
||||
// Debug: log RSRP stats and heatmap params (detailed per spec)
|
||||
if (import.meta.env.DEV && heatData.length > 0) {
|
||||
const rsrpValues = points.map((p) => p.rsrp);
|
||||
const intensityValues = heatData.map((d) => d[2]);
|
||||
console.log('Heatmap Debug:', {
|
||||
pointCount: points.length,
|
||||
const normalizedSample = points.slice(0, 5).map((p) => ({
|
||||
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`,
|
||||
intensityRange: `${Math.min(...intensityValues).toFixed(3)} to ${Math.max(...intensityValues).toFixed(3)}`,
|
||||
mapZoom,
|
||||
radius,
|
||||
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 CoordinateGrid from './CoordinateGrid.tsx';
|
||||
import MeasurementTool from './MeasurementTool.tsx';
|
||||
import ElevationDisplay from './ElevationDisplay.tsx';
|
||||
|
||||
interface MapViewProps {
|
||||
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 measurementMode = useSettingsStore((s) => s.measurementMode);
|
||||
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 mapRef = useRef<LeafletMap | null>(null);
|
||||
|
||||
@@ -89,8 +93,18 @@ export default function MapView({ onMapClick, onEditSite, children }: MapViewPro
|
||||
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} />
|
||||
<MapExtras />
|
||||
{showElevationInfo && <ElevationDisplay />}
|
||||
<CoordinateGrid visible={showGrid} />
|
||||
<MeasurementTool
|
||||
enabled={measurementMode}
|
||||
@@ -159,6 +173,16 @@ export default function MapView({ onMapClick, onEditSite, children }: MapViewPro
|
||||
>
|
||||
Ruler
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function SiteMarker({ site, onEdit }: SiteMarkerProps) {
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
{site.antennaType === 'sector' && (
|
||||
{site.antennaType === 'sector' && (site.beamwidth ?? 65) < 360 && (
|
||||
<Polygon
|
||||
positions={generateSectorWedge(site)}
|
||||
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 = {
|
||||
limesdr: {
|
||||
label: 'LimeSDR',
|
||||
style: 'purple',
|
||||
name: 'LimeSDR Mini',
|
||||
power: 20,
|
||||
gain: 2,
|
||||
@@ -25,6 +27,8 @@ const TEMPLATES = {
|
||||
antennaType: 'omni' as const,
|
||||
},
|
||||
lowBBU: {
|
||||
label: 'Low BBU',
|
||||
style: 'green',
|
||||
name: 'Low Power BBU',
|
||||
power: 40,
|
||||
gain: 8,
|
||||
@@ -33,6 +37,8 @@ const TEMPLATES = {
|
||||
antennaType: 'omni' as const,
|
||||
},
|
||||
highBBU: {
|
||||
label: 'High BBU',
|
||||
style: 'orange',
|
||||
name: 'High Power BBU',
|
||||
power: 43,
|
||||
gain: 15,
|
||||
@@ -42,6 +48,60 @@ const TEMPLATES = {
|
||||
azimuth: 0,
|
||||
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({
|
||||
@@ -346,33 +406,17 @@ export default function SiteForm({
|
||||
Quick Templates
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyTemplate('limesdr')}
|
||||
className="px-3 py-1.5 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
|
||||
rounded text-xs font-medium transition-colors min-h-[32px]"
|
||||
>
|
||||
LimeSDR
|
||||
</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>
|
||||
{Object.entries(TEMPLATES).map(([key, t]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => applyTemplate(key as keyof typeof TEMPLATES)}
|
||||
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors min-h-[32px]
|
||||
${TEMPLATE_COLORS[t.style] ?? TEMPLATE_COLORS.blue}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</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 selectAllSites = useSitesStore((s) => s.selectAllSites);
|
||||
const clearSelection = useSitesStore((s) => s.clearSelection);
|
||||
const cloneSiteAsSectors = useSitesStore((s) => s.cloneSiteAsSectors);
|
||||
const cloneSector = useSitesStore((s) => s.cloneSector);
|
||||
const addToast = useToastStore((s) => s.addToast);
|
||||
|
||||
// Track recently batch-updated site IDs for flash animation
|
||||
@@ -153,13 +153,13 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await cloneSiteAsSectors(site.id, 3);
|
||||
addToast(`Created 3 sectors from "${site.name}"`, 'success');
|
||||
await cloneSector(site.id);
|
||||
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"
|
||||
title="Clone as 3-sector site (120° spacing)"
|
||||
title="Clone sector (+30° azimuth offset)"
|
||||
>
|
||||
3S
|
||||
Clone
|
||||
</button>
|
||||
<button
|
||||
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;
|
||||
showGrid: boolean;
|
||||
measurementMode: boolean;
|
||||
showElevationInfo: boolean;
|
||||
showElevationOverlay: boolean;
|
||||
setTheme: (theme: Theme) => void;
|
||||
setShowTerrain: (show: boolean) => void;
|
||||
setTerrainOpacity: (opacity: number) => void;
|
||||
setShowGrid: (show: boolean) => void;
|
||||
setMeasurementMode: (mode: boolean) => void;
|
||||
setShowElevationInfo: (show: boolean) => void;
|
||||
setShowElevationOverlay: (show: boolean) => void;
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
@@ -35,6 +39,8 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
terrainOpacity: 0.5,
|
||||
showGrid: false,
|
||||
measurementMode: false,
|
||||
showElevationInfo: false,
|
||||
showElevationOverlay: false,
|
||||
setTheme: (theme: Theme) => {
|
||||
set({ theme });
|
||||
applyTheme(theme);
|
||||
@@ -43,6 +49,8 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
setTerrainOpacity: (opacity: number) => set({ terrainOpacity: opacity }),
|
||||
setShowGrid: (show: boolean) => set({ showGrid: show }),
|
||||
setMeasurementMode: (mode: boolean) => set({ measurementMode: mode }),
|
||||
setShowElevationInfo: (show: boolean) => set({ showElevationInfo: show }),
|
||||
setShowElevationOverlay: (show: boolean) => set({ showElevationOverlay: show }),
|
||||
}),
|
||||
{
|
||||
name: 'rfcp-settings',
|
||||
|
||||
@@ -26,6 +26,10 @@ interface SitesState {
|
||||
|
||||
// Multi-sector
|
||||
cloneSiteAsSectors: (siteId: string, sectorCount: 2 | 3) => Promise<void>;
|
||||
cloneSector: (siteId: string) => Promise<void>;
|
||||
|
||||
// Import/export
|
||||
importSites: (sitesData: SiteFormData[]) => Promise<number>;
|
||||
|
||||
// Batch operations
|
||||
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
|
||||
toggleSiteSelection: (siteId: string) => {
|
||||
set((state) => {
|
||||
|
||||
Reference in New Issue
Block a user