Files
rfcp/RFCP-Iteration7-Elevation-UX.md
2026-01-30 12:53:06 +02:00

14 KiB

RFCP - Iteration 7: Elevation Data + UX Polish

Issues from Iteration 6

  1. Omni circle visible - orange circle shows for omni antennas, should be hidden
  2. Zoom still breaks gradient - colors shift with zoom (maxIntensity not working?)
  3. No elevation visualization - can't see hills/mountains on map
  4. Sector cloning creates 3 sectors - should clone 1 at a time

QUICK FIX 1: Hide Omni Coverage Circle

Problem: Orange circle shows for omni antennas (360° beamwidth).

Solution: Only draw sector wedge for directional antennas (beamwidth < 360).

File: frontend/src/components/map/SiteMarker.tsx

// Only show wedge for directional sectors
{site.sectors.map(sector => (
  sector.enabled && sector.beamwidth < 360 && ( // ← ADD THIS CHECK
    <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'
      }}
    />
  )
))}

QUICK FIX 2: Debug Heatmap Zoom Issue

Problem: Colors still change with zoom despite maxIntensity=0.75 fix.

Debug first:

File: frontend/src/components/map/Heatmap.tsx

// Add detailed logging
useEffect(() => {
  if (import.meta.env.DEV && points.length > 0) {
    const rsrpValues = points.map(p => p.rsrp);
    const normalizedSample = points.slice(0, 5).map(p => ({
      rsrp: p.rsrp,
      normalized: normalizeRSRP(p.rsrp)
    }));
    
    console.log('🔍 Heatmap Debug:', {
      zoom: mapZoom,
      totalPoints: points.length,
      rsrpRange: `${Math.min(...rsrpValues).toFixed(1)} to ${Math.max(...rsrpValues).toFixed(1)} dBm`,
      radius,
      blur,
      maxIntensity, // ← Should be 0.75
      sample: normalizedSample
    });
  }
}, [points, mapZoom]);

Possible issue: If maxIntensity is still a formula instead of constant 0.75, replace it:

// MUST be constant!
const maxIntensity = 0.75; // NOT a formula!

QUICK FIX 3: Clone Single Sector

Problem: Tri-sector preset creates 3 sectors at once. User wants to clone one sector at a time.

Solution: Add "Clone Sector" button per sector.

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

const cloneSector = (sector: Sector) => {
  const newSector: Sector = {
    ...sector,
    id: `s${Date.now()}`, // Unique ID
    azimuth: (sector.azimuth + 30) % 360 // Offset by 30°
  };
  onUpdate([...sectors, newSector]);
};

// In sector item UI:
<div className="sector-actions">
  <button onClick={() => cloneSector(sector)} title="Clone this sector">
    📋 Clone
  </button>
  {sectors.length > 1 && (
    <button onClick={() => removeSector(sector.id)}>
      🗑️ Remove
    </button>
  )}
</div>

FEATURE 1: Elevation Visualization

What we want:

  1. See elevation (meters above sea level) when hovering cursor
  2. Color-coded elevation overlay on map
  3. Elevation profile along measurement line

A. Cursor Elevation Display

Option 1: Use Open-Elevation API (free, no auth)

File: frontend/src/hooks/useElevation.ts (new)

import { useState, useEffect } from 'react';
import { useMap } from 'react-leaflet';
import L from 'leaflet';

export function useElevation() {
  const map = useMap();
  const [elevation, setElevation] = useState<number | null>(null);
  const [position, setPosition] = useState<{ lat: number; lon: number } | null>(null);
  const [loading, setLoading] = useState(false);
  
  useEffect(() => {
    let timeoutId: number;
    let abortController: AbortController;
    
    const handleMouseMove = (e: L.LeafletMouseEvent) => {
      setPosition({ lat: e.latlng.lat, lon: e.latlng.lng });
      
      // Debounce API calls (300ms)
      clearTimeout(timeoutId);
      if (abortController) abortController.abort();
      
      timeoutId = window.setTimeout(async () => {
        setLoading(true);
        abortController = new AbortController();
        
        try {
          const response = await fetch(
            `https://api.open-elevation.com/api/v1/lookup?locations=${e.latlng.lat},${e.latlng.lng}`,
            { signal: abortController.signal }
          );
          const data = await response.json();
          setElevation(data.results[0].elevation);
        } catch (error) {
          if (error.name !== 'AbortError') {
            console.error('Elevation fetch failed:', error);
            setElevation(null);
          }
        } finally {
          setLoading(false);
        }
      }, 300);
    };
    
    map.on('mousemove', handleMouseMove);
    
    return () => {
      map.off('mousemove', handleMouseMove);
      clearTimeout(timeoutId);
      if (abortController) abortController.abort();
    };
  }, [map]);
  
  return { elevation, position, loading };
}

