Files
rfcp/RFCP-Iteration6-Complete.md
2026-01-30 12:32:39 +02:00

23 KiB

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

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

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

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)

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<Sector>) => {
    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 (
    <div className="sector-config">
      <h4>Sector Configuration</h4>
      
      {/* Quick presets */}
      <div className="preset-buttons">
        <Button size="sm" onClick={() => applyPreset('single_omni')}>
          Omni
        </Button>
        <Button size="sm" onClick={() => applyPreset('dual_sector')}>
          2-Sector
        </Button>
        <Button size="sm" onClick={() => applyPreset('tri_sector')}>
          3-Sector
        </Button>
      </div>
      
      {/* Individual sectors */}
      <div className="sectors-list">
        {sectors.map((sector, idx) => (
          <div key={sector.id} className="sector-item">
            <div className="sector-header">
              <input
                type="checkbox"
                checked={sector.enabled}
                onChange={(e) => updateSector(sector.id, { enabled: e.target.checked })}
              />
              <h5>Sector {idx + 1}</h5>
              {sectors.length > 1 && (
                <button 
                  onClick={() => removeSector(sector.id)}
                  className="remove-btn"
                >
                  
                </button>
              )}
            </div>
            
            {sector.enabled && (
              <>
                <Slider
                  label="Azimuth"
                  min={0}
                  max={360}
                  step={1}
                  value={sector.azimuth}
                  onChange={(v) => updateSector(sector.id, { azimuth: v })}
                  suffix="°"
                />
                
                <Slider
                  label="Beamwidth"
                  min={30}
                  max={120}
                  step={5}
                  value={sector.beamwidth}
                  onChange={(v) => updateSector(sector.id, { beamwidth: v })}
                  suffix="°"
                />
                
                <Slider
                  label="Gain"
                  min={0}
                  max={25}
                  step={1}
                  value={sector.gain}
                  onChange={(v) => updateSector(sector.id, { gain: v })}
                  suffix=" dBi"
                />
              </>
            )}
          </div>
        ))}
      </div>
      
      <Button onClick={addSector} size="sm" variant="outline">
        + Add Sector
      </Button>
    </div>
  );
}

Coverage Calculation

File: frontend/src/workers/rf-worker.js

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

// Show wedge for each sector
{site.sectors.map(sector => (
  sector.enabled && sector.beamwidth < 360 && (
    <Polygon
      key={sector.id}
      positions={generateSectorWedge(site.lat, site.lon, sector)}
      pathOptions={{
        color: site.color,
        weight: 2,
        opacity: 0.6,
        fillOpacity: 0.1,
        dashArray: '5, 5'
      }}
    />
  )
))}

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

// Frequency selector
<div className="frequency-selector">
  <label>Operating Frequency</label>
  
  <div className="band-buttons">
    <button
      className={frequency === 800 ? 'active' : ''}
      onClick={() => setFormData({ ...formData, frequency: 800 })}
    >
      800 MHz
      <small>Band 20</small>
    </button>
    
    <button
      className={frequency === 1800 ? 'active' : ''}
      onClick={() => setFormData({ ...formData, frequency: 1800 })}
    >
      1800 MHz
      <small>Band 3</small>
    </button>
    
    <button
      className={frequency === 1900 ? 'active' : ''}
      onClick={() => setFormData({ ...formData, frequency: 1900 })}
    >
      1900 MHz
      <small>Band 2</small>
    </button>
    
    <button
      className={frequency === 2100 ? 'active' : ''}
      onClick={() => setFormData({ ...formData, frequency: 2100 })}
    >
      2100 MHz
      <small>Band 1</small>
    </button>
    
    <button
      className={frequency === 2600 ? 'active' : ''}
      onClick={() => setFormData({ ...formData, frequency: 2600 })}
    >
      2600 MHz
      <small>Band 7</small>
    </button>
  </div>
  
  <input
    type="number"
    placeholder="Custom MHz..."
    value={customFreq}
    onChange={(e) => setCustomFreq(e.target.value)}
  />
</div>

{/* Band info */}
{frequency && (
  <p className="band-info">
    {getBandDescription(frequency)}
  </p>
)}

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

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:

npm install leaflet-graticule

File: frontend/src/components/map/CoordinateGrid.tsx (new)

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)

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 && (
        <Polyline
          positions={points}
          pathOptions={{ color: '#00ff00', weight: 3, dashArray: '10, 5' }}
        />
      )}
      
      {points.map((pos, idx) => (
        <Marker
          key={idx}
          position={pos}
          icon={L.divIcon({
            className: 'measurement-marker',
            html: '<div style="background: white; border: 2px solid #333; border-radius: 50%; width: 10px; height: 10px;"></div>'
          })}
        />
      ))}
      
      {totalDistance > 0 && (
        <div style={{
          position: 'absolute',
          top: '10px',
          left: '50%',
          transform: 'translateX(-50%)',
          background: 'rgba(0,0,0,0.8)',
          color: 'white',
          padding: '8px 16px',
          borderRadius: '4px',
          zIndex: 1000,
          pointerEvents: 'none'
        }}>
          📏 Distance: {totalDistance.toFixed(2)} km
        </div>
      )}
    </>
  );
}

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)

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 = `
        <svg width="50" height="50" viewBox="0 0 50 50">
          <circle cx="25" cy="25" r="22" fill="white" stroke="#333" stroke-width="2"/>
          <path d="M 25 8 L 28 18 L 25 25 L 22 18 Z" fill="#dc2626"/>
          <path d="M 25 42 L 28 32 L 25 25 L 22 32 Z" fill="white" stroke="#333"/>
          <text x="25" y="12" text-anchor="middle" font-size="12" font-weight="bold">N</text>
        </svg>
      `;
      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:

<div className="map-tools-section">
  <h3>Map Tools</h3>
  
  <label>
    <input 
      type="checkbox" 
      checked={showGrid}
      onChange={(e) => setShowGrid(e.target.checked)}
    />
    Coordinate Grid
  </label>
  
  <button
    onClick={() => setMeasurementMode(!measurementMode)}
    className={measurementMode ? 'active' : ''}
  >
    📏 {measurementMode ? 'Measuring...' : 'Measure Distance'}
  </button>
  
  <small>Click points on map. Right-click to finish.</small>
</div>

// In Map component:
<CoordinateGrid visible={showGrid} />
<MeasurementTool enabled={measurementMode} onComplete={(d) => toast.success(`${d.toFixed(2)} km`)} />
<MapExtras />

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!