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
@@ -10,8 +10,8 @@ import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common';
import { tourTrailGradeResponseSchema } from '@trek/shared';
/**
* BIK-05 POST /api/trips/:tripId/tours/trail-grades: the trail grades of the
* ways a mountain bike tour runs on. A read, so anyone who sees the trip may
* BIK-05 and MAP-03 POST /api/trips/:tripId/tours/trail-grades: the trail
* grades of the ways a tour runs on, mountain bike and SAC alike. A read, so anyone who sees the trip may
* ask; the ways come in the body because the draft may not be saved yet.
*/
@Controller('api/trips/:tripId/tours')
@@ -1,6 +1,12 @@
import { OverpassElementsClient } from '../overpass/overpass-elements.service';
import { Injectable } from '@nestjs/common';
import type { TourTrailGrade, TourTrailGradeRequest, TourTrailGradeResponse } from '@trek/shared';
import {
SAC_SCALE_UNCLEAR_MAX,
sacScaleSchema,
type TourTrailGrade,
type TourTrailGradeRequest,
type TourTrailGradeResponse,
} from '@trek/shared';
import { z } from 'zod';
@@ -24,19 +30,36 @@ function step(value: string | undefined, top: number, signed: boolean): string |
return signed ? `${match[1]}${match[2] ?? ''}` : match[1];
}
/**
* MAP-03: a way's `sac_scale` as one of the scale's six values, read as
* OpenStreetMap wrote it. A value that names no single step (`T3`,
* `hiking;mountain_hiking`, a typo) is handed on as written and never read to
* a step, so the planner says unclear rather than picking one.
*/
function sacOf(value: string | undefined): Pick<TourTrailGrade, 'sac_scale' | 'sac_scale_unclear'> {
const written = value?.trim() ?? '';
if (!written) return { sac_scale: null, sac_scale_unclear: null };
const known = sacScaleSchema.safeParse(written.toLowerCase());
if (known.success) return { sac_scale: known.data, sac_scale_unclear: null };
return { sac_scale: null, sac_scale_unclear: written.slice(0, SAC_SCALE_UNCLEAR_MAX) };
}
function gradeOf(id: number, tags: Record<string, string> = {}): TourTrailGrade | null {
const grade = {
id,
mtb_scale: step(tags['mtb:scale'], 6, true),
mtb_scale_uphill: step(tags['mtb:scale:uphill'], 5, true),
mtb_scale_imba: step(tags['mtb:scale:imba'], 4, false),
...sacOf(tags['sac_scale']),
};
return grade.mtb_scale || grade.mtb_scale_uphill || grade.mtb_scale_imba ? grade : null;
const graded = grade.mtb_scale || grade.mtb_scale_uphill || grade.mtb_scale_imba;
return graded || grade.sac_scale || grade.sac_scale_unclear ? grade : null;
}
/**
* BIK-05: the trail grades of the ways a mountain bike tour runs on, read from
* OpenStreetMap through the operator's Overpass mirrors. What was read is kept
* BIK-05 and MAP-03: the trail grades of the ways a tour runs on (the mountain
* bike scales, and the SAC hiking scale of a hike), read from OpenStreetMap
* through the operator's Overpass mirrors. What was read is kept
* a day, so the same tour asked about again costs the mirrors nothing; ways a
* mirror could not answer are said, never guessed.
*/