@mytec: docs before back
This commit is contained in:
151
docs/devlog/front/RFCP-Heatmap-Fix.md
Normal file
151
docs/devlog/front/RFCP-Heatmap-Fix.md
Normal 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.
|
||||
847
docs/devlog/front/RFCP-Iteration1-Full-Task.md
Normal file
847
docs/devlog/front/RFCP-Iteration1-Full-Task.md
Normal 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! 🚀
|
||||
1337
docs/devlog/front/RFCP-Iteration10-Final-Audit.md
Normal file
1337
docs/devlog/front/RFCP-Iteration10-Final-Audit.md
Normal file
File diff suppressed because it is too large
Load Diff
456
docs/devlog/front/RFCP-Iteration10.1-Critical-Bugfixes.md
Normal file
456
docs/devlog/front/RFCP-Iteration10.1-Critical-Bugfixes.md
Normal 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
|
||||
|
||||
319
docs/devlog/front/RFCP-Iteration10.2-Purple-Orange-Gradient.md
Normal file
319
docs/devlog/front/RFCP-Iteration10.2-Purple-Orange-Gradient.md
Normal 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
|
||||
487
docs/devlog/front/RFCP-Iteration10.3-Coverage-Boundary-Border.md
Normal file
487
docs/devlog/front/RFCP-Iteration10.3-Coverage-Boundary-Border.md
Normal 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
|
||||
320
docs/devlog/front/RFCP-Iteration10.3.1-Threshold-Filter-Fix.md
Normal file
320
docs/devlog/front/RFCP-Iteration10.3.1-Threshold-Filter-Fix.md
Normal 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)
|
||||
207
docs/devlog/front/RFCP-Iteration10.3.2-Fix-Boundary-Rendering.md
Normal file
207
docs/devlog/front/RFCP-Iteration10.3.2-Fix-Boundary-Rendering.md
Normal 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
|
||||
170
docs/devlog/front/RFCP-Iteration10.4-Fix-Stadia-Maps-401.md
Normal file
170
docs/devlog/front/RFCP-Iteration10.4-Fix-Stadia-Maps-401.md
Normal 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: '© Stamen Design, © OpenMapTiles, © OpenStreetMap',
|
||||
maxZoom: 18,
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**After (OpenTopoMap):**
|
||||
```typescript
|
||||
const topoLayer = L.tileLayer(
|
||||
'https://tile.opentopomap.org/{z}/{x}/{y}.png',
|
||||
{
|
||||
attribution: 'Map data: © <a href="https://openstreetmap.org">OpenStreetMap</a> contributors, <a href="http://viewfinderpanoramas.org">SRTM</a> | Map style: © <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
|
||||
@@ -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`
|
||||
@@ -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
|
||||
625
docs/devlog/front/RFCP-Iteration2-Task.md
Normal file
625
docs/devlog/front/RFCP-Iteration2-Task.md
Normal 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='© OpenStreetMap contributors'
|
||||
/>
|
||||
|
||||
{/* Terrain overlay (when enabled) */}
|
||||
{showTerrain && (
|
||||
<TileLayer
|
||||
url="https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png"
|
||||
attribution='Map data: © OpenStreetMap, SRTM | Style: © 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! 🚀
|
||||
473
docs/devlog/front/RFCP-Iteration3-Comprehensive-Task.md
Normal file
473
docs/devlog/front/RFCP-Iteration3-Comprehensive-Task.md
Normal 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! 🚀
|
||||
419
docs/devlog/front/RFCP-Iteration4-Critical-Fixes.md
Normal file
419
docs/devlog/front/RFCP-Iteration4-Critical-Fixes.md
Normal 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='© OpenStreetMap'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
{/* Terrain overlay - ABOVE base map, BELOW heatmap */}
|
||||
{showTerrain && (
|
||||
<TileLayer
|
||||
attribution='© 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!
|
||||
505
docs/devlog/front/RFCP-Iteration5-RF-Accuracy.md
Normal file
505
docs/devlog/front/RFCP-Iteration5-RF-Accuracy.md
Normal 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 (> -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 (< -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!
|
||||
860
docs/devlog/front/RFCP-Iteration6-Complete.md
Normal file
860
docs/devlog/front/RFCP-Iteration6-Complete.md
Normal 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!
|
||||
569
docs/devlog/front/RFCP-Iteration7-Elevation-UX.md
Normal file
569
docs/devlog/front/RFCP-Iteration7-Elevation-UX.md
Normal 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='© 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 (> -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 (< -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!
|
||||
258
docs/devlog/front/RFCP-Iteration7.2-Proper-Gradient.md
Normal file
258
docs/devlog/front/RFCP-Iteration7.2-Proper-Gradient.md
Normal 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!
|
||||
292
docs/devlog/front/RFCP-Iteration7.3-Geographic-Scale.md
Normal file
292
docs/devlog/front/RFCP-Iteration7.3-Geographic-Scale.md
Normal 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!
|
||||
389
docs/devlog/front/RFCP-Iteration7.4-Radius-Sector-UI.md
Normal file
389
docs/devlog/front/RFCP-Iteration7.4-Radius-Sector-UI.md
Normal 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!
|
||||
961
docs/devlog/front/RFCP-Iteration8-Custom-Canvas-Heatmap.md
Normal file
961
docs/devlog/front/RFCP-Iteration8-Custom-Canvas-Heatmap.md
Normal 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!
|
||||
|
||||
267
docs/devlog/front/RFCP-Iteration8.1-Clone-Coverage-Gaps.md
Normal file
267
docs/devlog/front/RFCP-Iteration8.1-Clone-Coverage-Gaps.md
Normal 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!
|
||||
583
docs/devlog/front/RFCP-Iteration9-UX-Polish.md
Normal file
583
docs/devlog/front/RFCP-Iteration9-UX-Polish.md
Normal 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!
|
||||
588
docs/devlog/front/RFCP-Iteration9.1-Final-Polish.md
Normal file
588
docs/devlog/front/RFCP-Iteration9.1-Final-Polish.md
Normal 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!
|
||||
279
docs/devlog/front/RFCP-QuickFix-Zoom-Bounds.md
Normal file
279
docs/devlog/front/RFCP-QuickFix-Zoom-Bounds.md
Normal 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! 🚀
|
||||
Reference in New Issue
Block a user