Skip to content

Commit fd23990

Browse files
karussellclaude
andcommitted
fix(elevation): smooth slope coloring to ignore short noise spikes
Small elevation jitter between very close samples looked like ±10% slopes, and the bin-painter then stretched that color far past the short noisy bit, so hundreds of meters got painted as steep climb or decline. Now slopes are measured over a fixed 30m forward window, so the colors show real sustained gradients. Applied in buildInclineDetail (map) and drawElevationArea (chart). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4b6284d commit fd23990

3 files changed

Lines changed: 96 additions & 4 deletions

File tree

src/pathDetails/elevationWidget/ChartRenderer.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
formatDetailTick,
88
} from './axisUtils'
99
import { getSlopeColor } from './colors'
10+
import { computeWindowedSlopes } from './pathDetailData'
1011

1112
const DEFAULT_MARGIN = { top: 10, right: 15, bottom: 26, left: 48 }
1213
const DETAIL_BAR_HEIGHT = 50
@@ -447,18 +448,20 @@ export default class ChartRenderer {
447448
// pick the steepest slope to determine the color — this preserves steep sections
448449
// visually even when they span only a few data points. Adjacent bins with the
449450
// same color are then merged into single polygons to minimize draw calls.
451+
// Slopes are computed over a fixed forward distance (see computeWindowedSlopes)
452+
// so polyline-quantization noise can't paint long bins as steep climbs/descents.
450453
const totalDist = elev[elev.length - 1].distance
451454
const plotWidth = xScale(totalDist) - xScale(0)
452455
const MIN_BIN_PX = 1
453456
const minBinDist = (MIN_BIN_PX / plotWidth) * totalDist
457+
const slopes = computeWindowedSlopes(elev)
454458
const bins: { fromIdx: number; toIdx: number; color: string }[] = []
455459
let binStart = 0
456460
let maxAbsSlope = 0
457461
let steepestSlope = 0
458462

459463
for (let i = 0; i < elev.length - 1; i++) {
460-
const segDist = elev[i + 1].distance - elev[i].distance
461-
const slope = segDist > 0 ? (100 * (elev[i + 1].elevation - elev[i].elevation)) / segDist : 0
464+
const slope = slopes[i]
462465
const absSlope = Math.abs(slope)
463466
if (absSlope > maxAbsSlope) {
464467
maxAbsSlope = absSlope

src/pathDetails/elevationWidget/pathDetailData.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,28 @@ export interface PathLike {
2323
distance: number
2424
}
2525

26+
// Distance window (meters) for slope computation. Encoded polylines quantize
27+
// elevation to ~0.01m, which makes ~1m bumps over <20m sub-segments read as
28+
// 10%+ slopes. Computing slope over a fixed forward distance filters that
29+
// noise while preserving real sustained gradients (typical climbs span ≥100m).
30+
export const SLOPE_HORIZON_M = 30
31+
32+
// Per-segment slope (%) using a forward distance window of SLOPE_HORIZON_M.
33+
// Returned array has length elevation.length - 1, indexed by segment start.
34+
export function computeWindowedSlopes(elevation: ElevationPoint[]): number[] {
35+
const n = elevation.length
36+
if (n < 2) return []
37+
const slopes: number[] = new Array(n - 1)
38+
let j = 1
39+
for (let i = 0; i < n - 1; i++) {
40+
if (j < i + 1) j = i + 1
41+
while (j < n - 1 && elevation[j].distance - elevation[i].distance < SLOPE_HORIZON_M) j++
42+
const dist = elevation[j].distance - elevation[i].distance
43+
slopes[i] = dist > 0 ? (100 * (elevation[j].elevation - elevation[i].elevation)) / dist : 0
44+
}
45+
return slopes
46+
}
47+
2648
export function extractElevationPoints(coordinates: number[][]): ElevationPoint[] {
2749
if (coordinates.length === 0) return []
2850
const has3D = coordinates[0].length >= 3
@@ -298,12 +320,12 @@ export function buildInclineDetail(elevation: ElevationPoint[]): ChartPathDetail
298320
}
299321

300322
// Compute slope between consecutive points and assign incline colors
323+
const slopes = computeWindowedSlopes(elevation)
301324
const raw: PathDetailSegment[] = []
302325
for (let i = 0; i < elevation.length - 1; i++) {
303326
const p = elevation[i]
304327
const q = elevation[i + 1]
305-
const dist = q.distance - p.distance
306-
const slopePercent = dist > 0 ? ((q.elevation - p.elevation) / dist) * 100 : 0
328+
const slopePercent = slopes[i]
307329
const color = getSlopeColor(slopePercent)
308330
raw.push({
309331
fromDistance: p.distance,

test/pathDetails/elevationWidget/pathDetailData.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

913
describe('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

Comments
 (0)