{sector.enabled && (
<>
updateSector(sector.id, { azimuth: v })}
suffix="°"
/>
updateSector(sector.id, { beamwidth: v })}
suffix="°"
/>
updateSector(sector.id, { gain: v })}
suffix=" dBi"
/>
>
)}
))}
);
}
```
### 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 && (
)
))}
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
setCustomFreq(e.target.value)}
/>
{/* Band info */}
{frequency && (
{getBandDescription(frequency)}
)}
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 && (
)}
{points.map((pos, idx) => (
'
})}
/>
))}
{totalDistance > 0 && (
📏 Distance: {totalDistance.toFixed(2)} km
)}
>
);
}
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 = `
`;
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
Map Tools
Click points on map. Right-click to finish.
// In Map component:
toast.success(`${d.toFixed(2)} km`)} />
```
---
## 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!