@mytec: iter3 start
This commit is contained in:
473
RFCP-Iteration3-Comprehensive-Task.md
Normal file
473
RFCP-Iteration3-Comprehensive-Task.md
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
# 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! 🚀
|
||||||
Reference in New Issue
Block a user