diff --git a/frontend/public/workers/rf-worker.js b/frontend/public/workers/rf-worker.js index 29ef595..8e77090 100644 --- a/frontend/public/workers/rf-worker.js +++ b/frontend/public/workers/rf-worker.js @@ -42,6 +42,13 @@ self.onmessage = function (e) { /** * Calculate RSRP at a specific point (universal formula) + * + * RSRP = P_tx + G_tx - FSPL - PatternLoss + * + * Includes: + * - Antenna gain (site.gain) + * - 3GPP sector pattern with back lobe (no hard cutoff) + * - Radio horizon limit based on antenna height */ function calculatePointRSRP(site, point) { var distance = haversineDistance(site.lat, site.lon, point.lat, point.lon); @@ -49,6 +56,14 @@ function calculatePointRSRP(site, point) { // Minimum distance to prevent -Infinity if (distance < 0.01) distance = 0.01; + // Radio horizon check: d_horizon = 3.57 * sqrt(h_meters) + // Uses 4/3 Earth radius for standard atmospheric refraction + var siteHeight = site.height || 10; // default 10m if missing + var horizon = calculateRadioHorizon(siteHeight); + if (distance > horizon) { + return -Infinity; + } + // Free space path loss (universal) var fspl = 20 * Math.log10(distance) + 20 * Math.log10(site.frequency) + 32.45; @@ -56,31 +71,63 @@ function calculatePointRSRP(site, point) { // Link budget: RSRP = P_tx + G_tx - FSPL var rsrp = site.power + site.gain - fspl; - // Apply sector antenna directivity: hard cutoff + gradual pattern loss + // Apply 3GPP sector antenna pattern (main lobe + side lobes + back lobe) + // No hard cutoff — back lobe is attenuated by front-to-back ratio (~25 dB) if (site.antennaType === 'sector' && site.azimuth !== undefined) { var bearing = calculateBearing(site.lat, site.lon, point.lat, point.lon); - var relativeAngle = Math.abs(bearing - site.azimuth); - var normalizedAngle = - relativeAngle > 180 ? 360 - relativeAngle : relativeAngle; - var beamwidth = site.beamwidth || 65; + var frontBackRatio = 25; // dB, typical for sector panel antennas - // Hard cutoff: no signal outside beamwidth - if (normalizedAngle > beamwidth / 2) { - return -Infinity; - } - - // Gradual 3GPP pattern loss within beamwidth - var patternLoss = calculateSectorPatternLoss( - normalizedAngle, - beamwidth + var patternLoss = calculate3GPPPattern( + site.azimuth, + bearing, + beamwidth, + frontBackRatio ); - rsrp -= patternLoss; + rsrp -= patternLoss; // patternLoss is positive dB } return rsrp; } +/** + * Radio horizon distance in km. + * d = 3.57 * sqrt(h) where h is antenna height in meters. + * Accounts for 4/3 Earth radius (standard atmosphere refraction). + */ +function calculateRadioHorizon(heightMeters) { + return 3.57 * Math.sqrt(heightMeters); +} + +/** + * 3GPP TR 36.814 Horizontal Antenna Pattern. + * + * A(θ) = min[ 12 * (θ / θ_3dB)², A_m ] + * + * Returns POSITIVE dB loss value (to be subtracted from RSRP). + * + * - θ = angular offset from boresight (0-180°) + * - θ_3dB = half-power beamwidth / 2 + * - A_m = maximum attenuation = front-to-back ratio + * + * At 0° → 0 dB loss (boresight) + * At ±θ_3dB → 3 dB loss (half-power points) + * At ±90° → capped at A_m (~25 dB) + * At 180° → capped at A_m (~25 dB, back lobe) + */ +function calculate3GPPPattern(azimuth, bearing, beamwidth, frontBackRatio) { + // Normalize angle difference to -180…+180 + var angleDiff = bearing - azimuth; + while (angleDiff > 180) angleDiff -= 360; + while (angleDiff < -180) angleDiff += 360; + + var theta = Math.abs(angleDiff); + var theta3dB = beamwidth / 2; + var Am = frontBackRatio; + + return Math.min(12 * Math.pow(theta / theta3dB, 2), Am); +} + /** * Haversine distance in km */ @@ -116,16 +163,3 @@ function calculateBearing(lat1, lon1, lat2, lon2) { var bearing = (Math.atan2(y, x) * 180) / Math.PI; return (bearing + 360) % 360; } - -/** - * Sector antenna pattern loss (3GPP model) - */ -function calculateSectorPatternLoss(angleOffBoresight, beamwidth) { - var theta3dB = beamwidth / 2; - var sideLobeLevel = 20; - - return Math.min( - 12 * Math.pow(angleOffBoresight / theta3dB, 2), - sideLobeLevel - ); -} diff --git a/frontend/src/components/map/Heatmap.tsx b/frontend/src/components/map/Heatmap.tsx index 32f2159..273dc90 100644 --- a/frontend/src/components/map/Heatmap.tsx +++ b/frontend/src/components/map/Heatmap.tsx @@ -30,26 +30,24 @@ function rsrpToIntensity(rsrp: number): number { } /** - * Calculate adaptive heatmap params based on zoom level. + * Calculate adaptive heatmap visual params based on zoom level. * - * radius/blur: smaller at close zoom to avoid blocky squares - * maxIntensity: lower at close zoom so densely packed points - * don't all saturate to a single solid color + * Only radius and blur change with zoom (visual quality). + * maxIntensity is CONSTANT at 1.0 so the same RSRP always + * maps to the same color regardless of zoom level. * - * Zoom 6 (country): radius=35, blur=18, max=0.90 - * Zoom 10 (region): radius=25, blur=13, max=0.70 - * Zoom 14 (city): radius=15, blur=9, max=0.50 - * Zoom 18 (street): radius=8, blur=6, max=0.30 + * Zoom 6 (country): radius=35, blur=18 + * Zoom 10 (region): radius=25, blur=13 + * Zoom 14 (city): radius=15, blur=9 + * Zoom 18 (street): radius=8, blur=6 */ function getHeatmapParams(zoom: number) { const radius = Math.max(8, Math.min(40, 50 - zoom * 2.5)); const blur = Math.max(6, Math.min(20, 30 - zoom * 1.5)); - // Dynamic max: prevents saturation at close zoom - const maxIntensity = Math.max(0.3, Math.min(1.0, 1.2 - zoom * 0.05)); return { radius: Math.round(radius), blur: Math.round(blur), - maxIntensity, + maxIntensity: 1.0, // CONSTANT — zoom-independent colors }; } diff --git a/frontend/src/components/panels/BatchEdit.tsx b/frontend/src/components/panels/BatchEdit.tsx index 45df58f..e64e42d 100644 --- a/frontend/src/components/panels/BatchEdit.tsx +++ b/frontend/src/components/panels/BatchEdit.tsx @@ -3,7 +3,11 @@ import { useSitesStore } from '@/store/sites.ts'; import { useToastStore } from '@/components/ui/Toast.tsx'; import Button from '@/components/ui/Button.tsx'; -export default function BatchEdit() { +interface BatchEditProps { + onBatchApplied?: (affectedIds: string[]) => void; +} + +export default function BatchEdit({ onBatchApplied }: BatchEditProps) { const selectedSiteIds = useSitesStore((s) => s.selectedSiteIds); const batchUpdateHeight = useSitesStore((s) => s.batchUpdateHeight); const batchSetHeight = useSitesStore((s) => s.batchSetHeight); @@ -15,9 +19,11 @@ export default function BatchEdit() { if (selectedSiteIds.length === 0) return null; const handleAdjustHeight = async (delta: number) => { + const ids = [...selectedSiteIds]; await batchUpdateHeight(delta); + onBatchApplied?.(ids); addToast( - `Adjusted ${selectedSiteIds.length} site(s) by ${delta > 0 ? '+' : ''}${delta}m`, + `Updated ${ids.length} site(s) by ${delta > 0 ? '+' : ''}${delta}m`, 'success' ); }; @@ -28,8 +34,10 @@ export default function BatchEdit() { addToast('Height must be between 1-100m', 'error'); return; } + const ids = [...selectedSiteIds]; await batchSetHeight(height); - addToast(`Set ${selectedSiteIds.length} site(s) to ${height}m`, 'success'); + onBatchApplied?.(ids); + addToast(`Set ${ids.length} site(s) to ${height}m`, 'success'); setCustomHeight(''); }; diff --git a/frontend/src/components/panels/SiteForm.tsx b/frontend/src/components/panels/SiteForm.tsx index d454f10..844d570 100644 --- a/frontend/src/components/panels/SiteForm.tsx +++ b/frontend/src/components/panels/SiteForm.tsx @@ -77,6 +77,24 @@ export default function SiteForm({ } }, [pendingLocation]); + // Live-sync: update form when the edited site changes externally + // (e.g., batch height adjustment, drag on map) + useEffect(() => { + if (editSite) { + setHeight(editSite.height); + setPower(editSite.power); + setGain(editSite.gain); + setName(editSite.name); + setLat(editSite.lat); + setLon(editSite.lon); + setFrequency(editSite.frequency); + setAntennaType(editSite.antennaType); + setAzimuth(editSite.azimuth ?? 0); + setBeamwidth(editSite.beamwidth ?? 65); + setNotes(editSite.notes ?? ''); + } + }, [editSite]); + const applyTemplate = (key: keyof typeof TEMPLATES) => { const t = TEMPLATES[key]; setName(t.name); diff --git a/frontend/src/components/panels/SiteList.tsx b/frontend/src/components/panels/SiteList.tsx index 250fa78..d1a93ba 100644 --- a/frontend/src/components/panels/SiteList.tsx +++ b/frontend/src/components/panels/SiteList.tsx @@ -1,3 +1,4 @@ +import { useState, useCallback } from 'react'; import type { Site } from '@/types/index.ts'; import { useSitesStore } from '@/store/sites.ts'; import { useToastStore } from '@/components/ui/Toast.tsx'; @@ -22,6 +23,15 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) { const clearSelection = useSitesStore((s) => s.clearSelection); const addToast = useToastStore((s) => s.addToast); + // Track recently batch-updated site IDs for flash animation + const [flashIds, setFlashIds] = useState>(new Set()); + + const triggerFlash = useCallback((ids: string[]) => { + setFlashIds(new Set(ids)); + // Clear after animation completes + setTimeout(() => setFlashIds(new Set()), 700); + }, []); + const handleDelete = async (id: string, name: string) => { await deleteSite(id); addToast(`"${name}" deleted`, 'info'); @@ -73,7 +83,7 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) { {/* Batch edit panel (appears when sites are selected) */} {selectedSiteIds.length > 0 && (
- +
)} @@ -86,6 +96,7 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
{sites.map((site) => { const isBatchSelected = selectedSiteIds.includes(site.id); + const isFlashing = flashIds.has(site.id); return (
selectSite(site.id)} > {/* Batch checkbox */} diff --git a/frontend/src/index.css b/frontend/src/index.css index 78098b7..f99d4e7 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -41,3 +41,13 @@ .dark .leaflet-tile-pane { filter: brightness(0.8) contrast(1.1) saturate(0.8); } + +/* Flash animation for batch-updated sites */ +@keyframes flash-update { + 0%, 100% { background-color: transparent; } + 50% { background-color: rgba(59, 130, 246, 0.3); } +} + +.flash-update { + animation: flash-update 0.6s ease-in-out; +}