Files
rfcp/docs/devlog/front/RFCP-Iteration7.4-Radius-Sector-UI.md
2026-01-30 20:39:13 +02:00

11 KiB

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

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:

// 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;

Forget geographic scale - it's too complex for heatmap library!

Use simple zoom-dependent formula with generous values:

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 (
    <div style={{ opacity }}>
      <HeatmapLayer
        points={heatmapPoints}
        longitudeExtractor={(p) => 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}
      />
    </div>
  );
}

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:

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:

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

export function SiteList() {
  const { sites, selectedSiteIds, toggleSiteSelection } = useSitesStore();
  const [expandedSites, setExpandedSites] = useState<Set<string>>(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 (
    <div className="site-list">
      <h3>Sites ({sites.length})</h3>
      
      {sites.map(site => {
        const isExpanded = expandedSites.has(site.id);
        const isSelected = selectedSiteIds.includes(site.id);
        
        return (
          <div key={site.id} className="site-tree-item">
            {/* Site header */}
            <div className={cn('site-header', isSelected && 'selected')}>
              <input
                type="checkbox"
                checked={isSelected}
                onChange={() => toggleSiteSelection(site.id)}
              />
              
              <button 
                onClick={() => toggleExpand(site.id)}
                className="expand-btn"
              >
                {isExpanded ? '▼' : '▶'}
              </button>
              
              <div className="site-info">
                <strong>{site.name}</strong>
                <small>{site.frequency} MHz · {site.height}m · {site.sectors.length} sectors</small>
              </div>
              
              <button onClick={() => editSite(site.id)}>Edit</button>
              <button onClick={() => cloneSite(site.id)}>Clone Site</button>
            </div>
            
            {/* Sectors (when expanded) */}
            {isExpanded && (
              <div className="sectors-tree">
                {site.sectors.map((sector, idx) => (
                  <div key={sector.id} className="sector-item">
                    <input
                      type="checkbox"
                      checked={sector.enabled}
                      onChange={() => toggleSector(site.id, sector.id)}
                    />
                    
                    <div className="sector-info">
                      <strong>Sector {idx + 1}</strong>
                      {sector.beamwidth < 360 && (
                        <small>
                          {sector.azimuth}° · {sector.beamwidth}° · {sector.gain} dBi
                        </small>
                      )}
                      {sector.beamwidth === 360 && (
                        <small>Omni · {sector.gain} dBi</small>
                      )}
                    </div>
                    
                    <button onClick={() => editSector(site.id, sector.id)}>
                      Edit
                    </button>
                    <button onClick={() => cloneSector(site.id, sector.id)}>
                      Clone Sector
                    </button>
                    {site.sectors.length > 1 && (
                      <button onClick={() => removeSector(site.id, sector.id)}>
                        
                      </button>
                    )}
                  </div>
                ))}
                
                <button 
                  onClick={() => addSector(site.id)}
                  className="add-sector-btn"
                >
                  + Add Sector
                </button>
              </div>
            )}
          </div>
        );
      })}
    </div>
  );
}

Simplified Alternative (Quick Fix)

If tree view is too complex, just fix the clone function:

File: frontend/src/components/panels/SiteList.tsx

// Change button label
<button onClick={() => cloneSector(site.id)}>
  + Add Sector {/* was: Clone */}
</button>

// 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

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!