584 lines
12 KiB
Markdown
584 lines
12 KiB
Markdown
# 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!
|