@mytec: iter9 start
This commit is contained in:
583
RFCP-Iteration9-UX-Polish.md
Normal file
583
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!
|
||||
Reference in New Issue
Block a user