# 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!

