Files
rfcp/docs/devlog/front/RFCP-Iteration2-Task.md
2026-01-30 20:39:13 +02:00

18 KiB
Raw Blame History

RFCP - Iteration 2: Terrain Overlay + Heatmap Fixes + Batch Operations

Context

Iteration 1 completed successfully (dark theme, new colors, radius 100km, shortcuts). Now addressing: heatmap pixelation at close zoom, terrain elevation overlay, batch height operations.


CRITICAL FIXES

1. Dynamic Heatmap Radius Based on Zoom Level

Problem: At close zoom (12-15), heatmap becomes blocky/pixelated with square artifacts Cause: Fixed radius (25px) doesn't scale with zoom

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

Solution:

import { useEffect, useState } from 'react';
import { useMap } from 'react-leaflet';

export function Heatmap({ points }: HeatmapProps) {
  const map = useMap();
  const [mapZoom, setMapZoom] = useState(map.getZoom());
  
  // Track zoom changes
  useEffect(() => {
    const handleZoomEnd = () => {
      setMapZoom(map.getZoom());
    };
    
    map.on('zoomend', handleZoomEnd);
    return () => {
      map.off('zoomend', handleZoomEnd);
    };
  }, [map]);
  
  // Calculate adaptive radius and blur based on zoom
  // Lower zoom (zoomed out) = larger radius
  // Higher zoom (zoomed in) = smaller radius
  const getHeatmapParams = (zoom: number) => {
    // Zoom 6 (country view): radius=40, blur=20
    // Zoom 10 (regional): radius=28, blur=14  
    // Zoom 14 (city): radius=16, blur=10
    // Zoom 18 (street): radius=8, blur=6
    
    const radius = Math.max(8, Math.min(40, 50 - zoom * 2.5));
    const blur = Math.max(6, Math.min(20, 30 - zoom * 1.5));
    
    return { radius, blur };
  };
  
  const { radius, blur } = getHeatmapParams(mapZoom);
  
  return (
    <HeatmapLayer
      points={heatmapPoints}
      longitudeExtractor={(p: any) => p[1]}
      latitudeExtractor={(p: any) => p[0]}
      intensityExtractor={(p: any) => p[2]}
      gradient={{
        0.0: '#0d47a1',
        0.2: '#00bcd4',
        0.4: '#4caf50',
        0.6: '#ffeb3b',
        0.8: '#ff9800',
        1.0: '#f44336',
      }}
      radius={radius}    // ← Dynamic
      blur={blur}        // ← Dynamic
      max={1.0}
      minOpacity={0.3}
    />
  );
}

Test: Zoom in close to a site, heatmap should remain smooth, not blocky


2. Terrain Elevation Overlay 🏔️

Feature: Toggle terrain/topography layer to see elevation while planning

Files:

  • frontend/src/components/map/Map.tsx - add layer
  • frontend/src/store/settings.ts - persist toggle state

Implementation:

A) Add to settings store:

// src/store/settings.ts
interface SettingsState {
  theme: 'light' | 'dark' | 'system';
  showTerrain: boolean;  // ← New
  setTheme: (theme: 'light' | 'dark' | 'system') => void;
  setShowTerrain: (show: boolean) => void;  // ← New
}

export const useSettingsStore = create<SettingsState>()(
  persist(
    (set) => ({
      theme: 'system',
      showTerrain: false,  // ← Default off
      setTheme: (theme) => {
        set({ theme });
        applyTheme(theme);
      },
      setShowTerrain: (show) => set({ showTerrain: show }),
    }),
    {
      name: 'rfcp-settings',
    }
  )
);

B) Add terrain layer to Map:

// src/components/map/Map.tsx
import { useSettingsStore } from '@/store/settings';
import { TileLayer } from 'react-leaflet';

function Map() {
  const showTerrain = useSettingsStore((s) => s.showTerrain);
  
  return (
    <MapContainer /* ... */>
      {/* Base OSM layer */}
      <TileLayer
        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
        attribution='&copy; OpenStreetMap contributors'
      />
      
      {/* Terrain overlay (when enabled) */}
      {showTerrain && (
        <TileLayer
          url="https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png"
          attribution='Map data: &copy; OpenStreetMap, SRTM | Style: &copy; OpenTopoMap'
          opacity={0.6}  // Semi-transparent so base map shows through
          zIndex={1}
        />
      )}
      
      {/* Rest of map content */}
    </MapContainer>
  );
}

