Files
rfcp/RFCP-Iteration10.3-Coverage-Boundary-Border.md
2026-01-30 17:11:37 +02:00

14 KiB

RFCP - Iteration 10.3: Coverage Boundary Border

Goal: Replace misleading purple "weak signal" fill with a clean dashed border around actual coverage zone
Priority: P2 (UX Polish)
Estimated Time: 30-45 minutes
Status: Ready for Implementation


📋 Overview

After Iteration 10.2 implemented purple→orange gradient, user feedback indicated that the purple "weak signal" zone (-100 to -130 dBm) creates a false impression of coverage. This iteration implements a combined approach:

  1. Solid gradient only for useful signal (-50 to -100 dBm)
  2. Hard cutoff at -100 dBm (nothing below rendered as fill)
  3. Dashed border around coverage zone edge

🐛 Current Issue

User Feedback: "наче норм, але воно створює враження шо покриття навколо вишки по факту є, оця зона фіолетова"

Problem:

  • Purple zone (-100 to -130 dBm) looks like "there is coverage"
  • In reality, this signal is unusable (No Service / Very Weak)
  • Creates false expectations about actual coverage area

Visual Issue:

Current:
┌─────────────────────────────┐
│ ░░░░░░ Purple fill ░░░░░░░ │  ← Looks like coverage!
│ ░░░┌───────────────┐░░░░░░ │
│ ░░░│ Orange/Peach  │░░░░░░ │
│ ░░░│   (useful)    │░░░░░░ │
│ ░░░└───────────────┘░░░░░░ │
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░ │
└─────────────────────────────┘

Solution: Combined Approach

New Visual:

After:
         ┌─ ─ ─ ─ ─ ─ ─┐
         │             │
    ─ ─ ─┤  🟠→🟡→🟣   ├─ ─ ─  ← Dashed border at -100 dBm
         │   gradient  │
         │  (useful)   │
         └─ ─ ─ ─ ─ ─ ─┘
         
         No fill outside border!

Three Changes:

1. Change Default Min Signal Threshold

  • Old: -120 dBm (shows purple weak zone)
  • New: -100 dBm (shows only useful coverage)

2. Render Dashed Border at Coverage Edge

  • Contour line at the Min Signal threshold (-100 dBm)
  • Dashed stroke style (5px dash, 3px gap)
  • Color: Dark purple (#4a148c) or dark gray (#666666)
  • Line width: 2px

3. Update Legend

  • Remove "No Service" and "Very Weak" from legend
  • Or mark them as "outside coverage zone"

📁 Implementation Plan

Step 1: Change Default Min Signal

File: src/store/settingsStore.ts (or similar)

// Find default settings
const defaultSettings = {
  minSignal: -100,  // Changed from -120
  // ... other settings
};

Or in constants file:

export const DEFAULT_MIN_SIGNAL = -100; // dBm (was -120)

Step 2: Create Coverage Boundary Component

New File: src/components/map/CoverageBoundary.tsx

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

interface CoverageBoundaryProps {
  coveragePoints: Array<{ lat: number; lng: number; rsrp: number }>;
  threshold: number; // e.g., -100 dBm
  color?: string;
  dashArray?: string;
  weight?: number;
}

export function CoverageBoundary({
  coveragePoints,
  threshold,
  color = '#4a148c',
  dashArray = '8, 4',
  weight = 2,
}: CoverageBoundaryProps) {
  const map = useMap();
  const layerRef = useRef<L.Layer | null>(null);

  useEffect(() => {
    if (!coveragePoints.length) return;

    // Find boundary points (points near threshold)
    const boundaryPoints = findBoundaryPoints(coveragePoints, threshold);
    
    // Create contour path
    const contourPath = createContourPath(boundaryPoints);
    
    // Remove old layer
    if (layerRef.current) {
      map.removeLayer(layerRef.current);
    }

    // Add new boundary layer
    if (contourPath.length > 0) {
      const polyline = L.polyline(contourPath, {
        color,
        weight,
        dashArray,
        opacity: 0.8,
        fill: false,
      });
      
      polyline.addTo(map);
      layerRef.current = polyline;
    }

    return () => {
      if (layerRef.current) {
        map.removeLayer(layerRef.current);
      }
    };
  }, [coveragePoints, threshold, color, dashArray, weight, map]);

  return null;
}

// Helper: Find points near the threshold boundary
function findBoundaryPoints(
  points: Array<{ lat: number; lng: number; rsrp: number }>,
  threshold: number,
  tolerance: number = 3 // dBm
): Array<{ lat: number; lng: number }> {
  return points
    .filter(p => Math.abs(p.rsrp - threshold) <= tolerance)
    .map(p => ({ lat: p.lat, lng: p.lng }));
}

// Helper: Create contour path from boundary points
function createContourPath(
  points: Array<{ lat: number; lng: number }>
): L.LatLngExpression[][] {
  if (points.length < 3) return [];
  
  // Use convex hull or alpha shape algorithm
  // For simplicity, start with convex hull
  const hull = convexHull(points);
  
  return [hull.map(p => [p.lat, p.lng] as L.LatLngExpression)];
}

// Convex Hull algorithm (Graham scan)
function convexHull(points: Array<{ lat: number; lng: number }>) {
  if (points.length < 3) return points;
  
  // Sort by lat, then lng
  const sorted = [...points].sort((a, b) => 
    a.lat === b.lat ? a.lng - b.lng : a.lat - b.lat
  );
  
  // Build lower hull
  const lower: typeof points = [];
  for (const p of sorted) {
    while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) {
      lower.pop();
    }
    lower.push(p);
  }
  
  // Build upper hull
  const upper: typeof points = [];
  for (const p of sorted.reverse()) {
    while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) {
      upper.pop();
    }
    upper.push(p);
  }
  
  // Remove last point of each half (it's repeated)
  lower.pop();
  upper.pop();
  
  return [...lower, ...upper];
}

