@mytec: docs before back

This commit is contained in:
2026-01-30 20:39:13 +02:00
parent 625cce31e4
commit ed60c4da9e
28 changed files with 1916 additions and 0 deletions

View File

@@ -0,0 +1,151 @@
# RFCP Heatmap Fix - Zoom-Dependent Intensity
## Problem
At close zoom (12-15), heatmap becomes solid yellow/orange with no gradient visible.
## Root Cause
The `intensity` values and `max` parameter aren't scaling properly with zoom level, causing saturation.
## Solution
### File: `frontend/src/components/map/Heatmap.tsx`
Replace the current implementation with this improved version:
```typescript
import { useEffect, useState } from 'react';
import { useMap } from 'react-leaflet';
import { HeatmapLayerFactory } from '@vgrid/react-leaflet-heatmap-layer';
const HeatmapLayer = HeatmapLayerFactory<[number, number, number]>();
interface HeatmapProps {
points: Array<{
lat: number;
lon: number;
rsrp: number;
siteId: string;
}>;
visible: boolean;
}
export function Heatmap({ points, visible }: HeatmapProps) {
const map = useMap();
const [mapZoom, setMapZoom] = useState(map.getZoom());
useEffect(() => {
const handleZoomEnd = () => {
setMapZoom(map.getZoom());
};
map.on('zoomend', handleZoomEnd);
return () => {
map.off('zoomend', handleZoomEnd);
};
}, [map]);
if (!visible || points.length === 0) {
return null;
}
// Zoom-dependent heatmap parameters
const getHeatmapParams = (zoom: number) => {
// Radius scales inversely with zoom
// zoom 6 (country): radius=40, blur=20
// zoom 10 (region): 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));
// Max intensity also scales with zoom
// Lower zoom (zoomed out) = higher max (more spread)
// Higher zoom (zoomed in) = lower max (more detail)
const maxIntensity = Math.max(0.3, Math.min(1.0, 1.2 - zoom * 0.05));
return { radius, blur, maxIntensity };
};
const { radius, blur, maxIntensity } = getHeatmapParams(mapZoom);
// Normalize RSRP to 0-1 intensity
// RSRP ranges: -120 (very weak) to -70 (excellent)
const normalizeRSRP = (rsrp: number): number => {
const minRSRP = -120;
const maxRSRP = -70;
const normalized = (rsrp - minRSRP) / (maxRSRP - minRSRP);
return Math.max(0, Math.min(1, normalized));
};
// Convert points to heatmap format: [lat, lon, intensity]
const heatmapPoints = points.map(point => [
point.lat,
point.lon,
normalizeRSRP(point.rsrp)
] as [number, number, number]);
return (
<HeatmapLayer
points={heatmapPoints}
longitudeExtractor={(p) => p[1]}
latitudeExtractor={(p) => p[0]}
intensityExtractor={(p) => p[2]}
gradient={{
0.0: '#0d47a1', // Dark Blue (very weak)
0.2: '#00bcd4', // Cyan (weak)
0.4: '#4caf50', // Green (fair)
0.6: '#ffeb3b', // Yellow (good)
0.8: '#ff9800', // Orange (strong)
1.0: '#f44336', // Red (excellent)
}}
radius={radius}
blur={blur}
max={maxIntensity} // ← KEY FIX: dynamic max
minOpacity={0.3}
/>
);
}
```
## Key Changes
1. **Dynamic `max` parameter**:
```typescript
const maxIntensity = Math.max(0.3, Math.min(1.0, 1.2 - zoom * 0.05));
```
- Zoom 6: max = 0.9 (spread out, needs higher threshold)
- Zoom 12: max = 0.6 (medium detail)
- Zoom 18: max = 0.3 (tight detail, lower threshold)
2. **Better RSRP normalization**:
```typescript
const normalized = (rsrp - minRSRP) / (maxRSRP - minRSRP);
```
- Ensures full 0-1 range is used
- Maps -120 dBm → 0.0 (blue)
- Maps -70 dBm → 1.0 (red)
3. **Clearer variable names** and comments
## Testing
After applying this fix:
1. **Zoom out (level 6-8)**: Should see smooth gradient, larger blob
2. **Zoom medium (level 10-12)**: Clear color transitions
3. **Zoom close (level 14-16)**: Should still show gradient, not solid color
4. **Very close (level 18+)**: Small detailed dots with gradient
## If Still Solid at Close Zoom
Try adjusting the `maxIntensity` formula:
```typescript
// More aggressive scaling (lower max at high zoom)
const maxIntensity = Math.max(0.2, Math.min(1.0, 1.5 - zoom * 0.08));
// Or even more aggressive
const maxIntensity = Math.max(0.15, Math.min(1.0, 2.0 - zoom * 0.1));
```
This will make the gradient more visible at close zoom levels.

View File

@@ -0,0 +1,847 @@
# RFCP - Iteration 1: Fixes & Features
## Comprehensive Task for Claude Code
Read RFCP-Fixes-Iteration1.md and RFCP-TechSpec-v3.0.md for context.
---
## CRITICAL FIXES (Priority: HIGH)
### 1. Heatmap Color Gradient - More Obvious
**Current:** Green → Yellow → Red (not very distinct)
**New:** Blue → Cyan → Green → Yellow → Orange → Red
**File:** `frontend/src/components/map/Heatmap.tsx`
```typescript
// Update gradient in HeatmapLayer component:
gradient={{
0.0: '#0d47a1', // Dark Blue (very weak, -120 dBm)
0.2: '#00bcd4', // Cyan (weak, -110 dBm)
0.4: '#4caf50', // Green (fair, -100 dBm)
0.6: '#ffeb3b', // Yellow (good, -85 dBm)
0.8: '#ff9800', // Orange (strong, -70 dBm)
1.0: '#f44336', // Red (excellent, > -70 dBm)
}}
```
**Reasoning:**
- Cold colors (blue/cyan) = weak signal
- Warm colors (yellow/orange/red) = strong signal
- More intuitive for RF planning
- Better contrast and visibility
---
### 2. Coverage Radius - Increase to 100km
**Current:** Max 20km
**New:** Max 100km with larger step
**File:** `frontend/src/components/panels/SiteForm.tsx` or wherever Coverage Settings panel is
```typescript
// Find the radius slider and update:
<Slider
label="Radius"
min={1}
max={100} // ← Changed from 20
step={5} // ← Changed from 1 (easier control)
value={coverageSettings.radius}
onChange={(value) => updateCoverageSettings({ radius: value })}
suffix="km"
help="Calculation area around each site"
/>
```
**Also update default value:**
**File:** `frontend/src/store/coverage.ts` (or wherever initial settings are)
```typescript
const defaultSettings: CoverageSettings = {
radius: 10, // Changed from 20 to 10 (better default)
resolution: 200, // 200m
rsrpThreshold: -120
};
```
---
### 3. "Save & Calculate" Button - Primary Action
**Current:** Separate "Save" and "Calculate" buttons
**New:** "Save & Calculate" as primary action + "Save Only" as secondary
**File:** `frontend/src/components/panels/SiteForm.tsx`
```typescript
// Replace button section:
<div className="flex gap-2">
<Button
onClick={handleSaveAndCalculate}
variant="primary"
className="flex-1"
>
💾 Save & Calculate
</Button>
<Button
onClick={handleSave}
variant="secondary"
>
💾 Save Only
</Button>
<Button
onClick={handleDelete}
variant="danger"
>
🗑
</Button>
</div>
// Add handler function:
const handleSaveAndCalculate = async () => {
try {
// Save the site first
await handleSave();
// Get all sites from store
const sites = useSitesStore.getState().sites;
if (sites.length === 0) {
toast.info('Add at least one site to calculate coverage');
return;
}
// Trigger coverage calculation
const coverageStore = useCoverageStore.getState();
await coverageStore.calculateCoverage(sites);
// Success feedback
toast.success(`Coverage calculated for ${sites.length} site(s)!`);
} catch (error) {
toast.error(`Failed: ${error.message}`);
}
};
```
---
### 4. Legend Colors - Match New Gradient
**File:** `frontend/src/components/map/Legend.tsx`
```typescript
const signalRanges = [
{
label: 'Excellent',
range: '> -70 dBm',
color: '#f44336', // Red
description: 'Very strong signal'
},
{
label: 'Good',
range: '-70 to -85 dBm',
color: '#ff9800', // Orange
description: 'Strong signal'
},
{
label: 'Fair',
range: '-85 to -100 dBm',
color: '#ffeb3b', // Yellow
description: 'Acceptable signal'
},
{
label: 'Poor',
range: '-100 to -110 dBm',
color: '#4caf50', // Green
description: 'Weak signal'
},
{
label: 'Weak',
range: '-110 to -120 dBm',
color: '#00bcd4', // Cyan
description: 'Very weak signal'
},
{
label: 'No Service',
range: '< -120 dBm',
color: '#0d47a1', // Dark Blue
description: 'No coverage'
},
];
```
**Also update constants:**
**File:** `frontend/src/constants/rsrp-thresholds.ts`
```typescript
export const SIGNAL_COLORS = {
excellent: '#f44336', // Red
good: '#ff9800', // Orange
fair: '#ffeb3b', // Yellow
poor: '#4caf50', // Green
weak: '#00bcd4', // Cyan
'no-service': '#0d47a1' // Dark Blue
} as const;
```
---
## UI IMPROVEMENTS (Priority: HIGH)
### 5. Dark Theme Support 🌙
**Add complete dark mode with toggle**
**Files to create/update:**
- `frontend/src/store/settings.ts` - theme state
- `frontend/src/components/ui/ThemeToggle.tsx` - toggle button
- `frontend/tailwind.config.js` - dark mode config
- `frontend/src/index.css` - dark theme styles
**Implementation:**
**A) Tailwind Config:**
```javascript
// tailwind.config.js
export default {
darkMode: 'class', // Enable class-based dark mode
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
// Custom colors for dark mode
dark: {
bg: '#1a1a1a',
surface: '#2d2d2d',
border: '#404040',
text: '#e0e0e0',
}
}
},
},
plugins: [],
}
```
**B) Settings Store:**
```typescript
// src/store/settings.ts (create if doesn't exist)
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface SettingsState {
theme: 'light' | 'dark' | 'system';
setTheme: (theme: 'light' | 'dark' | 'system') => void;
}
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
theme: 'system',
setTheme: (theme) => {
set({ theme });
applyTheme(theme);
},
}),
{
name: 'rfcp-settings',
}
)
);
function applyTheme(theme: 'light' | 'dark' | 'system') {
const root = window.document.documentElement;
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
root.classList.toggle('dark', systemTheme === 'dark');
} else {
root.classList.toggle('dark', theme === 'dark');
}
}
// Apply theme on page load
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('rfcp-settings');
if (stored) {
const { theme } = JSON.parse(stored);
applyTheme(theme);
}
}
```
**C) Theme Toggle Component:**
```typescript
// src/components/ui/ThemeToggle.tsx
import { useSettingsStore } from '@/store/settings';
export function ThemeToggle() {
const { theme, setTheme } = useSettingsStore();
const icons = {
light: '☀️',
dark: '🌙',
system: '💻'
};
return (
<div className="flex items-center gap-1 bg-gray-100 dark:bg-dark-surface rounded-lg p-1">
{(['light', 'dark', 'system'] as const).map((t) => (
<button
key={t}
onClick={() => setTheme(t)}
className={`
px-3 py-1.5 rounded text-sm transition-all
${theme === t
? 'bg-white dark:bg-dark-bg shadow-sm'
: 'hover:bg-gray-200 dark:hover:bg-dark-border'
}
`}
title={t.charAt(0).toUpperCase() + t.slice(1)}
>
{icons[t]}
</button>
))}
</div>
);
}
```
**D) Update App.tsx:**
```typescript
// Add ThemeToggle to header
import { ThemeToggle } from '@/components/ui/ThemeToggle';
function App() {
return (
<div className="h-screen flex flex-col bg-white dark:bg-dark-bg">
<header className="bg-slate-800 dark:bg-slate-900 text-white px-4 py-3 flex items-center justify-between">
<h1 className="text-xl font-bold">RFCP - RF Coverage Planner</h1>
<ThemeToggle />
</header>
{/* rest of app */}
</div>
);
}
```
**E) Dark Mode Styles for Components:**
Update all major components with dark mode classes:
```typescript
// Example pattern:
<div className="bg-white dark:bg-dark-surface text-gray-900 dark:text-dark-text">
{/* content */}
</div>
// Borders:
className="border border-gray-200 dark:border-dark-border"
// Inputs:
className="bg-white dark:bg-dark-bg border-gray-300 dark:border-dark-border"
// Buttons:
className="bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700"
```
**Apply dark mode to:**
- Map panels (SiteForm, SiteList, Coverage Settings)
- Legend component
- Toast notifications
- Modal overlays
- Input fields and sliders
---
### 6. Sites List - Show More Info
**Current:** Just site name
**New:** Show key parameters inline
**File:** `frontend/src/components/panels/SiteList.tsx`
```typescript
// Update site item rendering:
<div className="site-item p-3 border-b hover:bg-gray-50 dark:hover:bg-dark-surface">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{/* Color indicator */}
<div
className="w-4 h-4 rounded-full border-2 border-white shadow"
style={{ backgroundColor: site.color }}
/>
{/* Site info */}
<div>
<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">
📻 {site.frequency} MHz {site.power} dBm
📡 {site.antennaType === 'omni' ? 'Omni' : `Sector ${site.azimuth}°`}
</div>
</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>
```
---
### 7. Keyboard Shortcuts
**Add useful shortcuts for power users**
**File:** `frontend/src/App.tsx` or create `src/hooks/useKeyboardShortcuts.ts`
```typescript
// Create hook:
// src/hooks/useKeyboardShortcuts.ts
import { useEffect } from 'react';
import { useSitesStore } from '@/store/sites';
import { useCoverageStore } from '@/store/coverage';
import { toast } from '@/components/ui/Toast';
export function useKeyboardShortcuts() {
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
// Ignore if typing in input
if (e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement) {
return;
}
const isMac = navigator.platform.toLowerCase().includes('mac');
const modKey = isMac ? e.metaKey : e.ctrlKey;
if (modKey) {
switch(e.key.toLowerCase()) {
case 's': // Ctrl/Cmd+S: Save current site
e.preventDefault();
const selectedSite = useSitesStore.getState().selectedSite;
if (selectedSite) {
useSitesStore.getState().updateSite(selectedSite.id, selectedSite);
toast.success('Site saved');
}
break;
case 'enter': // Ctrl/Cmd+Enter: Calculate coverage
e.preventDefault();
const sites = useSitesStore.getState().sites;
if (sites.length > 0) {
useCoverageStore.getState().calculateCoverage(sites);
} else {
toast.info('Add sites first');
}
break;
case 'n': // Ctrl/Cmd+N: New site (enter placement mode)
e.preventDefault();
useSitesStore.getState().setPlacementMode(true);
toast.info('Click on map to place new site');
break;
}
}
// Non-modifier shortcuts
switch(e.key) {
case 'Escape': // Escape: Cancel/close
useSitesStore.getState().setSelectedSite(null);
useSitesStore.getState().setPlacementMode(false);
break;
case 'h': // H: Toggle heatmap
useCoverageStore.getState().toggleHeatmap();
break;
}
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, []);
}
// Use in App.tsx:
function App() {
useKeyboardShortcuts();
// ...
}
```
**Add keyboard shortcuts help:**
```typescript
// Add to header or settings:
<button
onClick={() => setShowShortcuts(true)}
className="text-sm text-gray-500 hover:text-gray-700"
title="Keyboard shortcuts"
>
</button>
{showShortcuts && (
<div className="modal">
<h3>Keyboard Shortcuts</h3>
<ul>
<li><kbd>Ctrl/Cmd</kbd> + <kbd>S</kbd> - Save site</li>
<li><kbd>Ctrl/Cmd</kbd> + <kbd>Enter</kbd> - Calculate coverage</li>
<li><kbd>Ctrl/Cmd</kbd> + <kbd>N</kbd> - New site</li>
<li><kbd>Esc</kbd> - Cancel/Close</li>
<li><kbd>H</kbd> - Toggle heatmap</li>
</ul>
</div>
)}
```
---
### 8. Map Controls - Fit to Sites & Reset View
**Add helpful map navigation buttons**
**File:** `frontend/src/components/map/Map.tsx`
```typescript
// Add control buttons:
<div className="absolute top-4 right-4 z-[1000] flex flex-col gap-2">
{/* Fit to sites */}
<button
onClick={handleFitToSites}
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
"
title="Fit view to all sites"
disabled={sites.length === 0}
>
🎯 Fit
</button>
{/* Reset to Ukraine */}
<button
onClick={handleResetView}
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
"
title="Reset to Ukraine view"
>
🏠 Reset
</button>
</div>
// Handlers:
const handleFitToSites = () => {
if (sites.length === 0) return;
const bounds = sites.map(site => [site.lat, site.lon] as [number, number]);
mapRef.current?.fitBounds(bounds, { padding: [50, 50] });
};
const handleResetView = () => {
mapRef.current?.setView([48.4, 35.0], 6); // Ukraine center
};
```
---
### 9. Better Error Handling
**Add comprehensive error handling with helpful messages**
**File:** `frontend/src/store/coverage.ts`
```typescript
calculateCoverage: async (sites: Site[]) => {
if (sites.length === 0) {
toast.error('No sites to calculate');
return;
}
set({ isCalculating: true, error: null });
try {
const settings = get().settings;
// Validation
if (settings.radius > 100) {
throw new Error('Radius too large (max 100km)');
}
if (settings.resolution < 50) {
throw new Error('Resolution too fine (min 50m)');
}
// Calculate
const result = await calculator.calculateCoverage(sites, bounds, settings);
if (result.points.length === 0) {
toast.warning('No coverage points found. Try increasing radius or lowering threshold.');
} else {
toast.success(`Calculated ${result.points.length.toLocaleString()} points in ${(result.calculationTime / 1000).toFixed(1)}s`);
}
set({
coveragePoints: result.points,
isCalculating: false
});
} catch (error) {
console.error('Coverage calculation error:', error);
// User-friendly error messages
let message = 'Calculation failed';
if (error.message.includes('timeout')) {
message = 'Calculation timeout. Try reducing radius or increasing resolution.';
} else if (error.message.includes('worker')) {
message = 'Web Worker error. Please refresh the page.';
} else if (error.message.includes('memory')) {
message = 'Out of memory. Try smaller radius or coarser resolution.';
}
toast.error(message);
set({
error: error.message,
isCalculating: false
});
}
}
```
---
### 10. Mobile Responsiveness Improvements
**Ensure panels don't block map on mobile**
**Files:** `SiteForm.tsx`, `SiteList.tsx`, `Legend.tsx`
```typescript
// Pattern for mobile-friendly panels:
<div className="
fixed bottom-0 left-0 right-0 // Mobile: bottom sheet
md:static md:w-96 // Desktop: side panel
bg-white dark:bg-dark-surface
shadow-lg rounded-t-lg md:rounded-lg
z-[1000] md:z-auto
max-h-[70vh] md:max-h-none // Mobile: don't cover whole screen
overflow-y-auto
transform transition-transform
${isOpen ? 'translate-y-0' : 'translate-y-full md:translate-y-0'}
">
{/* Panel content */}
</div>
// Add drag handle for mobile:
<div className="md:hidden flex justify-center p-2">
<div className="w-12 h-1 bg-gray-300 rounded-full" />
</div>
```
**Touch-friendly buttons:**
```typescript
// Minimum 44x44px touch targets
<button className="
min-w-[44px] min-h-[44px]
px-4 py-2
text-base md:text-sm
">
{/* button content */}
</button>
```
---
## OPTIONAL ENHANCEMENTS (if time permits)
### 11. Calculation Stats Display
**Show useful statistics after calculation**
**File:** `frontend/src/components/map/Legend.tsx`
```typescript
{coveragePoints.length > 0 && (
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-sm">
<h4 className="font-semibold mb-2">📊 Coverage Statistics</h4>
<div className="space-y-1 text-gray-700 dark:text-gray-300">
<div>Points: {coveragePoints.length.toLocaleString()}</div>
<div>Calculation: {(calculationTime / 1000).toFixed(2)}s</div>
<div>Coverage area: ~{calculateArea()} km²</div>
<div>Sites: {sites.length}</div>
</div>
</div>
)}
```
---
### 12. Templates Verification
**Ensure quick templates work correctly**
**File:** `frontend/src/components/panels/SiteForm.tsx`
```typescript
const QUICK_TEMPLATES = {
limesdr: {
name: 'LimeSDR Mini',
power: 20,
gain: 2,
frequency: 1800,
height: 10,
antennaType: 'omni' as const
},
'low-bbu': {
name: 'Low Power BBU',
power: 40,
gain: 8,
frequency: 1800,
height: 20,
antennaType: 'omni' as const
},
'high-bbu': {
name: 'High Power BBU',
power: 43,
gain: 15,
frequency: 1800,
height: 30,
antennaType: 'sector' as const,
azimuth: 0,
beamwidth: 65
}
};
const applyTemplate = (templateId: keyof typeof QUICK_TEMPLATES) => {
const template = QUICK_TEMPLATES[templateId];
// Apply ALL fields
setFormData({
...formData,
power: template.power,
gain: template.gain,
frequency: template.frequency,
height: template.height,
antennaType: template.antennaType,
azimuth: template.azimuth || 0,
beamwidth: template.beamwidth || 65
});
toast.success(`Applied: ${template.name}`);
};
```
---
## TESTING CHECKLIST
After implementation, verify:
### Visual Tests:
- [ ] Dark theme looks good on all components
- [ ] New heatmap gradient is clearly visible (blue=weak, red=strong)
- [ ] Legend matches heatmap colors
- [ ] Sites list shows frequency, power, antenna type
- [ ] Mobile panels don't block map completely
### Functional Tests:
- [ ] "Save & Calculate" button works (saves + calculates)
- [ ] "Save Only" button still works
- [ ] Coverage radius slider goes to 100km
- [ ] Large radius (50-100km) calculates without errors
- [ ] Dark/Light/System theme toggle works
- [ ] Theme persists after page refresh
### Keyboard Shortcuts:
- [ ] Ctrl/Cmd+S saves current site
- [ ] Ctrl/Cmd+Enter calculates coverage
- [ ] Ctrl/Cmd+N enters placement mode
- [ ] Esc cancels/closes
- [ ] H toggles heatmap visibility
### Mobile Tests (if possible):
- [ ] Panels slide up from bottom on mobile
- [ ] Touch targets are large enough (44x44px)
- [ ] Sliders work with touch
- [ ] Map can be panned/zoomed on mobile
### Error Handling:
- [ ] Helpful error messages for calculation failures
- [ ] Toast notifications for all actions
- [ ] No console errors
---
## IMPLEMENTATION NOTES
1. **Start with visual changes** (colors, legend) - quick wins
2. **Then functional changes** (button, radius, dark theme)
3. **Then UX improvements** (shortcuts, mobile, errors)
4. **Test thoroughly** - especially dark theme across all components
**Performance Note:**
Current calculation is instant because it's only FSPL without terrain data.
When Phase 4 (30m terrain) is implemented, large radius will take longer.
Performance warnings can be added then.
---
## FILES TO CREATE/MODIFY
### New Files:
- `frontend/src/store/settings.ts` - theme management
- `frontend/src/components/ui/ThemeToggle.tsx` - theme switcher
- `frontend/src/hooks/useKeyboardShortcuts.ts` - keyboard shortcuts
### Modified Files:
- `frontend/tailwind.config.js` - dark mode config
- `frontend/src/index.css` - dark theme CSS variables
- `frontend/src/App.tsx` - add ThemeToggle, use shortcuts hook
- `frontend/src/components/map/Heatmap.tsx` - new gradient
- `frontend/src/components/map/Legend.tsx` - new colors + stats
- `frontend/src/components/map/Map.tsx` - Fit/Reset buttons
- `frontend/src/components/panels/SiteForm.tsx` - Save&Calculate, radius
- `frontend/src/components/panels/SiteList.tsx` - more info display
- `frontend/src/store/coverage.ts` - better error handling, defaults
- `frontend/src/constants/rsrp-thresholds.ts` - new color constants
- All UI components - add dark mode classes
---
## SUCCESS CRITERIA
✅ New heatmap gradient is clearly visible
✅ Dark theme works flawlessly
✅ Coverage radius goes to 100km
✅ "Save & Calculate" is primary action
✅ Keyboard shortcuts work
✅ Mobile responsive
✅ Better error messages
✅ No TypeScript errors
✅ All existing features still work
---
**Estimated Time:** 20-30 minutes for full implementation
**Complexity:** Medium (mostly UI updates + new theme system)
**Risk:** Low (additive changes, not breaking existing functionality)
Good luck! 🚀

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,456 @@
# RFCP - Iteration 10.1: Critical Bugfixes
**Status:** Ready for Claude Code Implementation
**Priority:** P0 CRITICAL
**Time:** 30-60 minutes
---
## 🐛 Three Critical Issues Found
After Iteration 10 deployment, three critical bugs remain:
1.**Stack overflow crash at 50m resolution** - **ROOT CAUSE FOUND: Spread operator on large arrays**
2.**No confirmation on Delete key** - keyboard shortcut bypasses dialog
3.**Cyan/green coverage zone** - синьо-зелене коло на карті (visible on screenshot)
---
## 🔍 Bug Analysis
### Bug 1: Stack Overflow at 50m Resolution ⚡ ROOT CAUSE IDENTIFIED
**User Report:** "крашує на білий екран саме при розрахунку, натискання на кнопку"
**Stack Trace Analysis:**
```javascript
const M = b.map(ht => ht.rsrp) // Maps to RSRP array
const B = Math.min(...M) // ❌ CRASHES HERE!
const A = Math.max(...M) // ❌ AND HERE!
const C = M.reduce((ht, Nt) => ht + Nt, 0) / z
```
**ROOT CAUSE: Spread Operator Argument Limit**
JavaScript has a hard limit of ~65,000-125,000 function arguments (varies by engine):
- 50m resolution, 10km radius = ~158,000 points
- `Math.min(...array)` tries to pass 158,000 arguments
- **Exceeds engine limit → RangeError: Maximum call stack size exceeded**
**Why Previous Fix Didn't Work:**
Iteration 10 added `MAX_GRID_POINTS` cap and replaced `results.flat()`, but the **spread operator in Math.min/max was still there**!
```typescript
// ❌ This is still in the code:
const minRsrp = Math.min(...rsrpValues) // CRASHES with 150k+ items
const maxRsrp = Math.max(...rsrpValues) // CRASHES with 150k+ items
```
**Solution: Replace Spread with Reduce**
```typescript
// ❌ BAD (crashes on large arrays)
const minRsrp = Math.min(...rsrpValues)
const maxRsrp = Math.max(...rsrpValues)
// ✅ GOOD (works with ANY size array)
const minRsrp = rsrpValues.reduce((min, val) => Math.min(min, val), rsrpValues[0])
const maxRsrp = rsrpValues.reduce((max, val) => Math.max(max, val), rsrpValues[0])
// ✅ EVEN BETTER (slightly faster for large arrays)
const minRsrp = rsrpValues.reduce((min, val) => val < min ? val : min, rsrpValues[0])
const maxRsrp = rsrpValues.reduce((max, val) => val > max ? val : max, rsrpValues[0])
```
**Files to Fix:**
Search for ALL instances of:
```bash
grep -rn "Math.min\(\.\.\..*\)" src/
grep -rn "Math.max\(\.\.\..*\)" src/
```
Most likely locations:
- `src/store/coverage.ts` - coverage statistics calculation
- `src/lib/calculator.ts` - coverage calculations
- `src/components/panels/CoverageStats.tsx` - stats display
**Performance Comparison:**
- `Math.min(...array)`: ✅ Fast BUT ❌ Crashes >65k elements
- `reduce()`: ✅ Always works, ✅ Only ~2x slower
- For 200k elements: ~30ms (still imperceptible to user)
---
### Bug 2: No Confirmation on Delete Key
**User Report:** "нема підтвердження видалення сайту коли видаляєш кнопкою del"
**Current Behavior:**
- Delete button in UI → Shows confirmation dialog ✅
- Keyboard shortcut (Delete key) → Deletes immediately ❌
**Root Cause:**
Keyboard handler calls `deleteSite()` directly, bypassing the confirmation wrapper.
**Likely code pattern:**
```typescript
// Somewhere in App.tsx or SitesPanel.tsx
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Delete' && selectedSiteId) {
deleteSite(selectedSiteId); // ❌ No confirmation!
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedSiteId, deleteSite]);
```
**Solution:**
Find the existing delete button's onClick handler and reuse it:
```typescript
// Example: In SitesPanel.tsx or similar
// Existing delete button logic (already has confirmation)
const handleDeleteClick = useCallback(() => {
if (!selectedSite) return;
if (!window.confirm(`Delete site "${selectedSite.name}"?`)) {
return;
}
const deletedSite = { ...selectedSite };
deleteSite(selectedSiteId);
// Show undo toast
toast.success('Site deleted', {
duration: 10000,
action: {
label: 'Undo',
onClick: () => addSite(deletedSite),
},
});
}, [selectedSite, selectedSiteId, deleteSite, addSite]);
// Update keyboard handler to use SAME function
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Delete' && selectedSiteId) {
e.preventDefault();
handleDeleteClick(); // ✅ Reuses confirmation logic!
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedSiteId, handleDeleteClick]);
```
**Search for:**
```bash
grep -rn "key.*Delete\|Delete.*key" src/
grep -rn "handleKeyDown\|onKeyDown" src/
```
**Files to check:**
- `src/App.tsx` - main keyboard handlers
- `src/components/panels/SitesPanel.tsx` - site list with delete button
- `src/components/map/Map.tsx` - map-level keyboard handlers
---
### Bug 3: Cyan/Green Coverage Zone
**User Report:** "колір зони синьо - зелений :)" (visible on screenshot)
**Visual Evidence:**
- Синьо-зелене (cyan) коло видно навколо сайту
- Це НЕ частина RSRP gradient (який orange → red → dark red)
- Виглядає як dashed circle або зона
**Most Likely Source: Leaflet Circle Component**
```typescript
// Probably in src/components/map/SiteMarker.tsx or Map.tsx
<Circle
center={[site.lat, site.lon]}
radius={site.radius * 1000}
pathOptions={{
color: '#00bcd4', // ❌ Cyan color - this is it!
weight: 2,
dashArray: '10, 5',
fillOpacity: 0
}}
/>
```
**Investigation Steps:**
```bash
# 1. Find all Circle components
grep -rn "<Circle" src/components/map/
# 2. Check for cyan/blue colors
grep -rn "#00bcd4\|#00ff00\|cyan\|#0000ff" src/
# 3. Check pathOptions
grep -rn "pathOptions" src/components/map/
```
**Solution Options:**
**Option A: Remove the circle completely** (recommended if not needed)
```typescript
// Just delete or comment out:
{/* <Circle ... /> */}
```
**Option B: Change color to orange** (if circle is useful)
```typescript
<Circle
center={[site.lat, site.lon]}
radius={site.radius * 1000}
pathOptions={{
color: '#ff9800', // ✅ Orange (not in RSRP gradient)
weight: 2,
opacity: 0.6,
dashArray: '15, 10', // Longer dashes
fillOpacity: 0
}}
/>
```
**Option C: Make it toggleable**
```typescript
// Add to coverage settings store
const [showCalculationBounds, setShowCalculationBounds] = useState(false);
// In settings panel:
<label>
<input
type="checkbox"
checked={showCalculationBounds}
onChange={(e) => setShowCalculationBounds(e.target.checked)}
/>
Show Calculation Bounds
</label>
// In map:
{showCalculationBounds && (
<Circle
center={[site.lat, site.lon]}
radius={site.radius * 1000}
pathOptions={{ color: '#ff9800', ... }}
/>
)}
```
**Recommended:** Start with Option A (remove), can add back later if needed.
---
## 📋 Implementation Checklist for Claude Code
### Phase 1: Fix Spread Operator Crash (P0 - HIGHEST PRIORITY)
**Task 1.1: Find all Math.min/max with spread operators**
```bash
cd /opt/rfcp/frontend
grep -rn "Math.min(\.\.\." src/
grep -rn "Math.max(\.\.\." src/
```
**Task 1.2: Replace ALL instances**
Find patterns like:
```typescript
Math.min(...array)
Math.max(...array)
```
Replace with:
```typescript
array.reduce((min, val) => val < min ? val : min, array[0])
array.reduce((max, val) => val > max ? val : max, array[0])
```
**Task 1.3: Add safety checks**
```typescript
// Before using reduce, ensure array is not empty:
if (array.length === 0) {
return { minRsrp: 0, maxRsrp: 0, avgRsrp: 0 };
}
```
**Expected files to modify:**
- `src/store/coverage.ts` - most likely location
- `src/lib/calculator.ts` - possible location
- `src/components/panels/CoverageStats.tsx` - stats display
---
### Phase 2: Fix Delete Key Confirmation (P0)
**Task 2.1: Find keyboard event handler**
```bash
grep -rn "key.*Delete\|Delete.*key" src/
grep -rn "e.key === 'Delete'" src/
```
**Task 2.2: Find existing delete button confirmation**
```bash
grep -rn "window.confirm.*[Dd]elete" src/
grep -rn "handleDelete" src/
```
**Task 2.3: Make keyboard handler use same confirmation logic**
Pattern to find:
```typescript
if (e.key === 'Delete' && selectedSiteId) {
deleteSite(selectedSiteId); // ❌ No confirmation
}
```
Replace with:
```typescript
if (e.key === 'Delete' && selectedSiteId) {
e.preventDefault();
handleDeleteClick(); // ✅ Uses existing confirmation
}
```
---
### Phase 3: Fix Cyan/Green Circle (P1)
**Task 3.1: Find Circle component with cyan color**
```bash
grep -rn "<Circle" src/components/map/
grep -rn "#00bcd4\|cyan" src/
```
**Task 3.2: Decide on solution**
- If circle not needed: delete it
- If useful: change color to `#ff9800` (orange)
- If optional: add toggle in settings
**Task 3.3: Implement chosen solution**
---
### Phase 4: Testing (P0)
**Test 1: Stack Overflow Fix**
- [ ] Create site with 10km radius
- [ ] Set resolution to 50m
- [ ] Click "Calculate Coverage"
- [ ] **Expected:** No crash, calculation completes
- [ ] **Verify:** Console has no errors
**Test 2: Delete Confirmation**
- [ ] Create a site
- [ ] Select it (click marker)
- [ ] Press Delete key
- [ ] **Expected:** Confirmation dialog appears
- [ ] Click OK → Site deleted + undo toast shown
- [ ] Create another site, select, press Delete
- [ ] Click Cancel → Nothing happens
**Test 3: Circle Color**
- [ ] Create a site
- [ ] Calculate coverage
- [ ] Zoom in/out
- [ ] **Expected:** No cyan/green circles visible
- [ ] **Verify:** Only RSRP gradient (orange→red→dark red)
---
### Phase 5: Deployment
```bash
# After all fixes:
npm run build
npm run type-check # Should pass
npm run preview # Test locally
# Then deploy to VPS
# (copy dist/ to /opt/rfcp/frontend/dist/)
sudo systemctl reload caddy
```
---
## 🤖 Instructions for Claude Code
**Context:** This is a React + TypeScript + Vite frontend project for RF coverage planning.
**Project Location:** `/opt/rfcp/frontend/` on VPS (10.10.10.1)
**Critical Fixes Needed:**
1. **Spread operator crash** - Replace `Math.min(...array)` with `reduce()` pattern
2. **Delete key bypass** - Add confirmation to keyboard handler
3. **Cyan circle** - Find and remove/recolor Circle component
**Priority:** Fix #1 first (blocks all usage), then #2, then #3.
**Search Strategy:**
- Use `grep -rn` to find problematic patterns
- Focus on `src/store/coverage.ts` first for Bug #1
- Look in `src/App.tsx` or `src/components/panels/SitesPanel.tsx` for Bug #2
- Check `src/components/map/` for Bug #3
**Testing:**
- Must test 50m resolution after Bug #1 fix
- Must test Delete key after Bug #2 fix
- Verify no cyan circles after Bug #3 fix
**Build Commands:**
```bash
npm run build # Production build
npm run type-check # Verify TypeScript
npm run preview # Test locally
```
**Success Criteria:**
- ✅ No crash at 50m resolution
- ✅ Delete key shows confirmation
- ✅ No cyan/green circles on map
- ✅ TypeScript passes
- ✅ No console errors
---
## 📝 Additional Context
**RSRP Gradient Colors (for reference):**
- Excellent (>-80 dBm): `#ff6b35` (orange)
- Good (-80 to -95): `#ff4444` (red)
- Fair (-95 to -105): `#cc0000` (dark red)
- Poor (<-105): `#8b0000` (very dark red)
**Acceptable Colors for Circles (not in gradient):**
- Orange: `#ff9800`
- Purple: `#9c27b0`
- Black: `#000000`
**NOT acceptable (conflicts with gradient):**
- Red shades
- Orange shades
- Cyan/blue: `#00bcd4` ❌ (this is the bug!)
---
## 🎯 Success = All Three Bugs Fixed + Tested
**Estimated time:** 30-60 minutes
**Deploy when:** All tests pass + no TypeScript errors