C) Add toggle button:

// In Map controls section:
<div className="absolute top-4 right-4 z-[1000] flex flex-col gap-2">
  {/* Existing buttons (Fit, Reset) */}
  
  {/* Terrain toggle */}
  <button
    onClick={() => setShowTerrain(!showTerrain)}
    className={`
      bg-white dark:bg-dark-surface shadow-lg rounded px-3 py-2
      hover:bg-gray-50 dark:hover:bg-dark-border
      transition-colors
      ${showTerrain ? 'ring-2 ring-blue-500' : ''}
    `}
    title={showTerrain ? 'Hide terrain' : 'Show terrain elevation'}
  >
    {showTerrain ? '🗺️' : '🏔️'}
  </button>
</div>

Alternative terrain sources:

// Option 1: OpenTopoMap (best for Europe, shows contour lines)
url="https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png"

// Option 2: USGS Topo (USA focused, detailed)
url="https://basemap.nationalmap.gov/arcgis/rest/services/USGSTopo/MapServer/tile/{z}/{y}/{x}"

// Option 3: Thunderforest Landscape (requires API key but beautiful)
url="https://{s}.tile.thunderforest.com/landscape/{z}/{x}/{y}.png?apikey=YOUR_KEY"

// Option 4: Stamen Terrain (classic look)
url="https://stamen-tiles.a.ssl.fastly.net/terrain/{z}/{x}/{y}.jpg"

Recommendation: Start with OpenTopoMap (no API key needed, good for Ukraine)


3. Batch Operations for Site Height 📊

Feature: Select multiple sites and adjust height together

Files:

  • frontend/src/components/panels/SiteList.tsx - selection UI
  • frontend/src/components/panels/BatchEdit.tsx - new component
  • frontend/src/store/sites.ts - batch update methods

Implementation:

A) Update sites store with batch operations:

// src/store/sites.ts
interface SitesState {
  sites: Site[];
  selectedSite: Site | null;
  selectedSiteIds: string[];  // ← New for batch selection
  placementMode: boolean;
  
  // Existing methods...
  
  // New batch methods:
  toggleSiteSelection: (siteId: string) => void;
  selectAllSites: () => void;
  clearSelection: () => void;
  batchUpdateHeight: (adjustment: number) => void;
  batchSetHeight: (height: number) => void;
}

export const useSitesStore = create<SitesState>((set, get) => ({
  sites: [],
  selectedSite: null,
  selectedSiteIds: [],
  placementMode: false,
  
  // ... existing methods ...
  
  toggleSiteSelection: (siteId) => {
    set((state) => {
      const isSelected = state.selectedSiteIds.includes(siteId);
      return {
        selectedSiteIds: isSelected
          ? state.selectedSiteIds.filter(id => id !== siteId)
          : [...state.selectedSiteIds, siteId]
      };
    });
  },
  
  selectAllSites: () => {
    set((state) => ({
      selectedSiteIds: state.sites.map(s => s.id)
    }));
  },
  
  clearSelection: () => {
    set({ selectedSiteIds: [] });
  },
  
  batchUpdateHeight: (adjustment) => {
    set((state) => {
      const selectedIds = new Set(state.selectedSiteIds);
      return {
        sites: state.sites.map(site => 
          selectedIds.has(site.id)
            ? { ...site, height: Math.max(1, Math.min(100, site.height + adjustment)) }
            : site
        )
      };
    });
  },
  
  batchSetHeight: (height) => {
    set((state) => {
      const selectedIds = new Set(state.selectedSiteIds);
      return {
        sites: state.sites.map(site => 
          selectedIds.has(site.id)
            ? { ...site, height: Math.max(1, Math.min(100, height)) }
            : site
        )
      };
    });
  },
}));

B) Create BatchEdit component:

// src/components/panels/BatchEdit.tsx
import { useState } from 'react';
import { useSitesStore } from '@/store/sites';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { toast } from '@/components/ui/Toast';

