Files
rfcp/RFCP-Iteration3-Comprehensive-Task.md
2026-01-30 09:47:00 +02:00

12 KiB

RFCP - Iteration 3: Heatmap Fix + Phase 4 Preparation

Context

RFCP is successfully deployed on VPS (https://rfcp.eliah.one) with:

  • Dark theme working
  • Terrain overlay (Topo button) working
  • Batch operations working
  • ⚠️ Heatmap gradient issue: becomes solid yellow/orange at close zoom (12+)

Current deployment: VPS-A via Caddy reverse proxy + FastAPI backend on port 8888


CRITICAL FIX: Heatmap Gradient at Close Zoom

Problem: When zooming close (level 12-16), the entire heatmap becomes solid yellow/orange instead of showing the blue→cyan→green→yellow→orange→red gradient.

Root Cause: The max parameter in leaflet.heat and RSRP normalization range cause saturation at close zoom.

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

Fix Implementation:

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

export function Heatmap({ points, visible }: 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 1: Correct RSRP range
  const normalizeRSRP = (rsrp: number): number => {
    const minRSRP = -120;
    const maxRSRP = -70;  // ← CHANGED from -60 to -70 for full range
    const normalized = (rsrp - minRSRP) / (maxRSRP - minRSRP);
    return Math.max(0, Math.min(1, normalized));
  };
  
  // CRITICAL FIX 2: Dynamic max intensity based on zoom
  const getHeatmapParams = (zoom: number) => {
    const radius = Math.max(8, Math.min(40, 50 - zoom * 2.5));
    const blur = Math.max(6, Math.min(20, 30 - zoom * 1.5));
    
    // KEY FIX: Lower max at high zoom to prevent saturation
    // zoom 6 (country): max=0.90 → smooth blend
    // zoom 10 (region): max=0.70 → medium detail
    // zoom 14 (city): max=0.50 → gradient visible
    // zoom 18 (street): max=0.30 → tight detail
    const maxIntensity = Math.max(0.3, Math.min(1.0, 1.2 - zoom * 0.05));
    
    return { radius, blur, maxIntensity };
  };
  
  const { radius, blur, maxIntensity } = getHeatmapParams(mapZoom);
  
  // Convert points to heatmap format
  const heatmapPoints = points.map(point => [
    point.lat,
    point.lon,
    normalizeRSRP(point.rsrp)
  ] as [number, number, number]);
  
  return (
    <HeatmapLayer
      points={heatmapPoints}
      longitudeExtractor={(p) => p[1]}
      latitudeExtractor={(p) => p[0]}
      intensityExtractor={(p) => p[2]}
      gradient={{
        0.0: '#0d47a1',  // Dark Blue
        0.2: '#00bcd4',  // Cyan
        0.4: '#4caf50',  // Green
        0.6: '#ffeb3b',  // Yellow
        0.8: '#ff9800',  // Orange
        1.0: '#f44336',  // Red
      }}
      radius={radius}
      blur={blur}
      max={maxIntensity}  // ← DYNAMIC based on zoom
      minOpacity={0.3}
    />
  );
}

Why This Works:

Problem: At close zoom, many heatmap points overlap in small screen space. If max=1.0 (default), they all saturate to the peak color (solid orange).

Solution: By lowering max at high zoom levels (e.g., max=0.5 at zoom 14), we "stretch" the intensity scale. Now the densely packed points map to different parts of the 0.0-1.0 range, revealing the full gradient even at close zoom.

RSRP Fix: Changing maxRSRP from -60 to -70 dBm ensures points in the -70 to -60 range (excellent signal) map to intensity 1.0 (red), not stuck at 0.8 (orange).


ADDITIONAL IMPROVEMENTS

1. Add Heatmap Opacity Slider

File: frontend/src/components/panels/CoverageSettings.tsx (or wherever settings panel is)

import { Slider } from '@/components/ui/Slider';

// In coverage store:
interface CoverageSettings {
  radius: number;
  resolution: number;
  rsrpThreshold: number;
  heatmapOpacity: number;  // NEW
}

// In UI:
<Slider
  label="Heatmap Opacity"
  min={0.3}
  max={1.0}
  step={0.1}
  value={coverageSettings.heatmapOpacity}
  onChange={(value) => updateCoverageSettings({ heatmapOpacity: value })}
  suffix=""
  help="Adjust heatmap transparency"
/>

// Pass to Heatmap component:
<Heatmap 
  points={points} 
  visible={visible}
  opacity={coverageSettings.heatmapOpacity}  // NEW
/>

// In Heatmap.tsx, wrap layer:
<div style={{ opacity: opacity }}>
  <HeatmapLayer ... />
</div>

2. Export Coverage Data (CSV/GeoJSON)

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

import { Button } from '@/components/ui/Button';
import { useCoverageStore } from '@/store/coverage';
import { toast } from '@/components/ui/Toast';

export function ExportPanel() {
  const { coveragePoints, sites } = useCoverageStore();
  
  const exportCSV = () => {
    if (coveragePoints.length === 0) {
      toast.error('No coverage data to export');
      return;
    }
    
    const csv = [
      'lat,lon,rsrp,site_id',
      ...coveragePoints.map(p => `${p.lat},${p.lon},${p.rsrp},${p.siteId}`)
    ].join('\n');
    
    const blob = new Blob([csv], { type: 'text/csv' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `coverage-${Date.now()}.csv`;
    a.click();
    
    toast.success('Exported coverage data');
  };
  
  const exportGeoJSON = () => {
    if (coveragePoints.length === 0) {
      toast.error('No coverage data to export');
      return;
    }
    
    const geojson = {
      type: 'FeatureCollection',
      features: coveragePoints.map(p => ({
        type: 'Feature',
        geometry: {
          type: 'Point',
          coordinates: [p.lon, p.lat]
        },
        properties: {
          rsrp: p.rsrp,
          siteId: p.siteId
        }
      }))
    };
    
    const blob = new Blob([JSON.stringify(geojson, null, 2)], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `coverage-${Date.now()}.geojson`;
    a.click();
    
    toast.success('Exported GeoJSON');
  };
  
  return (
    <div className="space-y-2">
      <h3 className="font-semibold">Export Coverage</h3>
      <div className="flex gap-2">
        <Button onClick={exportCSV} size="sm">
          📊 CSV
        </Button>
        <Button onClick={exportGeoJSON} size="sm">
          🗺️ GeoJSON
        </Button>
      </div>
    </div>
  );
}

3. Project Save/Load (IndexedDB)

File: frontend/src/store/projects.ts (new)

import { create } from 'zustand';
import Dexie, { Table } from 'dexie';

interface Project {
  id: string;
  name: string;
  description?: string;
  sites: Site[];
  coverageSettings: CoverageSettings;
  createdAt: number;
  updatedAt: number;
}

class ProjectDatabase extends Dexie {
  projects!: Table<Project>;
  
  constructor() {
    super('rfcp-projects');
    this.version(1).stores({
      projects: 'id, name, updatedAt'
    });
  }
}

const db = new ProjectDatabase();

interface ProjectsState {
  currentProject: Project | null;
  projects: Project[];
  
  loadProjects: () => Promise<void>;
  saveProject: (project: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>) => Promise<void>;
  loadProject: (id: string) => Promise<void>;
  deleteProject: (id: string) => Promise<void>;
}

export const useProjectsStore = create<ProjectsState>((set, get) => ({
  currentProject: null,
  projects: [],
  
  loadProjects: async () => {
    const projects = await db.projects.orderBy('updatedAt').reverse().toArray();
    set({ projects });
  },
  
  saveProject: async (projectData) => {
    const id = crypto.randomUUID();
    const now = Date.now();
    
    const project: Project = {
      ...projectData,
      id,
      createdAt: now,
      updatedAt: now
    };
    
    await db.projects.put(project);
    await get().loadProjects();
    
    toast.success(`Project "${project.name}" saved`);
  },
  
  loadProject: async (id) => {
    const project = await db.projects.get(id);
    if (!project) {
      toast.error('Project not found');
      return;
    }
    
    // Load sites and settings into their respective stores
    useSitesStore.getState().setSites(project.sites);
    useCoverageStore.getState().updateSettings(project.coverageSettings);
    
    set({ currentProject: project });
    toast.success(`Loaded project "${project.name}"`);
  },
  
  deleteProject: async (id) => {
    await db.projects.delete(id);
    await get().loadProjects();
    toast.success('Project deleted');
  }
}));

TESTING CHECKLIST

After implementing fixes:

Heatmap Gradient Test:

  • Zoom out to level 6-8: Should see smooth gradient with large coverage area
  • Zoom to level 10-12: Gradient still visible, colors distinct
  • Zoom to level 14-16: NO solid yellow/orange, full blue→red range visible
  • Zoom to level 18+: Individual points with gradient, not solid blobs

Export Test:

  • Calculate coverage for 1 site
  • Export CSV: Should download with lat,lon,rsrp,site_id columns
  • Export GeoJSON: Should be valid GeoJSON FeatureCollection
  • Open in QGIS/online viewer: Points should render correctly

Project Save/Load Test:

  • Create 2-3 sites with custom settings
  • Save project with name
  • Clear all sites
  • Load project: Sites and settings restored
  • Delete project: Removed from list

BUILD & DEPLOY

After making changes:

# Frontend
cd /opt/rfcp/frontend
npm run build

# Check dist/
ls -lah dist/

# Deploy
sudo systemctl reload caddy

# Test
curl https://rfcp.eliah.one/api/health
curl https://rfcp.eliah.one/ | head -20

# From Windows via WireGuard:
# Open: https://rfcp.eliah.one

PHASE 4 PREPARATION (Future)

Terrain Integration Stub

File: frontend/src/services/terrain.ts (new)

export interface TerrainService {
  getElevation(lat: number, lon: number): Promise<number>;
  getElevationProfile(lat1: number, lon1: number, lat2: number, lon2: number, samples: number): Promise<number[]>;
}

export class MockTerrainService implements TerrainService {
  async getElevation(lat: number, lon: number): Promise<number> {
    // Return mock flat terrain for now
    return 0;
  }
  
  async getElevationProfile(lat1: number, lon1: number, lat2: number, lon2: number, samples: number): Promise<number[]> {
    return Array(samples).fill(0);
  }
}

export class BackendTerrainService implements TerrainService {
  constructor(private apiUrl: string) {}
  
  async getElevation(lat: number, lon: number): Promise<number> {
    const response = await fetch(`${this.apiUrl}/api/terrain/elevation?lat=${lat}&lon=${lon}`);
    const data = await response.json();
    return data.elevation;
  }
  
  async getElevationProfile(lat1: number, lon1: number, lat2: number, lon2: number, samples: number): Promise<number[]> {
    const response = await fetch(
      `${this.apiUrl}/api/terrain/profile?lat1=${lat1}&lon1=${lon1}&lat2=${lat2}&lon2=${lon2}&samples=${samples}`
    );
    const data = await response.json();
    return data.profile;
  }
}

// Default to mock for now
export const terrainService: TerrainService = new MockTerrainService();

SUCCESS CRITERIA

Heatmap shows full blue→cyan→green→yellow→orange→red gradient at all zoom levels No solid yellow/orange blobs at close zoom Opacity slider works smoothly CSV export produces valid data GeoJSON export renders in QGIS Projects can be saved and loaded All existing features still work Dark theme applies to new UI elements Mobile responsive


COMMIT MESSAGE TEMPLATE

fix(heatmap): resolve gradient saturation at close zoom

- Changed maxRSRP from -60 to -70 dBm for full intensity range
- Added dynamic max intensity based on zoom level (0.3-1.0)
- Prevents solid yellow/orange at zoom 14+
- Now shows full blue→red gradient at all zoom levels

feat(export): add coverage data export (CSV/GeoJSON)

- Export calculated coverage points as CSV
- Export as GeoJSON FeatureCollection
- Compatible with QGIS and other GIS tools

feat(projects): add project save/load functionality

- Save sites + settings as named projects
- Load projects from IndexedDB
- Project management UI in sidebar

Good luck! 🚀