1- import { Box , Tooltip , Typography , useTheme } from '@mui/material'
1+ import { Box , Palette , Tooltip , Typography , useTheme } from '@mui/material'
22import SignalCellularAltIcon from '@mui/icons-material/SignalCellularAlt'
33import SignalCellularAlt2BarIcon from '@mui/icons-material/SignalCellularAlt2Bar'
44import SignalCellularAlt1BarIcon from '@mui/icons-material/SignalCellularAlt1Bar'
55import SignalCellularConnectedNoInternet0BarIcon from '@mui/icons-material/SignalCellularConnectedNoInternet0Bar'
6+ import { useMemo } from 'react'
7+ import {
8+ STABILITY_THRESHOLD_MS ,
9+ CRITICAL_THRESHOLD_MS ,
10+ EXCELLENT_SIGNAL_THRESHOLD_MS ,
11+ GOOD_SIGNAL_THRESHOLD_MS ,
12+ SIGNAL_PULSE_DURATION_S ,
13+ SIGNAL_LABEL_MIN_WIDTH_PX ,
14+ } from '@/constants/bluetooth'
15+
16+ type SignalStatusKey =
17+ | 'critical'
18+ | 'warning'
19+ | 'excellent'
20+ | 'good'
21+ | 'poor'
22+ | 'none'
23+
24+ interface StatusConfig {
25+ color : string
26+ icon : typeof SignalCellularAltIcon
27+ isAnimated : boolean
28+ label ?: string
29+ }
30+
31+ const getStatusConfig = (
32+ key : SignalStatusKey ,
33+ palette : Palette ,
34+ periodMs : number ,
35+ isConnected : boolean
36+ ) : StatusConfig => {
37+ switch ( key ) {
38+ case 'critical' :
39+ return {
40+ color : palette . error . main ,
41+ label : 'Signal Lost' ,
42+ icon : SignalCellularConnectedNoInternet0BarIcon ,
43+ isAnimated : true ,
44+ }
45+ case 'warning' :
46+ return {
47+ color : palette . warning . main ,
48+ label : 'Weak Signal' ,
49+ icon : SignalCellularAlt1BarIcon ,
50+ isAnimated : true ,
51+ }
52+ case 'excellent' :
53+ return {
54+ color : palette . success . main ,
55+ label : `${ periodMs } ms` ,
56+ icon : SignalCellularAltIcon ,
57+ isAnimated : false ,
58+ }
59+ case 'good' :
60+ return {
61+ color : palette . warning . main ,
62+ label : `${ periodMs } ms` ,
63+ icon : SignalCellularAlt2BarIcon ,
64+ isAnimated : false ,
65+ }
66+ case 'poor' :
67+ return {
68+ color : palette . error . main ,
69+ label : `${ periodMs } ms` ,
70+ icon : SignalCellularAlt1BarIcon ,
71+ isAnimated : false ,
72+ }
73+ default :
74+ return {
75+ color : palette . text . disabled ,
76+ label : isConnected ? 'Waiting...' : 'Disconnected' ,
77+ icon : SignalCellularConnectedNoInternet0BarIcon ,
78+ isAnimated : false ,
79+ }
80+ }
81+ }
682
783interface SignalQualityIndicatorProps {
884 /**
985 * The average time in milliseconds between received Bluetooth packets.
1086 * Standard BLE HR profile is typically ~1000ms (1Hz).
1187 */
1288 periodMs : number
89+ /**
90+ * The most recent packet inter-arrival time for real-time jitter detection.
91+ */
92+ lastPeriodMs ?: number
1393 isConnected : boolean
1494}
1595
@@ -18,77 +98,70 @@ interface SignalQualityIndicatorProps {
1898 */
1999export const SignalQualityIndicator = ( {
20100 periodMs,
101+ lastPeriodMs = 0 ,
21102 isConnected,
22103} : SignalQualityIndicatorProps ) => {
23- const theme = useTheme ( )
24-
25- // Determine signal quality tier
26- // Ideally: ~1000ms.
27- // Acceptable jitter: +/- 20% (800ms - 1200ms)
28- // Dropped packet: > 1800ms
29- let quality : 'excellent' | 'good' | 'poor' | 'none' = 'none'
30- let color = theme . palette . text . disabled
104+ const { palette } = useTheme ( )
31105
32- if ( isConnected && periodMs > 0 ) {
33- if ( periodMs < 1200 ) {
34- quality = 'excellent'
35- color = theme . palette . success . main
36- } else if ( periodMs < 2200 ) {
37- // Likely missing every other packet
38- quality = 'good'
39- color = theme . palette . warning . main
40- } else {
41- // Missing multiple packets in a row
42- quality = 'poor'
43- color = theme . palette . error . main
44- }
45- }
106+ const currentStatusKey = useMemo ( ( ) : SignalStatusKey => {
107+ if ( ! isConnected || periodMs === 0 ) return 'none'
108+ if ( lastPeriodMs > CRITICAL_THRESHOLD_MS ) return 'critical'
109+ if ( lastPeriodMs > STABILITY_THRESHOLD_MS ) return 'warning'
46110
47- const getIcon = ( ) => {
48- switch ( quality ) {
49- case 'excellent' :
50- return < SignalCellularAltIcon sx = { { color } } />
51- case 'good' :
52- return < SignalCellularAlt2BarIcon sx = { { color } } />
53- case 'poor' :
54- return < SignalCellularAlt1BarIcon sx = { { color } } />
55- default :
56- return < SignalCellularConnectedNoInternet0BarIcon sx = { { color } } />
57- }
58- }
111+ if ( periodMs < EXCELLENT_SIGNAL_THRESHOLD_MS ) return 'excellent'
112+ if ( periodMs < GOOD_SIGNAL_THRESHOLD_MS ) return 'good'
113+ return 'poor'
114+ } , [ isConnected , periodMs , lastPeriodMs ] )
59115
60- // Calculate an approximate "Packet Success Rate" for the tooltip
61- // Assuming 1000ms target.
62- // 1000ms avg = ~100% success. 2000ms avg = ~50% success.
63- const estimatedReliability = Math . min (
64- 100 ,
65- Math . round ( ( 1000 / periodMs ) * 100 )
116+ const config = useMemo (
117+ ( ) => getStatusConfig ( currentStatusKey , palette , periodMs , isConnected ) ,
118+ [ currentStatusKey , palette , periodMs , isConnected ]
66119 )
67120
121+ const Icon = config . icon
122+ const reliability =
123+ periodMs > 0 ? Math . min ( 100 , Math . round ( 100000 / periodMs ) ) : 0
124+
68125 return (
69126 < Tooltip
127+ arrow
70128 title = {
71129 isConnected
72- ? `Signal Quality: ${ periodMs } ms avg period (~${ estimatedReliability } % capture)`
130+ ? `Signal Quality: ${ periodMs } ms avg (~${ reliability } % capture)${
131+ currentStatusKey === 'warning' || currentStatusKey === 'critical'
132+ ? ` | Latency: ${ lastPeriodMs } ms`
133+ : ''
134+ } `
73135 : 'No Signal'
74136 }
75- arrow
76137 >
77138 < Box
78139 sx = { {
79140 display : 'flex' ,
80141 alignItems : 'center' ,
81142 gap : 0.5 ,
82143 opacity : isConnected ? 1 : 0.5 ,
144+ color : config . color ,
145+ animation : config . isAnimated
146+ ? `pulse-signal ${ SIGNAL_PULSE_DURATION_S } s infinite`
147+ : 'none' ,
148+ // Keyframes are defined in global styles to avoid re-parsing on every render
83149 } }
84150 >
85- { getIcon ( ) }
151+ < Icon />
86152 { isConnected && (
87153 < Typography
88154 variant = "caption"
89- sx = { { color : 'text.secondary' , minWidth : 35 } }
155+ sx = { {
156+ color : config . isAnimated ? config . color : 'text.secondary' ,
157+ // Set a minWidth for ms labels to prevent jitter, but allow text labels to expand
158+ minWidth : config . label ?. endsWith ( 'ms' )
159+ ? SIGNAL_LABEL_MIN_WIDTH_PX
160+ : 'auto' ,
161+ fontWeight : config . isAnimated ? 'bold' : 'normal' ,
162+ } }
90163 >
91- { periodMs } ms
164+ { config . label }
92165 </ Typography >
93166 ) }
94167 </ Box >
0 commit comments