@@ -4,7 +4,11 @@ import {
44 transformPathDetail ,
55 sanitizeNumericValues ,
66 buildChartData ,
7+ buildInclineDetail ,
8+ SLOPE_HORIZON_M ,
79} from '@/pathDetails/elevationWidget/pathDetailData'
10+ import { getSlopeColor } from '@/pathDetails/elevationWidget/colors'
11+ import type { ElevationPoint } from '@/pathDetails/elevationWidget/types'
812
913describe ( 'pathDetailData' , ( ) => {
1014 describe ( 'extractElevationPoints' , ( ) => {
@@ -285,4 +289,67 @@ describe('pathDetailData', () => {
285289 expect ( result . alternativeElevations [ 0 ] ) . toHaveLength ( 3 )
286290 } )
287291 } )
292+
293+ describe ( 'buildInclineDetail color binning' , ( ) => {
294+ // Reproduces the GraphHopper-encoded-polyline noise pattern: a few sample points
295+ // that are close together (<20m) with ~1m elevation jitter from quantization,
296+ // surrounded by long, gently uphill sub-segments. Without windowed slopes the
297+ // tiny jitter sub-segment would paint a 600m+ stretch as a steep decline.
298+ it ( 'does not paint a long stretch as steep decline due to a single short noisy sub-segment' , ( ) => {
299+ const elev : ElevationPoint [ ] = [
300+ { distance : 0 , elevation : 200 , lng : 0 , lat : 0 } ,
301+ { distance : 264 , elevation : 207 , lng : 0 , lat : 0 } , // +2.65% over 264m (real gentle climb)
302+ { distance : 275 , elevation : 206 , lng : 0 , lat : 0 } , // 1m drop over 11m → -9% (noise)
303+ { distance : 880 , elevation : 220 , lng : 0 , lat : 0 } , // +2.31% over 605m
304+ { distance : 970 , elevation : 220 , lng : 0 , lat : 0 } , // flat
305+ { distance : 985 , elevation : 218 , lng : 0 , lat : 0 } , // 2m drop over 15m → -13% (noise)
306+ { distance : 1300 , elevation : 222 , lng : 0 , lat : 0 } , // +1.27% over 315m
307+ ]
308+ const detail = buildInclineDetail ( elev )
309+
310+ // Compute total distance painted in each "decline" color category and the
311+ // overall direction of every painted segment. No segment longer than 100m
312+ // should ever be colored as decline (≤-6%) when the section actually climbs.
313+ const declineColors = new Set ( [ getSlopeColor ( - 7 ) , getSlopeColor ( - 15 ) ] )
314+ for ( const seg of detail . segments ) {
315+ const span = seg . toDistance - seg . fromDistance
316+ if ( declineColors . has ( seg . color ) && span > 50 ) {
317+ // Find the actual elevation change over this segment using the source data
318+ const eAtFrom = interpElev ( elev , seg . fromDistance )
319+ const eAtTo = interpElev ( elev , seg . toDistance )
320+ const overall = ( ( eAtTo - eAtFrom ) / span ) * 100
321+ expect ( overall ) . toBeLessThan ( 0 ) // decline coloring should require an actual decline
322+ }
323+ }
324+ } )
325+
326+ it ( 'still colors a real sustained descent as decline' , ( ) => {
327+ // Continuous -8% over 500m — every sample is part of a real steep descent.
328+ const elev : ElevationPoint [ ] = [ ]
329+ for ( let d = 0 ; d <= 500 ; d += 25 ) {
330+ elev . push ( { distance : d , elevation : 200 - 0.08 * d , lng : 0 , lat : 0 } )
331+ }
332+ const detail = buildInclineDetail ( elev )
333+ const declineColor = getSlopeColor ( - 7 ) // matches the -10..-6% bucket
334+ const declineSpan = detail . segments
335+ . filter ( s => s . color === declineColor )
336+ . reduce ( ( sum , s ) => sum + ( s . toDistance - s . fromDistance ) , 0 )
337+ // Most of the route should be painted as decline (allow a small horizon tail-off)
338+ expect ( declineSpan ) . toBeGreaterThan ( 500 - SLOPE_HORIZON_M * 2 )
339+ } )
340+ } )
288341} )
342+
343+ // Linear interpolation of elevation at a given distance along the series.
344+ function interpElev ( elev : ElevationPoint [ ] , distance : number ) : number {
345+ if ( elev . length === 0 ) return 0
346+ if ( distance <= elev [ 0 ] . distance ) return elev [ 0 ] . elevation
347+ if ( distance >= elev [ elev . length - 1 ] . distance ) return elev [ elev . length - 1 ] . elevation
348+ for ( let i = 0 ; i < elev . length - 1 ; i ++ ) {
349+ if ( distance >= elev [ i ] . distance && distance <= elev [ i + 1 ] . distance ) {
350+ const t = ( distance - elev [ i ] . distance ) / ( elev [ i + 1 ] . distance - elev [ i ] . distance )
351+ return elev [ i ] . elevation + t * ( elev [ i + 1 ] . elevation - elev [ i ] . elevation )
352+ }
353+ }
354+ return 0
355+ }
0 commit comments