function cross(o: { lat: number; lng: number }, a: { lat: number; lng: number }, b: { lat: number; lng: number }) {
  return (a.lat - o.lat) * (b.lng - o.lng) - (a.lng - o.lng) * (b.lat - o.lat);
}

Step 3: Alternative - Simpler Canvas-Based Approach

If the component approach is too complex, modify existing heatmap renderer:

File: src/components/map/GeographicHeatmap.tsx (or similar)

// After rendering heatmap tiles, add border stroke

function renderCoverageBorder(
  ctx: CanvasRenderingContext2D,
  points: CoveragePoint[],
  threshold: number,
  bounds: L.LatLngBounds,
  tileSize: number
) {
  const boundaryPoints = points.filter(p => 
    Math.abs(p.rsrp - threshold) <= 3
  );
  
  if (boundaryPoints.length < 3) return;
  
  ctx.beginPath();
  ctx.strokeStyle = '#4a148c';
  ctx.lineWidth = 2;
  ctx.setLineDash([8, 4]);
  
  // Convert to pixel coordinates and draw
  const hull = convexHull(boundaryPoints);
  
  hull.forEach((point, i) => {
    const pixel = latLngToPixel(point, bounds, tileSize);
    if (i === 0) {
      ctx.moveTo(pixel.x, pixel.y);
    } else {
      ctx.lineTo(pixel.x, pixel.y);
    }
  });
  
  ctx.closePath();
  ctx.stroke();
}

Step 4: Update Legend

File: src/constants/rsrp-thresholds.ts

// Update legend to reflect new display
export const RSRP_LEGEND = [
  { min: -50, max: -70, label: 'Excellent', color: '#ffb74d' },
  { min: -70, max: -85, label: 'Strong', color: '#ff9800' },
  { min: -85, max: -95, label: 'Good', color: '#ff6f00' },
  { min: -95, max: -100, label: 'Fair', color: '#ff8a65' },
  // Remove or gray out:
  // { min: -100, max: -110, label: 'Weak', color: '#ab47bc' },
  // { min: -110, max: -130, label: 'No Service', color: '#4a148c' },
];

// Or add visual indicator that these are "outside coverage"
export const RSRP_LEGEND_EXTENDED = [
  // ... useful signal levels ...
  { min: -100, max: -130, label: 'Outside Coverage', color: 'transparent', border: '#4a148c' },
];

🔍 Investigation Commands

# Find settings store
find /opt/rfcp/frontend/src -name "*store*" -o -name "*settings*" | head -10

# Find current min signal default
grep -rn "minSignal\|-120\|MIN_SIGNAL" /opt/rfcp/frontend/src/

# Find heatmap component
find /opt/rfcp/frontend/src -name "*eatmap*" -o -name "*overage*"

# Find legend component
grep -rn "RSRP_LEGEND\|legend" /opt/rfcp/frontend/src/

# Check current threshold constants
cat /opt/rfcp/frontend/src/constants/rsrp-thresholds.ts

🧪 Testing Checklist

Visual Testing

  • No purple fill: Weak signal zone (-100 to -130) not filled
  • Gradient visible: Orange gradient shows useful coverage (-50 to -100)
  • Border visible: Dashed line around coverage edge
  • Border follows contour: Line traces actual coverage boundary
  • Clean appearance: No visual artifacts or gaps in border

