488 lines
14 KiB
Markdown
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
|