962 lines
23 KiB
Markdown
962 lines
23 KiB
Markdown
# RFCP - Iteration 8: Custom Geographic Canvas Heatmap
|
|
|
|
## Overview
|
|
|
|
Replace `leaflet-heatmap` with custom Canvas-based renderer that maintains true geographic scale.
|
|
|
|
**Goal:** 400m radius coverage point = always 400m on ground, regardless of zoom.
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
```
|
|
frontend/src/components/map/
|
|
├── GeographicHeatmap.tsx # React component (Leaflet integration)
|
|
├── HeatmapTileRenderer.ts # Canvas tile rendering logic
|
|
└── utils/
|
|
├── geographicScale.ts # Meters ↔ Pixels conversion
|
|
└── colorGradient.ts # RSRP → Color mapping
|
|
```
|
|
|
|
---
|
|
|
|
## Step 1: Geographic Scale Utils
|
|
|
|
**File:** `frontend/src/utils/geographicScale.ts`
|
|
|
|
```typescript
|
|
// Earth constants
|
|
const EARTH_RADIUS_KM = 6371;
|
|
const EARTH_CIRCUMFERENCE_M = 40075017;
|
|
|
|
/**
|
|
* Calculate pixels per meter at given latitude and zoom level
|
|
* Uses Web Mercator projection (EPSG:3857)
|
|
*/
|
|
export function getPixelsPerMeter(lat: number, zoom: number): number {
|
|
// Tile size in pixels
|
|
const tileSize = 256;
|
|
|
|
// Number of tiles at this zoom level
|
|
const numTiles = Math.pow(2, zoom);
|
|
|
|
// World width in pixels at this zoom
|
|
const worldWidthPixels = tileSize * numTiles;
|
|
|
|
// Meters per pixel at equator
|
|
const metersPerPixelEquator = EARTH_CIRCUMFERENCE_M / worldWidthPixels;
|
|
|
|
// Adjust for latitude (Mercator distortion)
|
|
const latRad = lat * Math.PI / 180;
|
|
const metersPerPixel = metersPerPixelEquator * Math.cos(latRad);
|
|
|
|
return 1 / metersPerPixel; // Return pixels per meter
|
|
}
|
|
|
|
/**
|
|
* Convert geographic radius (meters) to pixel radius at zoom level
|
|
*/
|
|
export function metersToPixels(meters: number, lat: number, zoom: number): number {
|
|
const pixelsPerMeter = getPixelsPerMeter(lat, zoom);
|
|
return meters * pixelsPerMeter;
|
|
}
|
|
|
|
/**
|
|
* Get tile bounds in lat/lon for given tile coordinates
|
|
*/
|
|
export function getTileBounds(x: number, y: number, zoom: number): [[number, number], [number, number]] {
|
|
const n = Math.pow(2, zoom);
|
|
|
|
const lonMin = (x / n) * 360 - 180;
|
|
const lonMax = ((x + 1) / n) * 360 - 180;
|
|
|
|
const latMin = Math.atan(Math.sinh(Math.PI * (1 - 2 * (y + 1) / n))) * 180 / Math.PI;
|
|
const latMax = Math.atan(Math.sinh(Math.PI * (1 - 2 * y / n))) * 180 / Math.PI;
|
|
|
|
return [[latMin, lonMin], [latMax, lonMax]];
|
|
}
|
|
|
|
/**
|
|
* Convert lat/lon to pixel position within tile
|
|
*/
|
|
export function latLonToTilePixel(
|
|
lat: number,
|
|
lon: number,
|
|
tileX: number,
|
|
tileY: number,
|
|
zoom: number
|
|
): [number, number] {
|
|
const n = Math.pow(2, zoom);
|
|
|
|
// Tile's top-left corner in world coordinates
|
|
const tileWorldX = tileX;
|
|
const tileWorldY = tileY;
|
|
|
|
// Point's world coordinates
|
|
const worldX = (lon + 180) / 360 * n;
|
|
const latRad = lat * Math.PI / 180;
|
|
const worldY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n;
|
|
|
|
// Offset within tile
|
|
const pixelX = (worldX - tileWorldX) * 256;
|
|
const pixelY = (worldY - tileWorldY) * 256;
|
|
|
|
return [pixelX, pixelY];
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Step 2: Color Gradient Utils
|
|
|
|
**File:** `frontend/src/utils/colorGradient.ts`
|
|
|
|
```typescript
|
|
interface ColorStop {
|
|
value: number; // 0-1
|
|
color: string; // hex
|
|
}
|
|
|
|
const GRADIENT_STOPS: ColorStop[] = [
|
|
{ value: 0.0, color: '#1a237e' }, // -130 dBm (dark blue)
|
|
{ value: 0.15, color: '#0d47a1' },
|
|
{ value: 0.25, color: '#2196f3' },
|
|
{ value: 0.35, color: '#00bcd4' }, // Cyan
|
|
{ value: 0.45, color: '#00897b' },
|
|
{ value: 0.55, color: '#4caf50' }, // Green
|
|
{ value: 0.65, color: '#8bc34a' },
|
|
{ value: 0.75, color: '#ffeb3b' }, // Yellow
|
|
{ value: 0.85, color: '#ff9800' }, // Orange
|
|
{ value: 1.0, color: '#f44336' }, // -50 dBm (red)
|
|
];
|
|
|
|
/**
|
|
* Normalize RSRP to 0-1 range
|
|
*/
|
|
export function normalizeRSRP(rsrp: number): number {
|
|
const minRSRP = -130;
|
|
const maxRSRP = -50;
|
|
const normalized = (rsrp - minRSRP) / (maxRSRP - minRSRP);
|
|
return Math.max(0, Math.min(1, normalized));
|
|
}
|
|
|
|
/**
|
|
* Convert normalized value (0-1) to RGB color
|
|
*/
|
|
export function valueToColor(value: number): [number, number, number] {
|
|
// Find surrounding gradient stops
|
|
let lowerStop = GRADIENT_STOPS[0];
|
|
let upperStop = GRADIENT_STOPS[GRADIENT_STOPS.length - 1];
|
|
|
|
for (let i = 0; i < GRADIENT_STOPS.length - 1; i++) {
|
|
if (value >= GRADIENT_STOPS[i].value && value <= GRADIENT_STOPS[i + 1].value) {
|
|
lowerStop = GRADIENT_STOPS[i];
|
|
upperStop = GRADIENT_STOPS[i + 1];
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Interpolate between stops
|
|
const range = upperStop.value - lowerStop.value;
|
|
const t = (value - lowerStop.value) / range;
|
|
|
|
const lower = hexToRgb(lowerStop.color);
|
|
const upper = hexToRgb(upperStop.color);
|
|
|
|
return [
|
|
Math.round(lower[0] + (upper[0] - lower[0]) * t),
|
|
Math.round(lower[1] + (upper[1] - lower[1]) * t),
|
|
Math.round(lower[2] + (upper[2] - lower[2]) * t),
|
|
];
|
|
}
|
|
|
|
function hexToRgb(hex: string): [number, number, number] {
|
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
return result ? [
|
|
parseInt(result[1], 16),
|
|
parseInt(result[2], 16),
|
|
parseInt(result[3], 16),
|
|
] : [0, 0, 0];
|
|
}
|
|
|
|
/**
|
|
* Apply gaussian blur to coverage value based on distance
|
|
*/
|
|
export function gaussianBlur(distance: number, radius: number, sigma?: number): number {
|
|
const s = sigma || radius / 3;
|
|
const exponent = -(distance * distance) / (2 * s * s);
|
|
return Math.exp(exponent);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Step 3: Tile Renderer
|
|
|
|
**File:** `frontend/src/components/map/HeatmapTileRenderer.ts`
|
|
|
|
```typescript
|
|
import { metersToPixels, latLonToTilePixel, getTileBounds } from '@/utils/geographicScale';
|
|
import { normalizeRSRP, valueToColor, gaussianBlur } from '@/utils/colorGradient';
|
|
|
|
interface CoveragePoint {
|
|
lat: number;
|
|
lon: number;
|
|
rsrp: number;
|
|
siteId: string;
|
|
}
|
|
|
|
export class HeatmapTileRenderer {
|
|
private tileSize = 256;
|
|
private radiusMeters = 400; // Fixed geographic radius
|
|
|
|
/**
|
|
* Render a single tile
|
|
*/
|
|
renderTile(
|
|
canvas: HTMLCanvasElement,
|
|
points: CoveragePoint[],
|
|
tileX: number,
|
|
tileY: number,
|
|
zoom: number
|
|
): void {
|
|
const ctx = canvas.getContext('2d')!;
|
|
canvas.width = this.tileSize;
|
|
canvas.height = this.tileSize;
|
|
|
|
// Clear canvas
|
|
ctx.clearRect(0, 0, this.tileSize, this.tileSize);
|
|
|
|
// Get tile bounds
|
|
const [[latMin, lonMin], [latMax, lonMax]] = getTileBounds(tileX, tileY, zoom);
|
|
|
|
// Filter points that could affect this tile
|
|
const relevantPoints = this.getRelevantPoints(points, latMin, latMax, lonMin, lonMax, zoom);
|
|
|
|
if (relevantPoints.length === 0) return;
|
|
|
|
// Create accumulation buffers
|
|
const intensityMap = new Float32Array(this.tileSize * this.tileSize);
|
|
const maxIntensity = new Float32Array(this.tileSize * this.tileSize);
|
|
|
|
// For each point, accumulate intensity
|
|
for (const point of relevantPoints) {
|
|
const [pixelX, pixelY] = latLonToTilePixel(point.lat, point.lon, tileX, tileY, zoom);
|
|
const radiusPixels = metersToPixels(this.radiusMeters, point.lat, zoom);
|
|
|
|
// Draw point influence
|
|
this.drawPoint(intensityMap, maxIntensity, point, pixelX, pixelY, radiusPixels);
|
|
}
|
|
|
|
// Render to canvas
|
|
this.renderToCanvas(ctx, intensityMap, maxIntensity);
|
|
}
|
|
|
|
/**
|
|
* Filter points that could affect this tile
|
|
*/
|
|
private getRelevantPoints(
|
|
points: CoveragePoint[],
|
|
latMin: number,
|
|
latMax: number,
|
|
lonMin: number,
|
|
lonMax: number,
|
|
zoom: number
|
|
): CoveragePoint[] {
|
|
// Add buffer for radius
|
|
const bufferDegrees = (this.radiusMeters / 111000) * 2; // Rough: 111km per degree
|
|
|
|
return points.filter(p =>
|
|
p.lat >= latMin - bufferDegrees &&
|
|
p.lat <= latMax + bufferDegrees &&
|
|
p.lon >= lonMin - bufferDegrees &&
|
|
p.lon <= lonMax + bufferDegrees
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Draw single point's influence on intensity map
|
|
*/
|
|
private drawPoint(
|
|
intensityMap: Float32Array,
|
|
maxIntensity: Float32Array,
|
|
point: CoveragePoint,
|
|
centerX: number,
|
|
centerY: number,
|
|
radiusPixels: number
|
|
): void {
|
|
const normalizedValue = normalizeRSRP(point.rsrp);
|
|
|
|
// Calculate bounding box
|
|
const minX = Math.max(0, Math.floor(centerX - radiusPixels));
|
|
const maxX = Math.min(this.tileSize, Math.ceil(centerX + radiusPixels));
|
|
const minY = Math.max(0, Math.floor(centerY - radiusPixels));
|
|
const maxY = Math.min(this.tileSize, Math.ceil(centerY + radiusPixels));
|
|
|
|
// For each pixel in radius
|
|
for (let y = minY; y < maxY; y++) {
|
|
for (let x = minX; x < maxX; x++) {
|
|
const dx = x - centerX;
|
|
const dy = y - centerY;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
if (distance > radiusPixels) continue;
|
|
|
|
// Apply gaussian blur
|
|
const blur = gaussianBlur(distance, radiusPixels);
|
|
const intensity = normalizedValue * blur;
|
|
|
|
const idx = y * this.tileSize + x;
|
|
|
|
// Accumulate intensity (additive blending)
|
|
intensityMap[idx] += intensity;
|
|
maxIntensity[idx] = Math.max(maxIntensity[idx], intensity);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render intensity map to canvas
|
|
*/
|
|
private renderToCanvas(
|
|
ctx: CanvasRenderingContext2D,
|
|
intensityMap: Float32Array,
|
|
maxIntensity: Float32Array
|
|
): void {
|
|
const imageData = ctx.createImageData(this.tileSize, this.tileSize);
|
|
const data = imageData.data;
|
|
|
|
for (let i = 0; i < intensityMap.length; i++) {
|
|
const intensity = intensityMap[i];
|
|
|
|
if (intensity > 0) {
|
|
// Normalize intensity (clamp to 0-1)
|
|
const normalizedIntensity = Math.min(1, intensity);
|
|
|
|
// Get color
|
|
const [r, g, b] = valueToColor(normalizedIntensity);
|
|
|
|
// Calculate alpha based on intensity
|
|
const alpha = Math.min(255, intensity * 200); // Adjust opacity
|
|
|
|
const idx = i * 4;
|
|
data[idx] = r;
|
|
data[idx + 1] = g;
|
|
data[idx + 2] = b;
|
|
data[idx + 3] = alpha;
|
|
}
|
|
}
|
|
|
|
ctx.putImageData(imageData, 0, 0);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Step 4: Leaflet Integration
|
|
|
|
**File:** `frontend/src/components/map/GeographicHeatmap.tsx`
|
|
|
|
```typescript
|
|
import { useEffect, useRef } from 'react';
|
|
import { useMap } from 'react-leaflet';
|
|
import L from 'leaflet';
|
|
import { HeatmapTileRenderer } from './HeatmapTileRenderer';
|
|
|
|
interface GeographicHeatmapProps {
|
|
points: Array<{
|
|
lat: number;
|
|
lon: number;
|
|
rsrp: number;
|
|
siteId: string;
|
|
}>;
|
|
visible: boolean;
|
|
opacity?: number;
|
|
}
|
|
|
|
export function GeographicHeatmap({ points, visible, opacity = 0.7 }: GeographicHeatmapProps) {
|
|
const map = useMap();
|
|
const layerRef = useRef<L.GridLayer | null>(null);
|
|
const rendererRef = useRef(new HeatmapTileRenderer());
|
|
|
|
useEffect(() => {
|
|
if (!visible) {
|
|
if (layerRef.current) {
|
|
map.removeLayer(layerRef.current);
|
|
layerRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Create custom tile layer
|
|
const HeatmapLayer = L.GridLayer.extend({
|
|
createTile: function(coords: L.Coords, done: (error: Error | null, tile: HTMLElement) => void) {
|
|
const canvas = document.createElement('canvas');
|
|
|
|
// Render tile
|
|
try {
|
|
rendererRef.current.renderTile(
|
|
canvas,
|
|
points,
|
|
coords.x,
|
|
coords.y,
|
|
coords.z
|
|
);
|
|
done(null, canvas);
|
|
} catch (error) {
|
|
console.error('Tile render error:', error);
|
|
done(error as Error, canvas);
|
|
}
|
|
|
|
return canvas;
|
|
}
|
|
});
|
|
|
|
// Add to map
|
|
const layer = new HeatmapLayer({
|
|
opacity,
|
|
zIndex: 200,
|
|
});
|
|
|
|
layer.addTo(map);
|
|
layerRef.current = layer;
|
|
|
|
return () => {
|
|
if (layerRef.current) {
|
|
map.removeLayer(layerRef.current);
|
|
}
|
|
};
|
|
}, [map, points, visible, opacity]);
|
|
|
|
// Update opacity
|
|
useEffect(() => {
|
|
if (layerRef.current) {
|
|
layerRef.current.setOpacity(opacity);
|
|
}
|
|
}, [opacity]);
|
|
|
|
// Redraw on points change
|
|
useEffect(() => {
|
|
if (layerRef.current && visible) {
|
|
layerRef.current.redraw();
|
|
}
|
|
}, [points, visible]);
|
|
|
|
return null;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Step 5: Replace Old Heatmap
|
|
|
|
**File:** `frontend/src/components/map/Map.tsx`
|
|
|
|
```typescript
|
|
// REMOVE:
|
|
// import { Heatmap } from './Heatmap';
|
|
|
|
// ADD:
|
|
import { GeographicHeatmap } from './GeographicHeatmap';
|
|
|
|
// In Map component:
|
|
<GeographicHeatmap
|
|
points={coveragePoints}
|
|
visible={showCoverage}
|
|
opacity={heatmapOpacity}
|
|
/>
|
|
```
|
|
|
|
---
|
|
|
|
## Performance Optimizations
|
|
|
|
### 1. Tile Caching
|
|
|
|
```typescript
|
|
class HeatmapTileRenderer {
|
|
private cache = new Map<string, HTMLCanvasElement>();
|
|
|
|
renderTile(...) {
|
|
const cacheKey = `${tileX}-${tileY}-${zoom}-${points.length}`;
|
|
|
|
if (this.cache.has(cacheKey)) {
|
|
return this.cache.get(cacheKey)!;
|
|
}
|
|
|
|
// ... render logic
|
|
|
|
this.cache.set(cacheKey, canvas);
|
|
return canvas;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2. Web Worker для тяжких обчислень
|
|
|
|
```typescript
|
|
// heatmap.worker.ts
|
|
self.onmessage = (e) => {
|
|
const { points, tileX, tileY, zoom } = e.data;
|
|
|
|
const intensityMap = renderIntensityMap(points, tileX, tileY, zoom);
|
|
|
|
self.postMessage({ intensityMap }, [intensityMap.buffer]);
|
|
};
|
|
```
|
|
|
|
### 3. Request Animation Frame
|
|
|
|
```typescript
|
|
createTile(coords, done) {
|
|
const canvas = document.createElement('canvas');
|
|
|
|
requestAnimationFrame(() => {
|
|
renderer.renderTile(canvas, ...);
|
|
done(null, canvas);
|
|
});
|
|
|
|
return canvas;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Testing Strategy
|
|
|
|
1. **Geographic Accuracy:**
|
|
- [ ] Measure 400m with ruler tool
|
|
- [ ] Coverage point radius = 400m at ALL zoom levels
|
|
- [ ] Verified with real coordinates
|
|
|
|
2. **Color Consistency:**
|
|
- [ ] Pick point at zoom 8, note color
|
|
- [ ] Zoom to 14, EXACT same color
|
|
- [ ] Test at 5-10 different locations
|
|
|
|
3. **Performance:**
|
|
- [ ] Smooth panning (60fps)
|
|
- [ ] Zoom transitions smooth
|
|
- [ ] <100ms per tile render
|
|
|
|
4. **Visual Quality:**
|
|
- [ ] Smooth gradient (no banding)
|
|
- [ ] No grid artifacts
|
|
- [ ] Proper alpha blending
|
|
|
|
---
|
|
|
|
## Migration Path
|
|
|
|
**Phase 1:** Implement core (this iteration)
|
|
**Phase 2:** Add caching
|
|
**Phase 3:** Add Web Worker
|
|
**Phase 4:** Backend pre-rendering (Phase 4+)
|
|
|
|
---
|
|
|
|
## Expected Benefits
|
|
|
|
✅ **True geographic scale** - 400m = 400m always
|
|
✅ **Zoom-independent colors** - guaranteed
|
|
✅ **No library limitations** - full control
|
|
✅ **Better performance** - optimized for our data
|
|
✅ **Professional quality** - like Google Maps
|
|
|
|
---
|
|
|
|
## Build & Test
|
|
|
|
```bash
|
|
cd /opt/rfcp/frontend
|
|
npm run build
|
|
sudo systemctl reload caddy
|
|
```
|
|
|
|
---
|
|
|
|
## Commit Message
|
|
|
|
```
|
|
feat(heatmap): custom geographic-scale canvas renderer
|
|
|
|
- Implemented custom GridLayer with Canvas rendering
|
|
- True geographic radius (400m constant across zoom levels)
|
|
- Zoom-independent color mapping (same RSRP = same color always)
|
|
- Gaussian blur for smooth gradients
|
|
- Removed dependency on leaflet-heatmap library
|
|
|
|
Coverage now maintains accurate geographic scale and consistent
|
|
colors at all zoom levels. Rendering optimized for our use case.
|
|
```
|
|
|
|
🚀 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!
|
|
|