@mytec: iter7.4 start
This commit is contained in:
389
RFCP-Iteration7.4-Radius-Sector-UI.md
Normal file
389
RFCP-Iteration7.4-Radius-Sector-UI.md
Normal file
@@ -0,0 +1,389 @@
|
||||
# RFCP - Iteration 7.4: Radius Fix + Sector Tree UI
|
||||
|
||||
## Issue 1: Coverage Invisible (Critical!)
|
||||
|
||||
**Problem:** Max radius clamped to 80px is TOO SMALL for geographic scale.
|
||||
|
||||
**At zoom 14:**
|
||||
- pixelsPerKm = 104,857
|
||||
- targetRadius = 0.4km
|
||||
- radiusPixels = 41,943px
|
||||
- **Clamped to 80px** ← This is TINY!
|
||||
- Should be ~200-500px for smooth coverage
|
||||
|
||||
**Root cause:** Geographic scale formula calculates HUGE pixel values at high zoom, but we clamp them down, losing all coverage!
|
||||
|
||||
### Solution A: Much Higher Clamps
|
||||
|
||||
**File:** `frontend/src/components/map/Heatmap.tsx`
|
||||
|
||||
```typescript
|
||||
const pixelsPerKm = Math.pow(2, mapZoom) * 6.4;
|
||||
const targetRadiusKm = 0.4; // 400m
|
||||
const radiusPixels = targetRadiusKm * pixelsPerKm;
|
||||
|
||||
// MUCH HIGHER clamps
|
||||
const minRadius = 20;
|
||||
const maxRadius = mapZoom < 10 ? 60 : 300; // Allow up to 300px at high zoom!
|
||||
const radius = Math.max(minRadius, Math.min(maxRadius, radiusPixels));
|
||||
|
||||
const blur = radius * 0.6;
|
||||
const maxIntensity = 0.75;
|
||||
```
|
||||
|
||||
### Solution B: Logarithmic Scaling
|
||||
|
||||
Instead of linear geographic scale, use log scale:
|
||||
|
||||
```typescript
|
||||
// Log scale: grows slower at high zoom
|
||||
const baseRadius = 30;
|
||||
const zoomFactor = Math.log2(mapZoom + 1) / Math.log2(19); // Normalize to 0-1
|
||||
const radius = baseRadius + (zoomFactor * 150); // 30px at zoom 1 → 180px at zoom 18
|
||||
|
||||
const blur = radius * 0.6;
|
||||
const maxIntensity = 0.75;
|
||||
```
|
||||
|
||||
### Solution C: Simple Progressive Formula (Recommended)
|
||||
|
||||
**Forget geographic scale - it's too complex for heatmap library!**
|
||||
|
||||
Use simple zoom-dependent formula with generous values:
|
||||
|
||||
```typescript
|
||||
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;
|
||||
|
||||
// RSRP normalization
|
||||
const normalizeRSRP = (rsrp: number): number => {
|
||||
const minRSRP = -130;
|
||||
const maxRSRP = -50;
|
||||
const normalized = (rsrp - minRSRP) / (maxRSRP - minRSRP);
|
||||
return Math.max(0, Math.min(1, normalized));
|
||||
};
|
||||
|
||||
// SIMPLE progressive formula
|
||||
// Small zoom: smaller radius (far view)
|
||||
// Large zoom: larger radius (close view)
|
||||
let radius, blur, maxIntensity;
|
||||
|
||||
if (mapZoom < 10) {
|
||||
// Far view: moderate radius
|
||||
radius = 30 + (mapZoom * 2); // 32px at zoom 1 → 50px at zoom 9
|
||||
blur = radius * 0.7;
|
||||
maxIntensity = 0.75;
|
||||
} else {
|
||||
// Close view: large radius to fill gaps
|
||||
radius = 50 + ((mapZoom - 10) * 25); // 50px at zoom 10 → 200px at zoom 16
|
||||
blur = radius * 0.6;
|
||||
maxIntensity = 0.65; // Slightly lower to prevent saturation
|
||||
}
|
||||
|
||||
const heatmapPoints = points.map(p => [
|
||||
p.lat,
|
||||
p.lon,
|
||||
normalizeRSRP(p.rsrp)
|
||||
] as [number, number, number]);
|
||||
|
||||
// Debug
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔍 Heatmap:', {
|
||||
zoom: mapZoom,
|
||||
radius: radius.toFixed(1),
|
||||
blur: blur.toFixed(1),
|
||||
maxIntensity,
|
||||
points: points.length
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ opacity }}>
|
||||
<HeatmapLayer
|
||||
points={heatmapPoints}
|
||||
longitudeExtractor={(p) => p[1]}
|
||||
latitudeExtractor={(p) => p[0]}
|
||||
intensityExtractor={(p) => p[2]}
|
||||
gradient={{
|
||||
0.0: '#1a237e',
|
||||
0.15: '#0d47a1',
|
||||
0.25: '#2196f3',
|
||||
0.35: '#00bcd4',
|
||||
0.45: '#00897b',
|
||||
0.55: '#4caf50',
|
||||
0.65: '#8bc34a',
|
||||
0.75: '#ffeb3b',
|
||||
0.85: '#ff9800',
|
||||
1.0: '#f44336',
|
||||
}}
|
||||
radius={radius}
|
||||
blur={blur}
|
||||
max={maxIntensity}
|
||||
minOpacity={0.3}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Expected results:**
|
||||
- Zoom 8: radius=46px, blur=32px → smooth coverage
|
||||
- Zoom 12: radius=100px, blur=60px → fills gaps
|
||||
- Zoom 16: radius=200px, blur=120px → no grid visible
|
||||
|
||||
---
|
||||
|
||||
## Issue 2: Sector Tree UI
|
||||
|
||||
**Problem:** Clone creates new site instead of new sector in same site.
|
||||
|
||||
**Current:**
|
||||
```
|
||||
Sites (2)
|
||||
- Station-1 (1800 MHz, 43 dBm, Sector 122°, 12m)
|
||||
- Station-1-clone (1800 MHz, 43 dBm, Sector 0°, 60m)
|
||||
```
|
||||
|
||||
**Should be:**
|
||||
```
|
||||
Sites (1)
|
||||
- Station-1 (1800 MHz, 30m)
|
||||
├─ Sector 1 (122°, 65°, 18 dBi)
|
||||
└─ Sector 2 (0°, 65°, 18 dBi)
|
||||
```
|
||||
|
||||
### Solution: Refactor Clone to Add Sector
|
||||
|
||||
**File:** `frontend/src/store/sites.ts`
|
||||
|
||||
Current `cloneSector` creates new site:
|
||||
```typescript
|
||||
const cloneSector = (siteId: string) => {
|
||||
const site = sites.find(s => s.id === siteId);
|
||||
const clone = { ...site, id: uuid(), name: `${site.name}-clone` };
|
||||
setSites([...sites, clone]);
|
||||
};
|
||||
```
|
||||
|
||||
**Change to add sector:**
|
||||
```typescript
|
||||
const cloneSector = (siteId: string, sectorId?: string) => {
|
||||
const site = sites.find(s => s.id === siteId);
|
||||
|
||||
// If sectorId provided, clone that specific sector
|
||||
// Otherwise clone the first sector
|
||||
const sourceSector = sectorId
|
||||
? site.sectors.find(s => s.id === sectorId)
|
||||
: site.sectors[0];
|
||||
|
||||
const newSector: Sector = {
|
||||
...sourceSector,
|
||||
id: `sector-${Date.now()}`,
|
||||
azimuth: (sourceSector.azimuth + 30) % 360, // Offset by 30°
|
||||
};
|
||||
|
||||
// Add sector to existing site
|
||||
updateSite(siteId, {
|
||||
sectors: [...site.sectors, newSector]
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### UI: Tree View for Sectors
|
||||
|
||||
**File:** `frontend/src/components/panels/SiteList.tsx`
|
||||
|
||||
```typescript
|
||||
export function SiteList() {
|
||||
const { sites, selectedSiteIds, toggleSiteSelection } = useSitesStore();
|
||||
const [expandedSites, setExpandedSites] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleExpand = (siteId: string) => {
|
||||
const newExpanded = new Set(expandedSites);
|
||||
if (newExpanded.has(siteId)) {
|
||||
newExpanded.delete(siteId);
|
||||
} else {
|
||||
newExpanded.add(siteId);
|
||||
}
|
||||
setExpandedSites(newExpanded);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="site-list">
|
||||
<h3>Sites ({sites.length})</h3>
|
||||
|
||||
{sites.map(site => {
|
||||
const isExpanded = expandedSites.has(site.id);
|
||||
const isSelected = selectedSiteIds.includes(site.id);
|
||||
|
||||
return (
|
||||
<div key={site.id} className="site-tree-item">
|
||||
{/* Site header */}
|
||||
<div className={cn('site-header', isSelected && 'selected')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleSiteSelection(site.id)}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => toggleExpand(site.id)}
|
||||
className="expand-btn"
|
||||
>
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</button>
|
||||
|
||||
<div className="site-info">
|
||||
<strong>{site.name}</strong>
|
||||
<small>{site.frequency} MHz · {site.height}m · {site.sectors.length} sectors</small>
|
||||
</div>
|
||||
|
||||
<button onClick={() => editSite(site.id)}>Edit</button>
|
||||
<button onClick={() => cloneSite(site.id)}>Clone Site</button>
|
||||
</div>
|
||||
|
||||
{/* Sectors (when expanded) */}
|
||||
{isExpanded && (
|
||||
<div className="sectors-tree">
|
||||
{site.sectors.map((sector, idx) => (
|
||||
<div key={sector.id} className="sector-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sector.enabled}
|
||||
onChange={() => toggleSector(site.id, sector.id)}
|
||||
/>
|
||||
|
||||
<div className="sector-info">
|
||||
<strong>Sector {idx + 1}</strong>
|
||||
{sector.beamwidth < 360 && (
|
||||
<small>
|
||||
{sector.azimuth}° · {sector.beamwidth}° · {sector.gain} dBi
|
||||
</small>
|
||||
)}
|
||||
{sector.beamwidth === 360 && (
|
||||
<small>Omni · {sector.gain} dBi</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button onClick={() => editSector(site.id, sector.id)}>
|
||||
Edit
|
||||
</button>
|
||||
<button onClick={() => cloneSector(site.id, sector.id)}>
|
||||
Clone Sector
|
||||
</button>
|
||||
{site.sectors.length > 1 && (
|
||||
<button onClick={() => removeSector(site.id, sector.id)}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => addSector(site.id)}
|
||||
className="add-sector-btn"
|
||||
>
|
||||
+ Add Sector
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Simplified Alternative (Quick Fix)
|
||||
|
||||
If tree view is too complex, just fix the clone function:
|
||||
|
||||
**File:** `frontend/src/components/panels/SiteList.tsx`
|
||||
|
||||
```typescript
|
||||
// Change button label
|
||||
<button onClick={() => cloneSector(site.id)}>
|
||||
+ Add Sector {/* was: Clone */}
|
||||
</button>
|
||||
|
||||
// Update store function
|
||||
const cloneSector = (siteId: string) => {
|
||||
const site = sites.find(s => s.id === siteId);
|
||||
const lastSector = site.sectors[site.sectors.length - 1];
|
||||
|
||||
const newSector = {
|
||||
...lastSector,
|
||||
id: `sector-${Date.now()}`,
|
||||
azimuth: (lastSector.azimuth + 120) % 360, // 120° spacing for tri-sector
|
||||
};
|
||||
|
||||
updateSite(siteId, {
|
||||
sectors: [...site.sectors, newSector]
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Heatmap Visibility:
|
||||
- [ ] Zoom 8: Coverage visible with gradient
|
||||
- [ ] Zoom 12: Full gradient blue→yellow→red
|
||||
- [ ] Zoom 16: No grid pattern, smooth coverage
|
||||
- [ ] All zoom levels show coverage extent
|
||||
|
||||
### Sector UI:
|
||||
- [ ] "Clone Sector" adds sector to SAME site
|
||||
- [ ] Site count shows correct number (1 site, 2 sectors = "Sites (1)")
|
||||
- [ ] Each sector has edit/remove buttons
|
||||
- [ ] Can enable/disable individual sectors
|
||||
|
||||
---
|
||||
|
||||
## Build & Deploy
|
||||
|
||||
```bash
|
||||
cd /opt/rfcp/frontend
|
||||
npm run build
|
||||
sudo systemctl reload caddy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commit Message
|
||||
|
||||
```
|
||||
fix(heatmap): increase radius range for visible coverage
|
||||
|
||||
- Remove geographic scale formula (too complex)
|
||||
- Use simple progressive formula: 30-50px (zoom <10), 50-200px (zoom ≥10)
|
||||
- Larger blur at high zoom to fill grid gaps
|
||||
- Coverage now visible at all zoom levels
|
||||
|
||||
fix(ui): clone creates sector not new site
|
||||
|
||||
- Changed cloneSector to add sector to existing site
|
||||
- Updated UI: "Clone Sector" instead of "Clone"
|
||||
- Site count now accurate (counts sites, not sectors)
|
||||
- Each sector independently editable/removable
|
||||
|
||||
refactor(ui): sector tree view (optional)
|
||||
|
||||
- Expandable site headers
|
||||
- Nested sector list with enable/disable toggles
|
||||
- Per-sector edit/clone/remove buttons
|
||||
- Clear visual hierarchy: Site → Sectors
|
||||
```
|
||||
|
||||
🚀 Ready for Iteration 7.4!
|
||||
Reference in New Issue
Block a user