Display Component:

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

import { useElevation } from '@/hooks/useElevation';

export function ElevationDisplay() {
  const { elevation, position, loading } = useElevation();
  
  if (!position) return null;
  
  return (
    <div className="elevation-display" style={{
      position: 'absolute',
      bottom: '40px',
      left: '10px',
      background: 'rgba(0,0,0,0.8)',
      color: 'white',
      padding: '8px 12px',
      borderRadius: '4px',
      fontSize: '12px',
      zIndex: 1000,
      pointerEvents: 'none'
    }}>
      <div>📍 {position.lat.toFixed(5)}°, {position.lon.toFixed(5)}°</div>
      <div>
        ⛰️ {loading ? 'Loading...' : elevation !== null ? `${elevation}m ASL` : 'N/A'}
      </div>
    </div>
  );
}

Usage in Map.tsx:

import { ElevationDisplay } from './ElevationDisplay';

{showElevationInfo && <ElevationDisplay />}

B. Elevation Color Overlay

Option: Use Stamen Terrain (color-coded by elevation)

File: frontend/src/components/map/Map.tsx

{showElevationOverlay && (
  <TileLayer
    url="https://tiles.stadiamaps.com/tiles/stamen_terrain/{z}/{x}/{y}.png"
    attribution='&copy; Stamen Design'
    opacity={0.5}
    zIndex={97}
  />
)}

Add control:

<label>
  <input 
    type="checkbox" 
    checked={showElevationOverlay}
    onChange={(e) => setShowElevationOverlay(e.target.checked)}
  />
  Elevation Colors
</label>

FEATURE 2: Site Templates & Quick Actions

What: Preset site configurations for common deployments.

File: frontend/src/components/panels/SiteTemplates.tsx (new)

const SITE_TEMPLATES = {
  urban_macro: {
    name: 'Urban Macro Site',
    height: 30,
    power: 43,
    frequency: 1800,
    sectors: [
      { azimuth: 0, beamwidth: 65, gain: 18 },
      { azimuth: 120, beamwidth: 65, gain: 18 },
      { azimuth: 240, beamwidth: 65, gain: 18 }
    ]
  },
  rural_tower: {
    name: 'Rural Tower',
    height: 50,
    power: 46,
    frequency: 800,
    sectors: [
      { azimuth: 0, beamwidth: 360, gain: 8 } // Omni
    ]
  },
  small_cell: {
    name: 'Small Cell',
    height: 6,
    power: 30,
    frequency: 2600,
    sectors: [
      { azimuth: 0, beamwidth: 90, gain: 12 }
    ]
  },
  indoor_das: {
    name: 'Indoor DAS',
    height: 3,
    power: 23,
    frequency: 2100,
    sectors: [
      { azimuth: 0, beamwidth: 360, gain: 2 }
    ]
  }
};

export function SiteTemplates({ onApply }: { onApply: (template: any) => void }) {
  return (
    <div className="site-templates">
      <h4>Quick Templates</h4>
      <div className="template-grid">
        {Object.entries(SITE_TEMPLATES).map(([key, template]) => (
          <button
            key={key}
            onClick={() => onApply(template)}
            className="template-btn"
          >
            {template.name}
          </button>
        ))}
      </div>
    </div>
  );
}

FEATURE 3: Coverage Analysis Tools

A. Best Server Map

What: Show which site provides best signal at each point (Voronoi-like).

Implementation: Color each point by siteId instead of RSRP.

File: frontend/src/store/coverage.ts

interface CoverageState {
  // ...existing
  viewMode: 'signal' | 'best-server'; // NEW
}

// In coverage calculation:
if (viewMode === 'best-server') {
  // Color by siteId instead of RSRP
  return { lat, lon, siteId, rsrp };
}

B. Coverage Statistics

What: Show coverage area, population, percentages.

File: frontend/src/components/panels/CoverageStats.tsx (new)

export function CoverageStats({ points, sites }: Props) {
  const totalArea = calculateArea(points); // km²
  const coverageByLevel = {
    excellent: points.filter(p => p.rsrp > -70).length,
    good: points.filter(p => p.rsrp > -85 && p.rsrp <= -70).length,
    fair: points.filter(p => p.rsrp > -100 && p.rsrp <= -85).length,
    weak: points.filter(p => p.rsrp <= -100).length
  };
  
  return (
    <div className="coverage-stats">
      <h4>Coverage Analysis</h4>
      
      <div className="stat-item">
        <span>Total Coverage Area:</span>
        <strong>{totalArea.toFixed(1)} km²</strong>
      </div>
      
      <div className="stat-item">
        <span>Excellent (&gt; -70 dBm):</span>
        <strong>{(coverageByLevel.excellent / points.length * 100).toFixed(1)}%</strong>
      </div>
      
      <div className="stat-item">
        <span>Good (-85 to -70 dBm):</span>
        <strong>{(coverageByLevel.good / points.length * 100).toFixed(1)}%</strong>
      </div>
      
      <div className="stat-item">
        <span>Fair (-100 to -85 dBm):</span>
        <strong>{(coverageByLevel.fair / points.length * 100).toFixed(1)}%</strong>
      </div>
      
      <div className="stat-item">
        <span>Weak (&lt; -100 dBm):</span>
        <strong>{(coverageByLevel.weak / points.length * 100).toFixed(1)}%</strong>
      </div>
    </div>
  );
}

