From b9326075213fa9b01913090e8e931c8371ca7463 Mon Sep 17 00:00:00 2001 From: mytec Date: Fri, 30 Jan 2026 14:47:32 +0200 Subject: [PATCH] @mytec: iter9 ready for test --- frontend/src/App.tsx | 89 ++++++-- frontend/src/components/panels/BatchEdit.tsx | 95 +++++++- frontend/src/components/panels/SiteForm.tsx | 29 ++- frontend/src/components/panels/SiteList.tsx | 228 ++++++++++++------- frontend/src/components/ui/NumberInput.tsx | 147 ++++++++++++ frontend/src/hooks/useKeyboardShortcuts.ts | 124 +++++++--- frontend/src/store/sites.ts | 60 +++++ 7 files changed, 629 insertions(+), 143 deletions(-) create mode 100644 frontend/src/components/ui/NumberInput.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5eabbe6..6eb246d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -169,6 +169,7 @@ export default function App() { useKeyboardShortcuts({ onCalculate: handleCalculate, onCloseForm: handleCloseForm, + onShowShortcuts: useCallback(() => setShowShortcuts(true), []), }); return ( @@ -227,24 +228,76 @@ export default function App() {

Keyboard Shortcuts

- +
+ {/* Coverage */} +
+

Coverage

+
    +
  • + Calculate coverage + Ctrl+Enter +
  • +
  • + Clear coverage + Shift+C +
  • +
  • + Toggle heatmap + H +
  • +
+
+ {/* Sites */} +
+

Sites

+
    +
  • + New site (place mode) + Shift+S +
  • +
  • + Delete selected + Delete +
  • +
+
+ {/* View */} +
+

View

+
    +
  • + Toggle grid + G +
  • +
  • + Toggle terrain + T +
  • +
  • + Toggle ruler + R +
  • +
  • + Fit to coverage + F +
  • +
+
+ {/* General */} +
+

General

+
    +
  • + Cancel / Close + Esc +
  • +
  • + Show shortcuts + ? +
  • +
+
+
+ + {/* Adjust azimuth */} +
+ +
+ + + + + + +
+
+ + {/* Set exact azimuth */} +
+ +
+ setCustomAzimuth(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSetAzimuth()} + placeholder="0-359°" + className="flex-1 px-3 py-1.5 border border-gray-300 dark:border-dark-border dark:bg-dark-bg dark:text-dark-text rounded-md text-sm + focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + +
+
); } diff --git a/frontend/src/components/panels/SiteForm.tsx b/frontend/src/components/panels/SiteForm.tsx index a700cce..c658255 100644 --- a/frontend/src/components/panels/SiteForm.tsx +++ b/frontend/src/components/panels/SiteForm.tsx @@ -4,7 +4,7 @@ import { useSitesStore } from '@/store/sites.ts'; import { useToastStore } from '@/components/ui/Toast.tsx'; import Button from '@/components/ui/Button.tsx'; import Input from '@/components/ui/Input.tsx'; -import Slider from '@/components/ui/Slider.tsx'; +import NumberInput from '@/components/ui/NumberInput.tsx'; import FrequencySelector from './FrequencySelector.tsx'; interface SiteFormProps { @@ -284,22 +284,24 @@ export default function SiteForm({ {/* Power */} - {/* Gain */} - {/* Height */} - - - diff --git a/frontend/src/components/panels/SiteList.tsx b/frontend/src/components/panels/SiteList.tsx index 3b5f83c..c9b36df 100644 --- a/frontend/src/components/panels/SiteList.tsx +++ b/frontend/src/components/panels/SiteList.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import type { Site } from '@/types/index.ts'; import { useSitesStore } from '@/store/sites.ts'; import { useToastStore } from '@/components/ui/Toast.tsx'; @@ -10,6 +10,33 @@ interface SiteListProps { onAddSite: () => void; } +/** Group co-located sites (within ~10m = 0.0001°) */ +function groupSites(sites: Site[]): { key: string; label: string; sites: Site[] }[] { + const groups = new Map(); + const order: string[] = []; + + for (const site of sites) { + // Round to 4 decimals (~11m precision) + const key = `${site.lat.toFixed(4)},${site.lon.toFixed(4)}`; + if (!groups.has(key)) { + groups.set(key, []); + order.push(key); + } + groups.get(key)!.push(site); + } + + return order.map((key) => { + const g = groups.get(key)!; + // Extract base name from first site (strip sector suffixes) + const baseName = g[0].name.replace(/-(Alpha|Beta|Gamma|Delta|Epsilon|Zeta|S\d+|clone)$/i, ''); + return { + key, + label: baseName, + sites: g, + }; + }); +} + export default function SiteList({ onEditSite, onAddSite }: SiteListProps) { const sites = useSitesStore((s) => s.sites); const deleteSite = useSitesStore((s) => s.deleteSite); @@ -24,12 +51,10 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) { const cloneSector = useSitesStore((s) => s.cloneSector); const addToast = useToastStore((s) => s.addToast); - // Track recently batch-updated site IDs for flash animation const [flashIds, setFlashIds] = useState>(new Set()); const triggerFlash = useCallback((ids: string[]) => { setFlashIds(new Set(ids)); - // Clear after animation completes setTimeout(() => setFlashIds(new Set()), 700); }, []); @@ -40,12 +65,112 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) { const allSelected = sites.length > 0 && selectedSiteIds.length === sites.length; + // Group co-located sites + const siteGroups = useMemo(() => groupSites(sites), [sites]); + const hasGroups = siteGroups.some((g) => g.sites.length > 1); + + // Count unique locations + const locationCount = siteGroups.length; + + const renderSiteRow = (site: Site, isGrouped: boolean) => { + const isBatchSelected = selectedSiteIds.includes(site.id); + const isFlashing = flashIds.has(site.id); + + return ( +
selectSite(site.id)} + > + {/* Batch checkbox */} + toggleSiteSelection(site.id)} + onClick={(e) => e.stopPropagation()} + className="w-3.5 h-3.5 rounded border-gray-300 dark:border-dark-border accent-blue-600 flex-shrink-0" + /> + + {/* Color indicator */} +
+ + {/* Info */} +
+
+ {site.name} + {/* Azimuth badge for grouped sectors */} + {isGrouped && site.antennaType === 'sector' && ( + + {site.azimuth ?? 0}° + + )} +
+
+ {site.frequency} MHz · {site.power} dBm ·{' '} + {site.antennaType === 'omni' + ? 'Omni' + : `Sector ${site.azimuth ?? 0}°`} + {' '}· {site.height}m +
+
+ + {/* Actions */} +
+ + + +
+
+ ); + }; + return (
{/* Header */}

Sites ({sites.length}) + {hasGroups && ( + + · {locationCount} location{locationCount !== 1 ? 's' : ''} + + )}

- {/* Select All row (only when sites exist) */} + {/* Select All row */} {sites.length > 0 && (
)} - {/* Batch edit panel (appears when sites are selected) */} + {/* Batch edit panel */} {selectedSiteIds.length > 0 && (
@@ -94,84 +219,27 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) { No sites yet. Click on the map or use "+ Manual" to add one.
) : ( -
- {sites.map((site) => { - const isBatchSelected = selectedSiteIds.includes(site.id); - const isFlashing = flashIds.has(site.id); +
+ {siteGroups.map((group) => { + if (group.sites.length === 1) { + // Single site — render normally + return renderSiteRow(group.sites[0], false); + } + // Multi-sector group return ( -
selectSite(site.id)} - > - {/* Batch checkbox */} - toggleSiteSelection(site.id)} - onClick={(e) => e.stopPropagation()} - className="w-3.5 h-3.5 rounded border-gray-300 dark:border-dark-border accent-blue-600 flex-shrink-0" - /> - - {/* Color indicator */} -
- - {/* Info */} -
-
- {site.name} -
-
- {site.frequency} MHz · {site.power} dBm ·{' '} - {site.antennaType === 'omni' - ? 'Omni' - : `Sector ${site.azimuth ?? 0}°`} - {' '}· {site.height}m -
-
- - {/* Actions */} -
- - - +
+ {/* Group header */} +
+ + {group.label} + + + {group.sites.length} sectors +
+ {/* Grouped site rows */} + {group.sites.map((site) => renderSiteRow(site, true))}
); })} diff --git a/frontend/src/components/ui/NumberInput.tsx b/frontend/src/components/ui/NumberInput.tsx new file mode 100644 index 0000000..cbeef2a --- /dev/null +++ b/frontend/src/components/ui/NumberInput.tsx @@ -0,0 +1,147 @@ +import { useCallback, useRef } from 'react'; + +interface NumberInputProps { + label: string; + value: number; + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number; + unit?: string; + hint?: string; + showSlider?: boolean; +} + +export default function NumberInput({ + label, + value, + onChange, + min = 0, + max = 100, + step = 1, + unit = '', + hint, + showSlider = true, +}: NumberInputProps) { + const holdTimer = useRef | null>(null); + + const clamp = useCallback( + (v: number) => Math.max(min, Math.min(max, v)), + [min, max] + ); + + const increment = useCallback(() => { + onChange(clamp(value + step)); + }, [value, step, onChange, clamp]); + + const decrement = useCallback(() => { + onChange(clamp(value - step)); + }, [value, step, onChange, clamp]); + + // Hold-to-repeat for arrow buttons + const startHold = useCallback( + (fn: () => void) => { + fn(); + holdTimer.current = setInterval(fn, 120); + }, + [] + ); + + const stopHold = useCallback(() => { + if (holdTimer.current) { + clearInterval(holdTimer.current); + holdTimer.current = null; + } + }, []); + + const handleTextChange = (e: React.ChangeEvent) => { + const raw = e.target.value; + if (raw === '' || raw === '-') return; + const v = Number(raw); + if (!isNaN(v)) { + onChange(clamp(v)); + } + }; + + // Format value for display — avoid long decimals + const displayValue = Number.isInteger(step) ? value : Number(value.toFixed(1)); + + return ( +
+ {/* Label + value badge */} +
+ + + {displayValue} {unit} + +
+ + {/* Text input + arrow buttons */} +
+ +
+ + +
+
+ + {/* Slider for visual feedback */} + {showSlider && ( + onChange(Number(e.target.value))} + min={min} + max={max} + step={step} + className="w-full h-1.5 bg-gray-200 dark:bg-dark-border rounded-lg appearance-none cursor-pointer accent-blue-600" + /> + )} + + {/* Min/max labels */} + {showSlider && ( +
+ {min}{unit} + {max}{unit} +
+ )} + + {hint &&

{hint}

} +
+ ); +} diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts index 3594221..6d46c9a 100644 --- a/frontend/src/hooks/useKeyboardShortcuts.ts +++ b/frontend/src/hooks/useKeyboardShortcuts.ts @@ -1,61 +1,121 @@ import { useEffect } from 'react'; import { useSitesStore } from '@/store/sites.ts'; import { useCoverageStore } from '@/store/coverage.ts'; +import { useSettingsStore } from '@/store/settings.ts'; import { useToastStore } from '@/components/ui/Toast.tsx'; interface ShortcutHandlers { onCalculate: () => void; onCloseForm: () => void; + onShowShortcuts?: () => void; } -export function useKeyboardShortcuts({ onCalculate, onCloseForm }: ShortcutHandlers) { +function isInputActive(): boolean { + const el = document.activeElement; + if (!el) return false; + const tag = el.tagName; + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; +} + +export function useKeyboardShortcuts({ + onCalculate, + onCloseForm, + onShowShortcuts, +}: ShortcutHandlers) { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - // Ignore if typing in input or textarea - if ( - e.target instanceof HTMLInputElement || - e.target instanceof HTMLTextAreaElement || - e.target instanceof HTMLSelectElement - ) { - return; - } - const isMac = navigator.platform.toLowerCase().includes('mac'); const modKey = isMac ? e.metaKey : e.ctrlKey; - if (modKey) { - switch (e.key.toLowerCase()) { - case 'enter': // Ctrl/Cmd+Enter: Calculate coverage - e.preventDefault(); - onCalculate(); - break; + // === Modifier shortcuts (work even in inputs) === + if (modKey && e.key === 'Enter') { + e.preventDefault(); + onCalculate(); + return; + } - case 'n': // Ctrl/Cmd+N: New site (enter placement mode) + // Escape always works + if (e.key === 'Escape') { + useSitesStore.getState().selectSite(null); + useSitesStore.getState().setPlacingMode(false); + onCloseForm(); + return; + } + + // === Skip remaining shortcuts if typing in an input === + if (isInputActive()) return; + + // Shift combos (no browser conflicts) + if (e.shiftKey && !modKey && !e.altKey) { + switch (e.key.toUpperCase()) { + case 'S': // Shift+S: New site (place mode) e.preventDefault(); useSitesStore.getState().setPlacingMode(true); useToastStore.getState().addToast('Click on map to place new site', 'info'); - break; + return; + case 'C': // Shift+C: Clear coverage + e.preventDefault(); + useCoverageStore.getState().clearCoverage(); + useToastStore.getState().addToast('Coverage cleared', 'info'); + return; } } - // Non-modifier shortcuts - switch (e.key) { - case 'Escape': // Escape: Cancel/close - useSitesStore.getState().selectSite(null); - useSitesStore.getState().setPlacingMode(false); - onCloseForm(); - break; - - case 'h': // H: Toggle heatmap - case 'H': - if (!e.ctrlKey && !e.metaKey && !e.altKey) { + // Single letter shortcuts (no modifiers) + if (!modKey && !e.altKey && !e.shiftKey) { + switch (e.key.toLowerCase()) { + case 'h': // H: Toggle heatmap useCoverageStore.getState().toggleHeatmap(); - } - break; + return; + case 'g': // G: Toggle grid + useSettingsStore.getState().setShowGrid( + !useSettingsStore.getState().showGrid + ); + return; + case 't': // T: Toggle terrain + useSettingsStore.getState().setShowElevationOverlay( + !useSettingsStore.getState().showElevationOverlay + ); + return; + case 'r': // R: Toggle ruler / measurement + useSettingsStore.getState().setMeasurementMode( + !useSettingsStore.getState().measurementMode + ); + return; + case 'f': // F: Fit to coverage — dispatch custom event for Map.tsx to handle + window.dispatchEvent(new CustomEvent('rfcp:fit-bounds')); + return; + case 'delete': // Delete key: delete selected sites + case 'backspace': + // Only if a site is selected (not batch) + { + const selectedId = useSitesStore.getState().selectedSiteId; + if (selectedId) { + const site = useSitesStore.getState().sites.find(s => s.id === selectedId); + if (site) { + useSitesStore.getState().deleteSite(selectedId); + useToastStore.getState().addToast(`"${site.name}" deleted`, 'info'); + } + } + } + return; + } + + // ? key for help (not shift on some layouts) + if (e.key === '?') { + onShowShortcuts?.(); + return; + } + } + + // ? with shift (most layouts: shift+/) + if (e.shiftKey && !modKey && !e.altKey && e.key === '?') { + onShowShortcuts?.(); + return; } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [onCalculate, onCloseForm]); + }, [onCalculate, onCloseForm, onShowShortcuts]); } diff --git a/frontend/src/store/sites.ts b/frontend/src/store/sites.ts index 4f82db7..4a99474 100644 --- a/frontend/src/store/sites.ts +++ b/frontend/src/store/sites.ts @@ -38,6 +38,8 @@ interface SitesState { clearSelection: () => void; batchUpdateHeight: (adjustment: number) => Promise; batchSetHeight: (height: number) => Promise; + batchAdjustAzimuth: (delta: number) => Promise; + batchSetAzimuth: (azimuth: number) => Promise; } export const useSitesStore = create((set, get) => ({ @@ -276,4 +278,62 @@ export const useSitesStore = create((set, get) => ({ set({ sites: updatedSites }); }, + + batchAdjustAzimuth: async (delta: number) => { + const { sites, selectedSiteIds } = get(); + const selectedSet = new Set(selectedSiteIds); + const now = new Date(); + + const updatedSites = sites.map((site) => { + if (!selectedSet.has(site.id)) return site; + const current = site.azimuth ?? 0; + return { + ...site, + azimuth: ((current + delta) % 360 + 360) % 360, + updatedAt: now, + }; + }); + + const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id)); + for (const site of toUpdate) { + await db.sites.put({ + id: site.id, + data: JSON.stringify(site), + createdAt: site.createdAt.getTime(), + updatedAt: now.getTime(), + }); + } + + set({ sites: updatedSites }); + useCoverageStore.getState().clearCoverage(); + }, + + batchSetAzimuth: async (azimuth: number) => { + const { sites, selectedSiteIds } = get(); + const selectedSet = new Set(selectedSiteIds); + const clamped = ((azimuth % 360) + 360) % 360; + const now = new Date(); + + const updatedSites = sites.map((site) => { + if (!selectedSet.has(site.id)) return site; + return { + ...site, + azimuth: clamped, + updatedAt: now, + }; + }); + + const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id)); + for (const site of toUpdate) { + await db.sites.put({ + id: site.id, + data: JSON.stringify(site), + createdAt: site.createdAt.getTime(), + updatedAt: now.getTime(), + }); + } + + set({ sites: updatedSites }); + useCoverageStore.getState().clearCoverage(); + }, }));