diff --git a/RFCP-Iteration7.4-Radius-Sector-UI.md b/RFCP-Iteration7.4-Radius-Sector-UI.md new file mode 100644 index 0000000..12d53d5 --- /dev/null +++ b/RFCP-Iteration7.4-Radius-Sector-UI.md @@ -0,0 +1,389 @@ +# 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!