@@ -73,13 +73,141 @@ export interface CombinedChartPoint {
7373 elevationPlot : number | null ;
7474}
7575
76+ export type CombinedChartBasePoint = Omit <
77+ CombinedChartPoint ,
78+ 'pacePlot' | 'speedPlot' | 'heartRatePlot' | 'cadencePlot' | 'powerPlot' | 'elevationPlot'
79+ > ;
80+
7681export interface CombinedChartModel {
7782 data : CombinedChartPoint [ ] ;
7883 has : Record < ChartSeriesKey , boolean > ;
7984 maxDistanceKm : number ;
8085 maxElapsedSeconds : number ;
8186}
8287
88+ type OutlierBounds = {
89+ min ?: number ;
90+ max ?: number ;
91+ } ;
92+
93+ const OUTLIER_BOUNDS_BY_KEY : Record < SplitMetricKey , OutlierBounds > = {
94+ paceSecondsPerKm : { min : 90 , max : 3600 } ,
95+ speedKmh : { min : 0 , max : 130 } ,
96+ heartRate : { min : 40 , max : 240 } ,
97+ elevationM : { } ,
98+ cadence : { min : 20 , max : 260 } ,
99+ powerWatts : { min : 0 , max : 2500 }
100+ } ;
101+
102+ const OUTLIER_WINDOW_RADIUS = 5 ;
103+ const OUTLIER_MIN_WINDOW_SAMPLES = 7 ;
104+ const OUTLIER_Z_THRESHOLD = 3.5 ;
105+ const OUTLIER_MAD_EPSILON = 1e-6 ;
106+
107+ function median ( values : number [ ] ) : number {
108+ if ( values . length === 0 ) {
109+ return NaN ;
110+ }
111+
112+ const sorted = [ ...values ] . sort ( ( left , right ) => left - right ) ;
113+ const middle = Math . floor ( sorted . length / 2 ) ;
114+ if ( sorted . length % 2 === 0 ) {
115+ return ( sorted [ middle - 1 ] + sorted [ middle ] ) / 2 ;
116+ }
117+ return sorted [ middle ] ;
118+ }
119+
120+ function filterSeriesOutliers ( values : Array < number | null > , bounds : OutlierBounds ) : Array < number | null > {
121+ const filtered : Array < number | null > = new Array ( values . length ) . fill ( null ) ;
122+
123+ for ( let index = 0 ; index < values . length ; index += 1 ) {
124+ const value = values [ index ] ;
125+ if ( value == null || ! Number . isFinite ( value ) ) {
126+ filtered [ index ] = null ;
127+ continue ;
128+ }
129+
130+ if ( ( bounds . min != null && value < bounds . min ) || ( bounds . max != null && value > bounds . max ) ) {
131+ filtered [ index ] = null ;
132+ continue ;
133+ }
134+
135+ const neighborhood : number [ ] = [ ] ;
136+ const start = Math . max ( 0 , index - OUTLIER_WINDOW_RADIUS ) ;
137+ const end = Math . min ( values . length - 1 , index + OUTLIER_WINDOW_RADIUS ) ;
138+ for ( let neighborIndex = start ; neighborIndex <= end ; neighborIndex += 1 ) {
139+ const neighborValue = values [ neighborIndex ] ;
140+ if ( neighborValue != null && Number . isFinite ( neighborValue ) ) {
141+ neighborhood . push ( neighborValue ) ;
142+ }
143+ }
144+
145+ if ( neighborhood . length < OUTLIER_MIN_WINDOW_SAMPLES ) {
146+ filtered [ index ] = value ;
147+ continue ;
148+ }
149+
150+ const center = median ( neighborhood ) ;
151+ if ( ! Number . isFinite ( center ) ) {
152+ filtered [ index ] = value ;
153+ continue ;
154+ }
155+
156+ const absoluteDeviations = neighborhood . map ( ( entry ) => Math . abs ( entry - center ) ) ;
157+ const mad = median ( absoluteDeviations ) ;
158+ if ( ! Number . isFinite ( mad ) || mad < OUTLIER_MAD_EPSILON ) {
159+ filtered [ index ] = value ;
160+ continue ;
161+ }
162+
163+ const robustZScore = Math . abs ( value - center ) / ( 1.4826 * mad ) ;
164+ filtered [ index ] = robustZScore > OUTLIER_Z_THRESHOLD ? null : value ;
165+ }
166+
167+ return filtered ;
168+ }
169+
170+ export function removeCombinedChartOutliers ( points : CombinedChartBasePoint [ ] ) : CombinedChartBasePoint [ ] {
171+ if ( points . length === 0 ) {
172+ return points ;
173+ }
174+
175+ const paceValues = filterSeriesOutliers (
176+ points . map ( ( point ) => point . paceSecondsPerKm ) ,
177+ OUTLIER_BOUNDS_BY_KEY . paceSecondsPerKm
178+ ) ;
179+ const speedValues = filterSeriesOutliers (
180+ points . map ( ( point ) => point . speedKmh ) ,
181+ OUTLIER_BOUNDS_BY_KEY . speedKmh
182+ ) ;
183+ const heartRateValues = filterSeriesOutliers (
184+ points . map ( ( point ) => point . heartRate ) ,
185+ OUTLIER_BOUNDS_BY_KEY . heartRate
186+ ) ;
187+ const cadenceValues = filterSeriesOutliers (
188+ points . map ( ( point ) => point . cadence ) ,
189+ OUTLIER_BOUNDS_BY_KEY . cadence
190+ ) ;
191+ const powerValues = filterSeriesOutliers (
192+ points . map ( ( point ) => point . powerWatts ) ,
193+ OUTLIER_BOUNDS_BY_KEY . powerWatts
194+ ) ;
195+ const elevationValues = filterSeriesOutliers (
196+ points . map ( ( point ) => point . elevationM ) ,
197+ OUTLIER_BOUNDS_BY_KEY . elevationM
198+ ) ;
199+
200+ return points . map ( ( point , index ) => ( {
201+ ...point ,
202+ paceSecondsPerKm : paceValues [ index ] ,
203+ speedKmh : speedValues [ index ] ,
204+ heartRate : heartRateValues [ index ] ,
205+ cadence : cadenceValues [ index ] ,
206+ powerWatts : powerValues [ index ] ,
207+ elevationM : elevationValues [ index ]
208+ } ) ) ;
209+ }
210+
83211export function defaultChartSeriesVisibility ( sportType ?: string ) : ChartSeriesVisibility {
84212 const normalizedSport = ( sportType ?? '' ) . trim ( ) . toLowerCase ( ) ;
85213
0 commit comments