Skip to content

Commit 74215c8

Browse files
karussellclaude
andcommitted
fix(elevation): color slopes by net per-bin grade, consistent with the popup
Reintroduces noise handling (dropped by the preceding revert) without a fixed smoothing window, so the slope colors stay in sync with the hover popup, which reports the exact local grade. The area fill now colors each bin by its net (distance-weighted) grade instead of the steepest sample in the bin. At normal zoom each bin is a single segment, so the color equals the popup grade (a steep dip reads dark blue and the climb out of it reads as a climb, not blue). When many points fall on one pixel the net grade averages them, so a single noisy sample can no longer paint a whole pixel as a steep climb/descent -- the artifact the reverted commit addressed, now handled per-pixel instead of over a fixed 30m window that flattened real short grades and disagreed with the popup. The bin/merge logic is extracted into computeElevationColorRuns (pure, unit tested); drawElevationArea just computes the pixel bin width and draws. The map (buildInclineDetail) likewise colors each segment by its exact grade. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent abf908f commit 74215c8

3 files changed

Lines changed: 116 additions & 40 deletions

File tree

src/pathDetails/elevationWidget/ChartRenderer.ts

Lines changed: 5 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
computeDetailYRange,
77
formatDetailTick,
88
} from './axisUtils'
9-
import { getSlopeColor } from './colors'
9+
import { computeElevationColorRuns } from './pathDetailData'
1010

1111
const DEFAULT_MARGIN = { top: 10, right: 15, bottom: 26, left: 48 }
1212
const DETAIL_BAR_HEIGHT = 50
@@ -442,48 +442,14 @@ export default class ChartRenderer {
442442
}
443443

444444
// For long routes the elevation data can have thousands of points, leading to
445-
// hundreds of tiny color-change polygons that create visual noise. To avoid this
446-
// we quantize into bins of at least MIN_BIN_PX pixels wide. Within each bin we
447-
// pick the steepest slope to determine the color — this preserves steep sections
448-
// visually even when they span only a few data points. Adjacent bins with the
449-
// same color are then merged into single polygons to minimize draw calls.
445+
// hundreds of tiny color-change polygons that create visual noise. We quantize
446+
// into bins at least MIN_BIN_PX pixels wide, colored by net grade and merged by
447+
// color (see computeElevationColorRuns), then draw one polygon per run.
450448
const totalDist = elev[elev.length - 1].distance
451449
const plotWidth = xScale(totalDist) - xScale(0)
452450
const MIN_BIN_PX = 1
453451
const minBinDist = (MIN_BIN_PX / plotWidth) * totalDist
454-
const bins: { fromIdx: number; toIdx: number; color: string }[] = []
455-
let binStart = 0
456-
let maxAbsSlope = 0
457-
let steepestSlope = 0
458-
459-
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
462-
const absSlope = Math.abs(slope)
463-
if (absSlope > maxAbsSlope) {
464-
maxAbsSlope = absSlope
465-
steepestSlope = slope
466-
}
467-
468-
const binSpan = elev[i + 1].distance - elev[binStart].distance
469-
if (binSpan >= minBinDist || i === elev.length - 2) {
470-
bins.push({ fromIdx: binStart, toIdx: i + 1, color: getSlopeColor(steepestSlope) })
471-
binStart = i + 1
472-
maxAbsSlope = 0
473-
steepestSlope = 0
474-
}
475-
}
476-
477-
// Merge consecutive bins with the same color
478-
const runs: { fromIdx: number; toIdx: number; color: string }[] = []
479-
for (const bin of bins) {
480-
const last = runs[runs.length - 1]
481-
if (last && last.color === bin.color) {
482-
last.toIdx = bin.toIdx
483-
} else {
484-
runs.push({ ...bin })
485-
}
486-
}
452+
const runs = computeElevationColorRuns(elev, minBinDist)
487453

