@mytec Phase 1 - Core UI & Manual Input, Phase 2 - RF Calculation Engine, Phase 3 - Heatmap Visualization
This commit is contained in:
67
frontend/src/components/map/Heatmap.tsx
Normal file
67
frontend/src/components/map/Heatmap.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet.heat';
|
||||
import type { CoveragePoint } from '@/types/index.ts';
|
||||
|
||||
// Extend L with heat layer type
|
||||
declare module 'leaflet' {
|
||||
function heatLayer(
|
||||
latlngs: Array<[number, number, number]>,
|
||||
options?: Record<string, unknown>
|
||||
): L.Layer;
|
||||
}
|
||||
|
||||
interface HeatmapProps {
|
||||
points: CoveragePoint[];
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize RSRP to 0-1 intensity for heatmap.
|
||||
* -120 dBm -> 0.0 (very weak)
|
||||
* -70 dBm -> 1.0 (excellent)
|
||||
*/
|
||||
function rsrpToIntensity(rsrp: number): number {
|
||||
const min = -120;
|
||||
const max = -60;
|
||||
return Math.max(0, Math.min(1, (rsrp - min) / (max - min)));
|
||||
}
|
||||
|
||||
export default function Heatmap({ points, visible }: HeatmapProps) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || points.length === 0) return;
|
||||
|
||||
const heatData: Array<[number, number, number]> = points.map((p) => [
|
||||
p.lat,
|
||||
p.lon,
|
||||
rsrpToIntensity(p.rsrp),
|
||||
]);
|
||||
|
||||
const heatLayer = L.heatLayer(heatData, {
|
||||
radius: 15,
|
||||
blur: 20,
|
||||
maxZoom: 17,
|
||||
max: 1.0,
|
||||
minOpacity: 0.3,
|
||||
gradient: {
|
||||
0.0: '#ef4444', // red (weak)
|
||||
0.2: '#f97316', // orange (poor)
|
||||
0.4: '#eab308', // yellow (fair)
|
||||
0.6: '#84cc16', // lime (good)
|
||||
0.8: '#22c55e', // green (excellent)
|
||||
1.0: '#16a34a', // dark green (very strong)
|
||||
},
|
||||
});
|
||||
|
||||
heatLayer.addTo(map);
|
||||
|
||||
return () => {
|
||||
map.removeLayer(heatLayer);
|
||||
};
|
||||
}, [map, points, visible]);
|
||||
|
||||
return null;
|
||||
}
|
||||
52
frontend/src/components/map/Legend.tsx
Normal file
52
frontend/src/components/map/Legend.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { RSRP_LEGEND } from '@/constants/rsrp-thresholds.ts';
|
||||
import { useCoverageStore } from '@/store/coverage.ts';
|
||||
|
||||
export default function Legend() {
|
||||
const result = useCoverageStore((s) => s.result);
|
||||
const heatmapVisible = useCoverageStore((s) => s.heatmapVisible);
|
||||
const toggleHeatmap = useCoverageStore((s) => s.toggleHeatmap);
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-6 right-2 z-[1000] bg-white rounded-lg shadow-lg border border-gray-200 p-3 min-w-[160px]">
|
||||
{/* Header with toggle */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xs font-semibold text-gray-700">Signal (RSRP)</h3>
|
||||
<button
|
||||
onClick={toggleHeatmap}
|
||||
className={`w-8 h-4 rounded-full transition-colors relative
|
||||
${heatmapVisible ? 'bg-blue-500' : 'bg-gray-300'}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-3 h-3 rounded-full bg-white shadow transition-transform
|
||||
${heatmapVisible ? 'left-4' : 'left-0.5'}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Legend items */}
|
||||
<div className="space-y-1">
|
||||
{RSRP_LEGEND.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-sm flex-shrink-0"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span className="text-[10px] text-gray-600">
|
||||
{item.label}: {item.range}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mt-2 pt-2 border-t border-gray-100 text-[10px] text-gray-400 space-y-0.5">
|
||||
<div>Points: {result.totalPoints.toLocaleString()}</div>
|
||||
<div>
|
||||
Time: {(result.calculationTime / 1000).toFixed(2)}s
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
frontend/src/components/map/Map.tsx
Normal file
54
frontend/src/components/map/Map.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { MapContainer, TileLayer, useMapEvents } from 'react-leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import type { Site } from '@/types/index.ts';
|
||||
import { useSitesStore } from '@/store/sites.ts';
|
||||
import SiteMarker from './SiteMarker.tsx';
|
||||
|
||||
interface MapViewProps {
|
||||
onMapClick: (lat: number, lon: number) => void;
|
||||
onEditSite: (site: Site) => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function MapClickHandler({
|
||||
onMapClick,
|
||||
}: {
|
||||
onMapClick: (lat: number, lon: number) => void;
|
||||
}) {
|
||||
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
|
||||
|
||||
useMapEvents({
|
||||
click: (e) => {
|
||||
if (isPlacingMode) {
|
||||
onMapClick(e.latlng.lat, e.latlng.lng);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function MapView({ onMapClick, onEditSite, children }: MapViewProps) {
|
||||
const sites = useSitesStore((s) => s.sites);
|
||||
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={[48.4, 35.0]}
|
||||
zoom={7}
|
||||
className={`w-full h-full ${isPlacingMode ? 'cursor-crosshair' : ''}`}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<MapClickHandler onMapClick={onMapClick} />
|
||||
{sites
|
||||
.filter((s) => s.visible)
|
||||
.map((site) => (
|
||||
<SiteMarker key={site.id} site={site} onEdit={onEditSite} />
|
||||
))}
|
||||
{children}
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
||||
88
frontend/src/components/map/SiteMarker.tsx
Normal file
88
frontend/src/components/map/SiteMarker.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Marker, Popup, useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import type { Site } from '@/types/index.ts';
|
||||
import { useSitesStore } from '@/store/sites.ts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface SiteMarkerProps {
|
||||
site: Site;
|
||||
onEdit: (site: Site) => void;
|
||||
}
|
||||
|
||||
function createSiteIcon(color: string, isSelected: boolean): L.DivIcon {
|
||||
const size = isSelected ? 16 : 12;
|
||||
const border = isSelected ? '3px solid white' : '2px solid white';
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
html: `<div style="
|
||||
width: ${size}px;
|
||||
height: ${size}px;
|
||||
border-radius: 50%;
|
||||
background: ${color};
|
||||
border: ${border};
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
||||
"></div>`,
|
||||
});
|
||||
}
|
||||
|
||||
function FlyToSelected({ site, isSelected }: { site: Site; isSelected: boolean }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (isSelected) {
|
||||
map.flyTo([site.lat, site.lon], map.getZoom(), { duration: 0.5 });
|
||||
}
|
||||
}, [isSelected, site.lat, site.lon, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function SiteMarker({ site, onEdit }: SiteMarkerProps) {
|
||||
const selectedSiteId = useSitesStore((s) => s.selectedSiteId);
|
||||
const selectSite = useSitesStore((s) => s.selectSite);
|
||||
const updateSite = useSitesStore((s) => s.updateSite);
|
||||
|
||||
const isSelected = selectedSiteId === site.id;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlyToSelected site={site} isSelected={isSelected} />
|
||||
<Marker
|
||||
position={[site.lat, site.lon]}
|
||||
icon={createSiteIcon(site.color, isSelected)}
|
||||
draggable
|
||||
eventHandlers={{
|
||||
click: () => selectSite(site.id),
|
||||
dragend: (e) => {
|
||||
const marker = e.target as L.Marker;
|
||||
const pos = marker.getLatLng();
|
||||
updateSite(site.id, { lat: pos.lat, lon: pos.lng });
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div className="text-xs space-y-1 min-w-[160px]">
|
||||
<div className="font-semibold">{site.name}</div>
|
||||
<div>
|
||||
{site.frequency} MHz · {site.power} dBm · {site.gain} dBi
|
||||
</div>
|
||||
<div>
|
||||
Height: {site.height}m · {site.antennaType}
|
||||
</div>
|
||||
{site.notes && (
|
||||
<div className="text-gray-500">{site.notes}</div>
|
||||
)}
|
||||
<div className="pt-1">
|
||||
<button
|
||||
onClick={() => onEdit(site)}
|
||||
className="text-blue-600 hover:underline text-xs"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
</>
|
||||
);
|
||||
}
|
||||
102
frontend/src/components/panels/FrequencySelector.tsx
Normal file
102
frontend/src/components/panels/FrequencySelector.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
QUICK_FREQUENCIES,
|
||||
getFrequencyInfo,
|
||||
getWavelength,
|
||||
} from '@/constants/frequencies.ts';
|
||||
|
||||
interface FrequencySelectorProps {
|
||||
value: number;
|
||||
onChange: (freq: number) => void;
|
||||
}
|
||||
|
||||
export default function FrequencySelector({
|
||||
value,
|
||||
onChange,
|
||||
}: FrequencySelectorProps) {
|
||||
const [customInput, setCustomInput] = useState('');
|
||||
const bandInfo = getFrequencyInfo(value);
|
||||
|
||||
const handleCustomSubmit = () => {
|
||||
const parsed = parseInt(customInput, 10);
|
||||
if (parsed > 0 && parsed <= 100000) {
|
||||
onChange(parsed);
|
||||
setCustomInput('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Operating Frequency
|
||||
</label>
|
||||
|
||||
{/* Quick buttons */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{QUICK_FREQUENCIES.map((freq) => (
|
||||
<button
|
||||
key={freq}
|
||||
type="button"
|
||||
onClick={() => onChange(freq)}
|
||||
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors
|
||||
${
|
||||
value === freq
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{freq}
|
||||
</button>
|
||||
))}
|
||||
<span className="self-center text-xs text-gray-400">MHz</span>
|
||||
</div>
|
||||
|
||||
{/* Custom input */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Custom MHz..."
|
||||
value={customInput}
|
||||
onChange={(e) => setCustomInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCustomSubmit()}
|
||||
className="flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
min={1}
|
||||
max={100000}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCustomSubmit}
|
||||
className="px-3 py-1.5 bg-gray-200 hover:bg-gray-300 rounded-md text-sm text-gray-700"
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Current frequency display */}
|
||||
<div className="text-sm text-gray-600 font-medium">
|
||||
Current: {value} MHz
|
||||
</div>
|
||||
|
||||
{/* Band info */}
|
||||
{bandInfo ? (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-2 text-xs space-y-0.5">
|
||||
<div className="font-semibold text-blue-700">
|
||||
{bandInfo.name} ({bandInfo.range})
|
||||
</div>
|
||||
<div className="text-blue-600">
|
||||
λ = {getWavelength(value)} · {bandInfo.characteristics.range} range ·{' '}
|
||||
{bandInfo.characteristics.penetration} penetration
|
||||
</div>
|
||||
<div className="text-blue-500">{bandInfo.characteristics.typical}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-md p-2 text-xs">
|
||||
<div className="text-gray-600">
|
||||
Custom frequency: {value} MHz · λ = {getWavelength(value)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
324
frontend/src/components/panels/SiteForm.tsx
Normal file
324
frontend/src/components/panels/SiteForm.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Site } from '@/types/index.ts';
|
||||
import { useSitesStore } from '@/store/sites.ts';
|
||||
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||
import Button from '@/components/ui/Button.tsx';
|
||||
import Input from '@/components/ui/Input.tsx';
|
||||
import Slider from '@/components/ui/Slider.tsx';
|
||||
import FrequencySelector from './FrequencySelector.tsx';
|
||||
|
||||
interface SiteFormProps {
|
||||
editSite?: Site | null;
|
||||
pendingLocation?: { lat: number; lon: number } | null;
|
||||
onClose: () => void;
|
||||
onClearPending?: () => void;
|
||||
}
|
||||
|
||||
const TEMPLATES = {
|
||||
limesdr: { name: 'LimeSDR', power: 20, gain: 3, frequency: 1800, height: 5 },
|
||||
lowBBU: { name: 'Low BBU', power: 37, gain: 15, frequency: 1800, height: 25 },
|
||||
highBBU: { name: 'High BBU', power: 46, gain: 18, frequency: 1800, height: 35 },
|
||||
};
|
||||
|
||||
export default function SiteForm({
|
||||
editSite,
|
||||
pendingLocation,
|
||||
onClose,
|
||||
onClearPending,
|
||||
}: SiteFormProps) {
|
||||
const addSite = useSitesStore((s) => s.addSite);
|
||||
const updateSite = useSitesStore((s) => s.updateSite);
|
||||
const addToast = useToastStore((s) => s.addToast);
|
||||
|
||||
const [name, setName] = useState(editSite?.name ?? 'Station-1');
|
||||
const [lat, setLat] = useState(editSite?.lat ?? pendingLocation?.lat ?? 48.4647);
|
||||
const [lon, setLon] = useState(editSite?.lon ?? pendingLocation?.lon ?? 35.0462);
|
||||
const [power, setPower] = useState(editSite?.power ?? 43);
|
||||
const [gain, setGain] = useState(editSite?.gain ?? 8);
|
||||
const [frequency, setFrequency] = useState(editSite?.frequency ?? 1800);
|
||||
const [height, setHeight] = useState(editSite?.height ?? 30);
|
||||
const [antennaType, setAntennaType] = useState<'omni' | 'sector'>(
|
||||
editSite?.antennaType ?? 'omni'
|
||||
);
|
||||
const [azimuth, setAzimuth] = useState(editSite?.azimuth ?? 0);
|
||||
const [beamwidth, setBeamwidth] = useState(editSite?.beamwidth ?? 65);
|
||||
const [notes, setNotes] = useState(editSite?.notes ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingLocation) {
|
||||
setLat(pendingLocation.lat);
|
||||
setLon(pendingLocation.lon);
|
||||
}
|
||||
}, [pendingLocation]);
|
||||
|
||||
const applyTemplate = (key: keyof typeof TEMPLATES) => {
|
||||
const t = TEMPLATES[key];
|
||||
setName(t.name);
|
||||
setPower(t.power);
|
||||
setGain(t.gain);
|
||||
setFrequency(t.frequency);
|
||||
setHeight(t.height);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (editSite) {
|
||||
await updateSite(editSite.id, {
|
||||
name,
|
||||
lat,
|
||||
lon,
|
||||
power,
|
||||
gain,
|
||||
frequency,
|
||||
height,
|
||||
antennaType,
|
||||
azimuth: antennaType === 'sector' ? azimuth : undefined,
|
||||
beamwidth: antennaType === 'sector' ? beamwidth : undefined,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
addToast('Site updated', 'success');
|
||||
} else {
|
||||
await addSite({
|
||||
name,
|
||||
lat,
|
||||
lon,
|
||||
power,
|
||||
gain,
|
||||
frequency,
|
||||
height,
|
||||
antennaType,
|
||||
azimuth: antennaType === 'sector' ? azimuth : undefined,
|
||||
beamwidth: antennaType === 'sector' ? beamwidth : undefined,
|
||||
color: '',
|
||||
visible: true,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
addToast('Site added', 'success');
|
||||
}
|
||||
onClearPending?.();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg shadow-lg overflow-y-auto max-h-[calc(100vh-120px)]">
|
||||
<div className="sticky top-0 bg-white border-b border-gray-200 px-4 py-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-800">
|
||||
{editSite ? 'Edit Site' : 'New Site Configuration'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 text-lg"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Site name */}
|
||||
<Input
|
||||
label="Site Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Station-1"
|
||||
/>
|
||||
|
||||
{/* Coordinates */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-gray-700">Coordinates</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={lat}
|
||||
onChange={(e) => setLat(Number(e.target.value))}
|
||||
className="w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Lat"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={lon}
|
||||
onChange={(e) => setLon(Number(e.target.value))}
|
||||
className="w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Lon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-gray-200 pt-2">
|
||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
RF Parameters
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Power */}
|
||||
<Slider
|
||||
label="Transmit Power (dBm)"
|
||||
value={power}
|
||||
min={10}
|
||||
max={50}
|
||||
unit="dBm"
|
||||
hint="LimeSDR 20, BBU 43, RRU 46"
|
||||
onChange={setPower}
|
||||
/>
|
||||
|
||||
{/* Gain */}
|
||||
<Slider
|
||||
label="Antenna Gain (dBi)"
|
||||
value={gain}
|
||||
min={0}
|
||||
max={25}
|
||||
unit="dBi"
|
||||
hint="Omni 2-8, Sector 15-18, Parabolic 20-25"
|
||||
onChange={setGain}
|
||||
/>
|
||||
|
||||
{/* Frequency */}
|
||||
<FrequencySelector value={frequency} onChange={setFrequency} />
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-gray-200 pt-2">
|
||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Physical Parameters
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Height */}
|
||||
<Slider
|
||||
label="Antenna Height (meters)"
|
||||
value={height}
|
||||
min={1}
|
||||
max={100}
|
||||
unit="m"
|
||||
hint="Height from ground to antenna center"
|
||||
onChange={setHeight}
|
||||
/>
|
||||
|
||||
{/* Antenna type */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Antenna Type
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="antennaType"
|
||||
checked={antennaType === 'omni'}
|
||||
onChange={() => setAntennaType('omni')}
|
||||
className="accent-blue-600"
|
||||
/>
|
||||
<span className="text-sm">Omni</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="antennaType"
|
||||
checked={antennaType === 'sector'}
|
||||
onChange={() => setAntennaType('sector')}
|
||||
className="accent-blue-600"
|
||||
/>
|
||||
<span className="text-sm">Sector</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sector parameters - collapsible */}
|
||||
{antennaType === 'sector' && (
|
||||
<div className="bg-gray-50 rounded-md p-3 space-y-3 border border-gray-200">
|
||||
<Slider
|
||||
label="Azimuth (degrees)"
|
||||
value={azimuth}
|
||||
min={0}
|
||||
max={360}
|
||||
unit="°"
|
||||
onChange={setAzimuth}
|
||||
/>
|
||||
<Slider
|
||||
label="Beamwidth (degrees)"
|
||||
value={beamwidth}
|
||||
min={30}
|
||||
max={120}
|
||||
unit="°"
|
||||
onChange={setBeamwidth}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-gray-200 pt-2">
|
||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Notes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Equipment / Notes (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="e.g., ZTE B8200 BBU + custom omni antenna"
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||
resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Templates */}
|
||||
{!editSite && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Quick Templates
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyTemplate('limesdr')}
|
||||
className="px-2 py-1 bg-purple-100 hover:bg-purple-200 text-purple-700
|
||||
rounded text-xs font-medium transition-colors"
|
||||
>
|
||||
LimeSDR
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyTemplate('lowBBU')}
|
||||
className="px-2 py-1 bg-green-100 hover:bg-green-200 text-green-700
|
||||
rounded text-xs font-medium transition-colors"
|
||||
>
|
||||
Low BBU
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyTemplate('highBBU')}
|
||||
className="px-2 py-1 bg-orange-100 hover:bg-orange-200 text-orange-700
|
||||
rounded text-xs font-medium transition-colors"
|
||||
>
|
||||
High BBU
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button onClick={handleSubmit} className="flex-1">
|
||||
{editSite ? 'Save Changes' : 'Add Site'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
frontend/src/components/panels/SiteList.tsx
Normal file
101
frontend/src/components/panels/SiteList.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { Site } from '@/types/index.ts';
|
||||
import { useSitesStore } from '@/store/sites.ts';
|
||||
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||
import Button from '@/components/ui/Button.tsx';
|
||||
|
||||
interface SiteListProps {
|
||||
onEditSite: (site: Site) => void;
|
||||
onAddSite: () => void;
|
||||
}
|
||||
|
||||
export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
||||
const sites = useSitesStore((s) => s.sites);
|
||||
const deleteSite = useSitesStore((s) => s.deleteSite);
|
||||
const selectSite = useSitesStore((s) => s.selectSite);
|
||||
const selectedSiteId = useSitesStore((s) => s.selectedSiteId);
|
||||
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
|
||||
const togglePlacingMode = useSitesStore((s) => s.togglePlacingMode);
|
||||
const addToast = useToastStore((s) => s.addToast);
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
await deleteSite(id);
|
||||
addToast(`"${name}" deleted`, 'info');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||
<div className="px-4 py-3 border-b border-gray-200 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-800">
|
||||
Sites ({sites.length})
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isPlacingMode ? 'danger' : 'primary'}
|
||||
onClick={togglePlacingMode}
|
||||
>
|
||||
{isPlacingMode ? 'Cancel' : '+ Place on Map'}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={onAddSite}>
|
||||
+ Manual
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sites.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-gray-400">
|
||||
No sites yet. Click on the map or use "+ Manual" to add one.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100 max-h-60 overflow-y-auto">
|
||||
{sites.map((site) => (
|
||||
<div
|
||||
key={site.id}
|
||||
className={`px-4 py-2 flex items-center gap-3 cursor-pointer hover:bg-gray-50
|
||||
transition-colors ${selectedSiteId === site.id ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => selectSite(site.id)}
|
||||
>
|
||||
{/* Color indicator */}
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: site.color }}
|
||||
/>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-800 truncate">
|
||||
{site.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{site.frequency} MHz · {site.power} dBm · {site.antennaType}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditSite(site);
|
||||
}}
|
||||
className="px-2 py-1 text-xs text-blue-600 hover:bg-blue-50 rounded"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(site.id, site.name);
|
||||
}}
|
||||
className="px-2 py-1 text-xs text-red-600 hover:bg-red-50 rounded"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
frontend/src/components/ui/Button.tsx
Normal file
40
frontend/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-blue-600 hover:bg-blue-700 text-white',
|
||||
secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-800',
|
||||
danger: 'bg-red-600 hover:bg-red-700 text-white',
|
||||
ghost: 'bg-transparent hover:bg-gray-100 text-gray-700',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-2 py-1 text-xs',
|
||||
md: 'px-3 py-1.5 text-sm',
|
||||
lg: 'px-4 py-2 text-base',
|
||||
};
|
||||
|
||||
export default function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
className = '',
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={`inline-flex items-center justify-center rounded-md font-medium transition-colors
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${variants[variant]} ${sizes[size]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/ui/Input.tsx
Normal file
21
frontend/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { InputHTMLAttributes } from 'react';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export default function Input({ label, className = '', ...props }: InputProps) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && (
|
||||
<label className="text-sm font-medium text-gray-700">{label}</label>
|
||||
)}
|
||||
<input
|
||||
className={`w-full px-3 py-2 border border-gray-300 rounded-md text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||
${className}`}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
frontend/src/components/ui/Slider.tsx
Normal file
47
frontend/src/components/ui/Slider.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
interface SliderProps {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
unit: string;
|
||||
hint?: string;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export default function Slider({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
unit,
|
||||
hint,
|
||||
onChange,
|
||||
}: SliderProps) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-700">{label}</label>
|
||||
<span className="text-sm font-semibold text-blue-600">
|
||||
{value} {unit}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer
|
||||
accent-blue-600"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>{min}</span>
|
||||
<span>{max}</span>
|
||||
</div>
|
||||
{hint && <p className="text-xs text-gray-400">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
frontend/src/components/ui/Toast.tsx
Normal file
85
frontend/src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface ToastMessage {
|
||||
id: number;
|
||||
text: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
}
|
||||
|
||||
interface ToastStore {
|
||||
toasts: ToastMessage[];
|
||||
addToast: (text: string, type?: 'success' | 'error' | 'info') => void;
|
||||
removeToast: (id: number) => void;
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
export const useToastStore = create<ToastStore>((set) => ({
|
||||
toasts: [],
|
||||
addToast: (text, type = 'info') => {
|
||||
const id = nextId++;
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts, { id, text, type }],
|
||||
}));
|
||||
setTimeout(() => {
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
}));
|
||||
}, 4000);
|
||||
},
|
||||
removeToast: (id) =>
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
})),
|
||||
}));
|
||||
|
||||
function ToastItem({ toast }: { toast: ToastMessage }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const removeToast = useToastStore((s) => s.removeToast);
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setVisible(false);
|
||||
setTimeout(() => removeToast(toast.id), 200);
|
||||
}, [toast.id, removeToast]);
|
||||
|
||||
const bgColor =
|
||||
toast.type === 'success'
|
||||
? 'bg-green-500'
|
||||
: toast.type === 'error'
|
||||
? 'bg-red-500'
|
||||
: 'bg-blue-500';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${bgColor} text-white px-4 py-2 rounded-lg shadow-lg text-sm
|
||||
transition-all duration-200 ${visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-1">{toast.text}</span>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-white/80 hover:text-white"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ToastContainer() {
|
||||
const toasts = useToastStore((s) => s.toasts);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user