FEATURE 4: Import/Export Sites

What: Save/load site configurations as JSON/CSV.

File: frontend/src/components/panels/SiteImportExport.tsx (new)

export function SiteImportExport() {
  const { sites, setSites } = useSitesStore();
  
  const exportSites = () => {
    const json = JSON.stringify(sites, null, 2);
    const blob = new Blob([json], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `rfcp-sites-${Date.now()}.json`;
    a.click();
    toast.success('Sites exported');
  };
  
  const importSites = async (file: File) => {
    try {
      const text = await file.text();
      const imported = JSON.parse(text);
      setSites(imported);
      toast.success(`Imported ${imported.length} sites`);
    } catch (error) {
      toast.error('Invalid file format');
    }
  };
  
  return (
    <div className="site-import-export">
      <h4>Import/Export</h4>
      
      <button onClick={exportSites}>
        📥 Export Sites (JSON)
      </button>
      
      <input
        type="file"
        accept=".json"
        onChange={(e) => e.target.files?.[0] && importSites(e.target.files[0])}
      />
    </div>
  );
}

FEATURE 5: 3D Terrain View (Future - Phase 4)

What: Optional 3D view with MapLibre GL + terrain data.

Teaser for later:

  • MapLibre GL for 3D rendering
  • Real terrain elevation from SRTM files
  • Line-of-sight visualization
  • 3D buildings in cities

TESTING CHECKLIST (Iteration 7)

Quick Fixes:

  • Omni coverage circle hidden (only sectors show wedges)
  • Zoom gradient: Check console - maxIntensity=0.75 always?
  • Clone sector: Creates 1 sector (not 3)

Elevation:

  • Hover cursor shows elevation (meters ASL)
  • Elevation overlay shows color-coded terrain
  • Elevation display updates smoothly (debounced)

Templates:

  • Apply "Urban Macro" template → 3 sectors created
  • Apply "Rural Tower" → 1 omni sector
  • Apply "Small Cell" → appropriate settings

Coverage Stats:

  • Shows total coverage area in km²
  • Shows percentage by signal level
  • Updates after recalculation

Import/Export:

  • Export sites → downloads JSON file
  • Import JSON → restores sites correctly
  • Invalid file shows error

BUILD & DEPLOY

cd /opt/rfcp/frontend
npm run build
sudo systemctl reload caddy

COMMIT MESSAGE

fix(ui): hide omni coverage circle visualization

- Only show sector wedges for beamwidth < 360
- Omni antennas (360°) no longer show orange circle

fix(heatmap): debug zoom-dependent gradient issue

- Added detailed console logging
- Verify maxIntensity is constant 0.75

feat(ux): clone single sector instead of tri-sector

- Added "Clone Sector" button per sector
- Creates duplicate with 30° azimuth offset
- Removed automatic tri-sector creation

feat(elevation): cursor elevation display

- Shows elevation (meters ASL) at cursor position
- Uses Open-Elevation API with debouncing
- Optional elevation color overlay (Stamen Terrain)

feat(templates): site configuration templates

- Urban Macro (3-sector, 30m, 1800 MHz)
- Rural Tower (omni, 50m, 800 MHz)
- Small Cell (single sector, 6m, 2600 MHz)
- Indoor DAS (omni, 3m, 2100 MHz)

feat(analysis): coverage statistics panel

- Total coverage area in km²
- Signal quality breakdown (excellent/good/fair/weak)
- Percentage distribution

feat(io): import/export site configurations

- Export sites as JSON
- Import sites from JSON file
- Preserves all site and sector settings

Iteration 7 Summary

Quick Fixes:

  1. Hide omni circle
  2. Debug/fix zoom gradient
  3. Clone 1 sector (not 3)

New Features: 4. Elevation display (cursor + overlay) 5. Site templates (4 presets) 6. Coverage statistics 7. Import/Export sites

Future (Iteration 8?):

  • 3D terrain view (MapLibre GL)
  • Line-of-sight analysis
  • Population coverage estimation
  • Network capacity planning

🚀 Ready for Iteration 7!