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

861 lines
23 KiB
Markdown

# 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`
```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;
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`
```typescript
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)
```typescript
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`
```javascript
// 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`
```typescript
// 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`
```typescript
// 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
```bash
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:**
```bash
npm install leaflet-graticule
```
**File:** `frontend/src/components/map/CoordinateGrid.tsx` (new)
```typescript
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)
```typescript
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)
```typescript
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:**
```typescript
<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!