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

488 lines
14 KiB
Markdown

# 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)
```typescript
// Find default settings
const defaultSettings = {
minSignal: -100, // Changed from -120
// ... other settings
};
```
**Or in constants file:**
```typescript
export const DEFAULT_MIN_SIGNAL = -100; // dBm (was -120)
```
### Step 2: Create Coverage Boundary Component
**New File:** `src/components/map/CoverageBoundary.tsx`
```typescript
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)
```typescript
// 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`
```typescript
// 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
```bash
# 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
```bash
# 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