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:
- Solid gradient only for useful signal (-50 to -100 dBm)
- Hard cutoff at -100 dBm (nothing below rendered as fill)
- 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:
- ✅ No purple/lavender fill outside useful coverage
- ✅ Dashed border visible around coverage zone
- ✅ Orange gradient shows useful signal (-50 to -100 dBm)
- ✅ Border follows actual coverage shape (including sectors)
- ✅ TypeScript: 0 errors
- ✅ ESLint: 0 errors
- ✅ 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:
-
First: Change default Min Signal to -100 dBm
- Find settings store or constants
- Update default value
- This alone will hide most purple
-
Second: Add coverage boundary border
- Create new component or modify heatmap renderer
- Use convex hull for boundary detection
- Render dashed polyline
-
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