@mytec: iter7 ready for test

This commit is contained in:
2026-01-30 13:06:31 +02:00
parent baebd29e1a
commit 3e1061e369
12 changed files with 592 additions and 39 deletions

View File

@@ -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>

View 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>
);
}

View File

@@ -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,
});
}

View File

@@ -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='&copy; 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>
</>
);

View File

@@ -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={{

View 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>
);
}

View File

@@ -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>

View 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>
);
}

View File

@@ -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) => {

View 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;
}

View File

@@ -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',

View File

@@ -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) => {