feat(tours): show the known SAC grades along a hike (MAP-03)

A hike in the planner, being edited or viewed as an import, now has an
"SAC grades" card. Switched on, it asks the router which OpenStreetMap
ways lie under the line (matched on foot, every grade of path open) and
the server reads their sac_scale through the Overpass mirrors, the same
trail-grades read the mountain bike grades use. The grades are drawn
along the route on the planner map in one ramp of a single hue, T1 to
T6, and listed by share and length; a row draws its class alone. Each
row names its source in a tooltip: the SAC hiking scale and the
sac_scale value OpenStreetMap wrote.

What is not known stays unknown and says why, never T1: a way without
a grade, a grade that names no single step (handed on as written), a
stretch with no way clearly under the tour's line, or a way that could
not be asked about. For imported tracks and stretches drawn by hand a
matched way only counts where it keeps within 25 m of the tour's own
line, so the original line is kept and nothing is guessed. The router's
own reading of the scale is not asked for, since it may be normalised.

The routing limit of a planned hike sits apart as a setting with its
own badge, and a known step above it is marked. Nothing about the tour,
its places or its days changes; switched off, the map is as it was.
No regional colour categories and no grade derived from slope, height
or trail markings.
This commit is contained in:
Maurice
2026-10-20 12:25:20 +02:00
parent ad2d653e9a
commit ea9e997338
63 changed files with 2327 additions and 108 deletions
@@ -557,7 +557,7 @@ describe('valhallaTraceAttributes (BIK-01)', () => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ shape: SHAPE, edges: [] })
}))
await valhallaTraceAttributes(SHAPE_POINTS, undefined, 'pedestrian')
await valhallaTraceAttributes(SHAPE_POINTS, undefined, 'run')
expect(bodies[0]).toMatchObject({
costing: 'pedestrian',
costing_options: { pedestrian: { max_hiking_difficulty: 6 } },
@@ -589,6 +589,21 @@ describe('valhallaTraceAttributes (BIK-01)', () => {
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toBeNull()
})
it('VALHALLA-TRACE-006: a hike is matched on foot with every grade of path open, never by its routing limit (MAP-03)', async () => {
const bodies: Record<string, unknown>[] = []
server.use(http.post(TRACE, async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ shape: SHAPE, edges: [] })
}))
await valhallaTraceAttributes(SHAPE_POINTS, undefined, 'foot')
expect(bodies[0]).toMatchObject({
costing: 'pedestrian',
costing_options: { pedestrian: { max_hiking_difficulty: 6 } },
shape_match: 'map_snap',
})
expect(JSON.stringify(bodies[0])).not.toContain('sac_scale')
})
it('VALHALLA-TRACE-004: a caller gone before its turn asks nothing', async () => {
let asked = false
server.use(http.post(TRACE, () => { asked = true; return HttpResponse.json({}) }))
+32 -18
View File
@@ -369,30 +369,46 @@ const TRACE_TIMEOUT_MS = 30_000
/**
* How a line is matched to the ways under it: as a mountain bike rides, the
* bicycle costing that refuses the fewest ways, or on foot with no hiking
* limit, so steps and mountain paths a run takes stay on the line (matched as
* a bike, a trail run over steps was described as the street beside them).
* bicycle costing that refuses the fewest ways, so a road bike's route and a
* gravel one are described alike; or on foot with every grade of path open,
* since a hike's line (MAP-03) may run on a T6 ridge the matcher must not
* refuse, and steps and mountain paths a run takes (RUN-02) stay on the line
* (matched as a bike, a trail run over steps was described as the street
* beside them). Matching is no routing: the limit a hike was planned with
* plays no part. A hike and a run are matched alike; they differ in whether
* the router's own hiking grade is asked for.
*/
export type TraceMatch = 'bicycle' | 'pedestrian'
const TRACE_COSTING = {
bike: { costing: 'bicycle', costing_options: { bicycle: { bicycle_type: 'Mountain' } } },
foot: { costing: 'pedestrian', costing_options: { pedestrian: { max_hiking_difficulty: 6 } } },
run: { costing: 'pedestrian', costing_options: { pedestrian: { max_hiking_difficulty: 6 } } },
} as const
const TRACE_COSTING: Record<TraceMatch, Readonly<Record<string, CostingOptions>>> = {
bicycle: { bicycle: { bicycle_type: 'Mountain' } },
pedestrian: { pedestrian: { max_hiking_difficulty: 6 } },
export type TraceMatching = keyof typeof TRACE_COSTING
const TRACE_ATTRIBUTES = ['shape', 'edge.surface', 'edge.use', 'edge.road_class', 'edge.cycle_lane', 'edge.length',
'edge.way_id', 'edge.begin_shape_index', 'edge.end_shape_index']
/** The router's hiking grade is asked along a bike tour's and a run's line, never a hike's (MAP-03 reads OpenStreetMap). */
function traceAttributesFor(matching: TraceMatching): string[] {
return matching === 'foot' ? TRACE_ATTRIBUTES : [...TRACE_ATTRIBUTES, 'edge.sac_scale']
}
/**
* BIK-01, RUN-02: how the router describes the ways under a line
* BIK-01, RUN-02 and MAP-03: how the router describes the ways under a line
* (`trace_attributes`): the surface, the use and road class, the cycle lane,
* the hiking grade (`sac_scale`, 0 where the way has none) and the
* OpenStreetMap way of every edge, with the line as matched. A bike tour's
* line is matched as a mountain bike would ride it, so a road bike's route and
* a gravel one are described alike; a run's on foot. Null for every failure:
* the description is an addition beside a route that already exists.
* the OpenStreetMap way of every edge and, but for a hike, the router's
* hiking grade (`sac_scale`, 0 where the way has none), with the line as
* matched. A run's grade shares (RUN-02) read the router's grade; the grade a
* hike shows (MAP-03) comes from OpenStreetMap itself, since the router's
* reading may be normalised or derived, so it is not asked for there. Null for
* every failure: the description is an addition beside a route that already
* exists.
*/
export async function valhallaTraceAttributes(
line: readonly [number, number][],
signal?: AbortSignal,
matchAs: TraceMatch = 'bicycle',
matching: TraceMatching = 'bike'
): Promise<unknown> {
const base = valhallaBase()
if (!base || line.length < 2) return null
@@ -407,12 +423,10 @@ export async function valhallaTraceAttributes(
headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
body: JSON.stringify({
shape,
costing: matchAs,
costing_options: TRACE_COSTING[matchAs],
...TRACE_COSTING[matching],
shape_match: 'map_snap',
filters: {
attributes: ['shape', 'edge.surface', 'edge.use', 'edge.road_class', 'edge.cycle_lane', 'edge.sac_scale', 'edge.length',
'edge.way_id', 'edge.begin_shape_index', 'edge.end_shape_index'],
attributes: traceAttributesFor(matching),
action: 'include',
},
}),