View File

@@ -0,0 +1,319 @@
# RFCP - Iteration 10.2: Purple → Orange Gradient
**Goal:** Replace current warm palette (maroon→yellow) with purple→orange palette for better map contrast and intuitive UX
**Priority:** P2 (UX Polish)
**Estimated Time:** 15-20 minutes
**Status:** Ready for Implementation
---
## 📋 Overview
After Iteration 10.1 fixed the cyan/green color conflict, user feedback indicated the current warm palette (yellow=good, red=bad) feels counter-intuitive. This iteration implements a purple→orange gradient that:
- Avoids map feature conflicts (no purple on maps)
- Provides intuitive "heat" visualization (orange = hot/strong signal)
- Maintains professional RF tool aesthetic
---
## 🐛 Current Issue
**User Feedback:** "тепер зона червона, а градієнт жовтий :) хз навіть як краще було"
**Problem:**
- Yellow (#ffeb3b) = excellent signal → near antenna center
- Red/Maroon (#8b0000) = weak signal → coverage edges
- This feels **counter-intuitive** (users expect red = danger/bad)
**Current Palette (Iteration 10.1):**
```
Deep Maroon → Red → Orange → Yellow
(weak) ←──────────────→ (strong)
#4a0000 → #8b0000 → #ff9800 → #ffeb3b
```
---
## ✅ Solution: Purple → Orange Palette
**New Palette:**
```
Deep Purple → Purple → Lavender → Peach → Orange → Bright Orange
(weak) ←────────────────────────────────────→ (strong)
```
**Color Values:**
```typescript
export function createRsrpGradient(): string[] {
return [
'#1a0033', // Deep purple (very poor signal, -130 dBm)
'#4a148c', // Dark purple
'#7b1fa2', // Purple
'#ab47bc', // Light purple / Lavender
'#ff8a65', // Peach / Light orange
'#ff6f00', // Dark orange
'#ffb74d', // Bright orange (excellent signal, -50 dBm)
];
}
```
**Why Purple → Orange:**
-**No map conflicts:** Purple doesn't appear on OpenTopoMap or OpenStreetMap
-**Intuitive heat:** Orange = "hot" = strong signal (like thermal imaging)
-**Professional aesthetic:** Used in professional RF planning tools
-**Good contrast:** Purple/Orange highly distinguishable
-**Colorblind-friendly:** Purple-orange works for most color vision types
---
## 📁 Files to Modify
### 1. `src/utils/colorGradient.ts`
**Current Code (lines ~5-15):**
```typescript
export function createRsrpGradient(): string[] {
return [
'#4a0000', // Deep maroon (very poor signal)
'#8b0000', // Dark red
'#cc0000', // Red
'#ff0000', // Bright red
'#ff4444', // Orange-red
'#ff9800', // Orange
'#ffeb3b', // Yellow (excellent signal)
];
}
```
**Replace With:**
```typescript
export function createRsrpGradient(): string[] {
return [
'#1a0033', // Deep purple (very poor signal, -130 dBm)
'#4a148c', // Dark purple
'#7b1fa2', // Purple
'#ab47bc', // Light purple / Lavender
'#ff8a65', // Peach / Light orange
'#ff6f00', // Dark orange
'#ffb74d', // Bright orange (excellent signal, -50 dBm)
];
}
```
### 2. `src/constants/rsrp-thresholds.ts`
**Find and Update SIGNAL_COLORS:**
```typescript
// Old:
export const SIGNAL_COLORS = {
excellent: '#ffeb3b', // Yellow
good: '#ff9800', // Orange
fair: '#ff4444', // Orange-red
poor: '#ff0000', // Red
veryPoor: '#cc0000', // Dark red
terrible: '#8b0000', // Very dark red
};
// New:
export const SIGNAL_COLORS = {
excellent: '#ffb74d', // Bright orange (-50 to -70 dBm)
good: '#ff6f00', // Dark orange (-70 to -85 dBm)
fair: '#ff8a65', // Peach (-85 to -100 dBm)
poor: '#ab47bc', // Light purple (-100 to -110 dBm)
veryPoor: '#7b1fa2', // Purple (-110 to -120 dBm)
terrible: '#4a148c', // Dark purple (-120 to -130 dBm)
};
```
### 3. Check Legend Component (if exists)
**Search for hardcoded colors:**
```bash
grep -rn "#ffeb3b\|#ff9800\|#8b0000" src/
```
If found in legend or other components, update to match new palette.
---
## 🔍 Investigation Commands
**Before making changes, verify file locations:**
```bash
# Find gradient file
find /opt/rfcp/frontend/src -name "colorGradient.ts" -o -name "*gradient*"
# Find threshold constants
find /opt/rfcp/frontend/src -name "*threshold*" -o -name "*rsrp*"
# Check for hardcoded old colors
grep -rn "#ffeb3b\|#4a0000\|#8b0000" /opt/rfcp/frontend/src/
# Check legend component
find /opt/rfcp/frontend/src -name "*Legend*" -o -name "*legend*"
```
---
## 🧪 Testing Checklist
### Visual Testing
- [ ] **Gradient smoothness:** Colors transition smoothly from purple to orange
- [ ] **Center coverage:** Bright orange (#ffb74d) visible near antenna
- [ ] **Edge coverage:** Deep purple (#1a0033) visible at coverage edges
- [ ] **Map contrast:** No color conflicts with OpenTopoMap features
- [ ] **Different zoom levels:** Gradient looks correct at zoom 8, 12, 16
### Legend Testing
- [ ] **Legend colors match:** RSRP legend shows new color scheme
- [ ] **Labels correct:** dBm values still display correctly
- [ ] **Readability:** Legend text readable against new colors
### Regression Testing
- [ ] **50m resolution:** Still works without crash (Iteration 10.1 fix intact)
- [ ] **Delete confirmation:** Still shows dialog (Iteration 10.1 fix intact)
- [ ] **Keyboard shortcuts:** All shortcuts still functional
- [ ] **Multi-sector:** Coverage calculation still correct
### Edge Cases
- [ ] **Single site:** Gradient displays correctly
- [ ] **Multiple sites:** Overlapping coverage shows strongest signal
- [ ] **Terrain enabled:** Gradient still visible with terrain layer
- [ ] **Dark mode:** Colors work in dark mode (if applicable)
---
## 🏗️ Build & Deploy
```bash
# Navigate to frontend
cd /opt/rfcp/frontend
# Install dependencies (if needed)
npm install
# Run development server to test
npm run dev
# Build for production
npm run build
# Check for errors
# Expected: 0 TypeScript errors, 0 ESLint errors
# Deploy (reload Caddy)
sudo systemctl reload caddy
# Verify deployment
curl -s https://rfcp.eliah.one | head -20
```
---
## 📝 Commit Message
```
feat(heatmap): replace warm palette with purple→orange gradient
- Changed RSRP gradient from maroon→yellow to purple→orange
- Updated SIGNAL_COLORS constants to match new palette
- Better map contrast (purple not present on maps)
- More intuitive heat visualization (orange = strong signal)
Colors: #1a0033 (weak) → #ffb74d (strong)
Iteration: 10.2
Fixes: User feedback on gradient aesthetics
```
---
## ✅ Success Criteria
**Must Pass:**
1. ✅ Gradient displays purple (weak) → orange (strong)
2. ✅ No cyan, green, or blue colors anywhere
3. ✅ Legend matches new color scheme
4. ✅ No regression in 50m resolution performance
5. ✅ TypeScript: 0 errors
6. ✅ ESLint: 0 errors
7. ✅ Production build succeeds
**User Acceptance:**
- Олег confirms: "виглядає краще" or "норм"
- If not satisfied → Iteration 10.3 with alternative palette
---
## 🎨 Alternative Palettes (If Needed)
**Option C: Grayscale → Orange (Fallback)**
```typescript
return [
'#1a1a1a', // Near black (very poor)
'#4a4a4a', // Dark gray
'#808080', // Medium gray
'#b3b3b3', // Light gray
'#ff6f00', // Dark orange
'#ff9800', // Orange
'#ffb74d', // Light orange (excellent)
];
```
**Option D: Blue → Red (Classic Heat)**
```typescript
return [
'#0d47a1', // Dark blue (very poor)
'#1976d2', // Blue
'#42a5f5', // Light blue
'#fff176', // Yellow
'#ff9800', // Orange
'#f44336', // Red
'#b71c1c', // Dark red (excellent)
];
```
---
## 📊 Color Reference Table
| RSRP Range | Signal Quality | Old Color | New Color | Hex |
|------------|----------------|-----------|-----------|-----|
| > -70 dBm | Excellent | Yellow | Bright Orange | #ffb74d |
| -70 to -85 | Good | Orange | Dark Orange | #ff6f00 |
| -85 to -100 | Fair | Orange-Red | Peach | #ff8a65 |
| -100 to -110 | Poor | Red | Light Purple | #ab47bc |
| -110 to -120 | Very Poor | Dark Red | Purple | #7b1fa2 |
| < -120 dBm | Terrible | Maroon | Dark Purple | #4a148c |
| < -130 dBm | No Service | Deep Maroon | Deep Purple | #1a0033 |
---
## 🤖 Instructions for Claude Code
**Context:**
- Project: RFCP (RF Coverage Planning) - `/opt/rfcp/frontend/`
- Framework: React 18 + TypeScript + Vite + Leaflet
- This is a simple color palette change in 2-3 files
**Implementation Steps:**
1. Search for `colorGradient.ts` and update gradient array
2. Search for `rsrp-thresholds.ts` and update SIGNAL_COLORS
3. Search for any hardcoded old colors and update them
4. Run `npm run build` to verify no errors
5. Test at https://rfcp.eliah.one after deploy
**Priority:** Quick UX fix, should take ~15 minutes
**Success:** User sees purple (weak) → orange (strong) gradient on map
---
**Document Created:** 2025-01-30
**Author:** Claude (Sonnet 4.5) + Олег
**Status:** Ready for Implementation
**Next:** Віддати Claude Code → Test → Screenshot → Confirm

View File

@@ -0,0 +1,487 @@
# 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

View File

@@ -0,0 +1,320 @@
# RFCP - Iteration 10.3.1: Heatmap Threshold Filter Fix
**Goal:** Fix purple "weak signal" still rendering by adding RSRP threshold filtering to heatmap renderer
**Priority:** P1 (Bug Fix)
**Estimated Time:** 15-20 minutes
**Status:** Ready for Implementation
---
## 📋 Overview
Iteration 10.3 added coverage boundary and changed default Min Signal to -100 dBm, but the heatmap tile renderer still draws all points including those below threshold. This creates the misleading purple fill that should have been removed.
**Root Cause:** `HeatmapTileRenderer.ts` filters points only by geographic bounds, not by RSRP threshold.
---
## 🐛 Current Bug
**Symptom:** Purple/lavender fill visible outside the "useful" coverage zone despite Min Signal = -100 dBm
**Location:** `src/components/map/HeatmapTileRenderer.ts` lines 100-107
**Current Code:**
```typescript
// Filter relevant points
const relevant = points.filter(
(p) =>
p.lat >= latMin - bufferDeg &&
p.lat <= latMax + bufferDeg &&
p.lon >= lonMin - bufferDeg &&
p.lon <= lonMax + bufferDeg
);
```
**Problem:** No RSRP threshold check — all points render regardless of signal strength.
---
## ✅ Solution
Add `rsrpThreshold` parameter to the rendering pipeline and filter out weak signals.
### Step 1: Update HeatmapTileRenderer.ts
**File:** `src/components/map/HeatmapTileRenderer.ts`
#### 1.1 Add threshold to class state
```typescript
// Around line 35, add new property:
export class HeatmapTileRenderer {
private tileSize = 256;
private radiusMeters: number;
private rsrpThreshold: number; // NEW
// LRU cache...
constructor(radiusMeters = 400, maxCacheSize = 150, rsrpThreshold = -100) {
this.radiusMeters = radiusMeters;
this.maxCacheSize = maxCacheSize;
this.rsrpThreshold = rsrpThreshold; // NEW
}
```
#### 1.2 Add setter for threshold
```typescript
// After setRadiusMeters(), add:
/** Update the RSRP threshold - points below this are not rendered. */
setRsrpThreshold(threshold: number): void {
if (threshold !== this.rsrpThreshold) {
this.rsrpThreshold = threshold;
this.clearCache(); // Important: invalidate cache when threshold changes
}
}
```
#### 1.3 Update renderTile() filter
```typescript
// Around line 100, update the filter:
// Filter relevant points - geographic bounds AND RSRP threshold
const relevant = points.filter(
(p) =>
p.rsrp >= this.rsrpThreshold && // 🔥 NEW: skip weak signals
p.lat >= latMin - bufferDeg &&
p.lat <= latMax + bufferDeg &&
p.lon >= lonMin - bufferDeg &&
p.lon <= lonMax + bufferDeg
);
```
### Step 2: Update GeographicHeatmap.tsx
**File:** `src/components/map/GeographicHeatmap.tsx`
#### 2.1 Add threshold prop
```typescript
interface GeographicHeatmapProps {
points: HeatmapPoint[];
visible: boolean;
opacity?: number;
radiusMeters?: number;
rsrpThreshold?: number; // NEW
}
export default function GeographicHeatmap({
points,
visible,
opacity = 0.7,
radiusMeters = 400,
rsrpThreshold = -100, // NEW
}: GeographicHeatmapProps) {
```
#### 2.2 Update renderer when threshold changes
```typescript
// After the useEffect for radiusMeters, add:
// Update renderer threshold when prop changes
useEffect(() => {
rendererRef.current.setRsrpThreshold(rsrpThreshold);
}, [rsrpThreshold]);
```
#### 2.3 Include threshold in cache invalidation
```typescript
// Update the pointsHash useEffect to include threshold:
useEffect(() => {
if (points.length === 0) {
rendererRef.current.setPointsHash('empty');
return;
}
const first = points[0];
const last = points[points.length - 1];
// Include threshold in hash so cache invalidates when it changes
const hash = `${points.length}:${first.lat.toFixed(4)},${first.lon.toFixed(4)}:${last.rsrp}:${rsrpThreshold}`;
rendererRef.current.setPointsHash(hash);
}, [points, rsrpThreshold]);
```
### Step 3: Update App.tsx
**File:** `src/App.tsx`
Pass threshold from settings to GeographicHeatmap:
```typescript
{coverageResult && (
<>
<GeographicHeatmap
points={coverageResult.points}
visible={heatmapVisible}
opacity={settings.heatmapOpacity}
radiusMeters={settings.heatmapRadius}
rsrpThreshold={settings.rsrpThreshold} // NEW
/>
<CoverageBoundary
points={coverageResult.points}
visible={heatmapVisible}
resolution={settings.resolution}
/>
</>
)}
```
---
## 📁 Files to Modify
| File | Changes |
|------|---------|
| `src/components/map/HeatmapTileRenderer.ts` | Add `rsrpThreshold` property, setter, filter in renderTile() |
| `src/components/map/GeographicHeatmap.tsx` | Add `rsrpThreshold` prop, pass to renderer, update cache hash |
| `src/App.tsx` | Pass `settings.rsrpThreshold` to GeographicHeatmap |
**Total:** 3 files, ~20 lines changed
---
## 🧪 Testing Checklist
### Visual Testing
- [ ] **No purple fill:** Only orange gradient visible (no purple/lavender outside)
- [ ] **Threshold respected:** Changing Min Signal slider updates heatmap immediately
- [ ] **Boundary matches:** Dashed border aligns with heatmap edge
- [ ] **Different thresholds:** Test -90, -100, -110 — heatmap adjusts accordingly
### Functional Testing
- [ ] **Slider reactive:** Moving Min Signal slider updates heatmap without "Calculate Coverage"
- [ ] **Cache invalidation:** Threshold change clears old tiles, renders new ones
- [ ] **Zoom levels:** Works at zoom 8, 12, 16
- [ ] **Multiple sites:** Each site respects threshold
### Regression Testing
- [ ] **50m resolution:** Still works without crash
- [ ] **Performance:** No significant slowdown
- [ ] **Boundary component:** Still renders correctly
- [ ] **Legend:** Still shows correct colors and threshold indicator
---
## 🏗️ Build & Deploy
```bash
cd /opt/rfcp/frontend
# Build
npm run build
# Expected: 0 TypeScript errors, 0 ESLint errors
# Deploy
sudo systemctl reload caddy
# Test at https://rfcp.eliah.one
# 1. Set Min Signal to -100 dBm
# 2. Verify NO purple visible outside orange zone
# 3. Move slider to -90 — coverage zone shrinks
# 4. Move slider to -110 — coverage zone expands (some purple appears)
```
---
## 📝 Commit Message
```
fix(heatmap): filter points by RSRP threshold to remove purple fill
- Added rsrpThreshold property to HeatmapTileRenderer
- Filter points below threshold before rendering tiles
- Cache invalidates when threshold changes
- Passed threshold from settings through GeographicHeatmap
Points below Min Signal threshold are now completely hidden,
not just dimmed. This removes the misleading purple "weak signal"
fill that made it look like coverage existed where it doesn't.
Iteration: 10.3.1
Fixes: Purple fill still visible after 10.3
```
---
## ✅ Success Criteria
**Must Pass:**
1. ✅ No purple/lavender fill when Min Signal = -100 dBm
2. ✅ Heatmap updates when Min Signal slider moves
3. ✅ Only orange gradient visible (excellent → fair signal)
4. ✅ Dashed boundary aligns with heatmap edge
5. ✅ TypeScript: 0 errors
6. ✅ ESLint: 0 errors
**User Acceptance:**
- Олег confirms: "фіолету нема, тільки оранжевий градієнт"
---
## 🎨 Expected Visual Result
**Before (10.3):**
```
┌─────────────────────────────┐
│ ▓▓▓ Purple fill ▓▓▓▓▓▓▓▓▓▓ │ ← Still visible!
│ ▓▓┌─ ─ ─ ─ ─ ─ ─ ─ ─┐▓▓▓▓ │
│ ▓▓│ Orange gradient │▓▓▓▓ │
│ ▓▓└─ ─ ─ ─ ─ ─ ─ ─ ─┘▓▓▓▓ │
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
└─────────────────────────────┘
```
**After (10.3.1):**
```
┌─ ─ ─ ─ ─ ─ ─┐
│ │
─ ─ ─┤ 🟠 Orange ├─ ─ ─ ← Dashed border
│ gradient │
│ (no purple │
│ outside!) │
└─ ─ ─ ─ ─ ─ ─┘
```
---
## 🤖 Instructions for Claude Code
**Context:**
- Project: RFCP - `/opt/rfcp/frontend/`
- Bug: Purple fill still renders despite Min Signal = -100 dBm
- Root cause: `HeatmapTileRenderer.ts` doesn't filter by threshold
**Implementation Order:**
1. `HeatmapTileRenderer.ts` — add threshold property + setter + filter
2. `GeographicHeatmap.tsx` — add prop, pass to renderer, update hash
3. `App.tsx` — pass `settings.rsrpThreshold` to component
**Key Points:**
- Threshold setter MUST call `clearCache()` to invalidate tiles
- Include threshold in pointsHash for proper cache invalidation
- Default threshold = -100 dBm (matches store default)
**Success:** User sees only orange gradient, no purple fill outside coverage zone
---
**Document Created:** 2025-01-30
**Author:** Claude (Opus 4.5) + Олег
**Status:** Ready for Implementation
**Depends On:** Iteration 10.3 (boundary component)

View File

@@ -0,0 +1,207 @@
# RFCP Iteration 10.3.2 — Fix Coverage Boundary Rendering
**Date:** 2025-01-30
**Status:** Ready for Implementation
**Priority:** High
**Estimated Effort:** 30-45 minutes
---
## Problem Statement
CoverageBoundary component renders but is nearly invisible:
- Boundary polygon is only **25x25 pixels** instead of covering the entire coverage area
- Edge detection algorithm returns only **~11 points** instead of hundreds
- Path exists in DOM with correct styling but wrong geometry
### Debug Evidence
```
[CoverageBoundary] Computing: {visible: true, pointsCount: 9132, resolution: 200}
[CoverageBoundary] Paths: 1
```
```javascript
document.querySelectorAll('.leaflet-overlay-pane path')[1].getBoundingClientRect()
// → DOMRect {width: 25, height: 25} // Should be ~500x500 or larger!
// Path has only 11 vertices:
// M780 422L774 422L768 416L768 402L773 397L787 397L793 403L793 415L791 419L786 422L780 422
```
---
## Root Cause Analysis
In `/opt/rfcp/frontend/src/components/map/CoverageBoundary.tsx`, function `computeEdgePath()`:
1. **Grid cell size too coarse:**
- Cell size = `resolution` (200m) converted to degrees
- With 9132 points spread over coverage area, most cells have neighbors
- Only ~11 cells on the very edge are detected as "boundary"
2. **Angular sorting from centroid fails for sector shapes:**
- Coverage is a sector (wedge), not a circle
- Sorting by angle from centroid produces zigzag paths for concave shapes
3. **Single representative point per cell:**
- Algorithm picks one point per grid cell
- Loses boundary detail when cells are large
---
## Solution: Use Turf.js Concave Hull
Replace custom edge detection with `@turf/concave` — purpose-built for this exact use case.
### Why Turf.js?
- **Concave hull** follows actual shape (sectors, irregular coverage)
- **Battle-tested** library used in GIS applications
- **Configurable** maxEdge parameter controls detail level
- **Fast** — optimized for thousands of points
---
## Implementation Plan
### Step 1: Install Dependencies
```bash
cd /opt/rfcp/frontend
npm install @turf/concave @turf/helpers
```
### Step 2: Rewrite computeEdgePath()
Replace the entire `computeEdgePath` function with:
```typescript
import concave from '@turf/concave';
import { featureCollection, point } from '@turf/helpers';
/**
* Compute concave hull boundary for coverage points.
* Uses Turf.js concave hull algorithm (alpha shape).
*
* @param pts - Coverage points for one site
* @param resolutionM - Resolution in meters, used to set maxEdge
* @returns Ordered boundary coordinates for Leaflet polyline
*/
function computeEdgePath(
pts: CoveragePoint[],
resolutionM: number
): L.LatLngExpression[] {
if (pts.length < 3) return [];
// Convert to GeoJSON points
const features = pts.map(p => point([p.lon, p.lat]));
const fc = featureCollection(features);
// Compute concave hull
// maxEdge in kilometers — use resolution * 3 for good detail
const maxEdge = (resolutionM * 3) / 1000;
try {
const hull = concave(fc, { maxEdge, units: 'kilometers' });
if (!hull || hull.geometry.type !== 'Polygon') {
console.warn('[CoverageBoundary] Concave hull failed, falling back to convex');
return [];
}
// Extract coordinates (GeoJSON is [lon, lat], Leaflet needs [lat, lon])
const coords = hull.geometry.coordinates[0];
return coords.map(([lon, lat]) => [lat, lon] as L.LatLngExpression);
} catch (error) {
console.error('[CoverageBoundary] Hull computation error:', error);
return [];
}
}
```
### Step 3: Update Imports at Top of File
```typescript
import { useEffect, useRef, useMemo } from 'react';
import { useMap } from 'react-leaflet';
import L from 'leaflet';
import concave from '@turf/concave';
import { featureCollection, point } from '@turf/helpers';
import type { CoveragePoint } from '@/types/index.ts';
```
### Step 4: Filter Points by Threshold
**Important:** Currently CoverageBoundary receives ALL points, but heatmap filters by `rsrpThreshold`. Boundary should match heatmap edge.
In `App.tsx`, filter points before passing:
```tsx
<CoverageBoundary
points={coverageResult.points.filter(p => p.rsrp >= settings.rsrpThreshold)}
visible={heatmapVisible}
resolution={settings.resolution}
/>
```
### Step 5: Remove Debug Logging
After confirming fix works, remove the `console.log` statements added during debugging.
---
## Files to Modify
| File | Changes |
|------|---------|
| `package.json` | Add @turf/concave, @turf/helpers |
| `src/components/map/CoverageBoundary.tsx` | Replace computeEdgePath with Turf.js implementation |
| `src/App.tsx` | Filter coverage points by rsrpThreshold |
---
## Expected Results
### Before (Current Bug)
- Boundary: 25x25 pixels, ~11 vertices
- Not visible at normal zoom
### After (Fixed)
- Boundary: Follows heatmap edge closely
- Purple dashed line (#7c3aed) visible around orange gradient
- Updates when Min Signal slider changes
- Properly handles sector shapes (wedges, not just circles)
---
## Testing Checklist
- [ ] Boundary visible around coverage area
- [ ] Boundary follows actual coverage shape (sector/wedge)
- [ ] Boundary updates when resolution changes
- [ ] Boundary updates when Min Signal threshold changes
- [ ] No console errors
- [ ] Performance acceptable (< 100ms for 10k points)
---
## Rollback Plan
If Turf.js causes issues, revert to previous edge detection but with smaller grid cells:
```typescript
// In computeEdgePath, change:
const cellLat = resolutionM / 111_000;
// To:
const cellLat = (resolutionM / 4) / 111_000; // 4x finer grid
```
---
## Reference
- Turf.js concave: https://turfjs.org/docs/#concave
- Leaflet polyline: https://leafletjs.com/reference.html#polyline
- Previous iteration: RFCP-Iteration10.3.1-Threshold-Filter-Fix.md

View File

@@ -0,0 +1,170 @@
# RFCP Iteration 10.4 — Fix Stadia Maps 401 Error on Mobile
**Date:** 2025-01-30
**Status:** Ready for Implementation
**Priority:** Medium
**Estimated Effort:** 10-15 minutes
---
## Problem Statement
On mobile Safari (iOS), the Topo/Elev map tiles show:
```
401 Error
Invalid Authentication
docs.stadiamaps.com/authentication
```
**Root Cause:** Stadia Maps requires API key authentication for production use. Desktop browsers may work due to referrer/caching, but mobile Safari enforces stricter policies.
**Affected Features:**
- Topo button (topographic map layer)
- Elev button (elevation/terrain layer)
---
## Solution
Replace Stadia Maps tiles with **OpenTopoMap** — a free, open-source topographic map that requires no authentication.
### Why OpenTopoMap?
| Feature | Stadia (Stamen) | OpenTopoMap |
|---------|-----------------|-------------|
| API Key Required | Yes | **No** |
| Rate Limits | 200k/month | None (fair use) |
| Mobile Support | Requires auth | **Works everywhere** |
| Contour Lines | Stylized | **More detailed** |
| Style | Artistic | Technical/Professional |
| Best For | Design | **RF Planning** ✓ |
OpenTopoMap is actually **better suited** for RF coverage planning — more technical appearance with detailed elevation contours.
---
## Implementation
### File to Modify
`/opt/rfcp/frontend/src/components/map/Map.tsx`
### Find Current Stadia URLs
Look for URLs containing:
- `tiles.stadiamaps.com`
- `stamen_terrain`
- `stamen_toner`
### Replace With OpenTopoMap
**Before (Stadia Maps):**
```typescript
const topoLayer = L.tileLayer(
'https://tiles.stadiamaps.com/tiles/stamen_terrain/{z}/{x}/{y}{r}.png',
{
attribution: '&copy; Stamen Design, &copy; OpenMapTiles, &copy; OpenStreetMap',
maxZoom: 18,
}
);
```
**After (OpenTopoMap):**
```typescript
const topoLayer = L.tileLayer(
'https://tile.opentopomap.org/{z}/{x}/{y}.png',
{
attribution: 'Map data: &copy; <a href="https://openstreetmap.org">OpenStreetMap</a> contributors, <a href="http://viewfinderpanoramas.org">SRTM</a> | Map style: &copy; <a href="https://opentopomap.org">OpenTopoMap</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)',
maxZoom: 17, // OpenTopoMap max zoom is 17
}
);
```
### Also Check for Elevation Layer
If there's a separate elevation/terrain layer, replace similarly:
**Before:**
```typescript
'https://tiles.stadiamaps.com/tiles/stamen_terrain_background/{z}/{x}/{y}{r}.png'
```
**After:**
```typescript
'https://tile.opentopomap.org/{z}/{x}/{y}.png'
```
### Alternative: Keep Both Options
If you want to keep Stadia as an option (for desktop users who like the style), you could:
1. Try OpenTopoMap first
2. Fallback to Stadia if user has API key configured
3. Or just replace completely (simpler)
**Recommendation:** Just replace completely — OpenTopoMap is better for this use case anyway.
---
## Testing Checklist
- [ ] Desktop Chrome: Topo button shows OpenTopoMap tiles
- [ ] Desktop Chrome: Elev button works (if separate from Topo)
- [ ] Mobile Safari: No 401 errors
- [ ] Mobile Safari: Topo/terrain tiles load correctly
- [ ] Mobile Chrome: Same tests
- [ ] Zoom levels 1-17 work correctly
- [ ] Attribution displays properly
- [ ] Coverage heatmap overlays correctly on new tiles
---
## Verification Commands
After implementation:
```bash
cd /opt/rfcp/frontend
# Check for any remaining Stadia references
grep -r "stadiamaps" src/
# Should return empty (no matches)
# Build and deploy
npm run build
sudo systemctl reload caddy
```
---
## Rollback Plan
If OpenTopoMap has issues (unlikely), can switch to other free providers:
1. **Esri World Topo:**
```
https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}
```
2. **CartoDB Voyager:**
```
https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png
```
---
## Notes
- OpenTopoMap maxZoom is 17 (not 18 like Stadia)
- OpenTopoMap servers are in Germany — may be slightly slower for Ukraine, but still fast
- Fair use policy: don't hammer the servers with excessive requests
- HTTPS required (already using it)
---
## Reference
- OpenTopoMap: https://opentopomap.org/
- Leaflet Providers: https://leaflet-extras.github.io/leaflet-providers/preview/
- Previous iteration: RFCP-Iteration10.3.2-Fix-Boundary-Rendering.md

View File

@@ -0,0 +1,395 @@
# RFCP Iteration 10.5 — Input Validation + Site/Sector Hierarchy UI
**Date:** 2025-01-30
**Status:** Ready for Implementation
**Priority:** Medium
**Estimated Effort:** 45-60 minutes
---
## Overview
This iteration addresses two UX issues:
1. **Bug:** Number input fields don't accept manually typed values
2. **Enhancement:** Site/Sector list needs clear visual hierarchy
---
## Part A: Input Validation Fix
### Problem
When user clears an input field and tries to type a new value (e.g., "15" or "120"), the input doesn't accept it. The field either:
- Resets to minimum value immediately
- Doesn't allow intermediate states (typing "1" before "15")
**Affected Fields:**
- Radius (1-100 km)
- Resolution (50-500 m)
- Min Signal (-140 to -50 dBm)
- Heatmap Opacity (30-100%)
- All numeric inputs in site/sector forms
### Root Cause
The `onChange` handler immediately validates and clamps values to min/max range. When user types "1" (intending to type "15"), it gets clamped or rejected before they can finish typing.
### Solution
**Validate on blur, not on change:**
```typescript
// BEFORE (problematic):
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = Math.max(min, Math.min(max, Number(e.target.value)));
setValue(value);
};
// AFTER (correct):
const [localValue, setLocalValue] = useState(String(value));
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Allow any input while typing
setLocalValue(e.target.value);
};
const handleBlur = () => {
// Validate and clamp only when focus leaves
const parsed = Number(localValue);
if (isNaN(parsed)) {
setLocalValue(String(value)); // Reset to previous valid value
} else {
const clamped = Math.max(min, Math.min(max, parsed));
setLocalValue(String(clamped));
setValue(clamped);
}
};
// Sync local state when prop changes
useEffect(() => {
setLocalValue(String(value));
}, [value]);
```
### Files to Check/Modify
Look for numeric input components in:
- `src/components/panels/SettingsPanel.tsx` (or similar)
- `src/components/ui/NumberInput.tsx` (if exists)
- `src/components/ui/Slider.tsx` (if has input field)
- `src/components/panels/SiteForm.tsx`
### Implementation Pattern
If there's a reusable `NumberInput` component, fix it once. Otherwise, apply the pattern to each input.
**Key Points:**
1. Use local string state for the input value
2. Allow free typing (no validation on change)
3. Validate and clamp on blur
4. Also validate on Enter key press
5. Sync local state when external value changes
```typescript
interface NumberInputProps {
value: number;
onChange: (value: number) => void;
min: number;
max: number;
step?: number;
label?: string;
}
function NumberInput({ value, onChange, min, max, step = 1, label }: NumberInputProps) {
const [localValue, setLocalValue] = useState(String(value));
// Sync when external value changes
useEffect(() => {
setLocalValue(String(value));
}, [value]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setLocalValue(e.target.value);
};
const commitValue = () => {
const parsed = parseFloat(localValue);
if (isNaN(parsed)) {
setLocalValue(String(value));
return;
}
const clamped = Math.max(min, Math.min(max, parsed));
setLocalValue(String(clamped));
if (clamped !== value) {
onChange(clamped);
}
};
const handleBlur = () => commitValue();
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
commitValue();
(e.target as HTMLInputElement).blur();
}
if (e.key === 'Escape') {
setLocalValue(String(value));
(e.target as HTMLInputElement).blur();
}
};
return (
<input
type="text" // Use text, not number, for better control
inputMode="numeric" // Shows numeric keyboard on mobile
value={localValue}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
);
}
```
---
## Part B: Site/Sector Hierarchy UI
### Problem
Current UI shows flat list with confusing structure:
```
Station-1 2 sectors
Station-1-Beta Edit + Sector ×
Station-1-Beta Edit + Sector ×
```
Issues:
- Sector names duplicate site name ("Station-1-Beta")
- No clear visual hierarchy
- "+ Sector" button on each sector (should be on site only)
- Can't easily distinguish site from sector
### Desired UI
```
📍 Station-1 [Edit Site] [+ Sector] [×]
├─ α Alpha 140° · 2100MHz · 43dBm [Edit] [×]
└─ β Beta 260° · 2100MHz · 43dBm [Edit] [×]
📍 Station-2 [Edit Site] [+ Sector] [×]
└─ α Alpha 90° · 1800MHz · 40dBm [Edit] [×]
```
### Design Specifications
**Site Row:**
- Icon: 📍 or radio tower icon
- Name: Bold, larger font
- Sector count badge: "2 sectors" (subtle)
- Actions: [Edit Site] [+ Sector] [Delete Site]
- Background: Slightly different shade
- Click to expand/collapse sectors
**Sector Row:**
- Indented (padding-left: 24px or similar)
- Greek letter prefix: α, β, γ, δ, ε, ζ (visual identifier)
- Sector name: "Alpha", "Beta" (without site prefix)
- Key params inline: Azimuth · Frequency · Power
- Actions: [Edit] [Delete]
- Tree line connector: ├─ and └─ (optional, CSS pseudo-elements)
**Greek Letters Mapping:**
```typescript
const GREEK_LETTERS = ['α', 'β', 'γ', 'δ', 'ε', 'ζ', 'η', 'θ'];
// Alpha=α, Beta=β, Gamma=γ, Delta=δ, Epsilon=ε, Zeta=ζ, Eta=η, Theta=θ
```
### Component Structure
```
SiteList
├── SiteItem (for each site)
│ ├── SiteHeader (name, actions, expand/collapse)
│ └── SectorList (when expanded)
│ └── SectorItem (for each sector)
│ ├── SectorInfo (greek letter, name, params)
│ └── SectorActions (edit, delete)
```
### File to Modify
`/opt/rfcp/frontend/src/components/panels/SiteList.tsx`
Or if there are sub-components:
- `SiteItem.tsx`
- `SectorItem.tsx`
### Implementation Notes
**1. Data Structure (existing):**
```typescript
interface Site {
id: string;
name: string;
lat: number;
lon: number;
sectors: Sector[];
}
interface Sector {
id: string;
name: string; // "Alpha", "Beta", etc.
azimuth: number;
frequency: number;
power: number;
// ... other props
}
```
**2. Greek Letter Helper:**
```typescript
const SECTOR_GREEK: Record<string, string> = {
'Alpha': 'α',
'Beta': 'β',
'Gamma': 'γ',
'Delta': 'δ',
'Epsilon': 'ε',
'Zeta': 'ζ',
'Eta': 'η',
'Theta': 'θ',
};
const getGreekLetter = (sectorName: string): string => {
return SECTOR_GREEK[sectorName] || '•';
};
```
**3. CSS for Hierarchy:**
```css
.site-item {
border-left: 3px solid transparent;
}
.site-item.selected {
border-left-color: #7c3aed;
background: rgba(124, 58, 237, 0.1);
}
.site-header {
display: flex;
align-items: center;
padding: 8px 12px;
font-weight: 600;
}
.sector-list {
padding-left: 16px;
border-left: 1px solid rgba(255, 255, 255, 0.1);
margin-left: 12px;
}
.sector-item {
display: flex;
align-items: center;
padding: 6px 12px;
font-size: 0.9em;
}
.sector-greek {
width: 20px;
color: #7c3aed;
font-weight: 600;
}
.sector-params {
color: rgba(255, 255, 255, 0.6);
font-size: 0.85em;
margin-left: 8px;
}
```
**4. Expand/Collapse (optional):**
```typescript
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggleExpand = (siteId: string) => {
setExpanded(prev => {
const next = new Set(prev);
if (next.has(siteId)) {
next.delete(siteId);
} else {
next.add(siteId);
}
return next;
});
};
```
---
## Testing Checklist
### Part A: Input Validation
- [ ] Can type "15" in Radius field (not rejected after "1")
- [ ] Can type "120" in Resolution field
- [ ] Value validates on blur (focus out)
- [ ] Value validates on Enter key
- [ ] Escape key reverts to previous value
- [ ] Invalid input (letters) reverts to previous value
- [ ] Values outside range are clamped on blur
- [ ] Slider still works with arrow buttons
- [ ] Mobile: numeric keyboard appears
### Part B: Hierarchy UI
- [ ] Sites show with icon and bold name
- [ ] Sectors indented under their site
- [ ] Greek letters (α, β, γ) show for sectors
- [ ] Sector names without site prefix ("Alpha" not "Station-1-Alpha")
- [ ] Key params visible inline (azimuth, frequency, power)
- [ ] "+ Sector" button only on site row
- [ ] Edit/Delete buttons work correctly
- [ ] Selected site/sector highlighted
- [ ] Multiple sites display correctly
- [ ] Empty state (no sites) handled
---
## Visual Reference
**Before:**
```
┌─────────────────────────────────────────────┐
│ Station-1 2 sectors │
│ ┌─────────────────────────────────────┐ │
│ │ Station-1-Beta Edit +Sector × │ │
│ └─────────────────────────────────────┘ │
│ ┌─────────────────────────────────────┐ │
│ │ Station-1-Beta Edit +Sector × │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
```
**After:**
```
┌─────────────────────────────────────────────┐
│ 📍 Station-1 [Edit] [+] [×] │
│ │ │
│ ├─ α Alpha 140° · 2100MHz · 43dBm [✎][×] │
│ └─ β Beta 260° · 2100MHz · 43dBm [✎][×] │
│ │
│ 📍 Station-2 [Edit] [+] [×] │
│ │ │
│ └─ α Alpha 90° · 1800MHz · 40dBm [✎][×] │
└─────────────────────────────────────────────┘
```
---
## Reference
- Previous iteration: RFCP-Iteration10.4-Fix-Stadia-Maps-401.md
- Site/Sector data model: `/opt/rfcp/frontend/src/types/`
- Current SiteList: `/opt/rfcp/frontend/src/components/panels/SiteList.tsx`

View File

@@ -0,0 +1,486 @@
# RFCP Iteration 10.6 — Site Modal Dialogs + Batch Power/Tilt
**Date:** 2025-01-30
**Status:** Ready for Implementation
**Priority:** Medium-High
**Estimated Effort:** 60-90 minutes
---
## Overview
Two UX improvements:
1. **Part A:** Modal dialogs for Create/Edit site (instead of sidebar form)
2. **Part B:** Additional batch operations (Power, Tilt)
---
## Part A: Site Configuration Modal
### Problem
Current flow is confusing:
1. User clicks "Place on Map" → no visual feedback
2. User clicks on map → form appears in sidebar (easy to miss)
3. User doesn't understand they're creating a site
Same issue with Edit — form in sidebar is not obvious.
### Solution
**Center-screen modal dialog** for:
- Creating new site (after clicking on map)
- Editing existing site
### Modal Design
```
┌──────────────────────────────────────────────────────┐
│ ✕ │
│ 📍 New Site Configuration │
│ │
├──────────────────────────────────────────────────────┤
│ │
│ Site Name │
│ ┌────────────────────────────────────────────────┐ │
│ │ Station-1 │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ Coordinates │
│ ┌───────────────────┐ ┌───────────────────┐ │
│ │ 48.52660975 │ │ 35.68222045 │ │
│ └───────────────────┘ └───────────────────┘ │
│ Latitude Longitude │
│ │
│ ─────────────── RF Parameters ─────────────── │
│ │
│ Transmit Power 43 dBm │
│ ┌────────────────────────────────────────────────┐ │
│ │ ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━○ │ │
│ └────────────────────────────────────────────────┘ │
│ 10 dBm 50 dBm │
│ LimeSDR 20, BBU 43, RRU 46 │
│ │
│ Antenna Gain 8 dBi │
│ ┌────────────────────────────────────────────────┐ │
│ │ ○━━━━━━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │
│ └────────────────────────────────────────────────┘ │
│ 0 dBi 25 dBi │
│ Omni 2-8, Sector 15-18, Parabolic 20-25 │
│ │
│ Operating Frequency │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ 800 │ │1800 │ │1900 │ │2100 │ │2600 │ │
│ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │
│ ▲ selected │
│ │
│ Custom MHz ┌──────────────┐ [Set] │
│ │ │ │
│ └──────────────┘ │
│ │
│ Current: 1800 MHz │
│ Band 3 (1710-1880 MHz) │
│ λ = 16.7 cm · medium range · good penetration │
│ │
├──────────────────────────────────────────────────────┤
│ │
│ [Cancel] [Create Site] │
│ │
└──────────────────────────────────────────────────────┘
```
### Modal Behavior
**Opening:**
- "Place on Map" → click on map → Modal opens with coordinates pre-filled
- "Edit" button on site → Modal opens with all data pre-filled
- "+ Manual" button → Modal opens with empty/default values
**Closing:**
- Click ✕ → close without saving
- Click "Cancel" → close without saving
- Press Escape → close without saving
- Click outside modal (backdrop) → close without saving
- Click "Create Site" / "Save Changes" → validate, save, close
**Validation:**
- Site name: required, non-empty
- Coordinates: valid lat (-90 to 90), lon (-180 to 180)
- Power: 10-50 dBm
- Gain: 0-25 dBi
- Frequency: 100-6000 MHz
### Component Structure
```
src/components/
├── modals/
│ ├── SiteConfigModal.tsx # Main modal component
│ ├── ModalBackdrop.tsx # Reusable backdrop
│ └── index.ts # Exports
```
### Implementation
**SiteConfigModal.tsx:**
```typescript
interface SiteConfigModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (site: SiteData) => void;
initialData?: Partial<SiteData>; // For edit mode
mode: 'create' | 'edit';
}
function SiteConfigModal({ isOpen, onClose, onSave, initialData, mode }: SiteConfigModalProps) {
const [formData, setFormData] = useState<SiteFormData>({
name: initialData?.name || 'Station-1',
lat: initialData?.lat || 0,
lon: initialData?.lon || 0,
power: initialData?.power || 43,
gain: initialData?.gain || 8,
frequency: initialData?.frequency || 2100,
});
// ... form handling
if (!isOpen) return null;
return (
<ModalBackdrop onClose={onClose}>
<div className="modal-content">
<header>
<h2>{mode === 'create' ? '📍 New Site Configuration' : '✏️ Edit Site'}</h2>
<button onClick={onClose}></button>
</header>
{/* Form fields */}
<footer>
<button onClick={onClose}>Cancel</button>
<button onClick={handleSave}>
{mode === 'create' ? 'Create Site' : 'Save Changes'}
</button>
</footer>
</div>
</ModalBackdrop>
);
}
```
**ModalBackdrop.tsx:**
```typescript
interface ModalBackdropProps {
children: React.ReactNode;
onClose: () => void;
}
function ModalBackdrop({ children, onClose }: ModalBackdropProps) {
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [onClose]);
return (
<div
className="modal-backdrop"
onClick={handleBackdropClick}
>
{children}
</div>
);
}
```
### CSS Styling
```css
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.modal-content {
background: #1e293b; /* slate-800 */
border-radius: 12px;
width: 90%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-content header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-content header h2 {
font-size: 1.25rem;
font-weight: 600;
color: white;
}
.modal-content footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
```
### Integration with App
**In App.tsx or SiteList.tsx:**
```typescript
const [modalState, setModalState] = useState<{
isOpen: boolean;
mode: 'create' | 'edit';
initialData?: Partial<SiteData>;
}>({ isOpen: false, mode: 'create' });
// When user clicks on map after "Place on Map"
const handleMapClick = (lat: number, lon: number) => {
if (isPlacingMode) {
setModalState({
isOpen: true,
mode: 'create',
initialData: { lat, lon },
});
setIsPlacingMode(false);
}
};
// When user clicks "Edit" on a site
const handleEditSite = (site: Site) => {
setModalState({
isOpen: true,
mode: 'edit',
initialData: site,
});
};
// Render modal
<SiteConfigModal
isOpen={modalState.isOpen}
mode={modalState.mode}
initialData={modalState.initialData}
onClose={() => setModalState({ ...modalState, isOpen: false })}
onSave={handleSaveSite}
/>
```
---
## Part B: Batch Power/Tilt Operations
### Current Batch Operations
Already implemented:
- ✅ Adjust Height: +10m, +5m, -5m, -10m
- ✅ Set Height: exact value
- ✅ Adjust Azimuth: -90°, -45°, -10°, +10°, +45°, +90°
- ✅ Set Azimuth: exact value
### New Batch Operations to Add
**1. Power Adjustment:**
```
Adjust Power:
[+6dB] [+3dB] [+1dB] [-1dB] [-3dB] [-6dB]
Set Power:
┌─────────────────┐ [Apply]
│ dBm │
└─────────────────┘
```
**2. Tilt Adjustment (for directional antennas):**
```
Adjust Tilt:
[+10°] [+5°] [+2°] [-2°] [-5°] [-10°]
Set Tilt:
┌─────────────────┐ [Apply]
│ degrees │
└─────────────────┘
```
**3. Frequency (Set only, no adjust):**
```
Set Frequency:
[800] [1800] [1900] [2100] [2600] ← quick buttons
Custom: ┌─────────────────┐ [Apply]
│ MHz │
└─────────────────┘
```
### Implementation Location
In the existing batch edit panel (visible when multiple sectors selected).
**File to modify:**
- `src/components/panels/SiteList.tsx` or wherever BatchEditPanel is
### Batch Operation Handler
```typescript
const handleBatchAdjustPower = (delta: number) => {
selectedSectors.forEach(sector => {
const newPower = Math.max(10, Math.min(50, sector.power + delta));
updateSector(sector.id, { power: newPower });
});
};
const handleBatchSetPower = (value: number) => {
const clamped = Math.max(10, Math.min(50, value));
selectedSectors.forEach(sector => {
updateSector(sector.id, { power: clamped });
});
};
const handleBatchAdjustTilt = (delta: number) => {
selectedSectors.forEach(sector => {
const newTilt = Math.max(-90, Math.min(90, sector.tilt + delta));
updateSector(sector.id, { tilt: newTilt });
});
};
const handleBatchSetFrequency = (frequency: number) => {
selectedSectors.forEach(sector => {
updateSector(sector.id, { frequency });
});
};
```
### UI Layout for Batch Panel
```
┌─────────────────────────────────────────┐
│ Batch Edit (3 selected) Clear │
├─────────────────────────────────────────┤
│ │
│ Adjust Height: │
│ [+10m] [+5m] [-5m] [-10m] │
│ │
│ Set Height: │
│ [________] meters [Apply] │
│ │
│ ─────────────────────────────────────── │
│ │
│ Adjust Azimuth: │
│ [-90°] [-45°] [-10°] [+10°] [+45°] [+90°]│
│ │
│ Set Azimuth: │
│ [________] 0-359° [N 0°] [Apply] │
│ │
│ ─────────────────────────────────────── │
│ │
│ Adjust Power: [NEW] │
│ [+6dB] [+3dB] [+1dB] [-1dB] [-3dB] [-6dB]│
│ │
│ Set Power: │
│ [________] dBm [Apply] │
│ │
│ ─────────────────────────────────────── │
│ │
│ Adjust Tilt: [NEW] │
│ [+10°] [+5°] [+2°] [-2°] [-5°] [-10°] │
│ │
│ Set Tilt: │
│ [________] degrees [Apply] │
│ │
│ ─────────────────────────────────────── │
│ │
│ Set Frequency: [NEW] │
│ [800] [1800] [1900] [2100] [2600] MHz │
│ │
│ Custom: [________] MHz [Apply] │
│ │
└─────────────────────────────────────────┘
```
---
## Testing Checklist
### Part A: Site Modal
- [ ] "Place on Map" → click map → modal opens
- [ ] Modal shows correct coordinates from click
- [ ] Can edit all fields in modal
- [ ] "Create Site" saves and closes modal
- [ ] Site appears on map after creation
- [ ] "Cancel" closes without saving
- [ ] Clicking backdrop closes without saving
- [ ] Escape key closes without saving
- [ ] "Edit" on existing site opens modal with data
- [ ] "Save Changes" updates site correctly
- [ ] Validation prevents invalid data
- [ ] Modal scrolls if content too tall
- [ ] Modal looks good on mobile
### Part B: Batch Operations
- [ ] Select multiple sectors shows batch panel
- [ ] Adjust Power buttons work (+6, +3, +1, -1, -3, -6)
- [ ] Set Power applies exact value to all selected
- [ ] Adjust Tilt buttons work
- [ ] Set Tilt applies exact value
- [ ] Frequency quick buttons work (800, 1800, etc.)
- [ ] Custom frequency input works
- [ ] Values stay within valid ranges (clamped)
- [ ] Coverage updates after batch changes
---
## Files to Modify/Create
**New Files:**
- `src/components/modals/SiteConfigModal.tsx`
- `src/components/modals/ModalBackdrop.tsx`
- `src/components/modals/index.ts`
**Modify:**
- `src/App.tsx` — integrate modal state
- `src/components/panels/SiteList.tsx` — trigger modal instead of sidebar form
- `src/components/panels/BatchEditPanel.tsx` (or equivalent) — add Power/Tilt/Frequency
---
## Notes
- Modal should use same form components as current sidebar (reuse)
- Consider extracting form fields to shared component
- Batch operations should trigger coverage recalculation
- Test with both single-sector and multi-sector sites
---
## Reference
- Previous iteration: RFCP-Iteration10.5-Input-Validation-Hierarchy-UI.md
- Current SiteForm: check existing implementation for field structure

View File

@@ -0,0 +1,625 @@
# 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:**
```typescript
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:**
```typescript
// 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:**
```typescript
// 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:**
```typescript
// 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:**
```typescript
// 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:**
```typescript
// 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:**
```typescript
// 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:**
```typescript
// 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.*
```typescript
// 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! 🚀

View File

@@ -0,0 +1,473 @@
# RFCP - Iteration 3: Heatmap Fix + Phase 4 Preparation
## Context
RFCP is successfully deployed on VPS (https://rfcp.eliah.one) with:
- ✅ Dark theme working
- ✅ Terrain overlay (Topo button) working
- ✅ Batch operations working
- ⚠️ Heatmap gradient issue: becomes solid yellow/orange at close zoom (12+)
Current deployment: VPS-A via Caddy reverse proxy + FastAPI backend on port 8888
---
## CRITICAL FIX: Heatmap Gradient at Close Zoom
**Problem:** When zooming close (level 12-16), the entire heatmap becomes solid yellow/orange instead of showing the blue→cyan→green→yellow→orange→red gradient.
**Root Cause:** The `max` parameter in leaflet.heat and RSRP normalization range cause saturation at close zoom.
**File:** `frontend/src/components/map/Heatmap.tsx`
### Fix Implementation:
```typescript
import { useEffect, useState } from 'react';
import { useMap } from 'react-leaflet';
import { HeatmapLayerFactory } from '@vgrid/react-leaflet-heatmap-layer';
const HeatmapLayer = HeatmapLayerFactory<[number, number, number]>();
interface HeatmapProps {
points: Array<{
lat: number;
lon: number;
rsrp: number;
siteId: string;
}>;
visible: boolean;
}
export function Heatmap({ points, visible }: HeatmapProps) {
const map = useMap();
const [mapZoom, setMapZoom] = useState(map.getZoom());
useEffect(() => {
const handleZoomEnd = () => {
setMapZoom(map.getZoom());
};
map.on('zoomend', handleZoomEnd);
return () => {
map.off('zoomend', handleZoomEnd);
};
}, [map]);
if (!visible || points.length === 0) {
return null;
}
// CRITICAL FIX 1: Correct RSRP range
const normalizeRSRP = (rsrp: number): number => {
const minRSRP = -120;
const maxRSRP = -70; // ← CHANGED from -60 to -70 for full range
const normalized = (rsrp - minRSRP) / (maxRSRP - minRSRP);
return Math.max(0, Math.min(1, normalized));
};
// CRITICAL FIX 2: Dynamic max intensity based on zoom
const getHeatmapParams = (zoom: number) => {
const radius = Math.max(8, Math.min(40, 50 - zoom * 2.5));
const blur = Math.max(6, Math.min(20, 30 - zoom * 1.5));
// KEY FIX: Lower max at high zoom to prevent saturation
// zoom 6 (country): max=0.90 → smooth blend
// zoom 10 (region): max=0.70 → medium detail
// zoom 14 (city): max=0.50 → gradient visible
// zoom 18 (street): max=0.30 → tight detail
const maxIntensity = Math.max(0.3, Math.min(1.0, 1.2 - zoom * 0.05));
return { radius, blur, maxIntensity };
};
const { radius, blur, maxIntensity } = getHeatmapParams(mapZoom);
// Convert points to heatmap format
const heatmapPoints = points.map(point => [
point.lat,
point.lon,
normalizeRSRP(point.rsrp)
] as [number, number, number]);
return (
<HeatmapLayer
points={heatmapPoints}
longitudeExtractor={(p) => p[1]}
latitudeExtractor={(p) => p[0]}
intensityExtractor={(p) => p[2]}
gradient={{
0.0: '#0d47a1', // Dark Blue
0.2: '#00bcd4', // Cyan
0.4: '#4caf50', // Green
0.6: '#ffeb3b', // Yellow
0.8: '#ff9800', // Orange
1.0: '#f44336', // Red
}}
radius={radius}
blur={blur}
max={maxIntensity} // ← DYNAMIC based on zoom
minOpacity={0.3}
/>
);
}
```
### Why This Works:
**Problem:** At close zoom, many heatmap points overlap in small screen space. If `max=1.0` (default), they all saturate to the peak color (solid orange).
**Solution:** By lowering `max` at high zoom levels (e.g., `max=0.5` at zoom 14), we "stretch" the intensity scale. Now the densely packed points map to different parts of the 0.0-1.0 range, revealing the full gradient even at close zoom.
**RSRP Fix:** Changing `maxRSRP` from -60 to -70 dBm ensures points in the -70 to -60 range (excellent signal) map to intensity 1.0 (red), not stuck at 0.8 (orange).
---
## ADDITIONAL IMPROVEMENTS
### 1. Add Heatmap Opacity Slider
**File:** `frontend/src/components/panels/CoverageSettings.tsx` (or wherever settings panel is)
```typescript
import { Slider } from '@/components/ui/Slider';
// In coverage store:
interface CoverageSettings {
radius: number;
resolution: number;
rsrpThreshold: number;
heatmapOpacity: number; // NEW
}
// In UI:
<Slider
label="Heatmap Opacity"
min={0.3}
max={1.0}
step={0.1}
value={coverageSettings.heatmapOpacity}
onChange={(value) => updateCoverageSettings({ heatmapOpacity: value })}
suffix=""
help="Adjust heatmap transparency"
/>
// Pass to Heatmap component:
<Heatmap
points={points}
visible={visible}
opacity={coverageSettings.heatmapOpacity} // NEW
/>
// In Heatmap.tsx, wrap layer:
<div style={{ opacity: opacity }}>
<HeatmapLayer ... />
</div>
```
### 2. Export Coverage Data (CSV/GeoJSON)
**File:** `frontend/src/components/panels/ExportPanel.tsx` (new)
```typescript
import { Button } from '@/components/ui/Button';
import { useCoverageStore } from '@/store/coverage';
import { toast } from '@/components/ui/Toast';
export function ExportPanel() {
const { coveragePoints, sites } = useCoverageStore();
const exportCSV = () => {
if (coveragePoints.length === 0) {
toast.error('No coverage data to export');
return;
}
const csv = [
'lat,lon,rsrp,site_id',
...coveragePoints.map(p => `${p.lat},${p.lon},${p.rsrp},${p.siteId}`)
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `coverage-${Date.now()}.csv`;
a.click();
toast.success('Exported coverage data');
};
const exportGeoJSON = () => {
if (coveragePoints.length === 0) {
toast.error('No coverage data to export');
return;
}
const geojson = {
type: 'FeatureCollection',
features: coveragePoints.map(p => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [p.lon, p.lat]
},
properties: {
rsrp: p.rsrp,
siteId: p.siteId
}
}))
};
const blob = new Blob([JSON.stringify(geojson, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `coverage-${Date.now()}.geojson`;
a.click();
toast.success('Exported GeoJSON');
};
return (
<div className="space-y-2">
<h3 className="font-semibold">Export Coverage</h3>
<div className="flex gap-2">
<Button onClick={exportCSV} size="sm">
📊 CSV
</Button>
<Button onClick={exportGeoJSON} size="sm">
🗺 GeoJSON
</Button>
</div>
</div>
);
}
```
### 3. Project Save/Load (IndexedDB)
**File:** `frontend/src/store/projects.ts` (new)
```typescript
import { create } from 'zustand';
import Dexie, { Table } from 'dexie';
interface Project {
id: string;
name: string;
description?: string;
sites: Site[];
coverageSettings: CoverageSettings;
createdAt: number;
updatedAt: number;
}
class ProjectDatabase extends Dexie {
projects!: Table<Project>;
constructor() {
super('rfcp-projects');
this.version(1).stores({
projects: 'id, name, updatedAt'
});
}
}
const db = new ProjectDatabase();
interface ProjectsState {
currentProject: Project | null;
projects: Project[];
loadProjects: () => Promise<void>;
saveProject: (project: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>) => Promise<void>;
loadProject: (id: string) => Promise<void>;
deleteProject: (id: string) => Promise<void>;
}
export const useProjectsStore = create<ProjectsState>((set, get) => ({
currentProject: null,
projects: [],
loadProjects: async () => {
const projects = await db.projects.orderBy('updatedAt').reverse().toArray();
set({ projects });
},
saveProject: async (projectData) => {
const id = crypto.randomUUID();
const now = Date.now();
const project: Project = {
...projectData,
id,
createdAt: now,
updatedAt: now
};
await db.projects.put(project);
await get().loadProjects();
toast.success(`Project "${project.name}" saved`);
},
loadProject: async (id) => {
const project = await db.projects.get(id);
if (!project) {
toast.error('Project not found');
return;
}
// Load sites and settings into their respective stores
useSitesStore.getState().setSites(project.sites);
useCoverageStore.getState().updateSettings(project.coverageSettings);
set({ currentProject: project });
toast.success(`Loaded project "${project.name}"`);
},
deleteProject: async (id) => {
await db.projects.delete(id);
await get().loadProjects();
toast.success('Project deleted');
}
}));
```
---
## TESTING CHECKLIST
After implementing fixes:
### Heatmap Gradient Test:
- [ ] Zoom out to level 6-8: Should see smooth gradient with large coverage area
- [ ] Zoom to level 10-12: Gradient still visible, colors distinct
- [ ] Zoom to level 14-16: NO solid yellow/orange, full blue→red range visible
- [ ] Zoom to level 18+: Individual points with gradient, not solid blobs
### Export Test:
- [ ] Calculate coverage for 1 site
- [ ] Export CSV: Should download with lat,lon,rsrp,site_id columns
- [ ] Export GeoJSON: Should be valid GeoJSON FeatureCollection
- [ ] Open in QGIS/online viewer: Points should render correctly
### Project Save/Load Test:
- [ ] Create 2-3 sites with custom settings
- [ ] Save project with name
- [ ] Clear all sites
- [ ] Load project: Sites and settings restored
- [ ] Delete project: Removed from list
---
## BUILD & DEPLOY
After making changes:
```bash
# Frontend
cd /opt/rfcp/frontend
npm run build
# Check dist/
ls -lah dist/
# Deploy
sudo systemctl reload caddy
# Test
curl https://rfcp.eliah.one/api/health
curl https://rfcp.eliah.one/ | head -20
# From Windows via WireGuard:
# Open: https://rfcp.eliah.one
```
---
## PHASE 4 PREPARATION (Future)
### Terrain Integration Stub
**File:** `frontend/src/services/terrain.ts` (new)
```typescript
export interface TerrainService {
getElevation(lat: number, lon: number): Promise<number>;
getElevationProfile(lat1: number, lon1: number, lat2: number, lon2: number, samples: number): Promise<number[]>;
}
export class MockTerrainService implements TerrainService {
async getElevation(lat: number, lon: number): Promise<number> {
// Return mock flat terrain for now
return 0;
}
async getElevationProfile(lat1: number, lon1: number, lat2: number, lon2: number, samples: number): Promise<number[]> {
return Array(samples).fill(0);
}
}
export class BackendTerrainService implements TerrainService {
constructor(private apiUrl: string) {}
async getElevation(lat: number, lon: number): Promise<number> {
const response = await fetch(`${this.apiUrl}/api/terrain/elevation?lat=${lat}&lon=${lon}`);
const data = await response.json();
return data.elevation;
}
async getElevationProfile(lat1: number, lon1: number, lat2: number, lon2: number, samples: number): Promise<number[]> {
const response = await fetch(
`${this.apiUrl}/api/terrain/profile?lat1=${lat1}&lon1=${lon1}&lat2=${lat2}&lon2=${lon2}&samples=${samples}`
);
const data = await response.json();
return data.profile;
}
}
// Default to mock for now
export const terrainService: TerrainService = new MockTerrainService();
```
---
## SUCCESS CRITERIA
✅ Heatmap shows full blue→cyan→green→yellow→orange→red gradient at all zoom levels
✅ No solid yellow/orange blobs at close zoom
✅ Opacity slider works smoothly
✅ CSV export produces valid data
✅ GeoJSON export renders in QGIS
✅ Projects can be saved and loaded
✅ All existing features still work
✅ Dark theme applies to new UI elements
✅ Mobile responsive
---
## COMMIT MESSAGE TEMPLATE
```
fix(heatmap): resolve gradient saturation at close zoom
- Changed maxRSRP from -60 to -70 dBm for full intensity range
- Added dynamic max intensity based on zoom level (0.3-1.0)
- Prevents solid yellow/orange at zoom 14+
- Now shows full blue→red gradient at all zoom levels
feat(export): add coverage data export (CSV/GeoJSON)
- Export calculated coverage points as CSV
- Export as GeoJSON FeatureCollection
- Compatible with QGIS and other GIS tools
feat(projects): add project save/load functionality
- Save sites + settings as named projects
- Load projects from IndexedDB
- Project management UI in sidebar
```
Good luck! 🚀

View File

@@ -0,0 +1,419 @@
# RFCP - Iteration 4: Critical Fixes
## Issues Found in Production
After Iteration 3 deployment, three critical issues identified:
1.**Antenna directivity not working** - Coverage is omni even for sector antennas
2.**Heatmap gradient broken** - Everything shows as solid red/orange
3.**Terrain overlay invisible** - OpenTopoMap layer not visible enough
---
## CRITICAL FIX 1: Antenna Directivity
**Problem:** Coverage calculation ignores antenna azimuth and beamwidth. A 75° sector antenna shows full 360° coverage.
**Root Cause:** The coverage calculation worker doesn't filter points based on antenna direction.
### Implementation
**File:** `frontend/src/workers/coverage.worker.ts`
Add directivity filter in the point loop:
```typescript
// In calculateCoverage function, after distance calculation:
for (let latIdx = 0; latIdx < latPoints; latIdx++) {
for (let lonIdx = 0; lonIdx < lonPoints; lonIdx++) {
const lat = minLat + latIdx * latStep;
const lon = minLon + lonIdx * lonStep;
const distance = calculateDistance(site.lat, site.lon, lat, lon);
if (distance > radius) continue;
// NEW: Check antenna directivity
if (site.antennaType === 'sector') {
const bearing = calculateBearing(site.lat, site.lon, lat, lon);
const azimuth = site.azimuth || 0;
const beamwidth = 75; // degrees (could be configurable)
// Calculate angular difference
let angleDiff = Math.abs(bearing - azimuth);
if (angleDiff > 180) angleDiff = 360 - angleDiff;
// Skip points outside beamwidth
if (angleDiff > beamwidth / 2) continue;
}
// Calculate RSRP (existing code)
const fspl = calculateFSPL(distance, site.frequency);
const rsrp = site.power - fspl;
// ... rest of code
}
}
// Add helper function:
function calculateBearing(lat1: number, lon1: number, lat2: number, lon2: number): number {
const φ1 = lat1 * Math.PI / 180;
const φ2 = lat2 * Math.PI / 180;
const Δλ = (lon2 - lon1) * Math.PI / 180;
const y = Math.sin(Δλ) * Math.cos(φ2);
const x = Math.cos(φ1) * Math.sin(φ2) -
Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
let θ = Math.atan2(y, x);
θ = θ * 180 / Math.PI; // radians to degrees
return (θ + 360) % 360; // normalize to 0-360
}
```
### Visual Indicator
**File:** `frontend/src/components/map/SiteMarkers.tsx`
Add sector wedge visualization:
```typescript
import { Polygon } from 'react-leaflet';
// In SiteMarker component, for sector antennas:
{site.antennaType === 'sector' && (
<Polygon
positions={generateSectorWedge(site)}
pathOptions={{
color: '#ffffff',
weight: 2,
opacity: 0.5,
fillOpacity: 0.1,
dashArray: '5, 5',
}}
/>
)}
// Helper function:
function generateSectorWedge(site: Site): [number, number][] {
const points: [number, number][] = [[site.lat, site.lon]];
const radius = 0.5; // km on map (visual only, not coverage radius)
const beamwidth = 75;
const azimuth = site.azimuth || 0;
const startAngle = azimuth - beamwidth / 2;
const endAngle = azimuth + beamwidth / 2;
// Generate arc points
for (let angle = startAngle; angle <= endAngle; angle += 5) {
const rad = angle * Math.PI / 180;
const latOffset = (radius / 111) * Math.cos(rad); // 1° lat ≈ 111km
const lonOffset = (radius / (111 * Math.cos(site.lat * Math.PI / 180))) * Math.sin(rad);
points.push([site.lat + latOffset, site.lon + lonOffset]);
}
points.push([site.lat, site.lon]); // close the wedge
return points;
}
```
---
## CRITICAL FIX 2: Heatmap Gradient (Retry)
**Problem:** Heatmap shows solid red/orange at all zoom levels despite Iteration 3 fix attempt.
**Root Cause:** The fix wasn't applied correctly, or RSRP values are outside expected range.
### Debug First
Check actual RSRP values being generated:
```typescript
// In Heatmap.tsx, add console.log:
console.log('Heatmap Debug:', {
pointCount: points.length,
rsrpSample: points.slice(0, 5).map(p => p.rsrp),
rsrpMin: Math.min(...points.map(p => p.rsrp)),
rsrpMax: Math.max(...points.map(p => p.rsrp)),
mapZoom: mapZoom,
maxIntensity: maxIntensity
});
```
### Apply Fix (Corrected)
**File:** `frontend/src/components/map/Heatmap.tsx`
```typescript
export function Heatmap({ points, visible, opacity = 0.7 }: HeatmapProps) {
const map = useMap();
const [mapZoom, setMapZoom] = useState(map.getZoom());
useEffect(() => {
const handleZoomEnd = () => setMapZoom(map.getZoom());
map.on('zoomend', handleZoomEnd);
return () => { map.off('zoomend', handleZoomEnd); };
}, [map]);
if (!visible || points.length === 0) return null;
// CRITICAL: Normalize RSRP correctly
const normalizeRSRP = (rsrp: number): number => {
const minRSRP = -120; // Very weak signal
const maxRSRP = -70; // Excellent signal (CHANGED from -60)
const normalized = (rsrp - minRSRP) / (maxRSRP - minRSRP);
return Math.max(0, Math.min(1, normalized));
};
// Dynamic heatmap parameters
const radius = Math.max(8, Math.min(40, 50 - mapZoom * 2.5));
const blur = Math.max(6, Math.min(20, 30 - mapZoom * 1.5));
// CRITICAL: Dynamic max intensity to prevent saturation
const maxIntensity = Math.max(0.3, Math.min(1.0, 1.2 - mapZoom * 0.05));
// Convert to heatmap format
const heatmapPoints = points.map(p => [
p.lat,
p.lon,
normalizeRSRP(p.rsrp)
] as [number, number, number]);
return (
<div style={{ opacity }}>
<HeatmapLayer
points={heatmapPoints}
longitudeExtractor={(p) => p[1]}
latitudeExtractor={(p) => p[0]}
intensityExtractor={(p) => p[2]}
gradient={{
0.0: '#0d47a1', // Dark Blue (very weak)
0.2: '#00bcd4', // Cyan (weak)
0.4: '#4caf50', // Green (fair)
0.6: '#ffeb3b', // Yellow (good)
0.8: '#ff9800', // Orange (strong)
1.0: '#f44336', // Red (excellent)
}}
radius={radius}
blur={blur}
max={maxIntensity} // DYNAMIC!
minOpacity={0.3}
/>
</div>
);
}
```
### If Still Red After Fix
The issue might be that ALL points have very strong RSRP (> -70 dBm). Solution:
**Option A: Adjust normalization range**
```typescript
const minRSRP = -140; // Extend weak end
const maxRSRP = -50; // Extend strong end
```
**Option B: Add RSRP clipping in worker**
```typescript
// In worker, clip RSRP to realistic range
const rsrp = Math.max(-140, Math.min(-50, site.power - fspl));
```
---
## CRITICAL FIX 3: Terrain Overlay Visibility
**Problem:** OpenTopoMap terrain overlay is barely visible or completely invisible.
**Root Causes:**
1. Opacity too low
2. Wrong layer order (under heatmap)
3. Tile URL might be wrong
### Fix Implementation
**File:** `frontend/src/components/map/Map.tsx`
```typescript
import { TileLayer, LayersControl } from 'react-leaflet';
// In Map component:
<MapContainer {...props}>
{/* Base map layer */}
<TileLayer
attribution='&copy; OpenStreetMap'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Terrain overlay - ABOVE base map, BELOW heatmap */}
{showTerrain && (
<TileLayer
attribution='&copy; OpenTopoMap'
url="https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png"
opacity={0.5} // Increased from 0.3 or whatever was there
zIndex={100} // Ensure it's above base map
/>
)}
{/* Heatmap on top */}
<Heatmap
points={coveragePoints}
visible={showHeatmap}
opacity={heatmapOpacity}
/>
{/* Site markers on very top */}
<SiteMarkers sites={sites} />
</MapContainer>
```
### Alternative: Hillshade Layer
If OpenTopoMap is too subtle, try dedicated hillshade:
```typescript
{showTerrain && (
<TileLayer
url="https://tiles.wmflabs.org/hillshading/{z}/{x}/{y}.png"
opacity={0.6}
zIndex={100}
/>
)}
```
### UI Improvements
Add terrain opacity slider:
**File:** `frontend/src/components/panels/CoverageSettings.tsx`
```typescript
<Slider
label="Terrain Opacity"
min={0.1}
max={1.0}
step={0.1}
value={terrainOpacity}
onChange={(value) => updateSettings({ terrainOpacity: value })}
suffix=""
help="Adjust terrain layer visibility"
disabled={!showTerrain}
/>
```
---
## TESTING CHECKLIST
### Antenna Directivity Test:
- [ ] Create sector antenna with azimuth 0° (north)
- [ ] Coverage should show wedge facing north only
- [ ] Create sector with azimuth 180° (south)
- [ ] Coverage wedge should face south
- [ ] Omni antenna should still show 360° coverage
- [ ] Sector wedge visualization should match coverage area
### Heatmap Gradient Test:
- [ ] Zoom level 6-8: Smooth blue→red gradient
- [ ] Zoom level 10-12: Colors distinct, no solid blob
- [ ] Zoom level 14-16: Full gradient visible, especially blue/cyan at edges
- [ ] Console log shows RSRP values in -120 to -70 range
- [ ] maxIntensity value changes with zoom (check console)
### Terrain Overlay Test:
- [ ] Click Topo button: Contour lines visible
- [ ] Terrain overlay shows hills/valleys clearly
- [ ] Opacity slider adjusts terrain visibility
- [ ] Terrain doesn't obscure heatmap or markers
- [ ] Works in both light and dark theme
---
## BUILD & DEPLOY
After changes:
```bash
cd /opt/rfcp/frontend
# Build
npm run build
# Deploy
sudo systemctl reload caddy
# Test from VPS
curl http://localhost:8888/health
curl https://rfcp.eliah.one/ | head -20
# Test from Windows
# https://rfcp.eliah.one
```
---
## COMMIT MESSAGE
```
fix(coverage): implement antenna directivity and sector patterns
- Calculate bearing for each coverage point
- Filter points outside sector beamwidth (75°)
- Add sector wedge visualization on map
- Omni antennas maintain 360° coverage
fix(heatmap): resolve solid red gradient issue (retry)
- Corrected RSRP normalization range (-120 to -70 dBm)
- Applied dynamic max intensity based on zoom
- Added debug logging for RSRP values
- Full blue→red gradient now visible at all zoom levels
fix(terrain): improve terrain overlay visibility
- Increased opacity from 0.3 to 0.5
- Correct layer ordering (base → terrain → heatmap)
- Added terrain opacity slider
- Alternative hillshade layer option
```
---
## Expected Results
**After Fix:**
1. **Directivity:**
- Sector antenna (75°) shows wedge-shaped coverage
- Coverage concentrated in azimuth direction
- Visual sector indicator on map
2. **Heatmap:**
- Blue at coverage edge (-120 dBm, weak)
- Cyan/green in medium range (-100 to -90 dBm)
- Yellow/orange closer to site (-85 to -75 dBm)
- Red only very close to site (-70 dBm, excellent)
3. **Terrain:**
- Contour lines clearly visible
- Hills and valleys distinguishable
- Doesn't obscure coverage data
- Opacity adjustable
---
## Phase 4 Preview (Next)
Once these fixes work, Phase 4 will add:
- **Terrain Loss Calculation** - Height + elevation profile affects RSRP
- **Backend Terrain API** - `/api/terrain/elevation?lat={}&lon={}`
- **Line-of-Sight Analysis** - Check if path obstructed by terrain
- **3D Terrain Visualization** - Optional 3D view with coverage overlay
🚀 Good luck!

View File

@@ -0,0 +1,505 @@
# RFCP - Iteration 5: RF Accuracy & UX Polish
## Issues Identified
1.**Antenna back lobe missing** - Real antennas radiate backwards (F/B ratio ~20-25 dB)
2.**Heatmap colors change with zoom** - Same RSRP shows different colors at different zoom levels
3.**Antenna gain ignored** - 25 dBi vs 0 dBi produces identical coverage
4.**Antenna height ignored** - 10m vs 100m towers show same coverage
5.**Batch edit UX issues** - Edit panel stays open, values not updated dynamically
---
## CRITICAL FIX 1: Accurate 3GPP Antenna Pattern
**Problem:** Current implementation has hard cutoff at beamwidth/2. Real sector antennas have:
- Main lobe with gradual attenuation
- Side lobes (~15-20 dB down)
- Back lobe (~20-25 dB down, not -∞)
### Correct 3GPP Formula
**File:** `frontend/src/workers/rf-worker.js`
Replace antenna pattern calculation:
```javascript
function calculate3GPPPattern(azimuth, bearing, beamwidth = 65, frontBackRatio = 25) {
// Normalize angle difference to -180...+180
let angleDiff = bearing - azimuth;
while (angleDiff > 180) angleDiff -= 360;
while (angleDiff < -180) angleDiff += 360;
const theta = Math.abs(angleDiff);
// 3GPP TR 36.814 Horizontal Pattern
// A(θ) = -min[12(θ/θ_3dB)², A_m]
const theta3dB = beamwidth / 2; // Half-power beamwidth
const Am = frontBackRatio; // Maximum attenuation (front-to-back ratio)
let attenuation;
if (theta <= 180) {
// Front hemisphere (includes main lobe and side lobes)
attenuation = -Math.min(12 * Math.pow(theta / theta3dB, 2), Am);
} else {
// This shouldn't happen after normalization, but just in case
attenuation = -Am;
}
return attenuation; // Returns dB loss (negative value)
}
// In coverage calculation:
if (site.antennaType === 'sector') {
const bearing = calculateBearing(site.lat, site.lon, lat, lon);
const azimuth = site.azimuth || 0;
const beamwidth = site.beamwidth || 65;
// Get antenna pattern loss
const patternLoss = calculate3GPPPattern(azimuth, bearing, beamwidth, 25);
// Add pattern loss to path loss
const effectiveRSRP = site.power + site.antennaGain + patternLoss - fspl;
// No hard cutoff! Back lobe will be ~25 dB weaker
}
```
### Visualization Update
Show actual pattern with gradient opacity:
**File:** `frontend/src/components/map/SiteMarker.tsx`
```typescript
// Generate sector wedge with gradient
function generateSectorWedge(site: Site) {
const mainLobe = generateArc(site, site.azimuth, site.beamwidth, 0.8); // Main beam
const sideLobe = generateArc(site, site.azimuth, 180, 0.3); // Sides
const backLobe = generateArc(site, site.azimuth + 180, 60, 0.1); // Back
return [mainLobe, sideLobe, backLobe]; // Array of polygons
}
```
---
## CRITICAL FIX 2: Zoom-Independent Heatmap Colors
**Problem:** `maxIntensity` changes with zoom, so same RSRP value maps to different colors.
**Solution:** Keep RSRP thresholds constant, adjust only visual radius/blur.
**File:** `frontend/src/components/map/Heatmap.tsx`
```typescript
export function Heatmap({ points, visible, opacity = 0.7 }: HeatmapProps) {
const map = useMap();
const [mapZoom, setMapZoom] = useState(map.getZoom());
useEffect(() => {
const handleZoomEnd = () => setMapZoom(map.getZoom());
map.on('zoomend', handleZoomEnd);
return () => { map.off('zoomend', handleZoomEnd); };
}, [map]);
if (!visible || points.length === 0) return null;
// FIXED: Normalize RSRP consistently regardless of zoom
const normalizeRSRP = (rsrp: number): number => {
const minRSRP = -120; // Very weak
const maxRSRP = -70; // Excellent
const normalized = (rsrp - minRSRP) / (maxRSRP - minRSRP);
return Math.max(0, Math.min(1, normalized));
};
// Zoom-dependent visual parameters ONLY
const radius = Math.max(8, Math.min(40, 50 - mapZoom * 2.5));
const blur = Math.max(6, Math.min(20, 30 - mapZoom * 1.5));
// CRITICAL FIX: maxIntensity is now CONSTANT
const maxIntensity = 1.0; // Always 1.0, NEVER changes with zoom
const heatmapPoints = points.map(p => [
p.lat,
p.lon,
normalizeRSRP(p.rsrp)
] as [number, number, number]);
return (
<div style={{ opacity }}>
<HeatmapLayer
points={heatmapPoints}
longitudeExtractor={(p) => p[1]}
latitudeExtractor={(p) => p[0]}
intensityExtractor={(p) => p[2]}
gradient={{
0.0: '#0d47a1', // -120 dBm (dark blue, no service)
0.2: '#00bcd4', // -110 dBm (cyan, weak)
0.4: '#4caf50', // -100 dBm (green, fair)
0.6: '#ffeb3b', // -85 dBm (yellow, good)
0.8: '#ff9800', // -75 dBm (orange, strong)
1.0: '#f44336', // -70 dBm (red, excellent)
}}
radius={radius}
blur={blur}
max={maxIntensity} // ALWAYS 1.0
minOpacity={0.3}
/>
</div>
);
}
```
### Add Legend with Actual dBm Values
**File:** `frontend/src/components/map/Legend.tsx`
```typescript
export function Legend() {
return (
<div className="legend">
<h4>Signal Strength (RSRP)</h4>
<div className="legend-item">
<span className="color" style={{ background: '#f44336' }}></span>
<span>Excellent (&gt; -70 dBm)</span>
</div>
<div className="legend-item">
<span className="color" style={{ background: '#ff9800' }}></span>
<span>Strong (-75 to -70 dBm)</span>
</div>
<div className="legend-item">
<span className="color" style={{ background: '#ffeb3b' }}></span>
<span>Good (-85 to -75 dBm)</span>
</div>
<div className="legend-item">
<span className="color" style={{ background: '#4caf50' }}></span>
<span>Fair (-100 to -85 dBm)</span>
</div>
<div className="legend-item">
<span className="color" style={{ background: '#00bcd4' }}></span>
<span>Weak (-110 to -100 dBm)</span>
</div>
<div className="legend-item">
<span className="color" style={{ background: '#0d47a1' }}></span>
<span>No Service (&lt; -120 dBm)</span>
</div>
</div>
);
}
```
---
## CRITICAL FIX 3: Antenna Gain Effect
**Problem:** Antenna gain is stored but not used in RSRP calculation.
**Formula:** `RSRP = TxPower + TxGain - PathLoss - PatternLoss`
**File:** `frontend/src/workers/rf-worker.js`
```javascript
// In calculateCoverage function:
for (const site of sites) {
for (let latIdx = 0; latIdx < latPoints; latIdx++) {
for (let lonIdx = 0; lonIdx < lonPoints; lonIdx++) {
const lat = minLat + latIdx * latStep;
const lon = minLon + lonIdx * lonStep;
const distance = calculateDistance(site.lat, site.lon, lat, lon);
if (distance > radius) continue;
// Path loss (FSPL)
const fspl = calculateFSPL(distance, site.frequency);
// Antenna pattern loss
let patternLoss = 0;
if (site.antennaType === 'sector') {
const bearing = calculateBearing(site.lat, site.lon, lat, lon);
patternLoss = -calculate3GPPPattern(site.azimuth, bearing, site.beamwidth, 25);
}
// CRITICAL: Include antenna gain!
const antennaGain = site.antennaGain || 0; // dBi
// Final RSRP calculation
const rsrp = site.power + antennaGain - fspl - patternLoss;
// Store point
if (rsrp > rsrpThreshold) {
points.push({ lat, lon, rsrp, siteId: site.id });
}
}
}
}
```
**Expected Behavior:**
- 0 dBi omni (dipole): baseline coverage
- 8 dBi omni (collinear): +8 dB → ~2x distance
- 15 dBi sector (panel): +15 dB → ~5x distance
- 25 dBi sector (parabolic): +25 dB → ~17x distance
---
## CRITICAL FIX 4: Antenna Height Effect
**Problem:** Height is stored but ignored. In reality, height affects:
1. **Line-of-sight distance** (radio horizon)
2. **Terrain shadowing** (future Phase 4)
For now, implement **radio horizon** effect:
**Formula:** `d_horizon = 3.57 * sqrt(h)` km, where h is in meters
**File:** `frontend/src/workers/rf-worker.js`
```javascript
function calculateRadioHorizon(heightMeters) {
// Earth curvature formula
// Assumes 4/3 Earth radius (k=4/3 for standard atmosphere)
return 3.57 * Math.sqrt(heightMeters); // km
}
// In coverage calculation:
for (const site of sites) {
const siteHorizon = calculateRadioHorizon(site.height);
const maxRange = Math.min(radius, siteHorizon);
for (let latIdx = 0; latIdx < latPoints; latIdx++) {
for (let lonIdx = 0; lonIdx < lonPoints; lonIdx++) {
const lat = minLat + latIdx * latStep;
const lon = minLon + lonIdx * lonStep;
const distance = calculateDistance(site.lat, site.lon, lat, lon);
// Check horizon limit
if (distance > maxRange) continue;
// ... rest of calculation
}
}
}
```
**Expected Behavior:**
- 10m tower: horizon = 11.3 km
- 30m tower: horizon = 19.5 km
- 100m tower: horizon = 35.7 km
**Note:** This is a simplified model. Phase 4 will add terrain-aware line-of-sight.
---
## FIX 5: Batch Edit UX Improvements
**Problem:**
1. Edit panel stays open during batch operations
2. Selected site values don't update dynamically
3. Unclear what changed
### Solution A: Close Edit Panel on Batch Operation
**File:** `frontend/src/components/panels/SiteList.tsx`
```typescript
const handleBatchUpdate = (delta: number) => {
batchUpdateHeight(delta);
// Close edit panel if currently editing a selected site
if (editingSiteId && selectedSiteIds.includes(editingSiteId)) {
setEditingSiteId(null);
}
toast.success(`Updated ${selectedSiteIds.length} sites by ${delta > 0 ? '+' : ''}${delta}m`);
};
const handleBatchSet = (height: number) => {
batchSetHeight(height);
// Close edit panel
if (editingSiteId && selectedSiteIds.includes(editingSiteId)) {
setEditingSiteId(null);
}
toast.success(`Set ${selectedSiteIds.length} sites to ${height}m`);
};
```
### Solution B: Live Update Edit Panel
**File:** `frontend/src/components/panels/SiteForm.tsx`
```typescript
// Watch for external changes to site being edited
useEffect(() => {
if (site) {
// Sync form with latest site data
setFormData({
name: site.name,
frequency: site.frequency,
power: site.power,
height: site.height, // Updated dynamically!
antennaType: site.antennaType,
azimuth: site.azimuth,
beamwidth: site.beamwidth,
antennaGain: site.antennaGain,
});
}
}, [site.height, site.power]); // Re-sync when these change
```
### Visual Feedback
Add flash animation when batch updated:
```typescript
// In SiteList item
<div
className={cn(
'site-item',
isSelected && 'selected',
wasBatchUpdated && 'flash-update'
)}
>
{/* ... */}
</div>
```
```css
@keyframes flash-update {
0%, 100% { background-color: transparent; }
50% { background-color: rgba(59, 130, 246, 0.3); }
}
.flash-update {
animation: flash-update 0.6s ease-in-out;
}
```
---
## TESTING CHECKLIST
### Antenna Pattern Test:
- [ ] Sector antenna (65° beam, 25 dBi gain):
- [ ] Main lobe strongest (0 dB loss)
- [ ] ±30° from azimuth: ~-12 dB loss (visible but weaker)
- [ ] ±90° from azimuth: ~-25 dB loss (faint coverage)
- [ ] 180° from azimuth: ~-25 dB loss (back lobe visible)
- [ ] Omni antenna: perfect circle (no directivity)
### Heatmap Color Consistency Test:
- [ ] Place site, calculate coverage at zoom 8
- [ ] Note color at specific location (e.g., 5 km away)
- [ ] Zoom to level 12, color at same location UNCHANGED
- [ ] Zoom to level 16, color STILL THE SAME
- [ ] Legend shows actual dBm values
### Antenna Gain Test:
- [ ] Site A: 0 dBi omni, 43 dBm power → note coverage radius
- [ ] Site B: 15 dBi sector, 43 dBm power → coverage should be ~5x larger
- [ ] Site C: 25 dBi sector, 43 dBm power → coverage should be ~17x larger
### Antenna Height Test:
- [ ] Site A: 10m height → coverage radius ~11 km max
- [ ] Site B: 30m height → coverage radius ~19 km max
- [ ] Site C: 100m height → coverage radius ~35 km max
- [ ] Even with high power, coverage stops at horizon
### Batch Edit UX Test:
- [ ] Select 3 sites
- [ ] Edit one of them (panel opens)
- [ ] Click +10m batch operation
- [ ] Edit panel closes OR values update live
- [ ] Flash animation on updated sites
- [ ] Toast shows "Updated 3 sites by +10m"
---
## BUILD & DEPLOY
```bash
cd /opt/rfcp/frontend
npm run build
sudo systemctl reload caddy
# Test
curl https://rfcp.eliah.one/api/health
```
---
## COMMIT MESSAGE
```
fix(rf): implement accurate 3GPP antenna patterns
- Added proper 3GPP TR 36.814 horizontal pattern formula
- Sector antennas now show main lobe, side lobes, and back lobe
- Front-to-back ratio 25 dB (realistic for sector panels)
- Removed hard cutoff at beamwidth/2
fix(heatmap): ensure zoom-independent color mapping
- Removed dynamic maxIntensity (now constant 1.0)
- Same RSRP now shows same color regardless of zoom level
- Added Legend component with actual dBm thresholds
- Only radius and blur adjust with zoom (visual quality)
fix(rf): apply antenna gain to coverage calculations
- Antenna gain now affects RSRP: TxPower + Gain - PathLoss
- 0 dBi vs 25 dBi now shows massive coverage difference
- Formula: RSRP = Power + AntennaGain - FSPL - PatternLoss
fix(rf): implement radio horizon based on antenna height
- Added Earth curvature calculation (d = 3.57 * sqrt(h))
- 10m tower: 11 km horizon | 100m tower: 35 km horizon
- Coverage now limited by line-of-sight distance
- Prepares for Phase 4 terrain-aware LOS
fix(ux): improve batch edit workflow
- Edit panel closes when batch operation affects edited site
- Added flash animation on batch-updated sites
- Toast shows count of affected sites
- Clear visual feedback for all operations
```
---
## Expected Results
**After all fixes:**
1. **Antenna Pattern:**
- Sector shows wedge with weak back lobe
- No sudden cutoff, gradual attenuation
- Realistic F/B ratio ~25 dB
2. **Heatmap:**
- Colors consistent across ALL zoom levels
- Blue = weak, red = strong, ALWAYS
- Legend shows exact dBm ranges
3. **Antenna Gain:**
- High gain = much larger coverage
- 25 dBi sector vs 0 dBi omni: huge difference
- Realistic for real equipment
4. **Antenna Height:**
- Tall towers = wider coverage
- Coverage stops at radio horizon
- 100m tower can't reach 100 km (limited by curvature)
5. **UX:**
- Batch edit doesn't confuse users
- Clear feedback on what changed
- Smooth, professional workflow
🚀 Ready to implement!

View File

@@ -0,0 +1,860 @@
# RFCP - Iteration 6: Heatmap Gradient Fix + Multi-Sector + LTE Bands
## Current State (from screenshots)
**Working well:**
- ✅ Sector wedge shows correctly (triangle shape visible)
- ✅ Batch edit displays changes (flash animation works)
- ✅ Edit panel stays open during batch operations
**Issues to fix:**
- ❌ Heatmap gradient changes dramatically with zoom:
- Far zoom: Good gradient (green→yellow→orange)
- Medium zoom: Mostly orange (~80%)
- Close zoom: Almost all yellow/orange
- Very close zoom: Solid yellow/green
- ❌ Missing LTE Band 1 (2100 MHz) - only have Band 3 (1800 MHz)
- ❌ No multi-sector support (need 2-3 sectors per site for realistic deployments)
---
## CRITICAL FIX: Zoom-Independent Heatmap Colors
**Problem:** Same physical location shows different colors at different zoom levels. This makes the heatmap misleading.
**Root Cause:** `maxIntensity` parameter changes with zoom, causing the color scale to shift.
**Solution:** Make RSRP-to-color mapping zoom-independent, only adjust visual quality (radius/blur) with zoom.
**File:** `frontend/src/components/map/Heatmap.tsx`
```typescript
import { useEffect, useState } from 'react';
import { useMap } from 'react-leaflet';
import { HeatmapLayerFactory } from '@vgrid/react-leaflet-heatmap-layer';
const HeatmapLayer = HeatmapLayerFactory<[number, number, number]>();
interface HeatmapProps {
points: Array<{
lat: number;
lon: number;
rsrp: number;
siteId: string;
}>;
visible: boolean;
opacity?: number;
}
export function Heatmap({ points, visible, opacity = 0.7 }: HeatmapProps) {
const map = useMap();
const [mapZoom, setMapZoom] = useState(map.getZoom());
useEffect(() => {
const handleZoomEnd = () => setMapZoom(map.getZoom());
map.on('zoomend', handleZoomEnd);
return () => { map.off('zoomend', handleZoomEnd); };
}, [map]);
if (!visible || points.length === 0) return null;
// CRITICAL FIX: Wider RSRP range for full gradient
const normalizeRSRP = (rsrp: number): number => {
const minRSRP = -130; // Very weak signal
const maxRSRP = -50; // Excellent signal (widened from -60)
const normalized = (rsrp - minRSRP) / (maxRSRP - minRSRP);
return Math.max(0, Math.min(1, normalized));
};
// Zoom-dependent visual parameters (for display quality only)
const radius = Math.max(10, Math.min(40, 60 - mapZoom * 3));
const blur = Math.max(8, Math.min(25, 35 - mapZoom * 1.5));
// CRITICAL FIX: Constant maxIntensity for zoom-independent colors
// BUT lower than 1.0 to prevent saturation
const maxIntensity = 0.75; // FIXED VALUE, never changes
const heatmapPoints = points.map(p => [
p.lat,
p.lon,
normalizeRSRP(p.rsrp)
] as [number, number, number]);
// Debug logging
if (import.meta.env.DEV && points.length > 0) {
const rsrpValues = points.map(p => p.rsrp);
console.log('Heatmap Debug:', {
totalPoints: points.length,
rsrpRange: `${Math.min(...rsrpValues).toFixed(1)} to ${Math.max(...rsrpValues).toFixed(1)} dBm`,
zoom: mapZoom,
radius,
blur,
maxIntensity
});
}
return (
<div style={{ opacity }}>
<HeatmapLayer
points={heatmapPoints}
longitudeExtractor={(p) => p[1]}
latitudeExtractor={(p) => p[0]}
intensityExtractor={(p) => p[2]}
gradient={{
0.0: '#1a237e', // Deep blue (-130 dBm - no service)
0.1: '#0d47a1', // Dark blue (-122 dBm)
0.2: '#2196f3', // Blue (-114 dBm)
0.3: '#00bcd4', // Cyan (-106 dBm - weak)
0.4: '#00897b', // Teal (-98 dBm)
0.5: '#4caf50', // Green (-90 dBm - fair)
0.6: '#8bc34a', // Light green (-82 dBm)
0.7: '#ffeb3b', // Yellow (-74 dBm - good)
0.8: '#ffc107', // Amber (-66 dBm)
0.9: '#ff9800', // Orange (-58 dBm - excellent)
1.0: '#f44336', // Red (-50 dBm - very strong)
}}
radius={radius}
blur={blur}
max={maxIntensity} // CONSTANT!
minOpacity={0.3}
/>
</div>
);
}
```
**Why this works:**
- RSRP -130 to -50 range captures full signal spectrum
- `maxIntensity=0.75` prevents color saturation while keeping gradient visible
- Same RSRP → same normalized value → same color at ANY zoom level
- Only radius/blur change with zoom (visual quality, not colors)
---
## FEATURE 1: Multi-Sector Support
**What:** Allow 2-3 sectors per site (standard for real cell towers).
### Data Model Update
**File:** `frontend/src/types/site.ts`
```typescript
export interface Site {
id: string;
name: string;
lat: number;
lon: number;
color: string;
// Physical parameters (shared across sectors)
height: number; // meters
frequency: number; // MHz
power: number; // dBm (per sector)
// Multi-sector configuration
sectors: Sector[];
}
export interface Sector {
id: string;
enabled: boolean;
azimuth: number; // degrees (0-360)
beamwidth: number; // degrees
gain: number; // dBi
notes?: string; // e.g., "Alpha sector", "Main lobe"
}
// Common presets
export const SECTOR_PRESETS = {
single_omni: [{
id: 's1',
enabled: true,
azimuth: 0,
beamwidth: 360,
gain: 2
}],
dual_sector: [
{ id: 's1', enabled: true, azimuth: 0, beamwidth: 90, gain: 15 },
{ id: 's2', enabled: true, azimuth: 180, beamwidth: 90, gain: 15 }
],
tri_sector: [
{ id: 's1', enabled: true, azimuth: 0, beamwidth: 65, gain: 18 },
{ id: 's2', enabled: true, azimuth: 120, beamwidth: 65, gain: 18 },
{ id: 's3', enabled: true, azimuth: 240, beamwidth: 65, gain: 18 }
]
};
```
### UI Component
**File:** `frontend/src/components/panels/SectorConfig.tsx` (new)
```typescript
import { useState } from 'react';
import { Sector, SECTOR_PRESETS } from '@/types/site';
import { Button } from '@/components/ui/Button';
import { Slider } from '@/components/ui/Slider';
interface SectorConfigProps {
sectors: Sector[];
onUpdate: (sectors: Sector[]) => void;
}
export function SectorConfig({ sectors, onUpdate }: SectorConfigProps) {
const applyPreset = (presetName: keyof typeof SECTOR_PRESETS) => {
onUpdate(SECTOR_PRESETS[presetName]);
};
const updateSector = (id: string, changes: Partial<Sector>) => {
onUpdate(sectors.map(s => s.id === id ? { ...s, ...changes } : s));
};
const addSector = () => {
const newSector: Sector = {
id: `s${sectors.length + 1}`,
enabled: true,
azimuth: 0,
beamwidth: 65,
gain: 18
};
onUpdate([...sectors, newSector]);
};
const removeSector = (id: string) => {
onUpdate(sectors.filter(s => s.id !== id));
};
return (
<div className="sector-config">
<h4>Sector Configuration</h4>
{/* Quick presets */}
<div className="preset-buttons">
<Button size="sm" onClick={() => applyPreset('single_omni')}>
Omni
</Button>
<Button size="sm" onClick={() => applyPreset('dual_sector')}>
2-Sector
</Button>
<Button size="sm" onClick={() => applyPreset('tri_sector')}>
3-Sector
</Button>
</div>
{/* Individual sectors */}
<div className="sectors-list">
{sectors.map((sector, idx) => (
<div key={sector.id} className="sector-item">
<div className="sector-header">
<input
type="checkbox"
checked={sector.enabled}
onChange={(e) => updateSector(sector.id, { enabled: e.target.checked })}
/>
<h5>Sector {idx + 1}</h5>
{sectors.length > 1 && (
<button
onClick={() => removeSector(sector.id)}
className="remove-btn"
>
</button>
)}
</div>
{sector.enabled && (
<>
<Slider
label="Azimuth"
min={0}
max={360}
step={1}
value={sector.azimuth}
onChange={(v) => updateSector(sector.id, { azimuth: v })}
suffix="°"
/>
<Slider
label="Beamwidth"
min={30}
max={120}
step={5}
value={sector.beamwidth}
onChange={(v) => updateSector(sector.id, { beamwidth: v })}
suffix="°"
/>
<Slider
label="Gain"
min={0}
max={25}
step={1}
value={sector.gain}
onChange={(v) => updateSector(sector.id, { gain: v })}
suffix=" dBi"
/>
</>
)}
</div>
))}
</div>
<Button onClick={addSector} size="sm" variant="outline">
+ Add Sector
</Button>
</div>
);
}
```
### Coverage Calculation
**File:** `frontend/src/workers/rf-worker.js`
```javascript
// Calculate coverage for all sectors of a site
function calculateSiteCoverage(site, bounds, radius, resolution, rsrpThreshold) {
const allPoints = [];
for (const sector of site.sectors) {
if (!sector.enabled) continue;
// Calculate for this sector
const sectorPoints = calculateSectorCoverage(
site,
sector,
bounds,
radius,
resolution,
rsrpThreshold
);
// Merge points (keep strongest signal at each location)
for (const point of sectorPoints) {
const existing = allPoints.find(p =>
Math.abs(p.lat - point.lat) < 0.00001 &&
Math.abs(p.lon - point.lon) < 0.00001
);
if (existing) {
if (point.rsrp > existing.rsrp) {
existing.rsrp = point.rsrp;
existing.sectorId = sector.id;
}
} else {
allPoints.push(point);
}
}
}
return allPoints;
}
function calculateSectorCoverage(site, sector, bounds, radius, resolution, rsrpThreshold) {
const points = [];
// Grid setup...
for (let latIdx = 0; latIdx < latPoints; latIdx++) {
for (let lonIdx = 0; lonIdx < lonPoints; lonIdx++) {
const lat = minLat + latIdx * latStep;
const lon = minLon + lonIdx * lonStep;
const distance = calculateDistance(site.lat, site.lon, lat, lon);
if (distance > radius) continue;
// Antenna pattern loss
const bearing = calculateBearing(site.lat, site.lon, lat, lon);
const patternLoss = calculateSectorLoss(sector.azimuth, bearing, sector.beamwidth);
// Skip very weak back lobe
if (patternLoss > 25) continue;
// FSPL
const fspl = calculateFSPL(distance, site.frequency);
// Final RSRP
const rsrp = site.power + sector.gain - fspl - patternLoss;
if (rsrp > rsrpThreshold) {
points.push({
lat,
lon,
rsrp,
siteId: site.id,
sectorId: sector.id
});
}
}
}
return points;
}
```
### Visualization
**File:** `frontend/src/components/map/SiteMarker.tsx`
```typescript
// Show wedge for each sector
{site.sectors.map(sector => (
sector.enabled && sector.beamwidth < 360 && (
<Polygon
key={sector.id}
positions={generateSectorWedge(site.lat, site.lon, sector)}
pathOptions={{
color: site.color,
weight: 2,
opacity: 0.6,
fillOpacity: 0.1,
dashArray: '5, 5'
}}
/>
)
))}
function generateSectorWedge(lat: number, lon: number, sector: Sector) {
const points: [number, number][] = [[lat, lon]];
const visualRadius = 0.5; // km
const startAngle = sector.azimuth - sector.beamwidth / 2;
const endAngle = sector.azimuth + sector.beamwidth / 2;
for (let angle = startAngle; angle <= endAngle; angle += 5) {
const rad = angle * Math.PI / 180;
const latOffset = (visualRadius / 111) * Math.cos(rad);
const lonOffset = (visualRadius / (111 * Math.cos(lat * Math.PI / 180))) * Math.sin(rad);
points.push([lat + latOffset, lon + lonOffset]);
}
points.push([lat, lon]);
return points;
}
```
---
## FEATURE 2: Add LTE Band 1
**Current:** Have Band 3 (1800 MHz), Band 7 (2600 MHz).
**Add:** Band 1 (2100 MHz) - most common LTE band globally.
**File:** `frontend/src/components/panels/SiteForm.tsx`
```typescript
// Frequency selector
<div className="frequency-selector">
<label>Operating Frequency</label>
<div className="band-buttons">
<button
className={frequency === 800 ? 'active' : ''}
onClick={() => setFormData({ ...formData, frequency: 800 })}
>
800 MHz
<small>Band 20</small>
</button>
<button
className={frequency === 1800 ? 'active' : ''}
onClick={() => setFormData({ ...formData, frequency: 1800 })}
>
1800 MHz
<small>Band 3</small>
</button>
<button
className={frequency === 1900 ? 'active' : ''}
onClick={() => setFormData({ ...formData, frequency: 1900 })}
>
1900 MHz
<small>Band 2</small>
</button>
<button
className={frequency === 2100 ? 'active' : ''}
onClick={() => setFormData({ ...formData, frequency: 2100 })}
>
2100 MHz
<small>Band 1</small>
</button>
<button
className={frequency === 2600 ? 'active' : ''}
onClick={() => setFormData({ ...formData, frequency: 2600 })}
>
2600 MHz
<small>Band 7</small>
</button>
</div>
<input
type="number"
placeholder="Custom MHz..."
value={customFreq}
onChange={(e) => setCustomFreq(e.target.value)}
/>
</div>
{/* Band info */}
{frequency && (
<p className="band-info">
{getBandDescription(frequency)}
</p>
)}
function getBandDescription(freq: number): string {
const info = {
800: 'Band 20 (LTE 800) - Best coverage, deep building penetration',
1800: 'Band 3 (DCS 1800) - Most common in Europe and Ukraine',
1900: 'Band 2 (PCS 1900) - Common in Americas',
2100: 'Band 1 (IMT 2100) - Most deployed LTE band globally',
2600: 'Band 7 (IMT-E 2600) - High capacity urban areas'
};
return info[freq] || `Custom ${freq} MHz`;
}
```
---
## TESTING CHECKLIST
### Heatmap Gradient (Critical):
- [ ] Zoom out to level 6: Note color at 5km from site
- [ ] Zoom to level 10: Same location should be SAME color
- [ ] Zoom to level 14: Color still unchanged
- [ ] Zoom to level 18: Color STILL the same!
- [ ] Check console logs: RSRP values and maxIntensity=0.75
### Multi-Sector:
- [ ] Apply 3-sector preset
- [ ] See 3 wedges (120° spacing)
- [ ] Disable sector 2 → wedge disappears
- [ ] Adjust azimuth of sector 1 → wedge rotates
- [ ] Coverage calculation includes all enabled sectors
### Band Addition:
- [ ] Band 1 (2100 MHz) button visible and works
- [ ] Band description shows "Most deployed LTE band globally"
- [ ] Coverage calculation uses 2100 MHz correctly
### Performance:
- [ ] Coverage calc still fast (< 2s for 10km radius)
- [ ] UI responsive during calculation
- [ ] No memory leaks
---
## BUILD & DEPLOY
```bash
cd /opt/rfcp/frontend
npm run build
ls -lh dist/ # Check size
sudo systemctl reload caddy
# Test
curl https://rfcp.eliah.one/api/health
curl https://rfcp.eliah.one/ | head -20
```
---
## COMMIT MESSAGE
```
fix(heatmap): make colors zoom-independent
- Extended RSRP range to -130 to -50 dBm
- Fixed maxIntensity at 0.75 (was zoom-dependent)
- Added more gradient steps for smoother transitions
- Same RSRP now shows same color at ANY zoom level
feat(multi-sector): support 2-3 sectors per site
- Sites can have multiple sectors with independent azimuth/gain
- Presets: single omni, dual sector (180°), tri-sector (120°)
- Each sector shows visual wedge on map
- Coverage calculation merges all enabled sectors
- Strongest signal wins at overlapping points
feat(bands): add LTE Band 1 (2100 MHz)
- Most deployed LTE band globally
- Common for international deployments
- Band selector shows description for each band
```
---
---
## FEATURE 3: Map Enhancements
### A. Coordinate Grid Overlay
**What:** Show lat/lon grid lines with labels.
**Install dependency:**
```bash
npm install leaflet-graticule
```
**File:** `frontend/src/components/map/CoordinateGrid.tsx` (new)
```typescript
import { useEffect } from 'react';
import { useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet-graticule';
interface CoordinateGridProps {
visible: boolean;
}
export function CoordinateGrid({ visible }: CoordinateGridProps) {
const map = useMap();
useEffect(() => {
if (!visible) return;
const graticule = (L as any).latlngGraticule({
showLabel: true,
opacity: 0.5,
weight: 1,
color: '#666',
font: '11px monospace',
fontColor: '#444',
dashArray: '3, 3',
zoomInterval: [
{ start: 1, end: 7, interval: 1 },
{ start: 8, end: 10, interval: 0.5 },
{ start: 11, end: 13, interval: 0.1 },
{ start: 14, end: 20, interval: 0.01 }
]
}).addTo(map);
return () => {
map.removeLayer(graticule);
};
}, [map, visible]);
return null;
}
```
### B. Distance Measurement Tool
**File:** `frontend/src/components/map/MeasurementTool.tsx` (new)
```typescript
import { useEffect, useState } from 'react';
import { useMap, Polyline, Marker } from 'react-leaflet';
import L from 'leaflet';
interface MeasurementToolProps {
enabled: boolean;
onComplete?: (distance: number) => void;
}
export function MeasurementTool({ enabled, onComplete }: MeasurementToolProps) {
const map = useMap();
const [points, setPoints] = useState<[number, number][]>([]);
const [totalDistance, setTotalDistance] = useState(0);
useEffect(() => {
if (!enabled) {
setPoints([]);
setTotalDistance(0);
return;
}
const handleClick = (e: L.LeafletMouseEvent) => {
const newPoints = [...points, [e.latlng.lat, e.latlng.lng] as [number, number]];
setPoints(newPoints);
if (newPoints.length >= 2) {
const distance = calculateTotalDistance(newPoints);
setTotalDistance(distance);
}
};
const handleRightClick = () => {
if (totalDistance > 0 && onComplete) {
onComplete(totalDistance);
}
setPoints([]);
setTotalDistance(0);
};
map.on('click', handleClick);
map.on('contextmenu', handleRightClick);
return () => {
map.off('click', handleClick);
map.off('contextmenu', handleRightClick);
};
}, [map, enabled, points, totalDistance, onComplete]);
if (points.length === 0) return null;
return (
<>
{points.length >= 2 && (
<Polyline
positions={points}
pathOptions={{ color: '#00ff00', weight: 3, dashArray: '10, 5' }}
/>
)}
{points.map((pos, idx) => (
<Marker
key={idx}
position={pos}
icon={L.divIcon({
className: 'measurement-marker',
html: '<div style="background: white; border: 2px solid #333; border-radius: 50%; width: 10px; height: 10px;"></div>'
})}
/>
))}
{totalDistance > 0 && (
<div style={{
position: 'absolute',
top: '10px',
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(0,0,0,0.8)',
color: 'white',
padding: '8px 16px',
borderRadius: '4px',
zIndex: 1000,
pointerEvents: 'none'
}}>
📏 Distance: {totalDistance.toFixed(2)} km
</div>
)}
</>
);
}
function calculateTotalDistance(points: [number, number][]): number {
let total = 0;
for (let i = 1; i < points.length; i++) {
const [lat1, lon1] = points[i - 1];
const [lat2, lon2] = points[i];
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
total += R * c;
}
return total;
}
```
### C. Scale Bar & Compass
**File:** `frontend/src/components/map/MapExtras.tsx` (new)
```typescript
import { useEffect } from 'react';
import { useMap } from 'react-leaflet';
import L from 'leaflet';
export function MapExtras() {
const map = useMap();
useEffect(() => {
// Scale bar
const scale = L.control.scale({
position: 'bottomleft',
metric: true,
imperial: false,
maxWidth: 200
}).addTo(map);
// Compass rose
const compass = L.control({ position: 'topright' });
compass.onAdd = () => {
const div = L.DomUtil.create('div', 'compass-rose');
div.innerHTML = `
<svg width="50" height="50" viewBox="0 0 50 50">
<circle cx="25" cy="25" r="22" fill="white" stroke="#333" stroke-width="2"/>
<path d="M 25 8 L 28 18 L 25 25 L 22 18 Z" fill="#dc2626"/>
<path d="M 25 42 L 28 32 L 25 25 L 22 32 Z" fill="white" stroke="#333"/>
<text x="25" y="12" text-anchor="middle" font-size="12" font-weight="bold">N</text>
</svg>
`;
div.style.cssText = 'background: rgba(255,255,255,0.9); border-radius: 50%; padding: 5px; box-shadow: 0 2px 5px rgba(0,0,0,0.3);';
return div;
};
compass.addTo(map);
return () => {
map.removeControl(scale);
map.removeControl(compass);
};
}, [map]);
return null;
}
```
### D. Map Controls UI
**Add to Coverage Settings panel:**
```typescript
<div className="map-tools-section">
<h3>Map Tools</h3>
<label>
<input
type="checkbox"
checked={showGrid}
onChange={(e) => setShowGrid(e.target.checked)}
/>
Coordinate Grid
</label>
<button
onClick={() => setMeasurementMode(!measurementMode)}
className={measurementMode ? 'active' : ''}
>
📏 {measurementMode ? 'Measuring...' : 'Measure Distance'}
</button>
<small>Click points on map. Right-click to finish.</small>
</div>
// In Map component:
<CoordinateGrid visible={showGrid} />
<MeasurementTool enabled={measurementMode} onComplete={(d) => toast.success(`${d.toFixed(2)} km`)} />
<MapExtras />
```
---
## SUCCESS CRITERIA
After ALL fixes:
✅ Zoom from 6 to 18: colors don't shift
✅ Can create 3-sector site with independent azimuths
✅ Band 1 (2100 MHz) available in selector
✅ Gradient shows full blue→cyan→green→yellow→orange→red spectrum
✅ Coordinate grid shows lat/lon lines with labels
✅ Distance measurement tool works (click to measure, right-click to finish)
✅ Scale bar visible at bottom left
✅ Compass rose (north arrow) visible at top right
✅ Hillshade terrain adds 3D relief effect
✅ Performance unchanged (< 2s for typical coverage)
🚀 Ready to implement!

View File

@@ -0,0 +1,569 @@
# RFCP - Iteration 7: Elevation Data + UX Polish
## Issues from Iteration 6
1.**Omni circle visible** - orange circle shows for omni antennas, should be hidden
2.**Zoom still breaks gradient** - colors shift with zoom (maxIntensity not working?)
3.**No elevation visualization** - can't see hills/mountains on map
4.**Sector cloning creates 3 sectors** - should clone 1 at a time
---
## QUICK FIX 1: Hide Omni Coverage Circle
**Problem:** Orange circle shows for omni antennas (360° beamwidth).
**Solution:** Only draw sector wedge for directional antennas (beamwidth < 360).
**File:** `frontend/src/components/map/SiteMarker.tsx`
```typescript
// Only show wedge for directional sectors
{site.sectors.map(sector => (
sector.enabled && sector.beamwidth < 360 && ( // ← ADD THIS CHECK
<Polygon
key={sector.id}
positions={generateSectorWedge(site.lat, site.lon, sector)}
pathOptions={{
color: site.color,
weight: 2,
opacity: 0.6,
fillOpacity: 0.1,
dashArray: '5, 5'
}}
/>
)
))}
```
---
## QUICK FIX 2: Debug Heatmap Zoom Issue
**Problem:** Colors still change with zoom despite maxIntensity=0.75 fix.
**Debug first:**
**File:** `frontend/src/components/map/Heatmap.tsx`
```typescript
// Add detailed logging
useEffect(() => {
if (import.meta.env.DEV && points.length > 0) {
const rsrpValues = points.map(p => p.rsrp);
const normalizedSample = points.slice(0, 5).map(p => ({
rsrp: p.rsrp,
normalized: normalizeRSRP(p.rsrp)
}));
console.log('🔍 Heatmap Debug:', {
zoom: mapZoom,
totalPoints: points.length,
rsrpRange: `${Math.min(...rsrpValues).toFixed(1)} to ${Math.max(...rsrpValues).toFixed(1)} dBm`,
radius,
blur,
maxIntensity, // ← Should be 0.75
sample: normalizedSample
});
}
}, [points, mapZoom]);
```
**Possible issue:** If `maxIntensity` is still a formula instead of constant 0.75, replace it:
```typescript
// MUST be constant!
const maxIntensity = 0.75; // NOT a formula!
```
---
## QUICK FIX 3: Clone Single Sector
**Problem:** Tri-sector preset creates 3 sectors at once. User wants to clone one sector at a time.
**Solution:** Add "Clone Sector" button per sector.
**File:** `frontend/src/components/panels/SectorConfig.tsx`
```typescript
const cloneSector = (sector: Sector) => {
const newSector: Sector = {
...sector,
id: `s${Date.now()}`, // Unique ID
azimuth: (sector.azimuth + 30) % 360 // Offset by 30°
};
onUpdate([...sectors, newSector]);
};
// In sector item UI:
<div className="sector-actions">
<button onClick={() => cloneSector(sector)} title="Clone this sector">
📋 Clone
</button>
{sectors.length > 1 && (
<button onClick={() => removeSector(sector.id)}>
🗑 Remove
</button>
)}
</div>
```
---
## FEATURE 1: Elevation Visualization
**What we want:**
1. See elevation (meters above sea level) when hovering cursor
2. Color-coded elevation overlay on map
3. Elevation profile along measurement line
### A. Cursor Elevation Display
**Option 1: Use Open-Elevation API (free, no auth)**
**File:** `frontend/src/hooks/useElevation.ts` (new)
```typescript
import { useState, useEffect } from 'react';
import { useMap } from 'react-leaflet';
import L from 'leaflet';
export function useElevation() {
const map = useMap();
const [elevation, setElevation] = useState<number | null>(null);
const [position, setPosition] = useState<{ lat: number; lon: number } | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
let timeoutId: number;
let abortController: AbortController;
const handleMouseMove = (e: L.LeafletMouseEvent) => {
setPosition({ lat: e.latlng.lat, lon: e.latlng.lng });
// Debounce API calls (300ms)
clearTimeout(timeoutId);
if (abortController) abortController.abort();
timeoutId = window.setTimeout(async () => {
setLoading(true);
abortController = new AbortController();
try {
const response = await fetch(
`https://api.open-elevation.com/api/v1/lookup?locations=${e.latlng.lat},${e.latlng.lng}`,
{ signal: abortController.signal }
);
const data = await response.json();
setElevation(data.results[0].elevation);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Elevation fetch failed:', error);
setElevation(null);
}
} finally {
setLoading(false);
}
}, 300);
};
map.on('mousemove', handleMouseMove);
return () => {
map.off('mousemove', handleMouseMove);
clearTimeout(timeoutId);
if (abortController) abortController.abort();
};
}, [map]);
return { elevation, position, loading };
}
```
**Display Component:**
**File:** `frontend/src/components/map/ElevationDisplay.tsx` (new)
```typescript
import { useElevation } from '@/hooks/useElevation';
export function ElevationDisplay() {
const { elevation, position, loading } = useElevation();
if (!position) return null;
return (
<div className="elevation-display" style={{
position: 'absolute',
bottom: '40px',
left: '10px',
background: 'rgba(0,0,0,0.8)',
color: 'white',
padding: '8px 12px',
borderRadius: '4px',
fontSize: '12px',
zIndex: 1000,
pointerEvents: 'none'
}}>
<div>📍 {position.lat.toFixed(5)}°, {position.lon.toFixed(5)}°</div>
<div>
{loading ? 'Loading...' : elevation !== null ? `${elevation}m ASL` : 'N/A'}
</div>
</div>
);
}
```
**Usage in Map.tsx:**
```typescript
import { ElevationDisplay } from './ElevationDisplay';
{showElevationInfo && <ElevationDisplay />}
```
### B. Elevation Color Overlay
**Option: Use Stamen Terrain (color-coded by elevation)**
**File:** `frontend/src/components/map/Map.tsx`
```typescript
{showElevationOverlay && (
<TileLayer
url="https://tiles.stadiamaps.com/tiles/stamen_terrain/{z}/{x}/{y}.png"
attribution='&copy; Stamen Design'
opacity={0.5}
zIndex={97}
/>
)}
```
**Add control:**
```typescript
<label>
<input
type="checkbox"
checked={showElevationOverlay}
onChange={(e) => setShowElevationOverlay(e.target.checked)}
/>
Elevation Colors
</label>
```
---
## FEATURE 2: Site Templates & Quick Actions
**What:** Preset site configurations for common deployments.
**File:** `frontend/src/components/panels/SiteTemplates.tsx` (new)
```typescript
const SITE_TEMPLATES = {
urban_macro: {
name: 'Urban Macro Site',
height: 30,
power: 43,
frequency: 1800,
sectors: [
{ azimuth: 0, beamwidth: 65, gain: 18 },
{ azimuth: 120, beamwidth: 65, gain: 18 },
{ azimuth: 240, beamwidth: 65, gain: 18 }
]
},
rural_tower: {
name: 'Rural Tower',
height: 50,
power: 46,
frequency: 800,
sectors: [
{ azimuth: 0, beamwidth: 360, gain: 8 } // Omni
]
},
small_cell: {
name: 'Small Cell',
height: 6,
power: 30,
frequency: 2600,
sectors: [
{ azimuth: 0, beamwidth: 90, gain: 12 }
]
},
indoor_das: {
name: 'Indoor DAS',
height: 3,
power: 23,
frequency: 2100,
sectors: [
{ azimuth: 0, beamwidth: 360, gain: 2 }
]
}
};
export function SiteTemplates({ onApply }: { onApply: (template: any) => void }) {
return (
<div className="site-templates">
<h4>Quick Templates</h4>
<div className="template-grid">
{Object.entries(SITE_TEMPLATES).map(([key, template]) => (
<button
key={key}
onClick={() => onApply(template)}
className="template-btn"
>
{template.name}
</button>
))}
</div>
</div>
);
}
```
---
## FEATURE 3: Coverage Analysis Tools
### A. Best Server Map
**What:** Show which site provides best signal at each point (Voronoi-like).
**Implementation:** Color each point by `siteId` instead of RSRP.
**File:** `frontend/src/store/coverage.ts`
```typescript
interface CoverageState {
// ...existing
viewMode: 'signal' | 'best-server'; // NEW
}
// In coverage calculation:
if (viewMode === 'best-server') {
// Color by siteId instead of RSRP
return { lat, lon, siteId, rsrp };
}
```
### B. Coverage Statistics
**What:** Show coverage area, population, percentages.
**File:** `frontend/src/components/panels/CoverageStats.tsx` (new)
```typescript
export function CoverageStats({ points, sites }: Props) {
const totalArea = calculateArea(points); // km²
const coverageByLevel = {
excellent: points.filter(p => p.rsrp > -70).length,
good: points.filter(p => p.rsrp > -85 && p.rsrp <= -70).length,
fair: points.filter(p => p.rsrp > -100 && p.rsrp <= -85).length,
weak: points.filter(p => p.rsrp <= -100).length
};
return (
<div className="coverage-stats">
<h4>Coverage Analysis</h4>
<div className="stat-item">
<span>Total Coverage Area:</span>
<strong>{totalArea.toFixed(1)} km²</strong>
</div>
<div className="stat-item">
<span>Excellent (&gt; -70 dBm):</span>
<strong>{(coverageByLevel.excellent / points.length * 100).toFixed(1)}%</strong>
</div>
<div className="stat-item">
<span>Good (-85 to -70 dBm):</span>
<strong>{(coverageByLevel.good / points.length * 100).toFixed(1)}%</strong>
</div>
<div className="stat-item">
<span>Fair (-100 to -85 dBm):</span>
<strong>{(coverageByLevel.fair / points.length * 100).toFixed(1)}%</strong>
</div>
<div className="stat-item">
<span>Weak (&lt; -100 dBm):</span>
<strong>{(coverageByLevel.weak / points.length * 100).toFixed(1)}%</strong>
</div>
</div>
);
}
```
---
## FEATURE 4: Import/Export Sites
**What:** Save/load site configurations as JSON/CSV.
**File:** `frontend/src/components/panels/SiteImportExport.tsx` (new)
```typescript
export function SiteImportExport() {
const { sites, setSites } = useSitesStore();
const exportSites = () => {
const json = JSON.stringify(sites, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `rfcp-sites-${Date.now()}.json`;
a.click();
toast.success('Sites exported');
};
const importSites = async (file: File) => {
try {
const text = await file.text();
const imported = JSON.parse(text);
setSites(imported);
toast.success(`Imported ${imported.length} sites`);
} catch (error) {
toast.error('Invalid file format');
}
};
return (
<div className="site-import-export">
<h4>Import/Export</h4>
<button onClick={exportSites}>
📥 Export Sites (JSON)
</button>
<input
type="file"
accept=".json"
onChange={(e) => e.target.files?.[0] && importSites(e.target.files[0])}
/>
</div>
);
}
```
---
## FEATURE 5: 3D Terrain View (Future - Phase 4)
**What:** Optional 3D view with MapLibre GL + terrain data.
**Teaser for later:**
- MapLibre GL for 3D rendering
- Real terrain elevation from SRTM files
- Line-of-sight visualization
- 3D buildings in cities
---
## TESTING CHECKLIST (Iteration 7)
### Quick Fixes:
- [ ] Omni coverage circle hidden (only sectors show wedges)
- [ ] Zoom gradient: Check console - maxIntensity=0.75 always?
- [ ] Clone sector: Creates 1 sector (not 3)
### Elevation:
- [ ] Hover cursor shows elevation (meters ASL)
- [ ] Elevation overlay shows color-coded terrain
- [ ] Elevation display updates smoothly (debounced)
### Templates:
- [ ] Apply "Urban Macro" template → 3 sectors created
- [ ] Apply "Rural Tower" → 1 omni sector
- [ ] Apply "Small Cell" → appropriate settings
### Coverage Stats:
- [ ] Shows total coverage area in km²
- [ ] Shows percentage by signal level
- [ ] Updates after recalculation
### Import/Export:
- [ ] Export sites → downloads JSON file
- [ ] Import JSON → restores sites correctly
- [ ] Invalid file shows error
---
## BUILD & DEPLOY
```bash
cd /opt/rfcp/frontend
npm run build
sudo systemctl reload caddy
```
---
## COMMIT MESSAGE
```
fix(ui): hide omni coverage circle visualization
- Only show sector wedges for beamwidth < 360
- Omni antennas (360°) no longer show orange circle
fix(heatmap): debug zoom-dependent gradient issue
- Added detailed console logging
- Verify maxIntensity is constant 0.75
feat(ux): clone single sector instead of tri-sector
- Added "Clone Sector" button per sector
- Creates duplicate with 30° azimuth offset
- Removed automatic tri-sector creation
feat(elevation): cursor elevation display
- Shows elevation (meters ASL) at cursor position
- Uses Open-Elevation API with debouncing
- Optional elevation color overlay (Stamen Terrain)
feat(templates): site configuration templates
- Urban Macro (3-sector, 30m, 1800 MHz)
- Rural Tower (omni, 50m, 800 MHz)
- Small Cell (single sector, 6m, 2600 MHz)
- Indoor DAS (omni, 3m, 2100 MHz)
feat(analysis): coverage statistics panel
- Total coverage area in km²
- Signal quality breakdown (excellent/good/fair/weak)
- Percentage distribution
feat(io): import/export site configurations
- Export sites as JSON
- Import sites from JSON file
- Preserves all site and sector settings
```
---
## Iteration 7 Summary
**Quick Fixes:**
1. Hide omni circle
2. Debug/fix zoom gradient
3. Clone 1 sector (not 3)
**New Features:**
4. Elevation display (cursor + overlay)
5. Site templates (4 presets)
6. Coverage statistics
7. Import/Export sites
**Future (Iteration 8?):**
- 3D terrain view (MapLibre GL)
- Line-of-sight analysis
- Population coverage estimation
- Network capacity planning
🚀 Ready for Iteration 7!

View File

@@ -0,0 +1,258 @@
# RFCP - Iteration 7.2: Proper Heatmap Gradient Fix
## Root Cause Analysis
**Why "nuclear fix" failed:**
- Fixed `radius=25, blur=15` works at ONE zoom level only
- At zoom 8: points too far apart → weak gradient
- At zoom 14: points too close → everything saturated
- Need: zoom-dependent VISUAL size, but color-independent INTENSITY
## The REAL Problem
Leaflet Heatmap uses `radius` and `blur` to determine:
1. **Visual size** of each point (pixels on screen)
2. **Overlap intensity** (how points combine)
When `radius` changes with zoom:
- More overlap → higher intensity → different colors
- **This is the bug!**
## The CORRECT Solution
**Keep zoom-dependent radius/blur for visual quality**
**BUT normalize intensity to compensate for overlap changes**
### Implementation
**File:** `frontend/src/components/map/Heatmap.tsx`
```typescript
import { useEffect, useState } from 'react';
import { useMap } from 'react-leaflet';
import { HeatmapLayerFactory } from '@vgrid/react-leaflet-heatmap-layer';
const HeatmapLayer = HeatmapLayerFactory<[number, number, number]>();
interface HeatmapProps {
points: Array<{ lat: number; lon: number; rsrp: number; siteId: string }>;
visible: boolean;
opacity?: number;
}
export function Heatmap({ points, visible, opacity = 0.7 }: HeatmapProps) {
const map = useMap();
const [mapZoom, setMapZoom] = useState(map.getZoom());
useEffect(() => {
const handleZoomEnd = () => setMapZoom(map.getZoom());
map.on('zoomend', handleZoomEnd);
return () => { map.off('zoomend', handleZoomEnd); };
}, [map]);
if (!visible || points.length === 0) return null;
// RSRP normalization (wide range for full gradient)
const normalizeRSRP = (rsrp: number): number => {
const minRSRP = -130;
const maxRSRP = -50;
const normalized = (rsrp - minRSRP) / (maxRSRP - minRSRP);
return Math.max(0, Math.min(1, normalized));
};
// Visual parameters (zoom-dependent for quality)
const radius = Math.max(12, Math.min(45, 55 - mapZoom * 2.5));
const blur = Math.max(10, Math.min(25, 30 - mapZoom * 1.2));
// CRITICAL FIX: Scale maxIntensity to compensate for radius changes
// When radius is larger (zoom out), points overlap more → need higher max
// When radius is smaller (zoom in), less overlap → need lower max
// This keeps COLORS consistent even though visual size changes!
const baseMax = 0.6;
const radiusScale = radius / 30; // Normalize to radius=30 baseline
const maxIntensity = baseMax * radiusScale;
// Clamp to reasonable range
const clampedMax = Math.max(0.4, Math.min(0.9, maxIntensity));
const heatmapPoints = points.map(p => [
p.lat,
p.lon,
normalizeRSRP(p.rsrp)
] as [number, number, number]);
// Debug
if (import.meta.env.DEV && points.length > 0) {
const rsrpValues = points.map(p => p.rsrp);
console.log('🔍 Heatmap:', {
zoom: mapZoom,
points: points.length,
rsrpRange: `${Math.min(...rsrpValues).toFixed(1)} to ${Math.max(...rsrpValues).toFixed(1)} dBm`,
radius: radius.toFixed(1),
blur: blur.toFixed(1),
maxIntensity: clampedMax.toFixed(3),
radiusScale: radiusScale.toFixed(3)
});
}
return (
<div style={{ opacity }}>
<HeatmapLayer
points={heatmapPoints}
longitudeExtractor={(p) => p[1]}
latitudeExtractor={(p) => p[0]}
intensityExtractor={(p) => p[2]}
gradient={{
0.0: '#1a237e', // Deep blue
0.15: '#0d47a1', // Dark blue
0.25: '#2196f3', // Blue
0.35: '#00bcd4', // Cyan
0.45: '#00897b', // Teal
0.55: '#4caf50', // Green
0.65: '#8bc34a', // Light green
0.75: '#ffeb3b', // Yellow
0.85: '#ff9800', // Orange
1.0: '#f44336', // Red
}}
radius={radius}
blur={blur}
max={clampedMax} // ← Compensates for radius changes!
minOpacity={0.3}
/>
</div>
);
}
```
### Why This Works
**Zoom Out (zoom 6-8):**
- `radius = 55 - 6*2.5 = 40`
- `radiusScale = 40/30 = 1.33`
- `maxIntensity = 0.6 * 1.33 = 0.8`
- Points overlap more → higher max compensates → same colors
**Zoom In (zoom 14-16):**
- `radius = 55 - 14*2.5 = 20` (clamped to 12)
- `radiusScale = 12/30 = 0.4`
- `maxIntensity = 0.6 * 0.4 = 0.24` (clamped to 0.4)
- Points overlap less → lower max compensates → same colors
---
## Alternative: Point Density Compensation
If above doesn't work perfectly, try density-based scaling:
```typescript
// Calculate point density (points per km²)
const bounds = map.getBounds();
const area = calculateArea(bounds); // km²
const density = points.length / area;
// Scale maxIntensity by density
const densityScale = Math.sqrt(density / 100); // Normalize to 100 pts/km²
const maxIntensity = 0.6 * densityScale * radiusScale;
```
---
## Alternative 2: Pre-normalize Intensities
Instead of using raw normalized RSRP, pre-scale by expected overlap:
```typescript
// Estimate overlap factor based on radius and point spacing
const avgPointSpacing = 0.2; // km (200m resolution)
const radiusKm = (radius / map.getZoom()) * 0.001; // Approx
const overlapFactor = Math.pow(radiusKm / avgPointSpacing, 2);
// Scale intensities DOWN when more overlap expected
const heatmapPoints = points.map(p => [
p.lat,
p.lon,
normalizeRSRP(p.rsrp) / overlapFactor
] as [number, number, number]);
// Keep maxIntensity constant
const maxIntensity = 0.75;
```
---
## Testing Strategy
1. **Pick reference point** 5km from site
2. **Zoom 8:** Note exact color (e.g., "yellow-green")
3. **Zoom 10:** Should be SAME color
4. **Zoom 12:** SAME
5. **Zoom 14:** SAME
6. **Zoom 16:** SAME
If colors shift more than 1-2 gradient stops, need to adjust:
- `baseMax` value (try 0.5, 0.6, 0.7)
- `radiusScale` formula
- Clamp range (min/max)
---
## Coverage Area Fix
**Why coverage looks smaller:**
- Fixed `radius=25` was too small at far zoom
- Points didn't reach edge of coverage
**Fix:** Restored zoom-dependent radius (above solution)
---
## Build & Test
```bash
cd /opt/rfcp/frontend
npm run build
sudo systemctl reload cadry
# Critical test: zoom 8 → 16 at same location
```
---
## Expected Result
✅ Full gradient visible (blue → cyan → green → yellow → orange → red)
✅ Coverage area back to normal size
✅ Colors STAY consistent when zooming
✅ No visible grid pattern
---
## Commit Message
```
fix(heatmap): proper zoom-independent color scaling
- Scale maxIntensity by radius to compensate for overlap changes
- Formula: maxIntensity = baseMax * (radius / baselineRadius)
- Visual size changes with zoom (radius/blur) for quality
- Color intensity compensates for overlap → consistent colors
- Restored zoom-dependent radius for proper coverage size
Colors now remain consistent across all zoom levels while
maintaining smooth visual appearance and full coverage extent.
```
---
## If This Still Doesn't Work...
**Ultimate fallback: Separate visual and data layers**
1. Calculate coverage at FIXED resolution (e.g., 100m)
2. Store in state with EXACT lat/lon grid
3. Render with SVG circles (fixed size, zoom-independent)
4. Color each circle by RSRP bucket
This gives **perfect** color consistency but loses heatmap smoothness.
🚀 Ready for Iteration 7.2!

View File

@@ -0,0 +1,292 @@
# RFCP - Iteration 7.3: Geographic Scale Compensation
## Root Cause
**Previous fix failed because:**
- `radius` is in **pixels**
- But coverage is in **kilometers**
- At zoom 8: 1km = ~100px
- At zoom 14: 1km = ~6400px
- Same `radius=30px` covers VERY different geographic areas!
## The REAL Solution
We need to keep **geographic radius constant**, not pixel radius!
### Implementation
**File:** `frontend/src/components/map/Heatmap.tsx`
```typescript
import { useEffect, useState } from 'react';
import { useMap } from 'react-leaflet';
import { HeatmapLayerFactory } from '@vgrid/react-leaflet-heatmap-layer';
const HeatmapLayer = HeatmapLayerFactory<[number, number, number]>();
interface HeatmapProps {
points: Array<{ lat: number; lon: number; rsrp: number; siteId: string }>;
visible: boolean;
opacity?: number;
}
export function Heatmap({ points, visible, opacity = 0.7 }: HeatmapProps) {
const map = useMap();
const [mapZoom, setMapZoom] = useState(map.getZoom());
useEffect(() => {
const handleZoomEnd = () => setMapZoom(map.getZoom());
map.on('zoomend', handleZoomEnd);
return () => { map.off('zoomend', handleZoomEnd); };
}, [map]);
if (!visible || points.length === 0) return null;
// RSRP normalization
const normalizeRSRP = (rsrp: number): number => {
const minRSRP = -130;
const maxRSRP = -50;
const normalized = (rsrp - minRSRP) / (maxRSRP - minRSRP);
return Math.max(0, Math.min(1, normalized));
};
// CRITICAL FIX: Calculate pixels per kilometer at current zoom
// Leaflet formula: pixelsPerKm = 2^zoom * 256 / (40075 * cos(lat))
// At equator (simplified): pixelsPerKm ≈ 2^zoom * 6.4
const pixelsPerKm = Math.pow(2, mapZoom) * 6.4;
// Target: 300m geographic radius (coverage point spacing)
const targetRadiusKm = 0.3; // 300 meters
const radiusPixels = targetRadiusKm * pixelsPerKm;
// Clamp for visual quality
const radius = Math.max(8, Math.min(60, radiusPixels));
// Blur proportional to radius
const blur = radius * 0.6; // 60% of radius
// FIXED maxIntensity (no compensation needed now!)
const maxIntensity = 0.75;
const heatmapPoints = points.map(p => [
p.lat,
p.lon,
normalizeRSRP(p.rsrp)
] as [number, number, number]);
// Debug
if (import.meta.env.DEV && points.length > 0) {
const rsrpValues = points.map(p => p.rsrp);
console.log('🔍 Heatmap Geographic:', {
zoom: mapZoom,
pixelsPerKm: pixelsPerKm.toFixed(1),
targetRadiusKm,
radiusPixels: radiusPixels.toFixed(1),
radiusClamped: radius.toFixed(1),
blur: blur.toFixed(1),
maxIntensity,
points: points.length,
rsrpRange: `${Math.min(...rsrpValues).toFixed(1)} to ${Math.max(...rsrpValues).toFixed(1)} dBm`
});
}
return (
<div style={{ opacity }}>
<HeatmapLayer
points={heatmapPoints}
longitudeExtractor={(p) => p[1]}
latitudeExtractor={(p) => p[0]}
intensityExtractor={(p) => p[2]}
gradient={{
0.0: '#1a237e',
0.15: '#0d47a1',
0.25: '#2196f3',
0.35: '#00bcd4',
0.45: '#00897b',
0.55: '#4caf50',
0.65: '#8bc34a',
0.75: '#ffeb3b',
0.85: '#ff9800',
1.0: '#f44336',
}}
radius={radius}
blur={blur}
max={maxIntensity} // CONSTANT! No more compensation!
minOpacity={0.3}
/>
</div>
);
}
```
### Why This Works
**Geographic consistency:**
- Same 300m radius covers same geographic area at ALL zooms
- Pixel radius auto-adjusts: zoom 8 = 20px, zoom 14 = 1920px
- Colors stay consistent because geographic coverage is consistent!
**Example:**
```
Zoom 8: pixelsPerKm = 1638 → radius = 0.3 * 1638 = 491px (clamped to 60)
Zoom 10: pixelsPerKm = 6553 → radius = 0.3 * 6553 = 1966px (clamped to 60)
Zoom 14: pixelsPerKm = 104857 → radius = 0.3 * 104857 = 31457px (clamped to 60)
```
**Wait, problem!** At high zoom, radius gets clamped to 60px which is TOO SMALL!
---
## Better Approach: Remove Clamp
```typescript
// Don't clamp! Let radius grow with zoom
const radius = targetRadiusKm * pixelsPerKm;
const blur = radius * 0.6;
// But limit max for performance
const radius = Math.min(radiusPixels, 100); // Max 100px
const blur = Math.min(radius * 0.6, 60);
```
---
## Even Better: Adaptive Target Radius
```typescript
// Smaller geographic radius at high zoom (more detail)
// Larger geographic radius at low zoom (smooth coverage)
const targetRadiusKm = mapZoom < 10 ? 0.5 : 0.3; // km
const radiusPixels = targetRadiusKm * pixelsPerKm;
const radius = Math.max(15, Math.min(50, radiusPixels));
```
---
## Alternative: Fix Resolution Instead
**Problem:** 200m resolution creates visible grid at high zoom.
**Solution:** Adaptive resolution based on zoom.
**File:** `frontend/src/store/coverage.ts`
```typescript
const calculateCoverage = async () => {
// Adaptive resolution: finer at close zoom
const adaptiveResolution = mapZoom >= 12
? 100 // 100m for close zoom
: 200; // 200m for far zoom
await coverageWorker.calculateCoverage({
sites,
radius,
resolution: adaptiveResolution,
rsrpThreshold
});
};
```
---
## Ultimate Solution: Multi-Resolution
Calculate different resolutions for different zoom levels:
```typescript
// Calculate 3 datasets
const coarse = calculate(resolution: 500); // Zoom 6-9
const medium = calculate(resolution: 200); // Zoom 10-13
const fine = calculate(resolution: 100); // Zoom 14+
// Show appropriate one based on zoom
const pointsToShow = mapZoom < 10 ? coarse
: mapZoom < 14 ? medium
: fine;
```
---
## Quick Fix: Just Increase Radius at High Zoom
**File:** `frontend/src/components/map/Heatmap.tsx`
```typescript
// Simple progressive formula
const radius = mapZoom < 10
? Math.max(20, Math.min(45, 55 - mapZoom * 2.5)) // Small zoom: old formula
: Math.max(30, Math.min(80, 10 + mapZoom * 3)); // Large zoom: grow radius
const blur = radius * 0.6;
const maxIntensity = 0.75; // Keep constant
```
---
## My Recommendation
**Try Option 1 first (geographic scale)** with adjusted clamps:
```typescript
const pixelsPerKm = Math.pow(2, mapZoom) * 6.4;
const targetRadiusKm = 0.4; // 400m
const radiusPixels = targetRadiusKm * pixelsPerKm;
// Progressive clamps
const minRadius = mapZoom < 10 ? 15 : 30;
const maxRadius = mapZoom < 10 ? 45 : 80;
const radius = Math.max(minRadius, Math.min(maxRadius, radiusPixels));
const blur = radius * 0.6;
const maxIntensity = 0.75;
```
**If that fails, try Option 2 (adaptive resolution).**
---
## Testing
1. **Coverage extent:**
- [ ] Zoom 8: Coverage reaches expected radius (e.g., 10km)
- [ ] Zoom 12: Coverage still reaches same distance
- [ ] No "shrinking" at any zoom
2. **Grid visibility:**
- [ ] Zoom 16+: No visible dots/grid pattern
- [ ] Smooth gradient even at close zoom
3. **Color consistency:**
- [ ] Pick point at 5km from site
- [ ] Check color at zoom 8, 12, 16
- [ ] Should be within 1-2 gradient stops
---
## Build & Test
```bash
cd /opt/rfcp/frontend
npm run build
sudo systemctl reload caddy
```
---
## Commit Message
```
fix(heatmap): geographic scale-aware radius calculation
- Calculate pixels per km based on zoom level
- Keep geographic radius constant (400m)
- Adjust pixel radius to maintain geographic coverage
- Progressive clamps for visual quality at all zooms
- Fixed maxIntensity at 0.75 (no compensation needed)
Coverage extent now consistent across zoom levels.
No visible grid pattern at high zoom.
Colors remain stable due to consistent geographic scale.
```
🚀 Ready for Iteration 7.3!

View File

@@ -0,0 +1,389 @@
# RFCP - Iteration 7.4: Radius Fix + Sector Tree UI
## Issue 1: Coverage Invisible (Critical!)
**Problem:** Max radius clamped to 80px is TOO SMALL for geographic scale.
**At zoom 14:**
- pixelsPerKm = 104,857
- targetRadius = 0.4km
- radiusPixels = 41,943px
- **Clamped to 80px** ← This is TINY!
- Should be ~200-500px for smooth coverage
**Root cause:** Geographic scale formula calculates HUGE pixel values at high zoom, but we clamp them down, losing all coverage!
### Solution A: Much Higher Clamps
**File:** `frontend/src/components/map/Heatmap.tsx`
```typescript
const pixelsPerKm = Math.pow(2, mapZoom) * 6.4;
const targetRadiusKm = 0.4; // 400m
const radiusPixels = targetRadiusKm * pixelsPerKm;
// MUCH HIGHER clamps
const minRadius = 20;
const maxRadius = mapZoom < 10 ? 60 : 300; // Allow up to 300px at high zoom!
const radius = Math.max(minRadius, Math.min(maxRadius, radiusPixels));
const blur = radius * 0.6;
const maxIntensity = 0.75;
```
### Solution B: Logarithmic Scaling
Instead of linear geographic scale, use log scale:
```typescript
// Log scale: grows slower at high zoom
const baseRadius = 30;
const zoomFactor = Math.log2(mapZoom + 1) / Math.log2(19); // Normalize to 0-1
const radius = baseRadius + (zoomFactor * 150); // 30px at zoom 1 → 180px at zoom 18
const blur = radius * 0.6;
const maxIntensity = 0.75;
```
### Solution C: Simple Progressive Formula (Recommended)
**Forget geographic scale - it's too complex for heatmap library!**
Use simple zoom-dependent formula with generous values:
```typescript
export function Heatmap({ points, visible, opacity = 0.7 }: HeatmapProps) {
const map = useMap();
const [mapZoom, setMapZoom] = useState(map.getZoom());
useEffect(() => {
const handleZoomEnd = () => setMapZoom(map.getZoom());
map.on('zoomend', handleZoomEnd);
return () => { map.off('zoomend', handleZoomEnd); };
}, [map]);
if (!visible || points.length === 0) return null;
// RSRP normalization
const normalizeRSRP = (rsrp: number): number => {
const minRSRP = -130;
const maxRSRP = -50;
const normalized = (rsrp - minRSRP) / (maxRSRP - minRSRP);
return Math.max(0, Math.min(1, normalized));
};
// SIMPLE progressive formula
// Small zoom: smaller radius (far view)
// Large zoom: larger radius (close view)
let radius, blur, maxIntensity;
if (mapZoom < 10) {
// Far view: moderate radius
radius = 30 + (mapZoom * 2); // 32px at zoom 1 → 50px at zoom 9
blur = radius * 0.7;
maxIntensity = 0.75;
} else {
// Close view: large radius to fill gaps
radius = 50 + ((mapZoom - 10) * 25); // 50px at zoom 10 → 200px at zoom 16
blur = radius * 0.6;
maxIntensity = 0.65; // Slightly lower to prevent saturation
}
const heatmapPoints = points.map(p => [
p.lat,
p.lon,
normalizeRSRP(p.rsrp)
] as [number, number, number]);
// Debug
if (import.meta.env.DEV) {
console.log('🔍 Heatmap:', {
zoom: mapZoom,
radius: radius.toFixed(1),
blur: blur.toFixed(1),
maxIntensity,
points: points.length
});
}
return (
<div style={{ opacity }}>
<HeatmapLayer
points={heatmapPoints}
longitudeExtractor={(p) => p[1]}
latitudeExtractor={(p) => p[0]}
intensityExtractor={(p) => p[2]}
gradient={{
0.0: '#1a237e',
0.15: '#0d47a1',
0.25: '#2196f3',
0.35: '#00bcd4',
0.45: '#00897b',
0.55: '#4caf50',
0.65: '#8bc34a',
0.75: '#ffeb3b',
0.85: '#ff9800',
1.0: '#f44336',
}}
radius={radius}
blur={blur}
max={maxIntensity}
minOpacity={0.3}
/>
</div>
);
}
```
**Expected results:**
- Zoom 8: radius=46px, blur=32px → smooth coverage
- Zoom 12: radius=100px, blur=60px → fills gaps
- Zoom 16: radius=200px, blur=120px → no grid visible
---
## Issue 2: Sector Tree UI
**Problem:** Clone creates new site instead of new sector in same site.
**Current:**
```
Sites (2)
- Station-1 (1800 MHz, 43 dBm, Sector 122°, 12m)
- Station-1-clone (1800 MHz, 43 dBm, Sector 0°, 60m)
```
**Should be:**
```
Sites (1)
- Station-1 (1800 MHz, 30m)
├─ Sector 1 (122°, 65°, 18 dBi)
└─ Sector 2 (0°, 65°, 18 dBi)
```
### Solution: Refactor Clone to Add Sector
**File:** `frontend/src/store/sites.ts`
Current `cloneSector` creates new site:
```typescript
const cloneSector = (siteId: string) => {
const site = sites.find(s => s.id === siteId);
const clone = { ...site, id: uuid(), name: `${site.name}-clone` };
setSites([...sites, clone]);
};
```
**Change to add sector:**
```typescript
const cloneSector = (siteId: string, sectorId?: string) => {
const site = sites.find(s => s.id === siteId);
// If sectorId provided, clone that specific sector
// Otherwise clone the first sector
const sourceSector = sectorId
? site.sectors.find(s => s.id === sectorId)
: site.sectors[0];
const newSector: Sector = {
...sourceSector,
id: `sector-${Date.now()}`,
azimuth: (sourceSector.azimuth + 30) % 360, // Offset by 30°
};
// Add sector to existing site
updateSite(siteId, {
sectors: [...site.sectors, newSector]
});
};
```
### UI: Tree View for Sectors
**File:** `frontend/src/components/panels/SiteList.tsx`
```typescript
export function SiteList() {
const { sites, selectedSiteIds, toggleSiteSelection } = useSitesStore();
const [expandedSites, setExpandedSites] = useState<Set<string>>(new Set());
const toggleExpand = (siteId: string) => {
const newExpanded = new Set(expandedSites);
if (newExpanded.has(siteId)) {
newExpanded.delete(siteId);
} else {
newExpanded.add(siteId);
}
setExpandedSites(newExpanded);
};
return (
<div className="site-list">
<h3>Sites ({sites.length})</h3>
{sites.map(site => {
const isExpanded = expandedSites.has(site.id);
const isSelected = selectedSiteIds.includes(site.id);
return (
<div key={site.id} className="site-tree-item">
{/* Site header */}
<div className={cn('site-header', isSelected && 'selected')}>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSiteSelection(site.id)}
/>
<button
onClick={() => toggleExpand(site.id)}
className="expand-btn"
>
{isExpanded ? '▼' : '▶'}
</button>
<div className="site-info">
<strong>{site.name}</strong>
<small>{site.frequency} MHz · {site.height}m · {site.sectors.length} sectors</small>
</div>
<button onClick={() => editSite(site.id)}>Edit</button>
<button onClick={() => cloneSite(site.id)}>Clone Site</button>
</div>
{/* Sectors (when expanded) */}
{isExpanded && (
<div className="sectors-tree">
{site.sectors.map((sector, idx) => (
<div key={sector.id} className="sector-item">
<input
type="checkbox"
checked={sector.enabled}
onChange={() => toggleSector(site.id, sector.id)}
/>
<div className="sector-info">
<strong>Sector {idx + 1}</strong>
{sector.beamwidth < 360 && (
<small>
{sector.azimuth}° · {sector.beamwidth}° · {sector.gain} dBi
</small>
)}
{sector.beamwidth === 360 && (
<small>Omni · {sector.gain} dBi</small>
)}
</div>
<button onClick={() => editSector(site.id, sector.id)}>
Edit
</button>
<button onClick={() => cloneSector(site.id, sector.id)}>
Clone Sector
</button>
{site.sectors.length > 1 && (
<button onClick={() => removeSector(site.id, sector.id)}>
</button>
)}
</div>
))}
<button
onClick={() => addSector(site.id)}
className="add-sector-btn"
>
+ Add Sector
</button>
</div>
)}
</div>
);
})}
</div>
);
}
```
### Simplified Alternative (Quick Fix)
If tree view is too complex, just fix the clone function:
**File:** `frontend/src/components/panels/SiteList.tsx`
```typescript
// Change button label
<button onClick={() => cloneSector(site.id)}>
+ Add Sector {/* was: Clone */}
</button>
// Update store function
const cloneSector = (siteId: string) => {
const site = sites.find(s => s.id === siteId);
const lastSector = site.sectors[site.sectors.length - 1];
const newSector = {
...lastSector,
id: `sector-${Date.now()}`,
azimuth: (lastSector.azimuth + 120) % 360, // 120° spacing for tri-sector
};
updateSite(siteId, {
sectors: [...site.sectors, newSector]
});
};
```
---
## Testing
### Heatmap Visibility:
- [ ] Zoom 8: Coverage visible with gradient
- [ ] Zoom 12: Full gradient blue→yellow→red
- [ ] Zoom 16: No grid pattern, smooth coverage
- [ ] All zoom levels show coverage extent
### Sector UI:
- [ ] "Clone Sector" adds sector to SAME site
- [ ] Site count shows correct number (1 site, 2 sectors = "Sites (1)")
- [ ] Each sector has edit/remove buttons
- [ ] Can enable/disable individual sectors
---
## Build & Deploy
```bash
cd /opt/rfcp/frontend
npm run build
sudo systemctl reload caddy
```
---
## Commit Message
```
fix(heatmap): increase radius range for visible coverage
- Remove geographic scale formula (too complex)
- Use simple progressive formula: 30-50px (zoom <10), 50-200px (zoom ≥10)
- Larger blur at high zoom to fill grid gaps
- Coverage now visible at all zoom levels
fix(ui): clone creates sector not new site
- Changed cloneSector to add sector to existing site
- Updated UI: "Clone Sector" instead of "Clone"
- Site count now accurate (counts sites, not sectors)
- Each sector independently editable/removable
refactor(ui): sector tree view (optional)
- Expandable site headers
- Nested sector list with enable/disable toggles
- Per-sector edit/clone/remove buttons
- Clear visual hierarchy: Site → Sectors
```
🚀 Ready for Iteration 7.4!

View File

@@ -0,0 +1,961 @@
# RFCP - Iteration 8: Custom Geographic Canvas Heatmap
## Overview
Replace `leaflet-heatmap` with custom Canvas-based renderer that maintains true geographic scale.
**Goal:** 400m radius coverage point = always 400m on ground, regardless of zoom.
---
## Architecture
```
frontend/src/components/map/
├── GeographicHeatmap.tsx # React component (Leaflet integration)
├── HeatmapTileRenderer.ts # Canvas tile rendering logic
└── utils/
├── geographicScale.ts # Meters ↔ Pixels conversion
└── colorGradient.ts # RSRP → Color mapping
```
---
## Step 1: Geographic Scale Utils
**File:** `frontend/src/utils/geographicScale.ts`
```typescript
// Earth constants
const EARTH_RADIUS_KM = 6371;
const EARTH_CIRCUMFERENCE_M = 40075017;
/**
* Calculate pixels per meter at given latitude and zoom level
* Uses Web Mercator projection (EPSG:3857)
*/
export function getPixelsPerMeter(lat: number, zoom: number): number {
// Tile size in pixels
const tileSize = 256;
// Number of tiles at this zoom level
const numTiles = Math.pow(2, zoom);
// World width in pixels at this zoom
const worldWidthPixels = tileSize * numTiles;
// Meters per pixel at equator
const metersPerPixelEquator = EARTH_CIRCUMFERENCE_M / worldWidthPixels;
// Adjust for latitude (Mercator distortion)
const latRad = lat * Math.PI / 180;
const metersPerPixel = metersPerPixelEquator * Math.cos(latRad);
return 1 / metersPerPixel; // Return pixels per meter
}
/**
* Convert geographic radius (meters) to pixel radius at zoom level
*/
export function metersToPixels(meters: number, lat: number, zoom: number): number {
const pixelsPerMeter = getPixelsPerMeter(lat, zoom);
return meters * pixelsPerMeter;
}
/**
* Get tile bounds in lat/lon for given tile coordinates
*/
export function getTileBounds(x: number, y: number, zoom: number): [[number, number], [number, number]] {
const n = Math.pow(2, zoom);
const lonMin = (x / n) * 360 - 180;
const lonMax = ((x + 1) / n) * 360 - 180;
const latMin = Math.atan(Math.sinh(Math.PI * (1 - 2 * (y + 1) / n))) * 180 / Math.PI;
const latMax = Math.atan(Math.sinh(Math.PI * (1 - 2 * y / n))) * 180 / Math.PI;
return [[latMin, lonMin], [latMax, lonMax]];
}
/**
* Convert lat/lon to pixel position within tile
*/
export function latLonToTilePixel(
lat: number,
lon: number,
tileX: number,
tileY: number,
zoom: number
): [number, number] {
const n = Math.pow(2, zoom);
// Tile's top-left corner in world coordinates
const tileWorldX = tileX;
const tileWorldY = tileY;
// Point's world coordinates
const worldX = (lon + 180) / 360 * n;
const latRad = lat * Math.PI / 180;
const worldY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n;
// Offset within tile
const pixelX = (worldX - tileWorldX) * 256;
const pixelY = (worldY - tileWorldY) * 256;
return [pixelX, pixelY];
}
```
---
## Step 2: Color Gradient Utils
**File:** `frontend/src/utils/colorGradient.ts`
```typescript
interface ColorStop {
value: number; // 0-1
color: string; // hex
}
const GRADIENT_STOPS: ColorStop[] = [
{ value: 0.0, color: '#1a237e' }, // -130 dBm (dark blue)
{ value: 0.15, color: '#0d47a1' },
{ value: 0.25, color: '#2196f3' },
{ value: 0.35, color: '#00bcd4' }, // Cyan
{ value: 0.45, color: '#00897b' },
{ value: 0.55, color: '#4caf50' }, // Green
{ value: 0.65, color: '#8bc34a' },
{ value: 0.75, color: '#ffeb3b' }, // Yellow
{ value: 0.85, color: '#ff9800' }, // Orange
{ value: 1.0, color: '#f44336' }, // -50 dBm (red)
];
/**
* Normalize RSRP to 0-1 range
*/
export function normalizeRSRP(rsrp: number): number {
const minRSRP = -130;
const maxRSRP = -50;
const normalized = (rsrp - minRSRP) / (maxRSRP - minRSRP);
return Math.max(0, Math.min(1, normalized));
}
/**
* Convert normalized value (0-1) to RGB color
*/
export function valueToColor(value: number): [number, number, number] {
// Find surrounding gradient stops
let lowerStop = GRADIENT_STOPS[0];
let upperStop = GRADIENT_STOPS[GRADIENT_STOPS.length - 1];
for (let i = 0; i < GRADIENT_STOPS.length - 1; i++) {
if (value >= GRADIENT_STOPS[i].value && value <= GRADIENT_STOPS[i + 1].value) {
lowerStop = GRADIENT_STOPS[i];
upperStop = GRADIENT_STOPS[i + 1];
break;
}
}
// Interpolate between stops
const range = upperStop.value - lowerStop.value;
const t = (value - lowerStop.value) / range;
const lower = hexToRgb(lowerStop.color);
const upper = hexToRgb(upperStop.color);
return [
Math.round(lower[0] + (upper[0] - lower[0]) * t),
Math.round(lower[1] + (upper[1] - lower[1]) * t),
Math.round(lower[2] + (upper[2] - lower[2]) * t),
];
}
function hexToRgb(hex: string): [number, number, number] {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? [
parseInt(result[1], 16),
parseInt(result[2], 16),
parseInt(result[3], 16),
] : [0, 0, 0];
}
/**
* Apply gaussian blur to coverage value based on distance
*/
export function gaussianBlur(distance: number, radius: number, sigma?: number): number {
const s = sigma || radius / 3;
const exponent = -(distance * distance) / (2 * s * s);
return Math.exp(exponent);
}
```
---
## Step 3: Tile Renderer
**File:** `frontend/src/components/map/HeatmapTileRenderer.ts`
```typescript
import { metersToPixels, latLonToTilePixel, getTileBounds } from '@/utils/geographicScale';
import { normalizeRSRP, valueToColor, gaussianBlur } from '@/utils/colorGradient';
interface CoveragePoint {
lat: number;
lon: number;
rsrp: number;
siteId: string;
}
export class HeatmapTileRenderer {
private tileSize = 256;
private radiusMeters = 400; // Fixed geographic radius
/**
* Render a single tile
*/
renderTile(
canvas: HTMLCanvasElement,
points: CoveragePoint[],
tileX: number,
tileY: number,
zoom: number
): void {
const ctx = canvas.getContext('2d')!;
canvas.width = this.tileSize;
canvas.height = this.tileSize;
// Clear canvas
ctx.clearRect(0, 0, this.tileSize, this.tileSize);
// Get tile bounds
const [[latMin, lonMin], [latMax, lonMax]] = getTileBounds(tileX, tileY, zoom);
// Filter points that could affect this tile
const relevantPoints = this.getRelevantPoints(points, latMin, latMax, lonMin, lonMax, zoom);
if (relevantPoints.length === 0) return;
// Create accumulation buffers
const intensityMap = new Float32Array(this.tileSize * this.tileSize);
const maxIntensity = new Float32Array(this.tileSize * this.tileSize);
// For each point, accumulate intensity
for (const point of relevantPoints) {
const [pixelX, pixelY] = latLonToTilePixel(point.lat, point.lon, tileX, tileY, zoom);
const radiusPixels = metersToPixels(this.radiusMeters, point.lat, zoom);
// Draw point influence
this.drawPoint(intensityMap, maxIntensity, point, pixelX, pixelY, radiusPixels);
}
// Render to canvas
this.renderToCanvas(ctx, intensityMap, maxIntensity);
}
/**
* Filter points that could affect this tile
*/
private getRelevantPoints(
points: CoveragePoint[],
latMin: number,
latMax: number,
lonMin: number,
lonMax: number,
zoom: number
): CoveragePoint[] {
// Add buffer for radius
const bufferDegrees = (this.radiusMeters / 111000) * 2; // Rough: 111km per degree
return points.filter(p =>
p.lat >= latMin - bufferDegrees &&
p.lat <= latMax + bufferDegrees &&
p.lon >= lonMin - bufferDegrees &&
p.lon <= lonMax + bufferDegrees
);
}
/**
* Draw single point's influence on intensity map
*/
private drawPoint(
intensityMap: Float32Array,
maxIntensity: Float32Array,
point: CoveragePoint,
centerX: number,
centerY: number,
radiusPixels: number
): void {
const normalizedValue = normalizeRSRP(point.rsrp);
// Calculate bounding box
const minX = Math.max(0, Math.floor(centerX - radiusPixels));
const maxX = Math.min(this.tileSize, Math.ceil(centerX + radiusPixels));
const minY = Math.max(0, Math.floor(centerY - radiusPixels));
const maxY = Math.min(this.tileSize, Math.ceil(centerY + radiusPixels));
// For each pixel in radius
for (let y = minY; y < maxY; y++) {
for (let x = minX; x < maxX; x++) {
const dx = x - centerX;
const dy = y - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > radiusPixels) continue;
// Apply gaussian blur
const blur = gaussianBlur(distance, radiusPixels);
const intensity = normalizedValue * blur;
const idx = y * this.tileSize + x;
// Accumulate intensity (additive blending)
intensityMap[idx] += intensity;
maxIntensity[idx] = Math.max(maxIntensity[idx], intensity);
}
}
}
/**
* Render intensity map to canvas
*/
private renderToCanvas(
ctx: CanvasRenderingContext2D,
intensityMap: Float32Array,
maxIntensity: Float32Array
): void {
const imageData = ctx.createImageData(this.tileSize, this.tileSize);
const data = imageData.data;
for (let i = 0; i < intensityMap.length; i++) {
const intensity = intensityMap[i];
if (intensity > 0) {
// Normalize intensity (clamp to 0-1)
const normalizedIntensity = Math.min(1, intensity);
// Get color
const [r, g, b] = valueToColor(normalizedIntensity);
// Calculate alpha based on intensity
const alpha = Math.min(255, intensity * 200); // Adjust opacity
const idx = i * 4;
data[idx] = r;
data[idx + 1] = g;
data[idx + 2] = b;
data[idx + 3] = alpha;
}
}
ctx.putImageData(imageData, 0, 0);
}
}
```
---
## Step 4: Leaflet Integration
**File:** `frontend/src/components/map/GeographicHeatmap.tsx`
```typescript
import { useEffect, useRef } from 'react';
import { useMap } from 'react-leaflet';
import L from 'leaflet';
import { HeatmapTileRenderer } from './HeatmapTileRenderer';
interface GeographicHeatmapProps {
points: Array<{
lat: number;
lon: number;
rsrp: number;
siteId: string;
}>;
visible: boolean;
opacity?: number;
}
export function GeographicHeatmap({ points, visible, opacity = 0.7 }: GeographicHeatmapProps) {
const map = useMap();
const layerRef = useRef<L.GridLayer | null>(null);
const rendererRef = useRef(new HeatmapTileRenderer());
useEffect(() => {
if (!visible) {
if (layerRef.current) {
map.removeLayer(layerRef.current);
layerRef.current = null;
}
return;
}
// Create custom tile layer
const HeatmapLayer = L.GridLayer.extend({
createTile: function(coords: L.Coords, done: (error: Error | null, tile: HTMLElement) => void) {
const canvas = document.createElement('canvas');
// Render tile
try {
rendererRef.current.renderTile(
canvas,
points,
coords.x,
coords.y,
coords.z
);
done(null, canvas);
} catch (error) {
console.error('Tile render error:', error);
done(error as Error, canvas);
}
return canvas;
}
});
// Add to map
const layer = new HeatmapLayer({
opacity,
zIndex: 200,
});
layer.addTo(map);
layerRef.current = layer;
return () => {
if (layerRef.current) {
map.removeLayer(layerRef.current);
}
};
}, [map, points, visible, opacity]);
// Update opacity
useEffect(() => {
if (layerRef.current) {
layerRef.current.setOpacity(opacity);
}
}, [opacity]);
// Redraw on points change
useEffect(() => {
if (layerRef.current && visible) {
layerRef.current.redraw();
}
}, [points, visible]);
return null;
}
```
---
## Step 5: Replace Old Heatmap
**File:** `frontend/src/components/map/Map.tsx`
```typescript
// REMOVE:
// import { Heatmap } from './Heatmap';
// ADD:
import { GeographicHeatmap } from './GeographicHeatmap';
// In Map component:
<GeographicHeatmap
points={coveragePoints}
visible={showCoverage}
opacity={heatmapOpacity}
/>
```
---
## Performance Optimizations
### 1. Tile Caching
```typescript
class HeatmapTileRenderer {
private cache = new Map<string, HTMLCanvasElement>();
renderTile(...) {
const cacheKey = `${tileX}-${tileY}-${zoom}-${points.length}`;
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey)!;
}
// ... render logic
this.cache.set(cacheKey, canvas);
return canvas;
}
}
```
### 2. Web Worker для тяжких обчислень
```typescript
// heatmap.worker.ts
self.onmessage = (e) => {
const { points, tileX, tileY, zoom } = e.data;
const intensityMap = renderIntensityMap(points, tileX, tileY, zoom);
self.postMessage({ intensityMap }, [intensityMap.buffer]);
};
```
### 3. Request Animation Frame
```typescript
createTile(coords, done) {
const canvas = document.createElement('canvas');
requestAnimationFrame(() => {
renderer.renderTile(canvas, ...);
done(null, canvas);
});
return canvas;
}
```
---
## Testing Strategy
1. **Geographic Accuracy:**
- [ ] Measure 400m with ruler tool
- [ ] Coverage point radius = 400m at ALL zoom levels
- [ ] Verified with real coordinates
2. **Color Consistency:**
- [ ] Pick point at zoom 8, note color
- [ ] Zoom to 14, EXACT same color
- [ ] Test at 5-10 different locations
3. **Performance:**
- [ ] Smooth panning (60fps)
- [ ] Zoom transitions smooth
- [ ] <100ms per tile render
4. **Visual Quality:**
- [ ] Smooth gradient (no banding)
- [ ] No grid artifacts
- [ ] Proper alpha blending
---
## Migration Path
**Phase 1:** Implement core (this iteration)
**Phase 2:** Add caching
**Phase 3:** Add Web Worker
**Phase 4:** Backend pre-rendering (Phase 4+)
---
## Expected Benefits
**True geographic scale** - 400m = 400m always
**Zoom-independent colors** - guaranteed
**No library limitations** - full control
**Better performance** - optimized for our data
**Professional quality** - like Google Maps
---
## Build & Test
```bash
cd /opt/rfcp/frontend
npm run build
sudo systemctl reload caddy
```
---
## Commit Message
```
feat(heatmap): custom geographic-scale canvas renderer
- Implemented custom GridLayer with Canvas rendering
- True geographic radius (400m constant across zoom levels)
- Zoom-independent color mapping (same RSRP = same color always)
- Gaussian blur for smooth gradients
- Removed dependency on leaflet-heatmap library
Coverage now maintains accurate geographic scale and consistent
colors at all zoom levels. Rendering optimized for our use case.
```
🚀 Ready to implement!
---
## BONUS: Sector UI Fix (for 8.1)
**Problem:** Clone creates new site instead of adding sector to existing site.
**Solution:** Fix cloneSector function + improve UI.
### Fix 1: Clone Sector (not Site)
**File:** `frontend/src/store/sites.ts`
```typescript
// CURRENT (wrong):
const cloneSector = (siteId: string) => {
const site = sites.find(s => s.id === siteId);
const clone = { ...site, id: uuid(), name: `${site.name}-clone` };
setSites([...sites, clone]); // Creates NEW site ❌
};
// FIXED (correct):
const cloneSector = (siteId: string, sectorId?: string) => {
const site = sites.find(s => s.id === siteId);
if (!site) return;
// Clone specific sector or first one
const sourceSector = sectorId
? site.sectors.find(s => s.id === sectorId)
: site.sectors[site.sectors.length - 1]; // Last sector
if (!sourceSector) return;
const newSector: Sector = {
...sourceSector,
id: `sector-${Date.now()}`,
azimuth: (sourceSector.azimuth + 120) % 360, // 120° offset for tri-sector
};
// Add sector to SAME site ✅
updateSite(siteId, {
sectors: [...site.sectors, newSector]
});
};
// Also add individual sector toggle
const toggleSector = (siteId: string, sectorId: string) => {
const site = sites.find(s => s.id === siteId);
if (!site) return;
const updatedSectors = site.sectors.map(s =>
s.id === sectorId ? { ...s, enabled: !s.enabled } : s
);
updateSite(siteId, { sectors: updatedSectors });
};
```
### Fix 2: Update Site Count Display
**File:** `frontend/src/components/panels/SiteList.tsx`
```typescript
// Show accurate count
<h3>Sites ({sites.length})</h3> {/* Not sector count! */}
// For each site, show sector count
<div className="site-info">
<strong>{site.name}</strong>
<small>
{site.frequency} MHz · {site.height}m ·
{site.sectors.length} sector{site.sectors.length > 1 ? 's' : ''}
</small>
</div>
```
### Fix 3: Better Button Labels
```typescript
// In site list item
<button onClick={() => cloneSector(site.id)}>
+ Add Sector {/* was: "Clone" */}
</button>
<button onClick={() => cloneSite(site.id)}>
Clone Site {/* Duplicate entire site */}
</button>
```
---
## BONUS FEATURES (Optional - if time permits)
### 1. Heatmap Quality Settings
**File:** `frontend/src/components/panels/CoverageSettings.tsx`
```typescript
<div className="heatmap-quality">
<label>Coverage Point Radius</label>
<select value={radiusMeters} onChange={(e) => setRadiusMeters(Number(e.target.value))}>
<option value={200}>200m (Fast)</option>
<option value={400}>400m (Balanced)</option>
<option value={600}>600m (Smooth)</option>
</select>
<small>
Larger radius = smoother gradient but slower rendering
</small>
</div>
```
### 2. Performance Monitor
**File:** `frontend/src/components/map/HeatmapTileRenderer.ts`
```typescript
renderTile(...) {
const startTime = performance.now();
// ... render logic
const renderTime = performance.now() - startTime;
if (import.meta.env.DEV) {
console.log(`Tile ${tileX},${tileY} rendered in ${renderTime.toFixed(1)}ms`);
}
}
```
### 3. Progressive Loading
Show low-res preview while rendering:
```typescript
createTile(coords, done) {
const canvas = document.createElement('canvas');
// Immediate low-res preview
renderLowRes(canvas, coords);
done(null, canvas);
// High-res in background
requestAnimationFrame(() => {
renderHighRes(canvas, coords);
});
return canvas;
}
```
### 4. Export as GeoTIFF
**File:** `frontend/src/components/panels/ExportPanel.tsx`
```typescript
const exportGeoTIFF = async () => {
// Generate coverage grid
const grid = generateCoverageGrid(sites, bounds);
// Convert to GeoTIFF format
const geotiff = await createGeoTIFF(grid, bounds);
// Download
const blob = new Blob([geotiff], { type: 'image/tiff' });
downloadBlob(blob, `coverage-${Date.now()}.tif`);
};
<button onClick={exportGeoTIFF}>
📥 Export GeoTIFF (QGIS)
</button>
```
### 5. Heatmap Legend with Actual Colors
**File:** `frontend/src/components/map/HeatmapLegend.tsx` (new)
```typescript
import { valueToColor } from '@/utils/colorGradient';
export function HeatmapLegend() {
const steps = [
{ rsrp: -130, label: 'No Service' },
{ rsrp: -110, label: 'Weak' },
{ rsrp: -100, label: 'Fair' },
{ rsrp: -85, label: 'Good' },
{ rsrp: -70, label: 'Excellent' },
];
return (
<div className="heatmap-legend">
<h4>Signal Strength (RSRP)</h4>
{steps.map(step => {
const normalized = (step.rsrp + 130) / 80; // -130 to -50
const [r, g, b] = valueToColor(normalized);
return (
<div key={step.rsrp} className="legend-item">
<div
className="color-box"
style={{ backgroundColor: `rgb(${r},${g},${b})` }}
/>
<span>{step.label}</span>
<small>{step.rsrp} dBm</small>
</div>
);
})}
</div>
);
}
```
### 6. Tile Load Progress Bar
```typescript
const [tilesLoaded, setTilesLoaded] = useState(0);
const [tilesTotal, setTilesTotal] = useState(0);
// In GridLayer
layer.on('tileloadstart', () => setTilesTotal(prev => prev + 1));
layer.on('tileload', () => setTilesLoaded(prev => prev + 1));
// Show progress
{tilesLoaded < tilesTotal && (
<div className="loading-progress">
Loading coverage: {tilesLoaded}/{tilesTotal} tiles
<progress value={tilesLoaded} max={tilesTotal} />
</div>
)}
```
---
## CRITICAL IMPROVEMENTS
### A. Memory Management
```typescript
class HeatmapTileRenderer {
private cache = new Map<string, HTMLCanvasElement>();
private maxCacheSize = 100; // Limit cache size
renderTile(...) {
// ... render logic
// Clean old cache entries
if (this.cache.size > this.maxCacheSize) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
}
clearCache() {
this.cache.clear();
}
}
```
### B. Error Handling
```typescript
createTile(coords, done) {
const canvas = document.createElement('canvas');
try {
renderer.renderTile(canvas, points, coords.x, coords.y, coords.z);
done(null, canvas);
} catch (error) {
console.error('Tile render error:', error);
// Draw error tile
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = '#ff000020';
ctx.fillRect(0, 0, 256, 256);
ctx.fillStyle = '#000';
ctx.font = '12px monospace';
ctx.fillText('Render Error', 10, 128);
done(null, canvas);
}
return canvas;
}
```
### C. Debug Overlay
```typescript
// Show tile boundaries in dev mode
if (import.meta.env.DEV) {
ctx.strokeStyle = '#ff0000';
ctx.strokeRect(0, 0, 256, 256);
ctx.fillStyle = '#000';
ctx.font = '10px monospace';
ctx.fillText(`${tileX},${tileY},${zoom}`, 5, 15);
}
```
---
## TESTING ADDITIONS
### Geographic Accuracy Test
```typescript
// Add to Map.tsx for testing
const [testMode, setTestMode] = useState(false);
{testMode && (
<>
{/* 400m circle for comparison */}
<Circle
center={[48.71, 35.07]}
radius={400} // meters
pathOptions={{ color: '#ff0000', weight: 2, fillOpacity: 0 }}
/>
{/* Coverage point at same location */}
<Marker position={[48.71, 35.07]}>
<Popup>
Test point: coverage radius should match red circle (400m)
</Popup>
</Marker>
</>
)}
```
---
## README ADDITIONS
Document the custom heatmap:
**File:** `frontend/README.md`
```markdown
## Custom Geographic Heatmap
RFCP uses a custom Canvas-based heatmap renderer for accurate geographic coverage visualization.
### Features
- True geographic scale (400m radius constant across zoom levels)
- Zoom-independent colors (same RSRP = same color always)
- Optimized tile rendering with caching
- Gaussian blur for smooth gradients
### Architecture
- `GeographicHeatmap.tsx` - React/Leaflet integration
- `HeatmapTileRenderer.ts` - Canvas rendering logic
- `geographicScale.ts` - Coordinate transformation
- `colorGradient.ts` - RSRP to color mapping
### Configuration
Adjust coverage point radius in `HeatmapTileRenderer.ts`:
```typescript
private radiusMeters = 400; // Coverage point radius
```
### Performance
- Tile caching enabled (100 tile limit)
- Typical render time: 10-50ms per tile
- Smooth at 60fps during pan/zoom
```
🚀 Ready to implement!

View File

@@ -0,0 +1,267 @@
# RFCP - Iteration 8.1: Clone Fix + Coverage Gaps
## Issue 1: Clone Creates New Site (Critical!)
**Problem:** Clone button creates new site instead of adding sector to existing site.
**Root Cause:** `cloneSector` function in sites store creates new site object.
### Solution
**File:** `frontend/src/store/sites.ts`
**Current (WRONG):**
```typescript
const cloneSector = (siteId: string) => {
const site = sites.find(s => s.id === siteId);
const clone = {
...site,
id: uuid(),
name: `${site.name}-clone`
};
setSites([...sites, clone]); // Creates NEW site ❌
};
```
**Fixed (CORRECT):**
```typescript
const cloneSector = (siteId: string) => {
const site = sites.find(s => s.id === siteId);
if (!site) return;
// Get last sector as template
const lastSector = site.sectors[site.sectors.length - 1];
// Create new sector (NOT new site!)
const newSector: Sector = {
...lastSector,
id: `sector-${Date.now()}`,
azimuth: (lastSector.azimuth + 120) % 360, // 120° offset for tri-sector
};
// Add sector to EXISTING site ✅
updateSite(siteId, {
sectors: [...site.sectors, newSector]
});
// Clear coverage to force recalculation
useCoverageStore.getState().clearCoverage();
};
```
### Update Button Label
**File:** `frontend/src/components/panels/SiteList.tsx`
```typescript
<button onClick={() => cloneSector(site.id)}>
+ Add Sector {/* was: "Clone" */}
</button>
```
---
## Issue 2: Coverage Gaps at 800m Wide
**Problem:** At 800m heatmap radius with 300m resolution, coverage points don't overlap enough → visible dots.
**Why it happens:**
- Resolution: 300m (distance between coverage points)
- Heatmap radius: 800m
- At high zoom, 800m radius in pixels is HUGE
- But point spacing (300m) stays same
- Result: Gaps between points visible
### Solution A: Warn User (Quick)
**File:** `frontend/src/components/panels/CoverageSettings.tsx`
```typescript
<select
value={heatmapRadius}
onChange={(e) => setHeatmapRadius(Number(e.target.value))}
>
<option value={200}>200m Fast</option>
<option value={400}>400m Balanced</option>
<option value={600}>600m Smooth</option>
<option value={800}>800m Wide</option>
</select>
{/* Warning for 800m */}
{heatmapRadius === 800 && resolution > 200 && (
<div className="warning">
Wide radius works best with fine resolution (200m).
Current: {resolution}m. Consider reducing for smoother coverage.
</div>
)}
```
### Solution B: Auto-adjust Resolution (Better)
**File:** `frontend/src/store/coverage.ts`
```typescript
const calculateCoverage = async () => {
// Auto-adjust resolution based on heatmap radius
// Rule: resolution should be ≤ radius/2 for smooth coverage
const recommendedResolution = Math.min(
resolution,
Math.floor(heatmapRadius / 2)
);
if (recommendedResolution < resolution) {
console.log(`Auto-adjusting resolution: ${resolution}m → ${recommendedResolution}m for ${heatmapRadius}m radius`);
}
const effectiveResolution = recommendedResolution;
// Calculate with adjusted resolution
await worker.calculateCoverage({
sites,
radius,
resolution: effectiveResolution,
rsrpThreshold
});
};
```
### Solution C: Dynamic Point Sampling (Advanced)
**File:** `frontend/src/components/map/HeatmapTileRenderer.ts`
Add adaptive point sampling in renderer:
```typescript
private drawPoint(
intensityMap: Float32Array,
point: CoveragePoint,
centerX: number,
centerY: number,
radiusPixels: number
): void {
// ... existing code
// ADAPTIVE: If radius is very large, increase sampling
const sampleFactor = radiusPixels > 100 ? 2 : 1;
for (let y = minY; y < maxY; y += sampleFactor) {
for (let x = minX; x < maxX; x += sampleFactor) {
// ... draw with interpolation
}
}
}
```
### Solution D: Clamp Max Radius (Safest)
**File:** `frontend/src/components/panels/CoverageSettings.tsx`
```typescript
// Limit radius based on resolution
const maxAllowedRadius = resolution * 3; // 3x resolution max
<select
value={heatmapRadius}
onChange={(e) => {
const newRadius = Number(e.target.value);
if (newRadius > maxAllowedRadius) {
toast.warning(`Radius ${newRadius}m too large for ${resolution}m resolution. Max: ${maxAllowedRadius}m`);
return;
}
setHeatmapRadius(newRadius);
}}
>
<option value={200} disabled={resolution > 100}>200m Fast</option>
<option value={400} disabled={resolution > 200}>400m Balanced</option>
<option value={600} disabled={resolution > 300}>600m Smooth</option>
<option value={800} disabled={resolution > 400}>800m Wide</option>
</select>
```
---
## Issue 3: Coverage Not Cleared on Sector Delete
**File:** `frontend/src/store/sites.ts`
```typescript
const removeSector = (siteId: string, sectorId: string) => {
const site = sites.find(s => s.id === siteId);
if (!site || site.sectors.length <= 1) {
toast.error('Cannot remove last sector');
return;
}
const updatedSectors = site.sectors.filter(s => s.id !== sectorId);
updateSite(siteId, { sectors: updatedSectors });
// CRITICAL: Clear coverage!
useCoverageStore.getState().clearCoverage();
toast.success('Sector removed. Recalculate coverage to update.');
};
```
---
## Recommended Fix Priority
**Priority 1 (Critical):**
- [ ] Fix cloneSector to add sector, not create site
- [ ] Update button label to "+ Add Sector"
- [ ] Clear coverage on sector delete
**Priority 2 (Important):**
- [ ] Add warning for 800m + 300m combo
- [ ] OR auto-adjust resolution based on radius
**Priority 3 (Nice to have):**
- [ ] Clamp max radius based on resolution
- [ ] Dynamic point sampling
---
## Testing
### Clone Fix:
1. Create site
2. Click "+ Add Sector"
3. Should show "Sites (1)" with 2 sectors ✅
4. NOT "Sites (2)" ❌
### Coverage Gaps:
1. Set resolution 300m
2. Set radius 800m
3. Calculate coverage
4. At high zoom (16+), check for dots
5. If dots visible → show warning OR auto-adjust
---
## Build & Deploy
```bash
cd /opt/rfcp/frontend
npm run build
sudo systemctl reload caddy
```
---
## Commit Message
```
fix(sites): clone adds sector to existing site, not new site
- Fixed cloneSector to add sector to same site
- Changed button label to "+ Add Sector"
- Added coverage cache clear on sector delete
- Sites count now accurate (counts sites, not sectors)
fix(coverage): prevent gaps with 800m radius
- Added warning for wide radius + coarse resolution
- Auto-adjust resolution to radius/2 for smooth coverage
- Clear coverage cache on sector changes
```
🚀 Ready for 8.1!

View File

@@ -0,0 +1,583 @@
# RFCP - Iteration 9: UX Polish & Workflow Improvements
## Overview
Polish existing features for better workflow without breaking changes.
**Focus:** Keyboard shortcuts, batch operations, number inputs, visual improvements.
---
## Feature 1: Better Keyboard Shortcuts
**Problems:**
1. Ctrl+N conflicts with browser (New Window)
2. Missing useful shortcuts for common actions
3. No sector-specific shortcuts
### New Hotkey Map
**File:** `frontend/src/hooks/useHotkeys.ts` (update)
```typescript
const HOTKEYS = {
// Coverage
'ctrl+enter': 'Calculate Coverage',
'shift+c': 'Clear Coverage',
// Sites
'shift+s': 'New Site (Place Mode)', // was Ctrl+N
'm': 'Manual Site Entry',
// View
'h': 'Toggle Heatmap',
'g': 'Toggle Grid',
't': 'Toggle Terrain',
'r': 'Toggle Ruler',
// Navigation
'f': 'Fit to Coverage',
'esc': 'Cancel/Close',
// Selection
'ctrl+a': 'Select All Sites',
'ctrl+d': 'Deselect All',
'delete': 'Delete Selected',
// Edit
'e': 'Edit Selected Site',
'shift+e': 'Batch Edit Selected',
// Sector operations
'ctrl+shift+s': 'Add Sector to Selected',
// Help
'?': 'Show Keyboard Shortcuts',
};
```
**No conflicts:**
- ✅ Avoid: Ctrl+N, Ctrl+T, Ctrl+W, Ctrl+R, Ctrl+S
- ✅ Use: Shift+, Alt+, single letters (when not in input)
- ✅ Ctrl+ only for safe combos (Ctrl+Enter, Ctrl+A, Ctrl+D)
### Implementation
```typescript
// Check if in input field
const isInputActive = () => {
const active = document.activeElement;
return active?.tagName === 'INPUT' ||
active?.tagName === 'TEXTAREA' ||
active?.tagName === 'SELECT';
};
// Only trigger shortcuts if NOT in input
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (isInputActive()) return;
// Handle shortcuts...
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
```
---
## Feature 2: Batch Edit Azimuth
**Problem:** Batch Edit can change height, power, frequency - but not azimuth!
### Solution
**File:** `frontend/src/components/panels/SiteList.tsx`
```typescript
// Add to batch edit UI
{selectedSiteIds.length > 0 && (
<div className="batch-edit">
<h4>Batch Edit ({selectedSiteIds.length} selected)</h4>
{/* Existing: Adjust Height, Set Height */}
{/* NEW: Adjust Azimuth */}
<div className="batch-control">
<label>Adjust Azimuth:</label>
<div className="button-group">
<button onClick={() => adjustAzimuthBatch(-90)}>-90°</button>
<button onClick={() => adjustAzimuthBatch(-45)}>-45°</button>
<button onClick={() => adjustAzimuthBatch(-10)}>-10°</button>
<button onClick={() => adjustAzimuthBatch(+10)}>+10°</button>
<button onClick={() => adjustAzimuthBatch(+45)}>+45°</button>
<button onClick={() => adjustAzimuthBatch(+90)}>+90°</button>
</div>
</div>
{/* NEW: Set Azimuth */}
<div className="batch-control">
<label>Set Azimuth:</label>
<input
type="number"
min={0}
max={359}
placeholder="0-359°"
onKeyDown={(e) => {
if (e.key === 'Enter') {
setAzimuthBatch(Number(e.currentTarget.value));
}
}}
/>
<button onClick={() => setAzimuthBatch(0)}>North (0°)</button>
</div>
</div>
)}
```
**Store methods:**
```typescript
// In sites.ts
const adjustAzimuthBatch = (delta: number) => {
const { sites, selectedSiteIds } = get();
selectedSiteIds.forEach(id => {
const site = sites.find(s => s.id === id);
if (site) {
const newAzimuth = (site.azimuth + delta + 360) % 360;
updateSite(id, { azimuth: newAzimuth });
}
});
toast.success(`Adjusted azimuth by ${delta}° for ${selectedSiteIds.length} sites`);
useCoverageStore.getState().clearCoverage();
};
const setAzimuthBatch = (azimuth: number) => {
const { selectedSiteIds } = get();
selectedSiteIds.forEach(id => {
updateSite(id, { azimuth });
});
toast.success(`Set azimuth to ${azimuth}° for ${selectedSiteIds.length} sites`);
useCoverageStore.getState().clearCoverage();
};
```
---
## Feature 3: Number Inputs with Arrow Controls
**Problem:** Sliders are imprecise. Need exact values + fine adjustments.
### Enhanced Input Component
**File:** `frontend/src/components/common/NumberInput.tsx` (new)
```typescript
interface NumberInputProps {
label: string;
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
unit?: string;
showSlider?: boolean;
}
export function NumberInput({
label,
value,
onChange,
min = 0,
max = 100,
step = 1,
unit = '',
showSlider = true
}: NumberInputProps) {
return (
<div className="number-input-group">
<label>{label}</label>
<div className="input-controls">
{/* Text input for exact value */}
<input
type="number"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
min={min}
max={max}
step={step}
className="number-field"
/>
{/* Arrow buttons */}
<div className="arrow-controls">
<button
onClick={() => onChange(Math.min(max, value + step))}
className="arrow-up"
title={`+${step}${unit}`}
>
</button>
<button
onClick={() => onChange(Math.max(min, value - step))}
className="arrow-down"
title={`-${step}${unit}`}
>
</button>
</div>
{unit && <span className="unit">{unit}</span>}
</div>
{/* Optional slider for visual feedback */}
{showSlider && (
<input
type="range"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
min={min}
max={max}
step={step}
className="slider"
/>
)}
</div>
);
}
```
**CSS:**
```css
.number-input-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.input-controls {
display: flex;
align-items: center;
gap: 4px;
}
.number-field {
width: 80px;
padding: 6px;
border: 1px solid #ccc;
border-radius: 4px;
text-align: center;
font-size: 14px;
}
.arrow-controls {
display: flex;
flex-direction: column;
gap: 2px;
}
.arrow-up, .arrow-down {
width: 24px;
height: 16px;
padding: 0;
border: 1px solid #ccc;
background: #fff;
cursor: pointer;
font-size: 8px;
line-height: 1;
}
.arrow-up:hover, .arrow-down:hover {
background: #f0f0f0;
}
.unit {
font-size: 12px;
color: #666;
margin-left: 4px;
}
.slider {
width: 100%;
}
```
**Usage in SiteForm:**
```typescript
import { NumberInput } from '@/components/common/NumberInput';
// Replace slider inputs:
<NumberInput
label="Azimuth"
value={azimuth}
onChange={setAzimuth}
min={0}
max={359}
step={1}
unit="°"
showSlider={true}
/>
<NumberInput
label="Height"
value={height}
onChange={setHeight}
min={1}
max={200}
step={1}
unit="m"
showSlider={true}
/>
<NumberInput
label="Power"
value={power}
onChange={setPower}
min={10}
max={60}
step={1}
unit="dBm"
showSlider={true}
/>
<NumberInput
label="Gain"
value={gain}
onChange={setGain}
min={0}
max={25}
step={0.5}
unit="dBi"
showSlider={true}
/>
<NumberInput
label="Beamwidth"
value={beamwidth}
onChange={setBeamwidth}
min={30}
max={360}
step={5}
unit="°"
showSlider={true}
/>
```
---
## Feature 4: Visual Sector Grouping
**Problem:** UI shows "Sites (2)" but they're actually sectors of same location.
**Solution:** Visual grouping WITHOUT data model change.
### Approach A: Color-coded Names
Auto-detect sites at same location and style them:
```typescript
// In SiteList.tsx
const getSiteGroups = (sites: Site[]) => {
const groups = new Map<string, Site[]>();
sites.forEach(site => {
// Group by lat/lon (within 10m)
const key = `${site.lat.toFixed(4)},${site.lon.toFixed(4)}`;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)!.push(site);
});
return groups;
};
// In render:
{siteGroups.map((group, idx) => {
if (group.length === 1) {
// Single site - render normally
return <SiteItem site={group[0]} />;
} else {
// Multi-sector group
return (
<div className="sector-group">
<div className="group-header">
📡 {group[0].name.replace(/-Alpha|-Beta|-Gamma|-clone/g, '')} ({group.length} sectors)
</div>
{group.map(site => (
<SiteItem
key={site.id}
site={site}
isGrouped={true}
sectorLabel={getSectorLabel(site.name)}
/>
))}
</div>
);
}
})}
```
### Approach B: Compact Display
```typescript
// Show azimuth as badge
<div className="site-info">
<strong>{site.name}</strong>
{isGrouped && (
<span className="sector-badge">
{site.azimuth}°
</span>
)}
</div>
```
---
## Feature 5: Quick Actions Menu
**Add right-click context menu for sites:**
```typescript
// On site item
<div
onContextMenu={(e) => {
e.preventDefault();
showContextMenu(e, site.id);
}}
>
{/* site content */}
</div>
// Context menu
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={[
{ label: 'Edit', action: () => editSite(siteId) },
{ label: 'Add Sector', action: () => cloneSector(siteId) },
{ label: 'Clone Site', action: () => cloneSite(siteId) },
{ label: 'Zoom to Site', action: () => zoomToSite(siteId) },
{ separator: true },
{ label: 'Delete', action: () => deleteSite(siteId), danger: true },
]}
onClose={() => setContextMenu(null)}
/>
)}
```
---
## Feature 6: Undo/Redo System
**Add undo for destructive operations:**
```typescript
// Simple undo stack
const undoStack: Array<{ action: string; data: any }> = [];
const deleteSite = (id: string) => {
const site = sites.find(s => s.id === id);
// Push to undo stack
undoStack.push({ action: 'delete', data: site });
// Perform delete
setSites(sites.filter(s => s.id !== id));
// Show undo toast
toast.success('Site deleted', {
action: {
label: 'Undo',
onClick: () => {
const lastAction = undoStack.pop();
if (lastAction?.action === 'delete') {
setSites([...sites, lastAction.data]);
}
}
}
});
};
```
---
## Recommended Implementation Order
**Priority 1 (Critical UX):**
1. ✅ Fix Ctrl+N hotkey → Shift+S
2. ✅ Add batch azimuth operations
3. ✅ Number inputs with arrows
**Priority 2 (Nice to have):**
4. ⭐ Visual sector grouping (color-coded)
5. ⭐ Additional hotkeys (Shift+C, G, T, R)
6. ⭐ Context menu for sites
**Priority 3 (Future):**
7. 🔮 Undo/Redo system
8. 🔮 Full data model refactor (Site → Sectors)
---
## Testing
### Hotkeys:
- [ ] Shift+S creates new site (Ctrl+N doesn't interfere)
- [ ] H toggles heatmap
- [ ] G toggles grid
- [ ] ? shows shortcuts modal
### Batch Azimuth:
- [ ] Select 3 sites
- [ ] Adjust +45°
- [ ] All sites rotate together
### Number Inputs:
- [ ] Type "135" in azimuth field
- [ ] Click ▲ → 136°
- [ ] Click ▼ → 135°
- [ ] Slider still works
### Visual Grouping:
- [ ] 3 sectors at same location show grouped
- [ ] Badge shows azimuth (0°, 120°, 240°)
- [ ] Single sites show normally
---
## Build & Deploy
```bash
cd /opt/rfcp/frontend
npm run build
sudo systemctl reload caddy
```
---
## Commit Message
```
feat(ux): keyboard shortcuts, batch azimuth, number inputs
- Changed new site hotkey: Ctrl+N → Shift+S (avoid browser conflict)
- Added batch azimuth operations (adjust ±10/45/90°, set absolute)
- Implemented NumberInput component with arrow controls (+/- buttons)
- Added visual sector grouping (detect co-located sites)
- Enhanced hotkey system (G=grid, T=terrain, R=ruler, ?=help)
- Site form now has precise numeric entry + sliders
Improved workflow for multi-sector site planning.
```
🚀 Ready for Iteration 9!

View File

@@ -0,0 +1,588 @@
# RFCP - Iteration 9.1: Final Polish & Safety
## Issue 1: Coverage Settings Still Use Old Sliders
**Problem:** Coverage Settings panel has sliders but not NumberInput components.
**Solution:** Replace all sliders with NumberInput.
**File:** `frontend/src/App.tsx` (Coverage Settings section)
```typescript
import { NumberInput } from '@/components/common/NumberInput';
// Replace:
<Slider label="Radius" min={1} max={100} ... />
// With:
<NumberInput
label="Radius"
value={settings.radius}
onChange={(v) => updateSettings({ radius: v })}
min={1}
max={100}
step={1}
unit="km"
showSlider={true}
/>
<NumberInput
label="Resolution"
value={settings.resolution}
onChange={(v) => updateSettings({ resolution: v })}
min={50}
max={500}
step={50}
unit="m"
showSlider={true}
/>
<NumberInput
label="Min Signal"
value={settings.rsrpThreshold}
onChange={(v) => updateSettings({ rsrpThreshold: v })}
min={-140}
max={-50}
step={5}
unit="dBm"
showSlider={true}
/>
<NumberInput
label="Heatmap Opacity"
value={Math.round(heatmapOpacity * 100)}
onChange={(v) => setHeatmapOpacity(v / 100)}
min={10}
max={100}
step={5}
unit="%"
showSlider={true}
/>
<NumberInput
label="Terrain Opacity"
value={Math.round(terrainOpacity * 100)}
onChange={(v) => setTerrainOpacity(v / 100)}
min={10}
max={100}
step={5}
unit="%"
showSlider={true}
/>
```
---
## Issue 2: Calculation Radius Color Confusion
**Problem:** Calculation bounds circle is cyan (#00bcd4) - same as weak signal color!
**Current:**
```typescript
// Blue circle - confuses with RSRP gradient
pathOptions: {
color: '#00bcd4', // ← Same as weak signal!
weight: 2,
opacity: 0.5,
dashArray: '5, 5'
}
```
**Solution:** Use neutral color that's NOT in RSRP gradient.
**Option A: Orange (Recommended)**
```typescript
pathOptions: {
color: '#ff9800', // Orange - clearly different
weight: 2,
opacity: 0.6,
dashArray: '5, 5',
fillOpacity: 0
}
```
**Option B: Purple**
```typescript
pathOptions: {
color: '#9c27b0', // Purple - not in gradient
weight: 2,
opacity: 0.6,
dashArray: '5, 5',
fillOpacity: 0
}
```
**Option C: White/Gray**
```typescript
pathOptions: {
color: '#ffffff', // White
weight: 2,
opacity: 0.7,
dashArray: '5, 5',
fillOpacity: 0
}
```
**Recommended: Orange** - highly visible, clearly different from RSRP colors.
**File:** Find where calculation bounds Circle/Rectangle is rendered (likely in `Map.tsx` or coverage component)
```typescript
// Find this:
<Circle
center={[site.lat, site.lon]}
radius={calculationRadius * 1000}
pathOptions={{ color: '#00bcd4', ... }}
/>
// Change to:
<Circle
center={[site.lat, site.lon]}
radius={calculationRadius * 1000}
pathOptions={{
color: '#ff9800', // Orange - not in RSRP gradient
weight: 2,
opacity: 0.6,
dashArray: '10, 5', // Longer dashes
fillOpacity: 0
}}
/>
```
---
## Issue 3: Delete Confirmation Dialog
**Problem:** Accidentally deleting sites with no confirmation!
**Solution:** Add confirmation dialog before delete.
**File:** `frontend/src/components/common/ConfirmDialog.tsx` (new)
```typescript
import { useEffect, useRef } from 'react';
interface ConfirmDialogProps {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void;
onCancel: () => void;
danger?: boolean;
}
export function ConfirmDialog({
title,
message,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
onConfirm,
onCancel,
danger = false
}: ConfirmDialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Focus confirm button
dialogRef.current?.querySelector('button')?.focus();
// Handle Esc key
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel();
}
};
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, [onCancel]);
return (
<div className="confirm-dialog-overlay">
<div className="confirm-dialog" ref={dialogRef}>
<h3>{title}</h3>
<p>{message}</p>
<div className="dialog-actions">
<button
onClick={onCancel}
className="btn-secondary"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
className={danger ? 'btn-danger' : 'btn-primary'}
autoFocus
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
```
**CSS:**
```css
.confirm-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.confirm-dialog {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 24px;
max-width: 400px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.confirm-dialog h3 {
margin: 0 0 12px;
font-size: 18px;
}
.confirm-dialog p {
margin: 0 0 20px;
color: var(--text-secondary);
}
.dialog-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.btn-danger {
background: #dc2626;
color: white;
}
.btn-danger:hover {
background: #b91c1c;
}
```
**Usage in SiteList.tsx:**
```typescript
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
// Delete button
<button onClick={() => setDeleteConfirm(site.id)}>
🗑 Delete
</button>
// Render dialog
{deleteConfirm && (
<ConfirmDialog
title="Delete Site?"
message={`Are you sure you want to delete "${sites.find(s => s.id === deleteConfirm)?.name}"? This cannot be undone.`}
confirmLabel="Delete"
cancelLabel="Cancel"
danger={true}
onConfirm={async () => {
await deleteSite(deleteConfirm);
setDeleteConfirm(null);
toast.success('Site deleted');
}}
onCancel={() => setDeleteConfirm(null)}
/>
)}
```
---
## Issue 4: Undo/Redo System
**Problem:** Accidents happen - need undo for destructive operations.
**Solution:** Simple undo stack with toast action.
**File:** `frontend/src/store/history.ts` (new)
```typescript
import { create } from 'zustand';
interface HistoryAction {
type: 'delete_site' | 'delete_sector' | 'batch_edit';
timestamp: number;
data: any;
description: string;
}
interface HistoryState {
undoStack: HistoryAction[];
redoStack: HistoryAction[];
pushUndo: (action: HistoryAction) => void;
undo: () => HistoryAction | null;
redo: () => HistoryAction | null;
clear: () => void;
}
export const useHistoryStore = create<HistoryState>((set, get) => ({
undoStack: [],
redoStack: [],
pushUndo: (action) => {
set({
undoStack: [...get().undoStack, action],
redoStack: [] // Clear redo stack on new action
});
// Limit stack size to 50 actions
if (get().undoStack.length > 50) {
set({ undoStack: get().undoStack.slice(-50) });
}
},
undo: () => {
const { undoStack, redoStack } = get();
if (undoStack.length === 0) return null;
const action = undoStack[undoStack.length - 1];
set({
undoStack: undoStack.slice(0, -1),
redoStack: [...redoStack, action]
});
return action;
},
redo: () => {
const { undoStack, redoStack } = get();
if (redoStack.length === 0) return null;
const action = redoStack[redoStack.length - 1];
set({
undoStack: [...undoStack, action],
redoStack: redoStack.slice(0, -1)
});
return action;
},
clear: () => set({ undoStack: [], redoStack: [] })
}));
```
**Integration in sites.ts:**
```typescript
import { useHistoryStore } from './history';
const deleteSite = async (id: string) => {
const site = sites.find(s => s.id === id);
if (!site) return;
// Push to undo stack
useHistoryStore.getState().pushUndo({
type: 'delete_site',
timestamp: Date.now(),
data: site,
description: `Delete "${site.name}"`
});
// Perform delete
await db.sites.delete(id);
set((state) => ({
sites: state.sites.filter((s) => s.id !== id)
}));
useCoverageStore.getState().clearCoverage();
};
```
**Undo Handler:**
```typescript
// In App.tsx or main component
const handleUndo = async () => {
const action = useHistoryStore.getState().undo();
if (!action) {
toast.error('Nothing to undo');
return;
}
switch (action.type) {
case 'delete_site':
// Restore deleted site
await useSitesStore.getState().addSite(action.data);
toast.success(`Restored "${action.data.name}"`);
break;
// ... other action types
}
};
const handleRedo = async () => {
const action = useHistoryStore.getState().redo();
if (!action) {
toast.error('Nothing to redo');
return;
}
switch (action.type) {
case 'delete_site':
// Re-delete site
await useSitesStore.getState().deleteSite(action.data.id);
toast.success(`Deleted "${action.data.name}"`);
break;
}
};
```
**Keyboard Shortcuts:**
```typescript
// Add to useKeyboardShortcuts.ts
case 'z':
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
if (e.shiftKey) {
handleRedo(); // Ctrl+Shift+Z = Redo
} else {
handleUndo(); // Ctrl+Z = Undo
}
}
break;
```
**UI Indicator:**
```typescript
// Show undo/redo availability
const { undoStack, redoStack } = useHistoryStore();
<div className="history-controls">
<button
onClick={handleUndo}
disabled={undoStack.length === 0}
title="Undo (Ctrl+Z)"
>
Undo
</button>
<button
onClick={handleRedo}
disabled={redoStack.length === 0}
title="Redo (Ctrl+Shift+Z)"
>
Redo
</button>
</div>
```
---
## Alternative: Simpler Undo (Toast-based)
**Lighter approach without full undo/redo system:**
```typescript
const deleteSite = async (id: string) => {
const site = sites.find(s => s.id === id);
if (!site) return;
// Show toast with undo action
const toastId = toast.success('Site deleted', {
duration: 10000, // 10 seconds to undo
action: {
label: 'Undo',
onClick: async () => {
// Restore site
await addSite(site);
toast.success('Site restored');
}
}
});
// Perform delete
await db.sites.delete(id);
set((state) => ({
sites: state.sites.filter((s) => s.id !== id)
}));
};
```
This is **much simpler** and covers 90% of use cases!
---
## Recommended Priority
**Priority 1 (Must Fix):**
1. ✅ Coverage Settings → NumberInput (consistency)
2. ✅ Calculation radius color → Orange (clarity)
3. ✅ Delete confirmation dialog (safety)
**Priority 2 (Nice to Have):**
4. ⭐ Toast-based undo (simple, effective)
**Priority 3 (Future):**
5. 🔮 Full undo/redo system (complex, might be overkill)
---
## Testing
### Coverage Settings:
- [ ] All sliders replaced with NumberInput
- [ ] Can type exact values (e.g., "73 km")
- [ ] Arrows work (+/- 1)
- [ ] Slider still works
### Calculation Radius:
- [ ] Circle is orange (not cyan)
- [ ] Clearly different from RSRP gradient
- [ ] Visible on all map styles
### Delete Confirmation:
- [ ] Click delete → dialog appears
- [ ] Can cancel (Esc key works)
- [ ] Can confirm → site deleted
- [ ] No accidental deletes
### Undo:
- [ ] Delete site → toast with "Undo" button
- [ ] Click Undo → site restored
- [ ] Ctrl+Z also works
- [ ] Undo expires after 10s
---
## Build & Deploy
```bash
cd /opt/rfcp/frontend
npm run build
sudo systemctl reload caddy
```
---
## Commit Message
```
fix(ux): coverage settings number inputs, calc radius color
- Replaced Coverage Settings sliders with NumberInput components
- Changed calculation radius color: cyan → orange (avoid RSRP conflict)
- Added delete confirmation dialog with Esc key support
- Implemented toast-based undo for delete operations (10s window)
Prevents accidental data loss and improves input consistency.
```
🚀 Ready for Iteration 9.1!

View File

@@ -0,0 +1,279 @@
# RFCP - Quick Fix: Zoom Gradient + Calculation Square
## Issue 1: Zoom Still Breaks Gradient
**Problem:** Despite maxIntensity=0.75, colors still shift with zoom.
**Debug:** Check console for `🔍 Heatmap Debug` - what does it show?
### Possible Root Causes:
**A. maxIntensity is STILL a formula** (not constant)
**File:** `frontend/src/components/map/Heatmap.tsx`
Check if this line exists:
```typescript
const maxIntensity = Math.max(0.5, Math.min(0.9, 1.0 - mapZoom * 0.03)); // ❌ BAD
```
**Replace with:**
```typescript
const maxIntensity = 0.75; // ✅ MUST be constant!
```
**B. Heatmap layer not re-rendering** on zoom
Add force re-render:
```typescript
const [key, setKey] = useState(0);
useEffect(() => {
const handleZoomEnd = () => {
setMapZoom(map.getZoom());
setKey(prev => prev + 1); // Force re-render
};
map.on('zoomend', handleZoomEnd);
return () => { map.off('zoomend', handleZoomEnd); };
}, [map]);
return (
<div style={{ opacity }} key={key}> {/* ← Add key */}
<HeatmapLayer ... />
</div>
);
```
**C. RSRP normalization range too narrow**
Try wider range:
```typescript
const normalizeRSRP = (rsrp: number): number => {
const minRSRP = -140; // Even wider (was -130)
const maxRSRP = -40; // Even wider (was -50)
const normalized = (rsrp - minRSRP) / (maxRSRP - minRSRP);
return Math.max(0, Math.min(1, normalized));
};
```
### Nuclear Option: Remove Dynamic Parameters Entirely
```typescript
export function Heatmap({ points, visible, opacity = 0.7 }: HeatmapProps) {
const map = useMap();
if (!visible || points.length === 0) return null;
// FIXED RSRP normalization
const normalizeRSRP = (rsrp: number): number => {
return Math.max(0, Math.min(1, (rsrp + 140) / 100)); // -140 to -40 dBm
};
const heatmapPoints = points.map(p => [
p.lat,
p.lon,
normalizeRSRP(p.rsrp)
] as [number, number, number]);
// CONSTANT parameters (NO zoom dependency!)
return (
<div style={{ opacity }}>
<HeatmapLayer
points={heatmapPoints}
longitudeExtractor={(p) => p[1]}
latitudeExtractor={(p) => p[0]}
intensityExtractor={(p) => p[2]}
gradient={{
0.0: '#1a237e',
0.2: '#2196f3',
0.4: '#00bcd4',
0.5: '#4caf50',
0.6: '#8bc34a',
0.7: '#ffeb3b',
0.8: '#ff9800',
1.0: '#f44336',
}}
radius={25} // FIXED (no zoom logic)
blur={15} // FIXED
max={0.75} // FIXED
minOpacity={0.3}
/>
</div>
);
}
```
---
## Issue 2: Calculation Square Too Visible
**Problem:** Green rectangle shows calculation bounds - too distracting.
### Option A: Make It Subtle
**File:** Find where calculation bounds are drawn (probably in Map or Coverage component)
**Current (green bold line):**
```typescript
<Rectangle
bounds={[[minLat, minLon], [maxLat, maxLon]]}
pathOptions={{ color: '#00ff00', weight: 3, opacity: 1 }}
/>
```
**Change to subtle dashed line:**
```typescript
<Rectangle
bounds={[[minLat, minLon], [maxLat, maxLon]]}
pathOptions={{
color: '#666', // Gray (was green)
weight: 1, // Thin (was 3)
opacity: 0.3, // Transparent (was 1)
dashArray: '5, 5', // Dashed
fillOpacity: 0 // No fill
}}
/>
```
### Option B: Hide It Entirely
**Add toggle:**
```typescript
// In settings store
showCalculationBounds: false, // Default hidden
// In Map component
{showCalculationBounds && (
<Rectangle ... />
)}
// In UI
<label>
<input
type="checkbox"
checked={showCalculationBounds}
onChange={(e) => setShowCalculationBounds(e.target.checked)}
/>
Show Calculation Bounds
</label>
```
### Option C: Auto-Hide After Calculation
```typescript
const [showBounds, setShowBounds] = useState(false);
// When calculation starts
setShowBounds(true);
// After calculation completes
setTimeout(() => setShowBounds(false), 3000); // Hide after 3s
{showBounds && <Rectangle ... />}
```
### Option D: Progress Indicator Instead
Replace rectangle with corner markers:
```typescript
// Instead of full rectangle, show 4 corner circles
{calculationBounds && (
<>
<CircleMarker center={[minLat, minLon]} radius={3} color="#666" />
<CircleMarker center={[maxLat, minLon]} radius={3} color="#666" />
<CircleMarker center={[minLat, maxLon]} radius={3} color="#666" />
<CircleMarker center={[maxLat, maxLon]} radius={3} color="#666" />
</>
)}
```
---
## Recommended Fix
**File:** `frontend/src/components/map/Map.tsx` (or wherever Rectangle is)
```typescript
// Find the calculation bounds Rectangle and replace with:
{calculationInProgress && (
<Rectangle
bounds={calculationBounds}
pathOptions={{
color: '#3b82f6', // Blue
weight: 1,
opacity: 0.5,
dashArray: '3, 3',
fillOpacity: 0
}}
/>
)}
// Auto-hide after calculation completes:
useEffect(() => {
if (!calculationInProgress && calculationBounds) {
const timer = setTimeout(() => {
setCalculationBounds(null);
}, 2000);
return () => clearTimeout(timer);
}
}, [calculationInProgress]);
```
---
## Testing
### Zoom Gradient:
1. Calculate coverage at zoom 8
2. Note color at specific location (e.g., 3km from site)
3. Zoom to 10, 12, 14, 16
4. Color at same location should NOT change
5. Check console - maxIntensity should always be 0.75
### Calculation Square:
1. Click "Calculate Coverage"
2. Rectangle should be subtle (thin, dashed, gray)
3. OR auto-hide after 2-3 seconds
4. Should not distract from heatmap
---
## Quick Apply
**For Claude Code:**
```
Fix two remaining issues:
1. Heatmap zoom gradient:
- Ensure maxIntensity is CONSTANT 0.75 (not a formula)
- Remove ALL zoom-dependent parameters from HeatmapLayer
- Make radius/blur/max all fixed values
- Test: same location = same color at any zoom
2. Calculation bounds rectangle:
- Make it subtle: gray, thin (weight: 1), dashed, opacity: 0.3
- OR auto-hide 2 seconds after calculation completes
- Should not distract from coverage heatmap
Test gradient thoroughly at zoom levels 8, 10, 12, 14, 16.
```
---
## Build & Test
```bash
cd /opt/rfcp/frontend
npm run build
sudo systemctl reload caddy
# Open https://rfcp.eliah.one
# Test zoom gradient (critical!)
# Check calculation bounds visibility
```
---
Віддати на Claude Code ці 2 фікси? Після цього можна братись за Backend! 🚀