488454
// Draw each run as a single filled polygon
489455
for (const run of runs) {

src/pathDetails/elevationWidget/pathDetailData.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,46 @@ export interface PathLike {
2323
distance: number
2424
}
2525

26+
export interface ElevationColorRun {
27+
fromIdx: number
28+
toIdx: number
29+
color: string
30+
}
31+
32+
// Quantize an elevation profile into colored runs for the area fill. Points are
33+
// grouped into bins at least minBinDist meters wide and each bin is colored by
34+
// its net (distance-weighted) grade — total elevation change over the bin's
35+
// distance. At normal zoom a bin is a single segment, so the color equals the
36+
// grade the hover popup reports there; when many points share one pixel the net
37+
// grade averages them, so a single noisy sample can't paint the whole pixel as a
38+
// steep climb/descent. Consecutive bins with the same color are merged so the
39+
// renderer draws one polygon per run. Indices refer to the input array.
40+
export function computeElevationColorRuns(elevation: ElevationPoint[], minBinDist: number): ElevationColorRun[] {
41+
const bins: ElevationColorRun[] = []
42+
let binStart = 0
43+
for (let i = 0; i < elevation.length - 1; i++) {
44+
const binSpan = elevation[i + 1].distance - elevation[binStart].distance
45+
if (binSpan >= minBinDist || i === elevation.length - 2) {
46+
const slope =
47+
binSpan > 0 ? (100 * (elevation[i + 1].elevation - elevation[binStart].elevation)) / binSpan : 0
48+
bins.push({ fromIdx: binStart, toIdx: i + 1, color: getSlopeColor(slope) })
49+
binStart = i + 1
50+
}
51+
}
52+
53+
// Merge consecutive bins with the same color.
54+
const runs: ElevationColorRun[] = []
55+
for (const bin of bins) {
56+
const last = runs[runs.length - 1]
57+
if (last && last.color === bin.color) {
58+
last.toIdx = bin.toIdx
59+
} else {
60+
runs.push({ ...bin })
61+
}
62+
}
63+
return runs
64+
}
65+
2666
export function extractElevationPoints(coordinates: number[][]): ElevationPoint[] {
2767
if (coordinates.length === 0) return []
2868
const has3D = coordinates[0].length >= 3
@@ -297,7 +337,7 @@ export function buildInclineDetail(elevation: ElevationPoint[]): ChartPathDetail
297337
return { key: '_incline', label: 'Incline', type: 'bars', segments: [], legend }
298338
}
299339

300-
// Compute slope between consecutive points and assign incline colors
340+
// Color each segment by its exact grade so the map matches the elevation popup.
301341
const raw: PathDetailSegment[] = []
302342
for (let i = 0; i < elevation.length - 1; i++) {
303343
const p = elevation[i]

test/pathDetails/elevationWidget/pathDetailData.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@ import {
44
transformPathDetail,
55
sanitizeNumericValues,
66
buildChartData,
7+
computeElevationColorRuns,
78
} from '@/pathDetails/elevationWidget/pathDetailData'
9+
import { ElevationPoint } from '@/pathDetails/elevationWidget/types'
10+
11+
// Create a list of mock ElevationPoint objects from (distance, elevation) pairs.
12+
function pts(pairs: [number, number][]): ElevationPoint[] {
13+
return pairs.map(([distance, elevation]) => ({ distance, elevation, lng: 0, lat: 0 }))
14+
}
815

916
describe('pathDetailData', () => {
1017
describe('extractElevationPoints', () => {
@@ -182,6 +189,69 @@ describe('pathDetailData', () => {
182189
})
183190
})
184191

192+
describe('computeElevationColorRuns', () => {
193+
const GREEN = '#2E7D32' // -6..3%
194+
const BLUE = '#42A5F5' // -10..-6%
195+
const DARK_BLUE = '#1565C0' // < -10%
196+
const RED = '#F44336' // 6..10%
197+
198+
it('returns no runs for fewer than two points', () => {
199+
expect(computeElevationColorRuns([], 1)).toEqual([])
200+
expect(computeElevationColorRuns(pts([[0, 100]]), 1)).toEqual([])
201+
})
202+
203+
it('colors each segment by its exact grade when bins are per-segment (the reported case)', () => {
204+
// Steep descent into a dip, then a climb out. With a tiny bin width every
205+
// segment is its own bin, so the color is the segment's own grade: the climb
206+
// must read as a climb, not inherit the neighboring descent's color.
207+
const runs = computeElevationColorRuns(
208+
pts([
209+
[0, 100],
210+
[10, 98.5], // -15% -> dark blue
211+
[20, 99.3], // +8% -> red (uphill)
212+
]),
213+
0.001,
214+
)
215+
expect(runs).toEqual([
216+
{ fromIdx: 0, toIdx: 1, color: DARK_BLUE },
217+
{ fromIdx: 1, toIdx: 2, color: RED },
218+
])
219+
})
220+
221+
it('averages a single noisy sample away inside a wide bin', () => {
222+
// 60m of flat ground with one +1m spike. The raw grade at the spike is ±10%,
223+
// but a bin spanning the whole route nets to 0% -> flat, so the spike cannot
224+
// paint the pixel steep.
225+
const runs = computeElevationColorRuns(
226+
pts([
227+
[0, 100],
228+
[10, 100],
229+
[20, 101], // spike
230+
[30, 100],
231+
[40, 100],
232+
[50, 100],
233+
[60, 100],
234+
]),
235+
1000,
236+
)
237+
expect(runs).toEqual([{ fromIdx: 0, toIdx: 6, color: GREEN }])
238+
})
239+
240+
it('merges a sustained steep grade into one run', () => {
241+
// Steady -8% descent: every per-segment bin is blue, so they merge.
242+
const runs = computeElevationColorRuns(
243+
pts([
244+
[0, 100],
245+
[10, 99.2],
246+
[20, 98.4],
247+
[30, 97.6],
248+
]),
249+
0.001,
250+
)
251+
expect(runs).toEqual([{ fromIdx: 0, toIdx: 3, color: BLUE }])
252+
})
253+
})
254+
185255
describe('buildChartData', () => {
186256
it('builds complete chart data', () => {
187257
const path = {

0 commit comments

Comments
 (0)