export function BatchEdit() {
  const { 
    selectedSiteIds, 
    batchUpdateHeight, 
    batchSetHeight,
    clearSelection 
  } = useSitesStore();
  
  const [customHeight, setCustomHeight] = useState('');
  
  if (selectedSiteIds.length === 0) return null;
  
  const handleAdjustHeight = (delta: number) => {
    batchUpdateHeight(delta);
    toast.success(`Adjusted ${selectedSiteIds.length} site(s) by ${delta > 0 ? '+' : ''}${delta}m`);
  };
  
  const handleSetHeight = () => {
    const height = parseInt(customHeight);
    if (isNaN(height) || height < 1 || height > 100) {
      toast.error('Height must be between 1-100m');
      return;
    }
    batchSetHeight(height);
    toast.success(`Set ${selectedSiteIds.length} site(s) to ${height}m`);
    setCustomHeight('');
  };
  
  return (
    <div className="
      bg-blue-50 dark:bg-blue-900/20 
      border border-blue-200 dark:border-blue-800
      rounded-lg p-4 space-y-3
    ">
      <div className="flex items-center justify-between">
        <h3 className="font-semibold text-blue-900 dark:text-blue-100">
          📊 Batch Edit ({selectedSiteIds.length} selected)
        </h3>
        <Button 
          onClick={clearSelection}
          size="sm"
          variant="ghost"
        >
          Clear
        </Button>
      </div>
      
      {/* Quick adjustments */}
      <div>
        <label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 block">
          Adjust Height:
        </label>
        <div className="flex gap-2">
          <Button onClick={() => handleAdjustHeight(10)} size="sm">
            +10m
          </Button>
          <Button onClick={() => handleAdjustHeight(5)} size="sm">
            +5m
          </Button>
          <Button onClick={() => handleAdjustHeight(-5)} size="sm">
            -5m
          </Button>
          <Button onClick={() => handleAdjustHeight(-10)} size="sm">
            -10m
          </Button>
        </div>
      </div>
      
      {/* Set exact height */}
      <div>
        <label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 block">
          Set Height:
        </label>
        <div className="flex gap-2">
          <Input
            type="number"
            min={1}
            max={100}
            value={customHeight}
            onChange={(e) => setCustomHeight(e.target.value)}
            placeholder="meters"
            className="flex-1"
          />
          <Button 
            onClick={handleSetHeight}
            disabled={!customHeight}
          >
            Apply
          </Button>
        </div>
      </div>
    </div>
  );
}

C) Update SiteList with checkboxes:

// src/components/panels/SiteList.tsx
import { useSitesStore } from '@/store/sites';
import { BatchEdit } from './BatchEdit';

