Files
rfcp/RFCP-Iteration9-UX-Polish.md
2026-01-30 14:38:06 +02:00

12 KiB

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)

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

// 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

// 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:

// 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)

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:

.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:

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:

// 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

// 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:

// 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:

// 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]);
        }
      }
    }
  });
};

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

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!