15 KiB
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
// 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
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
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
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
// 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
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 для тяжких обчислень
// 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
createTile(coords, done) {
const canvas = document.createElement('canvas');
requestAnimationFrame(() => {
renderer.renderTile(canvas, ...);
done(null, canvas);
});
return canvas;
}
Testing Strategy
-
Geographic Accuracy:
- Measure 400m with ruler tool
- Coverage point radius = 400m at ALL zoom levels
- Verified with real coordinates
-
Color Consistency:
- Pick point at zoom 8, note color
- Zoom to 14, EXACT same color
- Test at 5-10 different locations
-
Performance:
- Smooth panning (60fps)
- Zoom transitions smooth
- <100ms per tile render
-
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
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!