# 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:

```typescript
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)

```typescript
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)

```typescript
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)

```typescript
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:

```bash
# 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)

```typescript
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! 🚀