export function SiteList() {
  const { 
    sites, 
    selectedSiteIds,
    toggleSiteSelection,
    selectAllSites,
    clearSelection 
  } = useSitesStore();
  
  const allSelected = sites.length > 0 && selectedSiteIds.length === sites.length;
  const someSelected = selectedSiteIds.length > 0 && !allSelected;
  
  return (
    <div className="space-y-3">
      {/* Header with select all */}
      <div className="flex items-center justify-between">
        <h3 className="font-semibold">Sites ({sites.length})</h3>
        <div className="flex gap-2">
          <button
            onClick={() => allSelected ? clearSelection() : selectAllSites()}
            className="text-sm text-blue-600 hover:text-blue-700"
          >
            {allSelected ? 'Deselect All' : 'Select All'}
          </button>
          <Button onClick={onAddSite} size="sm">
            + Place on Map
          </Button>
        </div>
      </div>
      
      {/* Batch edit panel (shown when items selected) */}
      <BatchEdit />
      
      {/* Sites list */}
      <div className="space-y-2">
        {sites.map((site) => {
          const isSelected = selectedSiteIds.includes(site.id);
          
          return (
            <div 
              key={site.id}
              className={`
                p-3 rounded-lg border transition-all
                ${isSelected 
                  ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' 
                  : 'border-gray-200 dark:border-dark-border'
                }
                hover:shadow-md
              `}
            >
              <div className="flex items-start gap-3">
                {/* Checkbox */}
                <input
                  type="checkbox"
                  checked={isSelected}
                  onChange={() => toggleSiteSelection(site.id)}
                  className="mt-1 w-4 h-4 rounded border-gray-300"
                />
                
                {/* Color indicator */}
                <div 
                  className="w-4 h-4 rounded-full border-2 border-white shadow mt-0.5"
                  style={{ backgroundColor: site.color }}
                />
                
                {/* Site info */}
                <div className="flex-1 min-w-0">
                  <div className="font-medium text-gray-900 dark:text-dark-text">
                    {site.name}
                  </div>
                  <div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
                    📻 {site.frequency} MHz  
                     {site.power} dBm  
                    📏 {site.height}m  
                    📡 {site.antennaType === 'omni' ? 'Omni' : `Sector ${site.azimuth}°`}
                  </div>
                </div>
                
                {/* Actions */}
                <div className="flex gap-1">
                  <Button 
                    onClick={() => onEdit(site.id)} 
                    size="sm" 
                    variant="ghost"
                  >
                    Edit
                  </Button>
                  <Button 
                    onClick={() => onDelete(site.id)} 
                    size="sm" 
                    variant="ghost"
                  >
                    ×
                  </Button>
                </div>
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

ADDITIONAL IMPROVEMENTS

4. Show Current Elevation on Map Hover (Future)

Feature: When hovering mouse, show elevation at cursor position

Note: Requires terrain data loaded (Phase 4). Mark as TODO for now.

// TODO Phase 4: Add cursor elevation display
// When terrain manager is available:
const [cursorElevation, setCursorElevation] = useState<number | null>(null);

useEffect(() => {
  const handleMouseMove = async (e: L.LeafletMouseEvent) => {
    const { lat, lng } = e.latlng;
    const elevation = await terrainManager.getElevation(lat, lng);
    setCursorElevation(elevation);
  };
  
  map.on('mousemove', handleMouseMove);
  return () => map.off('mousemove', handleMouseMove);
}, [map]);

// Display in corner:
{cursorElevation !== null && (
  <div className="absolute bottom-4 left-4 bg-white dark:bg-dark-surface px-3 py-2 rounded shadow">
    🏔️ Elevation: {cursorElevation}m
  </div>
)}

5. Persist Batch Selection Across Panel Close

Enhancement: Remember which sites were selected even if user closes/reopens panel

Already handled by zustand store persistence!


TESTING CHECKLIST

Heatmap Zoom Test:

  • Zoom out (level 6-8): heatmap should be smooth, large radius
  • Zoom in (level 12-14): heatmap should remain smooth, smaller radius
  • No blocky/square artifacts at any zoom level

Terrain Overlay Test:

  • Toggle terrain on → contour lines visible
  • Toggle terrain off → back to normal OSM
  • Works in both light and dark theme
  • Terrain opacity allows base map to show through
  • Toggle state persists after page refresh

Batch Operations Test:

  • Select individual sites with checkboxes
  • "Select All" selects all sites
  • BatchEdit panel appears when sites selected
  • +10m button increases height of selected sites
  • -10m button decreases height (min 1m)
  • Custom height input sets exact height (1-100m validation)
  • Toast notifications show number of sites affected
  • Clear selection removes batch panel
  • Height changes reflected in site list immediately
  • Can still edit individual sites while others selected

FILES TO CREATE/MODIFY

New Files:

  • frontend/src/components/panels/BatchEdit.tsx - batch operations UI

Modified Files:

  • frontend/src/components/map/Heatmap.tsx - dynamic radius/blur
  • frontend/src/components/map/Map.tsx - terrain overlay toggle
  • frontend/src/components/panels/SiteList.tsx - checkboxes + batch UI
  • frontend/src/store/settings.ts - add showTerrain
  • frontend/src/store/sites.ts - batch operations methods

IMPLEMENTATION ORDER

  1. Heatmap zoom fix (5 min) - quick visual improvement
  2. Terrain overlay (10 min) - new feature, easy to add
  3. Batch operations (20 min) - more complex, needs store + UI

Total time: ~35 minutes


SUCCESS CRITERIA

Heatmap stays smooth at all zoom levels Terrain overlay toggle works and persists Can select multiple sites and batch-adjust height BatchEdit panel is intuitive and responsive All operations work in dark mode No TypeScript errors Toast feedback for all batch operations


NOTES

About antenna height in calculations: Currently height is stored but not used in FSPL calculations (correct behavior). Height will matter in Phase 4 when terrain loss is added:

  • terrainLoss = f(txHeight, rxHeight, elevation profile)
  • Higher antenna = better line-of-sight = less terrain loss

For now, height is cosmetic but will be critical in Phase 4.


About terrain overlay vs terrain data:

  • Terrain overlay (this iteration): Visual layer showing topography
  • Terrain data (Phase 4): 30m SRTM elevation data for calculations

They're different! Overlay is for user visualization, data is for RF calculations.

Good luck! 🚀