Files
rfcp/docs/devlog/front/RFCP-Iteration7.3-Geographic-Scale.md
2026-01-30 20:39:13 +02:00

7.6 KiB

RFCP - Iteration 7.3: Geographic Scale Compensation

Root Cause

Previous fix failed because:

  • radius is in pixels
  • But coverage is in kilometers
  • At zoom 8: 1km = ~100px
  • At zoom 14: 1km = ~6400px
  • Same radius=30px covers VERY different geographic areas!

The REAL Solution

We need to keep geographic radius constant, not pixel radius!

Implementation

File: frontend/src/components/map/Heatmap.tsx

import { useEffect, useState } from 'react';
import { useMap } from 'react-leaflet';
import { HeatmapLayerFactory } from '@vgrid/react-leaflet-heatmap-layer';

const HeatmapLayer = HeatmapLayerFactory<[number, number, number]>();

interface HeatmapProps {
  points: Array<{ lat: number; lon: number; rsrp: number; siteId: string }>;
  visible: boolean;
  opacity?: number;
}

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));
  };
  
  // CRITICAL FIX: Calculate pixels per kilometer at current zoom
  // Leaflet formula: pixelsPerKm = 2^zoom * 256 / (40075 * cos(lat))
  // At equator (simplified): pixelsPerKm ≈ 2^zoom * 6.4
  const pixelsPerKm = Math.pow(2, mapZoom) * 6.4;
  
  // Target: 300m geographic radius (coverage point spacing)
  const targetRadiusKm = 0.3; // 300 meters
  const radiusPixels = targetRadiusKm * pixelsPerKm;
  
  // Clamp for visual quality
  const radius = Math.max(8, Math.min(60, radiusPixels));
  
  // Blur proportional to radius
  const blur = radius * 0.6; // 60% of radius
  
  // FIXED maxIntensity (no compensation needed now!)
  const maxIntensity = 0.75;
  
  const heatmapPoints = points.map(p => [
    p.lat,
    p.lon,
    normalizeRSRP(p.rsrp)
  ] as [number, number, number]);
  
  // Debug
  if (import.meta.env.DEV && points.length > 0) {
    const rsrpValues = points.map(p => p.rsrp);
    console.log('🔍 Heatmap Geographic:', {
      zoom: mapZoom,
      pixelsPerKm: pixelsPerKm.toFixed(1),
      targetRadiusKm,
      radiusPixels: radiusPixels.toFixed(1),
      radiusClamped: radius.toFixed(1),
      blur: blur.toFixed(1),
      maxIntensity,
      points: points.length,
      rsrpRange: `${Math.min(...rsrpValues).toFixed(1)} to ${Math.max(...rsrpValues).toFixed(1)} dBm`
    });
  }
  
  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}  // CONSTANT! No more compensation!
        minOpacity={0.3}
      />
    </div>
  );
}

Why This Works

Geographic consistency:

  • Same 300m radius covers same geographic area at ALL zooms
  • Pixel radius auto-adjusts: zoom 8 = 20px, zoom 14 = 1920px
  • Colors stay consistent because geographic coverage is consistent!

Example:

Zoom 8:  pixelsPerKm = 1638 → radius = 0.3 * 1638 = 491px (clamped to 60)
Zoom 10: pixelsPerKm = 6553 → radius = 0.3 * 6553 = 1966px (clamped to 60)
Zoom 14: pixelsPerKm = 104857 → radius = 0.3 * 104857 = 31457px (clamped to 60)

Wait, problem! At high zoom, radius gets clamped to 60px which is TOO SMALL!


Better Approach: Remove Clamp

// Don't clamp! Let radius grow with zoom
const radius = targetRadiusKm * pixelsPerKm;
const blur = radius * 0.6;

// But limit max for performance
const radius = Math.min(radiusPixels, 100); // Max 100px
const blur = Math.min(radius * 0.6, 60);

Even Better: Adaptive Target Radius

// Smaller geographic radius at high zoom (more detail)
// Larger geographic radius at low zoom (smooth coverage)
const targetRadiusKm = mapZoom < 10 ? 0.5 : 0.3; // km
const radiusPixels = targetRadiusKm * pixelsPerKm;
const radius = Math.max(15, Math.min(50, radiusPixels));

Alternative: Fix Resolution Instead

Problem: 200m resolution creates visible grid at high zoom.

Solution: Adaptive resolution based on zoom.

File: frontend/src/store/coverage.ts

const calculateCoverage = async () => {
  // Adaptive resolution: finer at close zoom
  const adaptiveResolution = mapZoom >= 12 
    ? 100  // 100m for close zoom
    : 200; // 200m for far zoom
  
  await coverageWorker.calculateCoverage({
    sites,
    radius,
    resolution: adaptiveResolution,
    rsrpThreshold
  });
};

Ultimate Solution: Multi-Resolution

Calculate different resolutions for different zoom levels:

// Calculate 3 datasets
const coarse = calculate(resolution: 500); // Zoom 6-9
const medium = calculate(resolution: 200); // Zoom 10-13
const fine = calculate(resolution: 100);   // Zoom 14+

// Show appropriate one based on zoom
const pointsToShow = mapZoom < 10 ? coarse 
                   : mapZoom < 14 ? medium 
                   : fine;

Quick Fix: Just Increase Radius at High Zoom

File: frontend/src/components/map/Heatmap.tsx

// Simple progressive formula
const radius = mapZoom < 10 
  ? Math.max(20, Math.min(45, 55 - mapZoom * 2.5))  // Small zoom: old formula
  : Math.max(30, Math.min(80, 10 + mapZoom * 3));   // Large zoom: grow radius

const blur = radius * 0.6;
const maxIntensity = 0.75; // Keep constant

My Recommendation

Try Option 1 first (geographic scale) with adjusted clamps:

const pixelsPerKm = Math.pow(2, mapZoom) * 6.4;
const targetRadiusKm = 0.4; // 400m
const radiusPixels = targetRadiusKm * pixelsPerKm;

// Progressive clamps
const minRadius = mapZoom < 10 ? 15 : 30;
const maxRadius = mapZoom < 10 ? 45 : 80;
const radius = Math.max(minRadius, Math.min(maxRadius, radiusPixels));

const blur = radius * 0.6;
const maxIntensity = 0.75;

If that fails, try Option 2 (adaptive resolution).


Testing

  1. Coverage extent:

    • Zoom 8: Coverage reaches expected radius (e.g., 10km)
    • Zoom 12: Coverage still reaches same distance
    • No "shrinking" at any zoom
  2. Grid visibility:

    • Zoom 16+: No visible dots/grid pattern
    • Smooth gradient even at close zoom
  3. Color consistency:

    • Pick point at 5km from site
    • Check color at zoom 8, 12, 16
    • Should be within 1-2 gradient stops

Build & Test

cd /opt/rfcp/frontend
npm run build
sudo systemctl reload caddy

Commit Message

fix(heatmap): geographic scale-aware radius calculation

- Calculate pixels per km based on zoom level
- Keep geographic radius constant (400m)
- Adjust pixel radius to maintain geographic coverage
- Progressive clamps for visual quality at all zooms
- Fixed maxIntensity at 0.75 (no compensation needed)

Coverage extent now consistent across zoom levels.
No visible grid pattern at high zoom.
Colors remain stable due to consistent geographic scale.

🚀 Ready for Iteration 7.3!