1- import { useEffect } from 'react'
1+ import { useHotkeys } from '@rdub/use-hotkeys'
2+ import { useMemo } from 'react'
23import type { MetricsState } from "./useMetrics"
34import type { AwairRecord } from '../types/awair'
45
@@ -14,6 +15,37 @@ interface UseKeyboardShortcutsProps {
1415 setIgnoreNextPanCheck : ( ) => void
1516}
1617
18+ type Metric = 'temp' | 'co2' | 'humid' | 'pm25' | 'voc'
19+
20+ // Hotkey map: key combination -> action name
21+ const HOTKEY_MAP = {
22+ // Primary metrics (lowercase)
23+ 't' : 'metric:temp' ,
24+ 'c' : 'metric:co2' ,
25+ 'h' : 'metric:humid' ,
26+ 'p' : 'metric:pm25' ,
27+ 'v' : 'metric:voc' ,
28+ // Secondary metrics (shift)
29+ 'shift+t' : 'metric:temp:secondary' ,
30+ 'shift+c' : 'metric:co2:secondary' ,
31+ 'shift+h' : 'metric:humid:secondary' ,
32+ 'shift+p' : 'metric:pm25:secondary' ,
33+ 'shift+v' : 'metric:voc:secondary' ,
34+ 'shift+n' : 'metric:none:secondary' ,
35+ // Auto-range
36+ 'a' : 'autorange:left' ,
37+ 'shift+a' : 'autorange:right' ,
38+ // Time ranges
39+ '1' : 'range:1d' ,
40+ '3' : 'range:3d' ,
41+ '7' : 'range:7d' ,
42+ '2' : 'range:14d' ,
43+ 'm' : 'range:30d' ,
44+ // Other
45+ 'l' : 'latest' ,
46+ 'x' : 'all' ,
47+ }
48+
1749export function useKeyboardShortcuts ( {
1850 metrics,
1951 xAxisRange,
@@ -23,142 +55,89 @@ export function useKeyboardShortcuts({
2355 latestModeIntended,
2456 setLatestModeIntended,
2557 handleTimeRangeClick,
26- setIgnoreNextPanCheck
58+ setIgnoreNextPanCheck,
2759} : UseKeyboardShortcutsProps ) {
28- useEffect ( ( ) => {
29- const handleKeyPress = ( event : KeyboardEvent ) => {
30- // Only handle keypresses when not typing in an input/textarea/select
31- if ( event . target instanceof HTMLInputElement ||
32- event . target instanceof HTMLTextAreaElement ||
33- event . target instanceof HTMLSelectElement ) {
34- return
35- }
60+ const { l, r } = metrics
3661
37- // Ignore if any modifier keys are pressed (except Shift for uppercase)
38- if ( event . ctrlKey || event . metaKey || event . altKey ) {
39- return
62+ const handlers = useMemo ( ( ) => {
63+ const setMetricPrimary = ( metric : Metric ) => {
64+ l . set ( metric )
65+ // If secondary was the same, set it to none
66+ if ( r . val === metric ) {
67+ r . set ( 'none' )
4068 }
69+ }
4170
42- const key = event . key . toLowerCase ( )
43- const isShift = event . shiftKey
44-
45- // Map keys to metrics
46- const keyToMetric : { [ key : string ] : 'temp' | 'co2' | 'humid' | 'pm25' | 'voc' } = {
47- 't' : 'temp' ,
48- 'c' : 'co2' ,
49- 'h' : 'humid' ,
50- 'p' : 'pm25' ,
51- 'v' : 'voc'
71+ const setMetricSecondary = ( metric : Metric ) => {
72+ if ( metric === l . val && r . val !== 'none' ) {
73+ // Swap primary and secondary
74+ l . set ( r . val as Metric )
75+ r . set ( metric )
76+ } else if ( metric !== l . val ) {
77+ // Different metric, set as secondary
78+ r . set ( metric )
5279 }
80+ // If same metric and no secondary, it's a no-op
81+ }
5382
54- const { l, r } = metrics
55- if ( key in keyToMetric ) {
56- const selectedMetric = keyToMetric [ key ]
57-
58- if ( isShift ) {
59- // Capital letter = swap primary and secondary if same metric
60- if ( selectedMetric === l . val && r . val !== 'none' ) {
61- // Swap primary and secondary
62- l . set ( r . val as 'temp' | 'co2' | 'humid' | 'pm25' | 'voc' )
63- r . set ( selectedMetric )
64- } else if ( selectedMetric !== l . val ) {
65- // Different metric, set as secondary
66- r . set ( selectedMetric )
67- }
68- // If same metric and no secondary, it's a no-op
69- } else {
70- // Lowercase = primary metric
71- l . set ( selectedMetric )
72- // If secondary was the same, set it to none
73- if ( r . val === selectedMetric ) {
74- r . set ( 'none' )
75- }
83+ return {
84+ // Primary metrics
85+ 'metric:temp' : ( ) => setMetricPrimary ( 'temp' ) ,
86+ 'metric:co2' : ( ) => setMetricPrimary ( 'co2' ) ,
87+ 'metric:humid' : ( ) => setMetricPrimary ( 'humid' ) ,
88+ 'metric:pm25' : ( ) => setMetricPrimary ( 'pm25' ) ,
89+ 'metric:voc' : ( ) => setMetricPrimary ( 'voc' ) ,
90+ // Secondary metrics
91+ 'metric:temp:secondary' : ( ) => setMetricSecondary ( 'temp' ) ,
92+ 'metric:co2:secondary' : ( ) => setMetricSecondary ( 'co2' ) ,
93+ 'metric:humid:secondary' : ( ) => setMetricSecondary ( 'humid' ) ,
94+ 'metric:pm25:secondary' : ( ) => setMetricSecondary ( 'pm25' ) ,
95+ 'metric:voc:secondary' : ( ) => setMetricSecondary ( 'voc' ) ,
96+ 'metric:none:secondary' : ( ) => r . set ( 'none' ) ,
97+ // Auto-range
98+ 'autorange:left' : ( ) => l . setAutoRange ( ! l . autoRange ) ,
99+ 'autorange:right' : ( ) => {
100+ if ( r . val !== 'none' ) {
101+ r . setAutoRange ( ! r . autoRange )
76102 }
77- event . preventDefault ( )
78- } else if ( key === 'n' && isShift ) {
79- // Shift+N = None for secondary
80- r . set ( 'none' )
81- event . preventDefault ( )
82- } else if ( key === 'l' ) {
83- // L = Latest button (toggle)
103+ } ,
104+ // Time ranges
105+ 'range:1d' : ( ) => handleTimeRangeClick ( 24 ) ,
106+ 'range:3d' : ( ) => handleTimeRangeClick ( 24 * 3 ) ,
107+ 'range:7d' : ( ) => handleTimeRangeClick ( 24 * 7 ) ,
108+ 'range:14d' : ( ) => handleTimeRangeClick ( 24 * 14 ) ,
109+ 'range:30d' : ( ) => handleTimeRangeClick ( 24 * 30 ) ,
110+ // Latest mode
111+ 'latest' : ( ) => {
84112 if ( latestModeIntended ) {
85- // Toggle off Latest mode
86113 setLatestModeIntended ( false )
87114 } else if ( xAxisRange && data . length > 0 ) {
88- // Jump to latest and enable Latest mode
89115 const rangeStart = new Date ( xAxisRange [ 0 ] )
90116 const rangeEnd = new Date ( xAxisRange [ 1 ] )
91117 const currentWidth = rangeEnd . getTime ( ) - rangeStart . getTime ( )
92118 const latestTime = new Date ( data [ 0 ] . timestamp )
93119 const newStart = new Date ( latestTime . getTime ( ) - currentWidth )
94120 const newRange : [ string , string ] = [ formatForPlotly ( newStart ) , formatForPlotly ( latestTime ) ]
95- setIgnoreNextPanCheck ( ) // Don't disable Latest mode for our own update
121+ setIgnoreNextPanCheck ( )
96122 setXAxisRange ( newRange )
97123 setLatestModeIntended ( true )
98124 }
99- event . preventDefault ( )
100- } else if ( key === 'a' ) {
101- if ( isShift ) {
102- // Shift+A = Toggle right Y-axis auto-range
103- if ( r . val !== 'none' ) {
104- r . setAutoRange ( ! r . autoRange )
105- }
106- } else {
107- // a = Toggle left Y-axis auto-range
108- l . setAutoRange ( ! l . autoRange )
109- }
110- event . preventDefault ( )
111- } else if ( key === 'x' ) {
112- // X = All data (moved from 'a' to avoid conflict with auto-range)
125+ } ,
126+ // All data
127+ 'all' : ( ) => {
113128 if ( data . length > 0 ) {
114129 const fullRange : [ string , string ] = [
115130 formatForPlotly ( new Date ( data [ data . length - 1 ] . timestamp ) ) ,
116- formatForPlotly ( new Date ( data [ 0 ] . timestamp ) )
131+ formatForPlotly ( new Date ( data [ 0 ] . timestamp ) ) ,
117132 ]
118133 setXAxisRange ( fullRange )
119134 setLatestModeIntended ( true )
120135 } else {
121136 setXAxisRange ( null )
122137 }
123- event . preventDefault ( )
124- } else if ( key === 'h' && ! isShift ) {
125- // h = 12 hours (half-day)
126- handleTimeRangeClick ( 12 )
127- event . preventDefault ( )
128- } else if ( key === '1' ) {
129- // 1 = 1 day
130- handleTimeRangeClick ( 24 )
131- event . preventDefault ( )
132- } else if ( key === '3' ) {
133- // 3 = 3 days
134- handleTimeRangeClick ( 24 * 3 )
135- event . preventDefault ( )
136- } else if ( key === '7' ) {
137- // 7 = 7 days
138- handleTimeRangeClick ( 24 * 7 )
139- event . preventDefault ( )
140- } else if ( key === '2' ) {
141- // 2 = 14 days (2 weeks)
142- handleTimeRangeClick ( 24 * 14 )
143- event . preventDefault ( )
144- } else if ( key === 'm' ) {
145- // M = 30 days (month)
146- handleTimeRangeClick ( 24 * 30 )
147- event . preventDefault ( )
148- }
138+ } ,
149139 }
140+ } , [ l , r , handleTimeRangeClick , latestModeIntended , setLatestModeIntended , xAxisRange , data , formatForPlotly , setXAxisRange , setIgnoreNextPanCheck ] )
150141
151- window . addEventListener ( 'keydown' , handleKeyPress )
152- return ( ) => window . removeEventListener ( 'keydown' , handleKeyPress )
153- } , [
154- metrics ,
155- xAxisRange ,
156- setXAxisRange ,
157- data ,
158- formatForPlotly ,
159- latestModeIntended ,
160- setLatestModeIntended ,
161- handleTimeRangeClick ,
162- setIgnoreNextPanCheck
163- ] )
142+ useHotkeys ( HOTKEY_MAP , handlers )
164143}
0 commit comments