@mytec: iter8. Custom Geographic Canvas Heatmap bonus
This commit is contained in:
@@ -593,3 +593,369 @@ colors at all zoom levels. Rendering optimized for our use case.
|
|||||||
```
|
```
|
||||||
|
|
||||||
🚀 Ready to implement!
|
🚀 Ready to implement!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BONUS: Sector UI Fix (for 8.1)
|
||||||
|
|
||||||
|
**Problem:** Clone creates new site instead of adding sector to existing site.
|
||||||
|
|
||||||
|
**Solution:** Fix cloneSector function + improve UI.
|
||||||
|
|
||||||
|
### Fix 1: Clone Sector (not Site)
|
||||||
|
|
||||||
|
**File:** `frontend/src/store/sites.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// CURRENT (wrong):
|
||||||
|
const cloneSector = (siteId: string) => {
|
||||||
|
const site = sites.find(s => s.id === siteId);
|
||||||
|
const clone = { ...site, id: uuid(), name: `${site.name}-clone` };
|
||||||
|
setSites([...sites, clone]); // Creates NEW site ❌
|
||||||
|
};
|
||||||
|
|
||||||
|
// FIXED (correct):
|
||||||
|
const cloneSector = (siteId: string, sectorId?: string) => {
|
||||||
|
const site = sites.find(s => s.id === siteId);
|
||||||
|
if (!site) return;
|
||||||
|
|
||||||
|
// Clone specific sector or first one
|
||||||
|
const sourceSector = sectorId
|
||||||
|
? site.sectors.find(s => s.id === sectorId)
|
||||||
|
: site.sectors[site.sectors.length - 1]; // Last sector
|
||||||
|
|
||||||
|
if (!sourceSector) return;
|
||||||
|
|
||||||
|
const newSector: Sector = {
|
||||||
|
...sourceSector,
|
||||||
|
id: `sector-${Date.now()}`,
|
||||||
|
azimuth: (sourceSector.azimuth + 120) % 360, // 120° offset for tri-sector
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add sector to SAME site ✅
|
||||||
|
updateSite(siteId, {
|
||||||
|
sectors: [...site.sectors, newSector]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Also add individual sector toggle
|
||||||
|
const toggleSector = (siteId: string, sectorId: string) => {
|
||||||
|
const site = sites.find(s => s.id === siteId);
|
||||||
|
if (!site) return;
|
||||||
|
|
||||||
|
const updatedSectors = site.sectors.map(s =>
|
||||||
|
s.id === sectorId ? { ...s, enabled: !s.enabled } : s
|
||||||
|
);
|
||||||
|
|
||||||
|
updateSite(siteId, { sectors: updatedSectors });
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix 2: Update Site Count Display
|
||||||
|
|
||||||
|
**File:** `frontend/src/components/panels/SiteList.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Show accurate count
|
||||||
|
<h3>Sites ({sites.length})</h3> {/* Not sector count! */}
|
||||||
|
|
||||||
|
// For each site, show sector count
|
||||||
|
<div className="site-info">
|
||||||
|
<strong>{site.name}</strong>
|
||||||
|
<small>
|
||||||
|
{site.frequency} MHz · {site.height}m ·
|
||||||
|
{site.sectors.length} sector{site.sectors.length > 1 ? 's' : ''}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix 3: Better Button Labels
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In site list item
|
||||||
|
<button onClick={() => cloneSector(site.id)}>
|
||||||
|
+ Add Sector {/* was: "Clone" */}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => cloneSite(site.id)}>
|
||||||
|
Clone Site {/* Duplicate entire site */}
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BONUS FEATURES (Optional - if time permits)
|
||||||
|
|
||||||
|
### 1. Heatmap Quality Settings
|
||||||
|
|
||||||
|
**File:** `frontend/src/components/panels/CoverageSettings.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<div className="heatmap-quality">
|
||||||
|
<label>Coverage Point Radius</label>
|
||||||
|
<select value={radiusMeters} onChange={(e) => setRadiusMeters(Number(e.target.value))}>
|
||||||
|
<option value={200}>200m (Fast)</option>
|
||||||
|
<option value={400}>400m (Balanced)</option>
|
||||||
|
<option value={600}>600m (Smooth)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<small>
|
||||||
|
Larger radius = smoother gradient but slower rendering
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Performance Monitor
|
||||||
|
|
||||||
|
**File:** `frontend/src/components/map/HeatmapTileRenderer.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
renderTile(...) {
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
// ... render logic
|
||||||
|
|
||||||
|
const renderTime = performance.now() - startTime;
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log(`Tile ${tileX},${tileY} rendered in ${renderTime.toFixed(1)}ms`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Progressive Loading
|
||||||
|
|
||||||
|
Show low-res preview while rendering:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
createTile(coords, done) {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
|
||||||
|
// Immediate low-res preview
|
||||||
|
renderLowRes(canvas, coords);
|
||||||
|
done(null, canvas);
|
||||||
|
|
||||||
|
// High-res in background
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
renderHighRes(canvas, coords);
|
||||||
|
});
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Export as GeoTIFF
|
||||||
|
|
||||||
|
**File:** `frontend/src/components/panels/ExportPanel.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const exportGeoTIFF = async () => {
|
||||||
|
// Generate coverage grid
|
||||||
|
const grid = generateCoverageGrid(sites, bounds);
|
||||||
|
|
||||||
|
// Convert to GeoTIFF format
|
||||||
|
const geotiff = await createGeoTIFF(grid, bounds);
|
||||||
|
|
||||||
|
// Download
|
||||||
|
const blob = new Blob([geotiff], { type: 'image/tiff' });
|
||||||
|
downloadBlob(blob, `coverage-${Date.now()}.tif`);
|
||||||
|
};
|
||||||
|
|
||||||
|
<button onClick={exportGeoTIFF}>
|
||||||
|
📥 Export GeoTIFF (QGIS)
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Heatmap Legend with Actual Colors
|
||||||
|
|
||||||
|
**File:** `frontend/src/components/map/HeatmapLegend.tsx` (new)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { valueToColor } from '@/utils/colorGradient';
|
||||||
|
|
||||||
|
export function HeatmapLegend() {
|
||||||
|
const steps = [
|
||||||
|
{ rsrp: -130, label: 'No Service' },
|
||||||
|
{ rsrp: -110, label: 'Weak' },
|
||||||
|
{ rsrp: -100, label: 'Fair' },
|
||||||
|
{ rsrp: -85, label: 'Good' },
|
||||||
|
{ rsrp: -70, label: 'Excellent' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="heatmap-legend">
|
||||||
|
<h4>Signal Strength (RSRP)</h4>
|
||||||
|
{steps.map(step => {
|
||||||
|
const normalized = (step.rsrp + 130) / 80; // -130 to -50
|
||||||
|
const [r, g, b] = valueToColor(normalized);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step.rsrp} className="legend-item">
|
||||||
|
<div
|
||||||
|
className="color-box"
|
||||||
|
style={{ backgroundColor: `rgb(${r},${g},${b})` }}
|
||||||
|
/>
|
||||||
|
<span>{step.label}</span>
|
||||||
|
<small>{step.rsrp} dBm</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Tile Load Progress Bar
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [tilesLoaded, setTilesLoaded] = useState(0);
|
||||||
|
const [tilesTotal, setTilesTotal] = useState(0);
|
||||||
|
|
||||||
|
// In GridLayer
|
||||||
|
layer.on('tileloadstart', () => setTilesTotal(prev => prev + 1));
|
||||||
|
layer.on('tileload', () => setTilesLoaded(prev => prev + 1));
|
||||||
|
|
||||||
|
// Show progress
|
||||||
|
{tilesLoaded < tilesTotal && (
|
||||||
|
<div className="loading-progress">
|
||||||
|
Loading coverage: {tilesLoaded}/{tilesTotal} tiles
|
||||||
|
<progress value={tilesLoaded} max={tilesTotal} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRITICAL IMPROVEMENTS
|
||||||
|
|
||||||
|
### A. Memory Management
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class HeatmapTileRenderer {
|
||||||
|
private cache = new Map<string, HTMLCanvasElement>();
|
||||||
|
private maxCacheSize = 100; // Limit cache size
|
||||||
|
|
||||||
|
renderTile(...) {
|
||||||
|
// ... render logic
|
||||||
|
|
||||||
|
// Clean old cache entries
|
||||||
|
if (this.cache.size > this.maxCacheSize) {
|
||||||
|
const firstKey = this.cache.keys().next().value;
|
||||||
|
this.cache.delete(firstKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCache() {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### B. Error Handling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
createTile(coords, done) {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderer.renderTile(canvas, points, coords.x, coords.y, coords.z);
|
||||||
|
done(null, canvas);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Tile render error:', error);
|
||||||
|
|
||||||
|
// Draw error tile
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
ctx.fillStyle = '#ff000020';
|
||||||
|
ctx.fillRect(0, 0, 256, 256);
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.font = '12px monospace';
|
||||||
|
ctx.fillText('Render Error', 10, 128);
|
||||||
|
|
||||||
|
done(null, canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### C. Debug Overlay
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Show tile boundaries in dev mode
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
ctx.strokeStyle = '#ff0000';
|
||||||
|
ctx.strokeRect(0, 0, 256, 256);
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.font = '10px monospace';
|
||||||
|
ctx.fillText(`${tileX},${tileY},${zoom}`, 5, 15);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TESTING ADDITIONS
|
||||||
|
|
||||||
|
### Geographic Accuracy Test
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Add to Map.tsx for testing
|
||||||
|
const [testMode, setTestMode] = useState(false);
|
||||||
|
|
||||||
|
{testMode && (
|
||||||
|
<>
|
||||||
|
{/* 400m circle for comparison */}
|
||||||
|
<Circle
|
||||||
|
center={[48.71, 35.07]}
|
||||||
|
radius={400} // meters
|
||||||
|
pathOptions={{ color: '#ff0000', weight: 2, fillOpacity: 0 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Coverage point at same location */}
|
||||||
|
<Marker position={[48.71, 35.07]}>
|
||||||
|
<Popup>
|
||||||
|
Test point: coverage radius should match red circle (400m)
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## README ADDITIONS
|
||||||
|
|
||||||
|
Document the custom heatmap:
|
||||||
|
|
||||||
|
**File:** `frontend/README.md`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Custom Geographic Heatmap
|
||||||
|
|
||||||
|
RFCP uses a custom Canvas-based heatmap renderer for accurate geographic coverage visualization.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- True geographic scale (400m radius constant across zoom levels)
|
||||||
|
- Zoom-independent colors (same RSRP = same color always)
|
||||||
|
- Optimized tile rendering with caching
|
||||||
|
- Gaussian blur for smooth gradients
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- `GeographicHeatmap.tsx` - React/Leaflet integration
|
||||||
|
- `HeatmapTileRenderer.ts` - Canvas rendering logic
|
||||||
|
- `geographicScale.ts` - Coordinate transformation
|
||||||
|
- `colorGradient.ts` - RSRP to color mapping
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
Adjust coverage point radius in `HeatmapTileRenderer.ts`:
|
||||||
|
```typescript
|
||||||
|
private radiusMeters = 400; // Coverage point radius
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Tile caching enabled (100 tile limit)
|
||||||
|
- Typical render time: 10-50ms per tile
|
||||||
|
- Smooth at 60fps during pan/zoom
|
||||||
|
```
|
||||||
|
|
||||||
|
🚀 Ready to implement!
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user