# RFCP - Iteration 7.4: Radius Fix + Sector Tree UI ## Issue 1: Coverage Invisible (Critical!) **Problem:** Max radius clamped to 80px is TOO SMALL for geographic scale. **At zoom 14:** - pixelsPerKm = 104,857 - targetRadius = 0.4km - radiusPixels = 41,943px - **Clamped to 80px** ← This is TINY! - Should be ~200-500px for smooth coverage **Root cause:** Geographic scale formula calculates HUGE pixel values at high zoom, but we clamp them down, losing all coverage! ### Solution A: Much Higher Clamps **File:** `frontend/src/components/map/Heatmap.tsx` ```typescript const pixelsPerKm = Math.pow(2, mapZoom) * 6.4; const targetRadiusKm = 0.4; // 400m const radiusPixels = targetRadiusKm * pixelsPerKm; // MUCH HIGHER clamps const minRadius = 20; const maxRadius = mapZoom < 10 ? 60 : 300; // Allow up to 300px at high zoom! const radius = Math.max(minRadius, Math.min(maxRadius, radiusPixels)); const blur = radius * 0.6; const maxIntensity = 0.75; ``` ### Solution B: Logarithmic Scaling Instead of linear geographic scale, use log scale: ```typescript // Log scale: grows slower at high zoom const baseRadius = 30; const zoomFactor = Math.log2(mapZoom + 1) / Math.log2(19); // Normalize to 0-1 const radius = baseRadius + (zoomFactor * 150); // 30px at zoom 1 → 180px at zoom 18 const blur = radius * 0.6; const maxIntensity = 0.75; ``` ### Solution C: Simple Progressive Formula (Recommended) **Forget geographic scale - it's too complex for heatmap library!** Use simple zoom-dependent formula with generous values: ```typescript 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; // RSRP normalization const normalizeRSRP = (rsrp: number): number => { const minRSRP = -130; const maxRSRP = -50; const normalized = (rsrp - minRSRP) / (maxRSRP - minRSRP); return Math.max(0, Math.min(1, normalized)); }; // SIMPLE progressive formula // Small zoom: smaller radius (far view) // Large zoom: larger radius (close view) let radius, blur, maxIntensity; if (mapZoom < 10) { // Far view: moderate radius radius = 30 + (mapZoom * 2); // 32px at zoom 1 → 50px at zoom 9 blur = radius * 0.7; maxIntensity = 0.75; } else { // Close view: large radius to fill gaps radius = 50 + ((mapZoom - 10) * 25); // 50px at zoom 10 → 200px at zoom 16 blur = radius * 0.6; maxIntensity = 0.65; // Slightly lower to prevent saturation } const heatmapPoints = points.map(p => [ p.lat, p.lon, normalizeRSRP(p.rsrp) ] as [number, number, number]); // Debug if (import.meta.env.DEV) { console.log('🔍 Heatmap:', { zoom: mapZoom, radius: radius.toFixed(1), blur: blur.toFixed(1), maxIntensity, points: points.length }); } return (
p[1]} latitudeExtractor={(p) => p[0]} intensityExtractor={(p) => p[2]} gradient={{ 0.0: '#1a237e', 0.15: '#0d47a1', 0.25: '#2196f3', 0.35: '#00bcd4', 0.45: '#00897b', 0.55: '#4caf50', 0.65: '#8bc34a', 0.75: '#ffeb3b', 0.85: '#ff9800', 1.0: '#f44336', }} radius={radius} blur={blur} max={maxIntensity} minOpacity={0.3} />
); } ``` **Expected results:** - Zoom 8: radius=46px, blur=32px → smooth coverage - Zoom 12: radius=100px, blur=60px → fills gaps - Zoom 16: radius=200px, blur=120px → no grid visible --- ## Issue 2: Sector Tree UI **Problem:** Clone creates new site instead of new sector in same site. **Current:** ``` Sites (2) - Station-1 (1800 MHz, 43 dBm, Sector 122°, 12m) - Station-1-clone (1800 MHz, 43 dBm, Sector 0°, 60m) ``` **Should be:** ``` Sites (1) - Station-1 (1800 MHz, 30m) ├─ Sector 1 (122°, 65°, 18 dBi) └─ Sector 2 (0°, 65°, 18 dBi) ``` ### Solution: Refactor Clone to Add Sector **File:** `frontend/src/store/sites.ts` Current `cloneSector` creates new site: ```typescript const cloneSector = (siteId: string) => { const site = sites.find(s => s.id === siteId); const clone = { ...site, id: uuid(), name: `${site.name}-clone` }; setSites([...sites, clone]); }; ``` **Change to add sector:** ```typescript const cloneSector = (siteId: string, sectorId?: string) => { const site = sites.find(s => s.id === siteId); // If sectorId provided, clone that specific sector // Otherwise clone the first sector const sourceSector = sectorId ? site.sectors.find(s => s.id === sectorId) : site.sectors[0]; const newSector: Sector = { ...sourceSector, id: `sector-${Date.now()}`, azimuth: (sourceSector.azimuth + 30) % 360, // Offset by 30° }; // Add sector to existing site updateSite(siteId, { sectors: [...site.sectors, newSector] }); }; ``` ### UI: Tree View for Sectors **File:** `frontend/src/components/panels/SiteList.tsx` ```typescript export function SiteList() { const { sites, selectedSiteIds, toggleSiteSelection } = useSitesStore(); const [expandedSites, setExpandedSites] = useState>(new Set()); const toggleExpand = (siteId: string) => { const newExpanded = new Set(expandedSites); if (newExpanded.has(siteId)) { newExpanded.delete(siteId); } else { newExpanded.add(siteId); } setExpandedSites(newExpanded); }; return (

Sites ({sites.length})

{sites.map(site => { const isExpanded = expandedSites.has(site.id); const isSelected = selectedSiteIds.includes(site.id); return (
{/* Site header */}
toggleSiteSelection(site.id)} />
{site.name} {site.frequency} MHz · {site.height}m · {site.sectors.length} sectors
{/* Sectors (when expanded) */} {isExpanded && (
{site.sectors.map((sector, idx) => (
toggleSector(site.id, sector.id)} />
Sector {idx + 1} {sector.beamwidth < 360 && ( {sector.azimuth}° · {sector.beamwidth}° · {sector.gain} dBi )} {sector.beamwidth === 360 && ( Omni · {sector.gain} dBi )}
{site.sectors.length > 1 && ( )}
))}
)}
); })}
); } ``` ### Simplified Alternative (Quick Fix) If tree view is too complex, just fix the clone function: **File:** `frontend/src/components/panels/SiteList.tsx` ```typescript // Change button label // Update store function const cloneSector = (siteId: string) => { const site = sites.find(s => s.id === siteId); const lastSector = site.sectors[site.sectors.length - 1]; const newSector = { ...lastSector, id: `sector-${Date.now()}`, azimuth: (lastSector.azimuth + 120) % 360, // 120° spacing for tri-sector }; updateSite(siteId, { sectors: [...site.sectors, newSector] }); }; ``` --- ## Testing ### Heatmap Visibility: - [ ] Zoom 8: Coverage visible with gradient - [ ] Zoom 12: Full gradient blue→yellow→red - [ ] Zoom 16: No grid pattern, smooth coverage - [ ] All zoom levels show coverage extent ### Sector UI: - [ ] "Clone Sector" adds sector to SAME site - [ ] Site count shows correct number (1 site, 2 sectors = "Sites (1)") - [ ] Each sector has edit/remove buttons - [ ] Can enable/disable individual sectors --- ## Build & Deploy ```bash cd /opt/rfcp/frontend npm run build sudo systemctl reload caddy ``` --- ## Commit Message ``` fix(heatmap): increase radius range for visible coverage - Remove geographic scale formula (too complex) - Use simple progressive formula: 30-50px (zoom <10), 50-200px (zoom ≥10) - Larger blur at high zoom to fill grid gaps - Coverage now visible at all zoom levels fix(ui): clone creates sector not new site - Changed cloneSector to add sector to existing site - Updated UI: "Clone Sector" instead of "Clone" - Site count now accurate (counts sites, not sectors) - Each sector independently editable/removable refactor(ui): sector tree view (optional) - Expandable site headers - Nested sector list with enable/disable toggles - Per-sector edit/clone/remove buttons - Clear visual hierarchy: Site → Sectors ``` 🚀 Ready for Iteration 7.4!