@mytec Phase 1 - Core UI & Manual Input, Phase 2 - RF Calculation Engine, Phase 3 - Heatmap Visualization

This commit is contained in:
2026-01-30 07:12:00 +02:00
parent 343c8e078d
commit 18a7d6de81
41 changed files with 6014 additions and 0 deletions

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

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

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