# RFCP - Iteration 6: Heatmap Gradient Fix + Multi-Sector + LTE Bands ## Current State (from screenshots) **Working well:** - ✅ Sector wedge shows correctly (triangle shape visible) - ✅ Batch edit displays changes (flash animation works) - ✅ Edit panel stays open during batch operations **Issues to fix:** - ❌ Heatmap gradient changes dramatically with zoom: - Far zoom: Good gradient (green→yellow→orange) - Medium zoom: Mostly orange (~80%) - Close zoom: Almost all yellow/orange - Very close zoom: Solid yellow/green - ❌ Missing LTE Band 1 (2100 MHz) - only have Band 3 (1800 MHz) - ❌ No multi-sector support (need 2-3 sectors per site for realistic deployments) --- ## CRITICAL FIX: Zoom-Independent Heatmap Colors **Problem:** Same physical location shows different colors at different zoom levels. This makes the heatmap misleading. **Root Cause:** `maxIntensity` parameter changes with zoom, causing the color scale to shift. **Solution:** Make RSRP-to-color mapping zoom-independent, only adjust visual quality (radius/blur) with zoom. **File:** `frontend/src/components/map/Heatmap.tsx` ```typescript import { useEffect, useState } from 'react'; import { useMap } from 'react-leaflet'; import { HeatmapLayerFactory } from '@vgrid/react-leaflet-heatmap-layer'; const HeatmapLayer = HeatmapLayerFactory<[number, number, number]>(); interface HeatmapProps { points: Array<{ lat: number; lon: number; rsrp: number; siteId: string; }>; visible: boolean; opacity?: number; } export function Heatmap({ points, visible, opacity = 0.7 }: HeatmapProps) { const map = useMap(); const [mapZoom, setMapZoom] = useState(map.getZoom()); useEffect(() => { const handleZoomEnd = () => setMapZoom(map.getZoom()); map.on('zoomend', handleZoomEnd); return () => { map.off('zoomend', handleZoomEnd); }; }, [map]); if (!visible || points.length === 0) return null; // CRITICAL FIX: Wider RSRP range for full gradient const normalizeRSRP = (rsrp: number): number => { const minRSRP = -130; // Very weak signal const maxRSRP = -50; // Excellent signal (widened from -60) const normalized = (rsrp - minRSRP) / (maxRSRP - minRSRP); return Math.max(0, Math.min(1, normalized)); }; // Zoom-dependent visual parameters (for display quality only) const radius = Math.max(10, Math.min(40, 60 - mapZoom * 3)); const blur = Math.max(8, Math.min(25, 35 - mapZoom * 1.5)); // CRITICAL FIX: Constant maxIntensity for zoom-independent colors // BUT lower than 1.0 to prevent saturation const maxIntensity = 0.75; // FIXED VALUE, never changes const heatmapPoints = points.map(p => [ p.lat, p.lon, normalizeRSRP(p.rsrp) ] as [number, number, number]); // Debug logging if (import.meta.env.DEV && points.length > 0) { const rsrpValues = points.map(p => p.rsrp); console.log('Heatmap Debug:', { totalPoints: points.length, rsrpRange: `${Math.min(...rsrpValues).toFixed(1)} to ${Math.max(...rsrpValues).toFixed(1)} dBm`, zoom: mapZoom, radius, blur, maxIntensity }); } return (
p[1]} latitudeExtractor={(p) => p[0]} intensityExtractor={(p) => p[2]} gradient={{ 0.0: '#1a237e', // Deep blue (-130 dBm - no service) 0.1: '#0d47a1', // Dark blue (-122 dBm) 0.2: '#2196f3', // Blue (-114 dBm) 0.3: '#00bcd4', // Cyan (-106 dBm - weak) 0.4: '#00897b', // Teal (-98 dBm) 0.5: '#4caf50', // Green (-90 dBm - fair) 0.6: '#8bc34a', // Light green (-82 dBm) 0.7: '#ffeb3b', // Yellow (-74 dBm - good) 0.8: '#ffc107', // Amber (-66 dBm) 0.9: '#ff9800', // Orange (-58 dBm - excellent) 1.0: '#f44336', // Red (-50 dBm - very strong) }} radius={radius} blur={blur} max={maxIntensity} // CONSTANT! minOpacity={0.3} />
); } ``` **Why this works:** - RSRP -130 to -50 range captures full signal spectrum - `maxIntensity=0.75` prevents color saturation while keeping gradient visible - Same RSRP → same normalized value → same color at ANY zoom level - Only radius/blur change with zoom (visual quality, not colors) --- ## FEATURE 1: Multi-Sector Support **What:** Allow 2-3 sectors per site (standard for real cell towers). ### Data Model Update **File:** `frontend/src/types/site.ts` ```typescript export interface Site { id: string; name: string; lat: number; lon: number; color: string; // Physical parameters (shared across sectors) height: number; // meters frequency: number; // MHz power: number; // dBm (per sector) // Multi-sector configuration sectors: Sector[]; } export interface Sector { id: string; enabled: boolean; azimuth: number; // degrees (0-360) beamwidth: number; // degrees gain: number; // dBi notes?: string; // e.g., "Alpha sector", "Main lobe" } // Common presets export const SECTOR_PRESETS = { single_omni: [{ id: 's1', enabled: true, azimuth: 0, beamwidth: 360, gain: 2 }], dual_sector: [ { id: 's1', enabled: true, azimuth: 0, beamwidth: 90, gain: 15 }, { id: 's2', enabled: true, azimuth: 180, beamwidth: 90, gain: 15 } ], tri_sector: [ { id: 's1', enabled: true, azimuth: 0, beamwidth: 65, gain: 18 }, { id: 's2', enabled: true, azimuth: 120, beamwidth: 65, gain: 18 }, { id: 's3', enabled: true, azimuth: 240, beamwidth: 65, gain: 18 } ] }; ``` ### UI Component **File:** `frontend/src/components/panels/SectorConfig.tsx` (new) ```typescript import { useState } from 'react'; import { Sector, SECTOR_PRESETS } from '@/types/site'; import { Button } from '@/components/ui/Button'; import { Slider } from '@/components/ui/Slider'; interface SectorConfigProps { sectors: Sector[]; onUpdate: (sectors: Sector[]) => void; } export function SectorConfig({ sectors, onUpdate }: SectorConfigProps) { const applyPreset = (presetName: keyof typeof SECTOR_PRESETS) => { onUpdate(SECTOR_PRESETS[presetName]); }; const updateSector = (id: string, changes: Partial) => { onUpdate(sectors.map(s => s.id === id ? { ...s, ...changes } : s)); }; const addSector = () => { const newSector: Sector = { id: `s${sectors.length + 1}`, enabled: true, azimuth: 0, beamwidth: 65, gain: 18 }; onUpdate([...sectors, newSector]); }; const removeSector = (id: string) => { onUpdate(sectors.filter(s => s.id !== id)); }; return (

Sector Configuration

{/* Quick presets */}
{/* Individual sectors */}
{sectors.map((sector, idx) => (
updateSector(sector.id, { enabled: e.target.checked })} />
Sector {idx + 1}
{sectors.length > 1 && ( )}
{sector.enabled && ( <> updateSector(sector.id, { azimuth: v })} suffix="°" /> updateSector(sector.id, { beamwidth: v })} suffix="°" /> updateSector(sector.id, { gain: v })} suffix=" dBi" /> )}
))}
); } ``` ### Coverage Calculation **File:** `frontend/src/workers/rf-worker.js` ```javascript // Calculate coverage for all sectors of a site function calculateSiteCoverage(site, bounds, radius, resolution, rsrpThreshold) { const allPoints = []; for (const sector of site.sectors) { if (!sector.enabled) continue; // Calculate for this sector const sectorPoints = calculateSectorCoverage( site, sector, bounds, radius, resolution, rsrpThreshold ); // Merge points (keep strongest signal at each location) for (const point of sectorPoints) { const existing = allPoints.find(p => Math.abs(p.lat - point.lat) < 0.00001 && Math.abs(p.lon - point.lon) < 0.00001 ); if (existing) { if (point.rsrp > existing.rsrp) { existing.rsrp = point.rsrp; existing.sectorId = sector.id; } } else { allPoints.push(point); } } } return allPoints; } function calculateSectorCoverage(site, sector, bounds, radius, resolution, rsrpThreshold) { const points = []; // Grid setup... for (let latIdx = 0; latIdx < latPoints; latIdx++) { for (let lonIdx = 0; lonIdx < lonPoints; lonIdx++) { const lat = minLat + latIdx * latStep; const lon = minLon + lonIdx * lonStep; const distance = calculateDistance(site.lat, site.lon, lat, lon); if (distance > radius) continue; // Antenna pattern loss const bearing = calculateBearing(site.lat, site.lon, lat, lon); const patternLoss = calculateSectorLoss(sector.azimuth, bearing, sector.beamwidth); // Skip very weak back lobe if (patternLoss > 25) continue; // FSPL const fspl = calculateFSPL(distance, site.frequency); // Final RSRP const rsrp = site.power + sector.gain - fspl - patternLoss; if (rsrp > rsrpThreshold) { points.push({ lat, lon, rsrp, siteId: site.id, sectorId: sector.id }); } } } return points; } ``` ### Visualization **File:** `frontend/src/components/map/SiteMarker.tsx` ```typescript // Show wedge for each sector {site.sectors.map(sector => ( sector.enabled && sector.beamwidth < 360 && ( ) ))} function generateSectorWedge(lat: number, lon: number, sector: Sector) { const points: [number, number][] = [[lat, lon]]; const visualRadius = 0.5; // km const startAngle = sector.azimuth - sector.beamwidth / 2; const endAngle = sector.azimuth + sector.beamwidth / 2; for (let angle = startAngle; angle <= endAngle; angle += 5) { const rad = angle * Math.PI / 180; const latOffset = (visualRadius / 111) * Math.cos(rad); const lonOffset = (visualRadius / (111 * Math.cos(lat * Math.PI / 180))) * Math.sin(rad); points.push([lat + latOffset, lon + lonOffset]); } points.push([lat, lon]); return points; } ``` --- ## FEATURE 2: Add LTE Band 1 **Current:** Have Band 3 (1800 MHz), Band 7 (2600 MHz). **Add:** Band 1 (2100 MHz) - most common LTE band globally. **File:** `frontend/src/components/panels/SiteForm.tsx` ```typescript // Frequency selector
setCustomFreq(e.target.value)} />
{/* Band info */} {frequency && (

{getBandDescription(frequency)}

)} function getBandDescription(freq: number): string { const info = { 800: 'Band 20 (LTE 800) - Best coverage, deep building penetration', 1800: 'Band 3 (DCS 1800) - Most common in Europe and Ukraine', 1900: 'Band 2 (PCS 1900) - Common in Americas', 2100: 'Band 1 (IMT 2100) - Most deployed LTE band globally', 2600: 'Band 7 (IMT-E 2600) - High capacity urban areas' }; return info[freq] || `Custom ${freq} MHz`; } ``` --- ## TESTING CHECKLIST ### Heatmap Gradient (Critical): - [ ] Zoom out to level 6: Note color at 5km from site - [ ] Zoom to level 10: Same location should be SAME color - [ ] Zoom to level 14: Color still unchanged - [ ] Zoom to level 18: Color STILL the same! - [ ] Check console logs: RSRP values and maxIntensity=0.75 ### Multi-Sector: - [ ] Apply 3-sector preset - [ ] See 3 wedges (120° spacing) - [ ] Disable sector 2 → wedge disappears - [ ] Adjust azimuth of sector 1 → wedge rotates - [ ] Coverage calculation includes all enabled sectors ### Band Addition: - [ ] Band 1 (2100 MHz) button visible and works - [ ] Band description shows "Most deployed LTE band globally" - [ ] Coverage calculation uses 2100 MHz correctly ### Performance: - [ ] Coverage calc still fast (< 2s for 10km radius) - [ ] UI responsive during calculation - [ ] No memory leaks --- ## BUILD & DEPLOY ```bash cd /opt/rfcp/frontend npm run build ls -lh dist/ # Check size sudo systemctl reload caddy # Test curl https://rfcp.eliah.one/api/health curl https://rfcp.eliah.one/ | head -20 ``` --- ## COMMIT MESSAGE ``` fix(heatmap): make colors zoom-independent - Extended RSRP range to -130 to -50 dBm - Fixed maxIntensity at 0.75 (was zoom-dependent) - Added more gradient steps for smoother transitions - Same RSRP now shows same color at ANY zoom level feat(multi-sector): support 2-3 sectors per site - Sites can have multiple sectors with independent azimuth/gain - Presets: single omni, dual sector (180°), tri-sector (120°) - Each sector shows visual wedge on map - Coverage calculation merges all enabled sectors - Strongest signal wins at overlapping points feat(bands): add LTE Band 1 (2100 MHz) - Most deployed LTE band globally - Common for international deployments - Band selector shows description for each band ``` --- --- ## FEATURE 3: Map Enhancements ### A. Coordinate Grid Overlay **What:** Show lat/lon grid lines with labels. **Install dependency:** ```bash npm install leaflet-graticule ``` **File:** `frontend/src/components/map/CoordinateGrid.tsx` (new) ```typescript import { useEffect } from 'react'; import { useMap } from 'react-leaflet'; import L from 'leaflet'; import 'leaflet-graticule'; interface CoordinateGridProps { visible: boolean; } export function CoordinateGrid({ visible }: CoordinateGridProps) { const map = useMap(); useEffect(() => { if (!visible) return; const graticule = (L as any).latlngGraticule({ showLabel: true, opacity: 0.5, weight: 1, color: '#666', font: '11px monospace', fontColor: '#444', dashArray: '3, 3', zoomInterval: [ { start: 1, end: 7, interval: 1 }, { start: 8, end: 10, interval: 0.5 }, { start: 11, end: 13, interval: 0.1 }, { start: 14, end: 20, interval: 0.01 } ] }).addTo(map); return () => { map.removeLayer(graticule); }; }, [map, visible]); return null; } ``` ### B. Distance Measurement Tool **File:** `frontend/src/components/map/MeasurementTool.tsx` (new) ```typescript import { useEffect, useState } from 'react'; import { useMap, Polyline, Marker } from 'react-leaflet'; import L from 'leaflet'; interface MeasurementToolProps { enabled: boolean; onComplete?: (distance: number) => void; } export function MeasurementTool({ enabled, onComplete }: MeasurementToolProps) { const map = useMap(); const [points, setPoints] = useState<[number, number][]>([]); const [totalDistance, setTotalDistance] = useState(0); useEffect(() => { if (!enabled) { setPoints([]); setTotalDistance(0); return; } const handleClick = (e: L.LeafletMouseEvent) => { const newPoints = [...points, [e.latlng.lat, e.latlng.lng] as [number, number]]; setPoints(newPoints); if (newPoints.length >= 2) { const distance = calculateTotalDistance(newPoints); setTotalDistance(distance); } }; const handleRightClick = () => { if (totalDistance > 0 && onComplete) { onComplete(totalDistance); } setPoints([]); setTotalDistance(0); }; map.on('click', handleClick); map.on('contextmenu', handleRightClick); return () => { map.off('click', handleClick); map.off('contextmenu', handleRightClick); }; }, [map, enabled, points, totalDistance, onComplete]); if (points.length === 0) return null; return ( <> {points.length >= 2 && ( )} {points.map((pos, idx) => ( ' })} /> ))} {totalDistance > 0 && (
📏 Distance: {totalDistance.toFixed(2)} km
)} ); } function calculateTotalDistance(points: [number, number][]): number { let total = 0; for (let i = 1; i < points.length; i++) { const [lat1, lon1] = points[i - 1]; const [lat2, lon2] = points[i]; const R = 6371; const dLat = (lat2 - lat1) * Math.PI / 180; const dLon = (lon2 - lon1) * Math.PI / 180; const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2; const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); total += R * c; } return total; } ``` ### C. Scale Bar & Compass **File:** `frontend/src/components/map/MapExtras.tsx` (new) ```typescript import { useEffect } from 'react'; import { useMap } from 'react-leaflet'; import L from 'leaflet'; export function MapExtras() { const map = useMap(); useEffect(() => { // Scale bar const scale = L.control.scale({ position: 'bottomleft', metric: true, imperial: false, maxWidth: 200 }).addTo(map); // Compass rose const compass = L.control({ position: 'topright' }); compass.onAdd = () => { const div = L.DomUtil.create('div', 'compass-rose'); div.innerHTML = ` N `; div.style.cssText = 'background: rgba(255,255,255,0.9); border-radius: 50%; padding: 5px; box-shadow: 0 2px 5px rgba(0,0,0,0.3);'; return div; }; compass.addTo(map); return () => { map.removeControl(scale); map.removeControl(compass); }; }, [map]); return null; } ``` ### D. Map Controls UI **Add to Coverage Settings panel:** ```typescript

Map Tools

Click points on map. Right-click to finish.
// In Map component: toast.success(`${d.toFixed(2)} km`)} /> ``` --- ## SUCCESS CRITERIA After ALL fixes: ✅ Zoom from 6 to 18: colors don't shift ✅ Can create 3-sector site with independent azimuths ✅ Band 1 (2100 MHz) available in selector ✅ Gradient shows full blue→cyan→green→yellow→orange→red spectrum ✅ Coordinate grid shows lat/lon lines with labels ✅ Distance measurement tool works (click to measure, right-click to finish) ✅ Scale bar visible at bottom left ✅ Compass rose (north arrow) visible at top right ✅ Hillshade terrain adds 3D relief effect ✅ Performance unchanged (< 2s for typical coverage) 🚀 Ready to implement!