@mytec: iter6 start
This commit is contained in:
860
RFCP-Iteration6-Complete.md
Normal file
860
RFCP-Iteration6-Complete.md
Normal file
@@ -0,0 +1,860 @@
|
||||
# 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!
|
||||
Reference in New Issue
Block a user