Functional Testing

  • Min Signal slider: Still works, updates border position
  • Zoom levels: Border looks correct at zoom 8, 12, 16
  • Multiple sites: Each site has its own border
  • Sector coverage: Border follows sector wedge shape
  • Grid mode: Works correctly with new boundary

Regression Testing

  • 50m resolution: Still works without crash
  • Performance: No significant slowdown
  • Legend: Displays correctly
  • All other features: Keyboard shortcuts, delete, etc.

Edge Cases

  • Single point: Graceful handling (no border or minimal)
  • Overlapping sites: Borders don't conflict
  • Min Signal = -50: Very small coverage, border still visible
  • Min Signal = -130: Large coverage (original behavior if user wants)

🏗️ Build & Deploy

# Navigate to frontend
cd /opt/rfcp/frontend

# Development test
npm run dev

# Build for production
npm run build

# Expected: 0 TypeScript errors, 0 ESLint errors

# Deploy
sudo systemctl reload caddy

# Test
# Open https://rfcp.eliah.one and verify:
# 1. No purple fill
# 2. Dashed border around coverage
# 3. Orange gradient inside border

📝 Commit Message

feat(heatmap): add dashed coverage boundary, remove weak signal fill

- Changed default Min Signal from -120 to -100 dBm
- Added dashed border around coverage zone edge
- Removed misleading purple "weak signal" fill
- Border uses convex hull algorithm for smooth contour
- Color: dark purple (#4a148c), style: dashed (8px, 4px gap)

Visual change: coverage zone now shows only useful signal
with clear boundary instead of gradual fade to purple.

Iteration: 10.3
Fixes: User feedback about misleading coverage display

Success Criteria

Must Pass:

  1. No purple/lavender fill outside useful coverage
  2. Dashed border visible around coverage zone
  3. Orange gradient shows useful signal (-50 to -100 dBm)
  4. Border follows actual coverage shape (including sectors)
  5. TypeScript: 0 errors
  6. ESLint: 0 errors
  7. Performance: No significant slowdown

User Acceptance:

  • Олег confirms: "тепер видно реальне покриття" or similar
  • If border looks wrong → Iteration 10.3.1 to adjust algorithm

🎨 Visual Reference

Before (10.2):

┌─────────────────────────────┐
│ ▓▓▓▓▓ Purple fade ▓▓▓▓▓▓▓▓ │
│ ▓▓┌─────────────────┐▓▓▓▓▓ │
│ ▓▓│  Orange/Peach   │▓▓▓▓▓ │
│ ▓▓│    gradient     │▓▓▓▓▓ │
│ ▓▓└─────────────────┘▓▓▓▓▓ │
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
└─────────────────────────────┘

After (10.3):

       ╭─ ─ ─ ─ ─ ─ ─ ─ ─╮
       │                 │
  ─ ─ ─┤  Orange/Peach   ├─ ─ ─
       │    gradient     │
       │   (clear map    │
       │   visible here) │
       ╰─ ─ ─ ─ ─ ─ ─ ─ ─╯
       
       ↑ Dashed border
       ↑ No fill outside!

🤖 Instructions for Claude Code

Context:

  • Project: RFCP (RF Coverage Planning) - /opt/rfcp/frontend/
  • Framework: React 18 + TypeScript + Vite + Leaflet
  • Current state: Purple→Orange gradient working (Iteration 10.2)

Implementation Priority:

  1. First: Change default Min Signal to -100 dBm

    • Find settings store or constants
    • Update default value
    • This alone will hide most purple
  2. Second: Add coverage boundary border

    • Create new component or modify heatmap renderer
    • Use convex hull for boundary detection
    • Render dashed polyline
  3. Third: Update legend if needed

    • Remove or gray out "Weak" and "No Service" entries

Algorithm Notes:

  • Boundary detection: Find points where RSRP ≈ threshold (±3 dBm)
  • Contour: Use convex hull for simplicity, or marching squares for precision
  • Performance: Cache boundary, recalculate only when coverage changes

Success: User sees clean coverage zone with dashed border, no misleading purple fill


📊 Complexity Assessment

Component Complexity Time
Change Min Signal default Easy 5 min
Boundary detection algorithm Medium 15 min
Dashed border rendering Medium 15 min
Integration & testing Easy 10 min
Total Medium ~45 min

Document Created: 2025-01-30
Author: Claude (Opus 4.5) + Олег
Status: Ready for Implementation
Next: Віддати Claude Code → Test → Screenshot → Confirm