diff --git a/client/src/components/Map/valhallaRoute.test.ts b/client/src/components/Map/valhallaRoute.test.ts
index 011b5fd27..2182a8eec 100644
--- a/client/src/components/Map/valhallaRoute.test.ts
+++ b/client/src/components/Map/valhallaRoute.test.ts
@@ -557,7 +557,7 @@ describe('valhallaTraceAttributes (BIK-01)', () => {
bodies.push(await request.json() as Record)
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[] = []
+ server.use(http.post(TRACE, async ({ request }) => {
+ bodies.push(await request.json() as Record)
+ 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({}) }))
diff --git a/client/src/components/Map/valhallaRoute.ts b/client/src/components/Map/valhallaRoute.ts
index ab970d2d2..30cf31b91 100644
--- a/client/src/components/Map/valhallaRoute.ts
+++ b/client/src/components/Map/valhallaRoute.ts
@@ -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>> = {
- 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 {
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',
},
}),
diff --git a/client/src/components/Tours/ReadStatusNotice.tsx b/client/src/components/Tours/ReadStatusNotice.tsx
new file mode 100644
index 000000000..5d26ead77
--- /dev/null
+++ b/client/src/components/Tours/ReadStatusNotice.tsx
@@ -0,0 +1,40 @@
+import { AlertTriangle, Loader2, RefreshCw, WifiOff } from 'lucide-react';
+import { useTranslation } from '../../i18n';
+import { fs } from '../shared/DialogShell';
+import { CardAction, TourNotice } from './tourParts';
+
+/**
+ * How a card's online read stands while it has no answer to show (a bike
+ * tour's ways, a hike's SAC grades): still asking, offline, or failed with a
+ * way to ask again. Nothing while the answer is there, so the card shows it.
+ */
+export function ReadStatusNotice({
+ status,
+ messages,
+ onRetry,
+}: {
+ status: string;
+ messages: { loading: string; offline: string; failed: string };
+ onRetry: () => void;
+}) {
+ const { t } = useTranslation();
+ if (status === 'loading')
+ return (
+
+
+ {messages.loading}
+
+ );
+ if (status === 'offline') return }>{messages.offline};
+ if (status !== 'failed') return null;
+ return (
+ }>
+
+ {messages.failed}
+ } onClick={onRetry}>
+ {t('common.errorRetry')}
+
+
+
+ );
+}
diff --git a/client/src/components/Tours/bike/BikeWaysCard.test.tsx b/client/src/components/Tours/bike/BikeWaysCard.test.tsx
index 21fa030d0..6cbd3e92e 100644
--- a/client/src/components/Tours/bike/BikeWaysCard.test.tsx
+++ b/client/src/components/Tours/bike/BikeWaysCard.test.tsx
@@ -49,6 +49,8 @@ const grade = (id: number, mtb: string | null, uphill: string | null = null): To
mtb_scale: mtb,
mtb_scale_uphill: uphill,
mtb_scale_imba: null,
+ sac_scale: null,
+ sac_scale_unclear: null,
});
function show(state: Partial, overrides: Partial = {}) {
@@ -147,11 +149,11 @@ describe('BikeWaysCard (BIK-01 to BIK-05)', () => {
hike.unmount();
show({}, { routeCurrent: false, mode: { type: 'view-gpx' } } as Partial);
expect(screen.queryByTestId('bike-ways-card')).toBeNull();
- expect(hook.input).toMatchObject({ tripId: 4, active: false, route: null, matchAs: 'bicycle', withGrades: false });
+ expect(hook.input).toMatchObject({ tripId: 4, active: false, route: null, matching: 'bike', withGrades: false });
});
it('FE-TOUR-BIKECARD-009: matches the line as ridden, and asks the trail grades for a mountain bike tour only', () => {
show({}, { bikeProfile: 'mtb' } as Partial);
- expect(hook.input).toMatchObject({ active: true, route: LINE, matchAs: 'bicycle', withGrades: true });
+ expect(hook.input).toMatchObject({ active: true, route: LINE, matching: 'bike', withGrades: true });
});
});
diff --git a/client/src/components/Tours/bike/BikeWaysCard.tsx b/client/src/components/Tours/bike/BikeWaysCard.tsx
index 4cd579aa8..fc3ed0897 100644
--- a/client/src/components/Tours/bike/BikeWaysCard.tsx
+++ b/client/src/components/Tours/bike/BikeWaysCard.tsx
@@ -155,7 +155,7 @@ export function BikeWaysCard({ planner }: { planner: TourPlannerController }) {
tripId: planner.tripId,
active: planner.isBike && editing,
route,
- matchAs: 'bicycle',
+ matching: 'bike',
withGrades: planner.bikeProfile === 'mtb',
});
const { ways } = state;
diff --git a/client/src/components/Tours/bike/RouteWaySections.tsx b/client/src/components/Tours/bike/RouteWaySections.tsx
index 5cfc7d5f7..cc6174d4c 100644
--- a/client/src/components/Tours/bike/RouteWaySections.tsx
+++ b/client/src/components/Tours/bike/RouteWaySections.tsx
@@ -1,11 +1,12 @@
-import { AlertTriangle, Info, Loader2, RefreshCw, WifiOff } from 'lucide-react';
+import { Info } from 'lucide-react';
import { useMemo, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import { formatDistance } from '../../../utils/units';
import { fs } from '../../shared/DialogShell';
-import { CardAction, InfoDot, TourNotice } from '../tourParts';
+import { ReadStatusNotice } from '../ReadStatusNotice';
+import { InfoDot } from '../tourParts';
import { bikePaintCss, surfacePaintKey, type BikePaintKey } from './bikePaint';
import {
sharesBy,
@@ -202,25 +203,16 @@ export function WaySection({ ways }: { ways: RouteWays }) {
/** How reading the ways went while there are none to show: on its way, offline, or failed with a way to ask again. */
export function RouteWaysProgress({ state }: { state: Pick & { retry: () => void } }) {
const { t } = useTranslation();
- if (state.status === 'loading')
- return (
-
-
- {t('tours.bike.ways.loading')}
-
- );
- if (state.status === 'offline')
- return }>{t('tours.bike.ways.offline')};
- if (state.status !== 'failed') return null;
return (
- }>
-
- {t('tours.bike.ways.failed')}
- } onClick={state.retry}>
- {t('common.errorRetry')}
-
-
-
+
);
}
diff --git a/client/src/components/Tours/bike/routeHighlight.ts b/client/src/components/Tours/bike/routeHighlight.ts
index 552bf5ebe..7d570c217 100644
--- a/client/src/components/Tours/bike/routeHighlight.ts
+++ b/client/src/components/Tours/bike/routeHighlight.ts
@@ -1,8 +1,9 @@
import type { RouteHighlight } from '../../../store/tourRouteHighlightStore';
-import type { BikePaintKey } from './bikePaint';
type Line = [number, number][];
type LineColor = { line: string; casing: string } | undefined;
+/** The route paints resolved for the map, by key: the bike's and the hike's, with the accent and the rim. */
+type RoutePaint = Readonly> & { readonly way: string; readonly casing: string };
/**
* The planner map's lines with a highlight laid over them: the route's own
@@ -15,13 +16,16 @@ export function withRouteHighlight(
route: Line[] | null,
walking: boolean[] | null,
colors: LineColor[] | null,
- paint: Record
+ paint: RoutePaint
): { route: Line[] | null; walking: boolean[] | null; colors: LineColor[] | null } {
if (!highlight?.lines.length || !route?.length) return { route, walking, colors };
- const color = { line: paint[highlight.paint as BikePaintKey] ?? paint.way, casing: paint.casing };
+ const colorOf = (index: number) => ({
+ line: paint[highlight.paints?.[index] ?? highlight.paint] ?? paint.way,
+ casing: paint.casing,
+ });
return {
route: [...route, ...highlight.lines],
walking: walking ? [...walking, ...highlight.lines.map(() => false)] : null,
- colors: [...(colors ?? route.map(() => undefined)), ...highlight.lines.map(() => color)],
+ colors: [...(colors ?? route.map(() => undefined)), ...highlight.lines.map((_, index) => colorOf(index))],
};
}
diff --git a/client/src/components/Tours/bike/routeWays.test.ts b/client/src/components/Tours/bike/routeWays.test.ts
index ea3a94f53..e33df96d0 100644
--- a/client/src/components/Tours/bike/routeWays.test.ts
+++ b/client/src/components/Tours/bike/routeWays.test.ts
@@ -13,7 +13,7 @@ import {
type RouteWays,
} from './routeWays';
-// FE-TOUR-WAYS-001 to FE-TOUR-WAYS-013
+// FE-TOUR-WAYS-001 to FE-TOUR-WAYS-014
const LINE: [number, number][] = [
[47.0, 11.0],
@@ -186,4 +186,19 @@ describe('the paints and the highlight (BIK-01 to BIK-05)', () => {
expect(withRouteHighlight({ id: 'x', lines: [], paint: 'gravel' }, route, null, null, paint).route).toBe(route);
expect(withRouteHighlight({ id: 'x', lines: [LINE], paint: 'gravel' }, null, null, null, paint).route).toBeNull();
});
+
+ it('FE-TOUR-WAYS-014: lines of different paints each take their own, an unknown key the accent (MAP-03)', () => {
+ const drawn = withRouteHighlight(
+ { id: 'sac', lines: [LINE.slice(0, 2), LINE.slice(1, 3), LINE.slice(2)], paint: 'gravel', paints: ['way', 'nope'] },
+ [LINE],
+ null,
+ null,
+ paint
+ );
+ expect(drawn.colors?.slice(1)).toEqual([
+ { line: '#111', casing: '#fff' },
+ { line: '#111', casing: '#fff' },
+ { line: '#a0522d', casing: '#fff' },
+ ]);
+ });
});
diff --git a/client/src/components/Tours/bike/useHighlightedRoute.test.ts b/client/src/components/Tours/bike/useHighlightedRoute.test.ts
index 7009828e9..9ea649366 100644
--- a/client/src/components/Tours/bike/useHighlightedRoute.test.ts
+++ b/client/src/components/Tours/bike/useHighlightedRoute.test.ts
@@ -8,7 +8,7 @@ const LINE: [number, number][] = [
[47.01, 11],
];
-describe('useHighlightedRoute (BIK-01 to BIK-05)', () => {
+describe('useHighlightedRoute (BIK-01 to BIK-05, MAP-03)', () => {
it('lays the highlight over the route while shown, and leaves the route alone otherwise', () => {
act(() => useTourRouteHighlightStore.getState().show({ id: 'surface-gravel', lines: [LINE], paint: 'gravel' }));
const shown = renderHook(() => useHighlightedRoute(true, [LINE], null, null));
@@ -19,4 +19,19 @@ describe('useHighlightedRoute (BIK-01 to BIK-05)', () => {
act(() => useTourRouteHighlightStore.getState().clear());
expect(shown.result.current.route).toEqual([LINE]);
});
+
+ it('draws the SAC grades of a hike in their own paints beside the bike paints (MAP-03)', () => {
+ act(() =>
+ useTourRouteHighlightStore
+ .getState()
+ .show({ id: 'sac', lines: [LINE, LINE], paint: 'unknown', paints: ['t3', 'unknown'] })
+ );
+ const { result } = renderHook(() => useHighlightedRoute(true, [LINE], null, null));
+ expect(result.current.route).toHaveLength(3);
+ expect(result.current.colors?.slice(1)).toEqual([
+ { line: expect.any(String), casing: expect.any(String) },
+ { line: expect.any(String), casing: expect.any(String) },
+ ]);
+ act(() => useTourRouteHighlightStore.getState().clear());
+ });
});
diff --git a/client/src/components/Tours/bike/useHighlightedRoute.ts b/client/src/components/Tours/bike/useHighlightedRoute.ts
index 077b2667c..92325242a 100644
--- a/client/src/components/Tours/bike/useHighlightedRoute.ts
+++ b/client/src/components/Tours/bike/useHighlightedRoute.ts
@@ -1,4 +1,6 @@
+import { useMemo } from 'react';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
+import { useHikePaint } from '../hike/hikePaint';
import { useBikePaint } from './bikePaint';
import { withRouteHighlight } from './routeHighlight';
@@ -7,12 +9,14 @@ type Walking = Parameters[2];
type Colors = Parameters[3];
/**
- * BIK-01 to BIK-05: the planner map's route with the stretches a bike tour's
- * card points at laid over it, while `shown` (the route being edited is on
- * the map); otherwise the lines as they came.
+ * BIK-01 to BIK-05 and MAP-03: the planner map's route with the stretches a
+ * bike tour's or a hike's card points at laid over it, while `shown` (the
+ * tour's own line is on the map); otherwise the lines as they came.
*/
export function useHighlightedRoute(shown: boolean, route: Lines, walking: Walking, colors: Colors) {
const highlight = useTourRouteHighlightStore((state) => state.highlight);
- const paint = useBikePaint();
+ const bike = useBikePaint();
+ const hike = useHikePaint();
+ const paint = useMemo(() => ({ ...bike, ...hike }), [bike, hike]);
return withRouteHighlight(shown ? highlight : null, route, walking, colors, paint);
}
diff --git a/client/src/components/Tours/bike/useRouteWays.test.tsx b/client/src/components/Tours/bike/useRouteWays.test.tsx
index 4d95f85a2..b7d7f394f 100644
--- a/client/src/components/Tours/bike/useRouteWays.test.tsx
+++ b/client/src/components/Tours/bike/useRouteWays.test.tsx
@@ -12,7 +12,7 @@ vi.mock('../../Map/valhallaRoute', () => trace);
vi.mock('../../../repo/tourTrailGradeRepo', () => ({ tourTrailGradeRepo: gradesRepo }));
vi.mock('../../../hooks/useNetworkMode', () => ({ useNetworkMode: () => ({ offline: network.offline }) }));
-// FE-TOUR-WAYS-HOOK-001 to FE-TOUR-WAYS-HOOK-009
+// FE-TOUR-WAYS-HOOK-001 to FE-TOUR-WAYS-HOOK-011
const ROUTE: [number, number][] = [
[47.0, 11.0],
@@ -32,7 +32,7 @@ const input = (overrides: Partial = {}): Input => ({
tripId: 3,
active: true,
route: ROUTE,
- matchAs: 'bicycle',
+ matching: 'bike',
withGrades: false,
...overrides,
});
@@ -66,7 +66,7 @@ describe('useRouteWays (BIK-01 to BIK-05, RUN-02)', () => {
expect(result.current.status).toBe('loading');
expect(trace.valhallaTraceAttributes).not.toHaveBeenCalled();
await settle();
- expect(trace.valhallaTraceAttributes).toHaveBeenCalledWith(ROUTE, expect.any(AbortSignal), 'bicycle');
+ expect(trace.valhallaTraceAttributes).toHaveBeenCalledWith(ROUTE, expect.any(AbortSignal), 'bike');
expect(result.current.status).toBe('ready');
expect(result.current.ways?.edges.map((edge) => edge.surface)).toEqual(['gravel', 'ground']);
expect(result.current.grades).toBeNull();
@@ -75,7 +75,9 @@ describe('useRouteWays (BIK-01 to BIK-05, RUN-02)', () => {
it('FE-TOUR-WAYS-HOOK-003: a mountain bike tour asks the grades of its ways, and keeps what could not be asked apart', async () => {
gradesRepo.grades.mockResolvedValue({
- ways: [{ id: 11, mtb_scale: '2', mtb_scale_uphill: null, mtb_scale_imba: null }],
+ ways: [
+ { id: 11, mtb_scale: '2', mtb_scale_uphill: null, mtb_scale_imba: null, sac_scale: null, sac_scale_unclear: null },
+ ],
unanswered: [12],
});
const { result } = renderHook(() => useRouteWays(input({ withGrades: true })));
@@ -127,17 +129,17 @@ describe('useRouteWays (BIK-01 to BIK-05, RUN-02)', () => {
expect(trace.valhallaTraceAttributes).not.toHaveBeenCalled();
});
- it('FE-TOUR-WAYS-HOOK-009: a run is matched on foot, and matched again when asked another way (RUN-02)', async () => {
+ it('FE-TOUR-WAYS-HOOK-011: a run is matched on foot, and matched again when asked another way (RUN-02)', async () => {
const { result, rerender } = renderHook((props: Input) => useRouteWays(props), {
- initialProps: input({ matchAs: 'pedestrian' }),
+ initialProps: input({ matching: 'run' }),
});
await settle();
- expect(trace.valhallaTraceAttributes).toHaveBeenLastCalledWith(ROUTE, expect.any(AbortSignal), 'pedestrian');
+ expect(trace.valhallaTraceAttributes).toHaveBeenLastCalledWith(ROUTE, expect.any(AbortSignal), 'run');
expect(result.current.status).toBe('ready');
- rerender(input({ matchAs: 'bicycle' }));
+ rerender(input({ matching: 'bike' }));
expect(result.current.status).toBe('loading');
await settle();
- expect(trace.valhallaTraceAttributes).toHaveBeenLastCalledWith(ROUTE, expect.any(AbortSignal), 'bicycle');
+ expect(trace.valhallaTraceAttributes).toHaveBeenLastCalledWith(ROUTE, expect.any(AbortSignal), 'bike');
});
it('FE-TOUR-WAYS-HOOK-008: a new route drops the highlight and the old answer, and leaving takes the highlight down', async () => {
@@ -152,4 +154,16 @@ describe('useRouteWays (BIK-01 to BIK-05, RUN-02)', () => {
unmount();
expect(useTourRouteHighlightStore.getState().highlight).toBeNull();
});
+
+ it('FE-TOUR-WAYS-HOOK-009: a hike is matched on foot, and a change of matching asks again (MAP-03)', async () => {
+ gradesRepo.grades.mockResolvedValue({ ways: [], unanswered: [] });
+ const { result, rerender } = renderHook((props: Input) => useRouteWays(props), {
+ initialProps: input({ matching: 'foot', withGrades: true }),
+ });
+ await settle();
+ expect(trace.valhallaTraceAttributes).toHaveBeenCalledWith(ROUTE, expect.any(AbortSignal), 'foot');
+ expect(result.current.grades?.status).toBe('ready');
+ rerender(input({ matching: 'bike', withGrades: true }));
+ expect(result.current.status).toBe('loading');
+ });
});
diff --git a/client/src/components/Tours/bike/useRouteWays.ts b/client/src/components/Tours/bike/useRouteWays.ts
index 99fe3c565..e8a399174 100644
--- a/client/src/components/Tours/bike/useRouteWays.ts
+++ b/client/src/components/Tours/bike/useRouteWays.ts
@@ -4,7 +4,7 @@ import { useNetworkMode } from '../../../hooks/useNetworkMode';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { tourTrailGradeRepo } from '../../../repo/tourTrailGradeRepo';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
-import { valhallaTraceAttributes, type TraceMatch } from '../../Map/valhallaRoute';
+import { valhallaTraceAttributes, type TraceMatching } from '../../Map/valhallaRoute';
import { routeWaysFrom, wayIdsOf, type RouteWays } from './routeWays';
/** How long a route has to stay before its ways are asked about, so a route being shaped asks once. */
@@ -22,7 +22,7 @@ export interface TrailGrades {
export interface RouteWaysState {
status: RouteWaysStatus;
ways: RouteWays | null;
- /** BIK-05, a mountain bike tour's only: the trail grades of its ways. */
+ /** BIK-05 and MAP-03, where asked for: the trail grades of its ways. */
grades: TrailGrades | null;
}
@@ -30,31 +30,27 @@ interface Answer extends RouteWaysState {
attempt: number;
/** The route and the question it answers; another one makes it stale. */
route: [number, number][];
- matchAs: TraceMatch;
+ matching: TraceMatching;
withGrades: boolean;
}
const IDLE: RouteWaysState = { status: 'idle', ways: null, grades: null };
const NO_GRADES = { byWay: new Map(), unasked: new Set() };
-interface RouteWaysInput {
+interface Question {
tripId: number | string;
- active: boolean;
- route: [number, number][] | null;
- /** How the line is matched to its ways: a bike tour as ridden, a run on foot. */
- matchAs: TraceMatch;
- /** BIK-05, a mountain bike tour's only: the trail grades of its ways are asked too. */
+ route: [number, number][];
+ matching: TraceMatching;
withGrades: boolean;
}
-/** The ways of a route, and for a mountain bike tour their grades; the grades' outcome as it came. */
+/** The ways of a route, and where asked for their grades; the grades' outcome as it came. */
async function askRouteWays(
- question: Pick & { route: [number, number][] },
+ { tripId, route, matching, withGrades }: Question,
signal: AbortSignal,
onWays: (ways: RouteWays) => void
): Promise {
- const { tripId, route, matchAs, withGrades } = question;
- const ways = routeWaysFrom(await valhallaTraceAttributes(route, signal, matchAs));
+ const ways = routeWaysFrom(await valhallaTraceAttributes(route, signal, matching));
if (!ways) return { status: 'failed', ways: null, grades: null };
if (!withGrades) return { status: 'ready', ways, grades: null };
onWays(ways);
@@ -71,13 +67,21 @@ async function askRouteWays(
}
/**
- * BIK-01 to BIK-05, RUN-02: what the bike tour or the run being planned runs
- * on, asked of the router once its route has settled, and for a mountain bike
- * tour the trail grades of its ways. A new route makes the answer on its way
- * stale and takes the map's highlight down; nothing of it changes the tour.
+ * BIK-01 to BIK-05 and MAP-03: what the tour being planned runs on, asked of
+ * the router once its route has settled, and where asked for the trail grades
+ * of its ways (a mountain bike tour's, a hike's SAC grades). A new route makes
+ * the answer on its way stale and takes the map's highlight down; nothing of
+ * it changes the tour.
*/
-export function useRouteWays(input: RouteWaysInput): RouteWaysState & { retry: () => void } {
- const { tripId, active, route, matchAs, withGrades } = input;
+export function useRouteWays(input: {
+ tripId: number | string;
+ active: boolean;
+ route: [number, number][] | null;
+ /** How the line is matched to its ways: as a bike rides it, or on foot for a hike. */
+ matching: TraceMatching;
+ withGrades: boolean;
+}): RouteWaysState & { retry: () => void } {
+ const { tripId, active, route, matching, withGrades } = input;
const { offline } = useNetworkMode();
const [answer, setAnswer] = useState(null);
const [attempt, setAttempt] = useState(0);
@@ -89,10 +93,10 @@ export function useRouteWays(input: RouteWaysInput): RouteWaysState & { retry: (
if (!asking || !route) return;
const controller = new AbortController();
const settle = (state: RouteWaysState) => {
- if (!controller.signal.aborted) setAnswer({ ...state, route, matchAs, withGrades, attempt });
+ if (!controller.signal.aborted) setAnswer({ ...state, route, matching, withGrades, attempt });
};
const timer = window.setTimeout(() => {
- askRouteWays({ tripId, route, matchAs, withGrades }, controller.signal, (ways) =>
+ askRouteWays({ tripId, route, matching, withGrades }, controller.signal, (ways) =>
settle({ status: 'ready', ways, grades: { status: 'loading', ...NO_GRADES } })
)
.then(settle)
@@ -103,7 +107,7 @@ export function useRouteWays(input: RouteWaysInput): RouteWaysState & { retry: (
window.clearTimeout(timer);
controller.abort();
};
- }, [asking, attempt, matchAs, route, tripId, withGrades]);
+ }, [asking, attempt, matching, route, tripId, withGrades]);
// The highlight belongs to the route it was drawn on.
useEffect(() => () => useTourRouteHighlightStore.getState().clear(), []);
@@ -112,7 +116,7 @@ export function useRouteWays(input: RouteWaysInput): RouteWaysState & { retry: (
if (offline) return { status: 'offline', ways: null, grades: null, retry };
const current =
answer?.route === route &&
- answer.matchAs === matchAs &&
+ answer.matching === matching &&
answer.withGrades === withGrades &&
answer.attempt === attempt;
return current ? { ...answer, retry } : { status: 'loading', ways: null, grades: null, retry };
diff --git a/client/src/components/Tours/hike/SacGradesCard.test.tsx b/client/src/components/Tours/hike/SacGradesCard.test.tsx
new file mode 100644
index 000000000..403ec97b1
--- /dev/null
+++ b/client/src/components/Tours/hike/SacGradesCard.test.tsx
@@ -0,0 +1,135 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
+import { SacGradesCard } from './SacGradesCard';
+import type { SacGradeRow, SacGrades } from './useSacGrades';
+
+const hook = vi.hoisted(() => ({ grades: null as unknown, input: null as unknown }));
+vi.mock('./useSacGrades', () => ({
+ useSacGrades: (input: unknown) => {
+ hook.input = input;
+ return hook.grades;
+ },
+}));
+
+// FE-HIKE-SACCARD-001 to FE-HIKE-SACCARD-007
+
+const LINE: [number, number][] = [
+ [47.0, 11.0],
+ [47.01, 11.0],
+];
+const ROWS: SacGradeRow[] = [
+ { key: 't2', lengthM: 1200, share: 0.4, tags: ['mountain_hiking'] },
+ { key: 't4', lengthM: 600, share: 0.2, tags: ['alpine_hiking'] },
+ { key: 'unclear', lengthM: 300, share: 0.1, tags: ['T3', 'hiking;mountain_hiking'] },
+ { key: 'untagged', lengthM: 700, share: 0.2, tags: [] },
+ { key: 'unmatched', lengthM: 100, share: 0.05, tags: [] },
+ { key: 'unasked', lengthM: 100, share: 0.05, tags: [] },
+];
+
+function grades(overrides: Partial = {}): SacGrades {
+ return {
+ available: true,
+ shown: true,
+ toggle: vi.fn(),
+ status: 'ready',
+ rows: ROWS,
+ knownShare: 0.6,
+ highest: 4,
+ focus: null,
+ focusOn: vi.fn(),
+ retry: vi.fn(),
+ ...overrides,
+ };
+}
+
+function show(state: SacGrades, routingLimit: number | null = 3) {
+ hook.grades = state;
+ return render();
+}
+
+beforeEach(() => localStorage.clear());
+
+describe('SacGradesCard (MAP-03)', () => {
+ it('FE-HIKE-SACCARD-001: without a line to grade there is no card', () => {
+ show(grades({ available: false, shown: false, status: 'off', rows: [] }));
+ expect(screen.queryByTestId('sac-grades-card')).toBeNull();
+ expect(hook.input).toEqual({ tripId: 4, route: LINE, current: true });
+ });
+
+ it('FE-HIKE-SACCARD-002: switched off it only offers the switch, and the switch turns it on', () => {
+ const state = grades({ shown: false, status: 'off', rows: [] });
+ show(state);
+ const toggle = screen.getByRole('button', { name: 'Show along the route' });
+ expect(toggle).toHaveAttribute('aria-pressed', 'false');
+ expect(screen.queryByTestId('sac-row-t2')).toBeNull();
+ fireEvent.click(toggle);
+ expect(state.toggle).toHaveBeenCalledTimes(1);
+ });
+
+ it('FE-HIKE-SACCARD-003: lists the known steps with their share, the unknown apart, and the routing limit as a setting', () => {
+ show(grades());
+ expect(within(screen.getByTestId('sac-row-t2')).getByText('T2')).toBeInTheDocument();
+ expect(screen.getByTestId('sac-row-t2')).toHaveTextContent('Mountain hiking');
+ expect(screen.getByTestId('sac-row-t2')).toHaveTextContent('40 %');
+ expect(within(screen.getByTestId('sac-row-untagged')).getByText('?')).toBeInTheDocument();
+ expect(screen.getByText('Unknown')).toBeInTheDocument();
+ expect(screen.getByTestId('sac-known')).toHaveTextContent('60 %');
+ expect(screen.getByTestId('sac-highest')).toHaveTextContent('T4');
+ expect(screen.getByTestId('sac-routing-limit')).toHaveTextContent('Routing limit');
+ expect(screen.getByTestId('sac-routing-limit')).toHaveTextContent('T3');
+ // Only a known step above the limit says so; the unknown never does.
+ expect(within(screen.getByTestId('sac-row-t4')).getByText('Above the limit')).toBeInTheDocument();
+ expect(within(screen.getByTestId('sac-row-t2')).queryByText('Above the limit')).toBeNull();
+ expect(within(screen.getByTestId('sac-row-untagged')).queryByText('Above the limit')).toBeNull();
+ });
+
+ it('FE-HIKE-SACCARD-004: an import, planned without a routing limit, shows none and nothing above it', () => {
+ show(grades({ highest: null, rows: ROWS.slice(2), knownShare: 0 }), null);
+ expect(screen.queryByTestId('sac-routing-limit')).toBeNull();
+ expect(screen.queryByTestId('sac-highest')).toBeNull();
+ expect(screen.queryByText('Above the limit')).toBeNull();
+ expect(screen.getByTestId('sac-known')).toHaveTextContent('0 %');
+ });
+
+ it('FE-HIKE-SACCARD-005: a row draws its class alone on the map, and shows that it does', () => {
+ const state = grades({ focus: 't4' });
+ show(state);
+ expect(screen.getByTestId('sac-row-t4')).toHaveAttribute('aria-pressed', 'true');
+ expect(screen.getByTestId('sac-row-t2')).toHaveAttribute('aria-pressed', 'false');
+ fireEvent.click(screen.getByTestId('sac-row-t2'));
+ expect(state.focusOn).toHaveBeenCalledWith('t2');
+ });
+
+ it('FE-HIKE-SACCARD-006: says when it is asking, offline, or failed with a way to ask again', () => {
+ const loading = show(grades({ status: 'loading', rows: [] }));
+ expect(screen.getByRole('status')).toHaveTextContent('Reading the SAC grades');
+ loading.unmount();
+ const offline = show(grades({ status: 'offline', rows: [] }));
+ expect(screen.getByText('The SAC grades need a connection.')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /try again/i })).toBeNull();
+ offline.unmount();
+ const state = grades({ status: 'failed', rows: [] });
+ show(state);
+ expect(screen.getByText(/The tour is unchanged/)).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: /try again/i }));
+ expect(state.retry).toHaveBeenCalledTimes(1);
+ });
+
+ it('FE-HIKE-SACCARD-007: a row names its scale and its source, an unclear one what OpenStreetMap wrote', async () => {
+ show(grades());
+ fireEvent.mouseEnter(screen.getByTestId('sac-row-t2'));
+ const source = 'SAC hiking scale T2, from OpenStreetMap: sac_scale=mountain_hiking';
+ expect(await screen.findByText(source)).toBeInTheDocument();
+ fireEvent.mouseLeave(screen.getByTestId('sac-row-t2'));
+ fireEvent.mouseEnter(screen.getByTestId('sac-row-unclear'));
+ expect(await screen.findByText(/T3, hiking;mountain_hiking/)).toBeInTheDocument();
+ fireEvent.mouseLeave(screen.getByTestId('sac-row-unclear'));
+ fireEvent.mouseEnter(screen.getByTestId('sac-row-untagged'));
+ expect(await screen.findByText(/Unknown does not mean easy/)).toBeInTheDocument();
+ fireEvent.mouseLeave(screen.getByTestId('sac-row-untagged'));
+ fireEvent.mouseEnter(screen.getByTestId('sac-row-unmatched'));
+ expect(await screen.findByText(/no way lies clearly under the line/i)).toBeInTheDocument();
+ // A way that could not be asked about says so in its row; there is nothing more to name.
+ expect(screen.getByTestId('sac-row-unasked')).toHaveTextContent('Could not be asked this time');
+ });
+});
diff --git a/client/src/components/Tours/hike/SacGradesCard.tsx b/client/src/components/Tours/hike/SacGradesCard.tsx
new file mode 100644
index 000000000..69c92b548
--- /dev/null
+++ b/client/src/components/Tours/hike/SacGradesCard.tsx
@@ -0,0 +1,218 @@
+import { Info, MountainSnow, SlidersHorizontal } from 'lucide-react';
+import { useTranslation } from '../../../i18n';
+import { useSettingsStore } from '../../../store/settingsStore';
+import { formatDistance } from '../../../utils/units';
+import { SoftPill } from '../../Planner/planParts';
+import ToggleSwitch from '../../Settings/ToggleSwitch';
+import { fs } from '../../shared/DialogShell';
+import { Tooltip } from '../../shared/Tooltip';
+import { bikePaintCss } from '../bike/bikePaint';
+import { FigureBadge } from '../paddle/FigureBadge';
+import { ReadStatusNotice } from '../ReadStatusNotice';
+import { RailCard } from '../tourParts';
+import { hikePaintCss } from './hikePaint';
+import { isSacStep, sacStepOfClass, type SacClass } from './sacGrades';
+import { useSacGrades, type SacGradeRow, type SacGrades } from './useSacGrades';
+
+const percent = (share: number) => `${Math.round(share * 100)} %`;
+const stepName = (step: number) => `T${step}`;
+
+/** The colour a class is drawn in on the map, as a CSS variable: the step's own, the grey of the unknown otherwise. */
+const paintOf = (key: SacClass) => (isSacStep(key) ? hikePaintCss(key) : bikePaintCss('unknown'));
+
+/** A grade as a small white chip: a dot in its map colour and the step, or a question mark for the unknown. */
+function GradeChip({ grade }: { grade: SacClass }) {
+ const step = sacStepOfClass(grade);
+ return (
+
+
+
+ {step === null ? '?' : stepName(step)}
+
+
+ );
+}
+
+/** Where a row's grade comes from, said in its tooltip: the scale and the tag, or why it is unknown. */
+function useRowSource(row: SacGradeRow): string {
+ const { t } = useTranslation();
+ const step = sacStepOfClass(row.key);
+ if (step !== null) return t('tours.hike.sac.stepTip', { step: stepName(step), tag: row.tags[0] ?? '' });
+ if (row.key === 'untagged') return t('tours.hike.sac.untaggedTip');
+ if (row.key === 'unclear') return t('tours.hike.sac.unclearTip', { tags: row.tags.join(', ') });
+ if (row.key === 'unmatched') return t('tours.hike.sac.unmatchedTip');
+ return '';
+}
+
+/**
+ * One class of the hike: its chip, its name, how far and how much of the
+ * route it covers. Pressed, the map draws its stretches alone; pressed again,
+ * every grade again.
+ */
+function GradeRow({ row, grades, limit }: { row: SacGradeRow; grades: SacGrades; limit: number | null }) {
+ const { t } = useTranslation();
+ const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
+ const source = useRowSource(row);
+ const step = sacStepOfClass(row.key);
+ const pressed = grades.focus === row.key;
+ return (
+
+
+
+
+
+ );
+}
+
+/** The classes as one bar along the route's length, in the map's colours, so the mix reads at a glance. */
+function GradeBar({ rows }: { rows: SacGradeRow[] }) {
+ return (
+
+ {rows.map((row) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * What is known at a glance: how much of the route has a grade, the highest
+ * one, and apart from them, marked as a setting, the routing limit the hike
+ * was planned with, which says nothing about the ways themselves.
+ */
+function GradeSummary({ grades, limit }: { grades: SacGrades; limit: number | null }) {
+ const { t } = useTranslation();
+ return (
+
+ )}
+
+
+ {t('tours.hike.sac.source')}
+
+ >
+ );
+}
+
+/**
+ * MAP-03: the SAC grades along a hike, on the traveller's word. Switched on,
+ * the grades OpenStreetMap gives the ways under the line are read and drawn
+ * along it on the map, each class with its share; the unknown stays unknown
+ * and says why. The routing limit, where the hike was planned with one, sits
+ * apart as the setting it is. Nothing here changes the tour.
+ */
+export function SacGradesCard({
+ tripId,
+ route,
+ routeCurrent = true,
+ routingLimit,
+}: {
+ tripId: number | string;
+ /** The tour's own line, or null while there is none. */
+ route: [number, number][] | null;
+ /** False while the line is being planned anew. */
+ routeCurrent?: boolean;
+ /** The routing limit the hike is planned with, or null where it was not routed (an import). */
+ routingLimit: number | null;
+}) {
+ const { t } = useTranslation();
+ const grades = useSacGrades({ tripId, route, current: routeCurrent });
+ if (!grades.available) return null;
+ return (
+ } title={t('tours.hike.sac.title')} foldId="hike-sac">
+
@@ -470,6 +474,11 @@ export function TourPlannerRail({ planner,
+ {planner.tourType === 'hike' && (
+
+ )}
+
diff --git a/client/src/components/Tours/run/RunWaysCard.test.tsx b/client/src/components/Tours/run/RunWaysCard.test.tsx
index 9bebd3918..6c27c47c7 100644
--- a/client/src/components/Tours/run/RunWaysCard.test.tsx
+++ b/client/src/components/Tours/run/RunWaysCard.test.tsx
@@ -116,7 +116,7 @@ describe('RunWaysCard (RUN-02)', () => {
tripId: 4,
active: true,
route: LINE,
- matchAs: 'pedestrian',
+ matching: 'run',
withGrades: false,
});
run.unmount();
diff --git a/client/src/components/Tours/run/RunWaysCard.tsx b/client/src/components/Tours/run/RunWaysCard.tsx
index 765705966..29022ee42 100644
--- a/client/src/components/Tours/run/RunWaysCard.tsx
+++ b/client/src/components/Tours/run/RunWaysCard.tsx
@@ -99,7 +99,7 @@ export function RunWaysCard({ planner }: { planner: RunWaysPlanner }) {
tripId: planner.tripId,
active: planner.isRun && editing,
route: planner.routeCurrent ? planner.route : null,
- matchAs: 'pedestrian',
+ matching: 'run',
withGrades: false,
});
const { ways } = state;
diff --git a/client/src/main.tsx b/client/src/main.tsx
index ad84138d4..da06fdd24 100644
--- a/client/src/main.tsx
+++ b/client/src/main.tsx
@@ -29,6 +29,7 @@ import './styles/ferratas.css'
import './styles/paddle.css'
import './styles/bike.css'
import './styles/horse.css'
+import './styles/hike.css'
// Native HTML5 drag-and-drop never fires on touch input, so the planner's place /
// day reordering was dead on Android and iOS. The `drag-drop-touch` polyfill synthesises
// the standard drag events from touch gestures over draggable elements (#1265). It is
diff --git a/client/src/pages/tripPlanner/useTripPlannerPage.ts b/client/src/pages/tripPlanner/useTripPlannerPage.ts
index a3a92e287..6a0484412 100644
--- a/client/src/pages/tripPlanner/useTripPlannerPage.ts
+++ b/client/src/pages/tripPlanner/useTripPlannerPage.ts
@@ -76,8 +76,10 @@ export function useTripPlannerPage() {
{ portage: paddlePaint.portage, rapids: paddlePaint.rapids, gap: paddlePaint.gap, casing: paddlePaint.disc },
)
const routeWalking = partStyle.walking ?? (route && route.length === 2 && tourConversion.line ? [true, false] : null)
- // BIK-01 to BIK-05: the stretches a bike tour's card points at, over the route being edited.
- const drawn = useHighlightedRoute(editingRoute && !wandererBrowser.open, route, routeWalking, partStyle.colors)
+ // BIK-01 to BIK-05 and MAP-03: the stretches a tour's card points at, over the tour's own line,
+ // the one being edited or an import being viewed, never over a preview beside it.
+ const ownLine = editingRoute || (tourPlanner.mode.type === 'view-gpx' && !tourConversion.line)
+ const drawn = useHighlightedRoute(ownLine && !wandererBrowser.open, route, routeWalking, partStyle.colors)
const { closeGpxTour } = tourPlanner
useEffect(() => {
diff --git a/client/src/store/tourRouteHighlightStore.ts b/client/src/store/tourRouteHighlightStore.ts
index ba84b266e..b1d08477f 100644
--- a/client/src/store/tourRouteHighlightStore.ts
+++ b/client/src/store/tourRouteHighlightStore.ts
@@ -1,17 +1,19 @@
import { create } from 'zustand';
/**
- * BIK-01 to BIK-05, RUN-02: the stretches of the planned route a card points
- * at (a surface, a kind of way, a trail or hiking grade), drawn over the route
- * on the planner map in the colour of what they are. `paint` names a token of
- * the bike paints, resolved where the map is drawn, so the theme decides the
- * colour.
+ * BIK-01 to BIK-05, RUN-02 and MAP-03: the stretches of the planned route a
+ * card points at (a surface, a kind of way, a trail or hiking grade, a hike's
+ * SAC grades), drawn over the route on the planner map in the colour of what
+ * they are. `paint` names a token of the route paints (bike and hike),
+ * resolved where the map is drawn, so the theme decides the colour.
*/
export interface RouteHighlight {
/** What is pointed at, so its row can show it is on. */
id: string;
lines: [number, number][][];
paint: string;
+ /** One paint per line where the lines differ, as when every SAC grade is drawn at once; else `paint` for all. */
+ paints?: readonly string[];
}
interface TourRouteHighlightState {
diff --git a/client/src/styles/hike.css b/client/src/styles/hike.css
new file mode 100644
index 000000000..efd344f3c
--- /dev/null
+++ b/client/src/styles/hike.css
@@ -0,0 +1,30 @@
+/* ============================================================================
+ Hikes on the planner map and in its cards (MAP-03): the SAC hiking grades
+ OpenStreetMap gives the ways along a hike, T1 to T6. Global, since the 2D,
+ GL and 3D maps and the planner's cards all read the tokens.
+ ========================================================================== */
+
+/* One ramp of a single hue, from light to deep as the steps grow harder, so
+ the order reads at a glance. It is deliberately no trail marking colour
+ (yellow, white and red, white and blue) and no regional blue, red or black
+ category: the scale is shown as the SAC scale and nothing else. A way
+ without a known grade takes the grey of the unknown (--bike-surface-unknown),
+ never a step. In the dark theme the ramp turns round, so the harder steps
+ stay the ones that stand out. */
+:root {
+ --hike-sac-t1: #c4b5fd;
+ --hike-sac-t2: #a78bfa;
+ --hike-sac-t3: #8b5cf6;
+ --hike-sac-t4: #7c3aed;
+ --hike-sac-t5: #6d28d9;
+ --hike-sac-t6: #4c1d95;
+}
+
+.dark {
+ --hike-sac-t1: #5b21b6;
+ --hike-sac-t2: #6d28d9;
+ --hike-sac-t3: #7c3aed;
+ --hike-sac-t4: #8b5cf6;
+ --hike-sac-t5: #a78bfa;
+ --hike-sac-t6: #ddd6fe;
+}
diff --git a/server/src/nest/tours/tour-trail-grade.controller.ts b/server/src/nest/tours/tour-trail-grade.controller.ts
index f1256c3ed..7ca546d8d 100644
--- a/server/src/nest/tours/tour-trail-grade.controller.ts
+++ b/server/src/nest/tours/tour-trail-grade.controller.ts
@@ -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')
diff --git a/server/src/nest/tours/tour-trail-grade.service.ts b/server/src/nest/tours/tour-trail-grade.service.ts
index 575f9e9b8..e88646b7b 100644
--- a/server/src/nest/tours/tour-trail-grade.service.ts
+++ b/server/src/nest/tours/tour-trail-grade.service.ts
@@ -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 {
+ 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 = {}): 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.
*/
diff --git a/server/tests/e2e/tours-bike.e2e.test.ts b/server/tests/e2e/tours-bike.e2e.test.ts
index a0aadc8f3..0b77e66f7 100644
--- a/server/tests/e2e/tours-bike.e2e.test.ts
+++ b/server/tests/e2e/tours-bike.e2e.test.ts
@@ -283,11 +283,29 @@ describe('Bike tours e2e (real guards + temp SQLite)', () => {
it('answers the grades of the ways asked about to anyone who sees the trip', async () => {
overpassQuery.mockResolvedValueOnce([
{ type: 'way', id: 11, tags: { 'mtb:scale': '2', 'mtb:scale:uphill': '1' } },
+ { type: 'way', id: 12, tags: { sac_scale: 'mountain_hiking' } },
]);
const res = await ask({ way_ids: [11, 12] }, VIEWER);
expect(res.status).toBe(200);
expect(res.body).toEqual({
- ways: [{ id: 11, mtb_scale: '2', mtb_scale_uphill: '1', mtb_scale_imba: null }],
+ ways: [
+ {
+ id: 11,
+ mtb_scale: '2',
+ mtb_scale_uphill: '1',
+ mtb_scale_imba: null,
+ sac_scale: null,
+ sac_scale_unclear: null,
+ },
+ {
+ id: 12,
+ mtb_scale: null,
+ mtb_scale_uphill: null,
+ mtb_scale_imba: null,
+ sac_scale: 'mountain_hiking',
+ sac_scale_unclear: null,
+ },
+ ],
unanswered: [],
});
});
diff --git a/server/tests/unit/nest/tour-trail-grade.service.test.ts b/server/tests/unit/nest/tour-trail-grade.service.test.ts
index a53242e06..f3b0d37d2 100644
--- a/server/tests/unit/nest/tour-trail-grade.service.test.ts
+++ b/server/tests/unit/nest/tour-trail-grade.service.test.ts
@@ -4,7 +4,7 @@ import { Test } from '@nestjs/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
-// TOUR-GRADE-001 to TOUR-GRADE-006
+// TOUR-GRADE-001 to TOUR-GRADE-008
const query = vi.fn<(q: string, caller: string) => Promise>();
let service: TourTrailGradeService;
@@ -18,6 +18,8 @@ beforeEach(async () => {
});
const way = (id: number, tags: Record) => ({ type: 'way', id, tags });
+const NO_SAC = { sac_scale: null, sac_scale_unclear: null };
+const NO_MTB = { mtb_scale: null, mtb_scale_uphill: null, mtb_scale_imba: null };
describe('TourTrailGradeService (BIK-05)', () => {
it('TOUR-GRADE-001: reads each scale to its steps and leaves a way without one out', async () => {
@@ -30,8 +32,8 @@ describe('TourTrailGradeService (BIK-05)', () => {
const answer = await service.grades({ way_ids: [1, 2, 3, 4, 5] });
expect(answer).toEqual({
ways: [
- { id: 1, mtb_scale: '2+', mtb_scale_uphill: '3', mtb_scale_imba: null },
- { id: 2, mtb_scale: '1', mtb_scale_uphill: null, mtb_scale_imba: '2' },
+ { ...NO_SAC, id: 1, mtb_scale: '2+', mtb_scale_uphill: '3', mtb_scale_imba: null },
+ { ...NO_SAC, id: 2, mtb_scale: '1', mtb_scale_uphill: null, mtb_scale_imba: '2' },
],
unanswered: [],
});
@@ -91,4 +93,30 @@ describe('TourTrailGradeService (BIK-05)', () => {
await service.grades({ way_ids: [52_000] });
expect(query).not.toHaveBeenCalled();
});
+
+ it('TOUR-GRADE-007: reads sac_scale as OpenStreetMap wrote it and hands an unclear one on as written (MAP-03)', async () => {
+ query.mockResolvedValue([
+ way(1, { sac_scale: 'demanding_mountain_hiking' }),
+ way(2, { sac_scale: ' Alpine_Hiking ', 'mtb:scale': '3' }),
+ way(3, { sac_scale: 'T3' }),
+ way(4, { sac_scale: 'hiking;mountain_hiking' }),
+ way(5, { sac_scale: ' ' }),
+ way(6, { sac_scale: `x${'y'.repeat(80)}` }),
+ ]);
+ const answer = await service.grades({ way_ids: [1, 2, 3, 4, 5, 6] });
+ expect(answer.ways).toEqual([
+ { ...NO_MTB, id: 1, sac_scale: 'demanding_mountain_hiking', sac_scale_unclear: null },
+ { ...NO_MTB, id: 2, mtb_scale: '3', sac_scale: 'alpine_hiking', sac_scale_unclear: null },
+ { ...NO_MTB, id: 3, sac_scale: null, sac_scale_unclear: 'T3' },
+ { ...NO_MTB, id: 4, sac_scale: null, sac_scale_unclear: 'hiking;mountain_hiking' },
+ { ...NO_MTB, id: 6, sac_scale: null, sac_scale_unclear: `x${'y'.repeat(63)}` },
+ ]);
+ });
+
+ it('TOUR-GRADE-008: never reads a hiking grade from anything but sac_scale (MAP-03)', async () => {
+ query.mockResolvedValue([
+ way(1, { trail_visibility: 'bad', 'osmc:symbol': 'blue:white:blue_bar', highway: 'path', incline: '30%' }),
+ ]);
+ expect(await service.grades({ way_ids: [1] })).toEqual({ ways: [], unanswered: [] });
+ });
});
diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts
index f1cab1545..f6e298c9c 100644
--- a/shared/src/i18n/ar/tours.ts
+++ b/shared/src/i18n/ar/tours.ts
@@ -521,6 +521,33 @@ const tours: TranslationStrings = {
'الدرجات كما وضعها مساهمو OpenStreetMap. الطريق بلا درجة مجهول وليس سهلًا. ولا تقول شيئًا عن ملاءمة المسار لك.',
'tours.bike.trail.loading': 'جارٍ قراءة درجات المسارات…',
'tours.bike.trail.failed': 'تعذرت قراءة درجات المسارات هذه المرة.',
+ 'tours.hike.sac.title': 'درجات SAC',
+ 'tours.hike.sac.show': 'عرض على طول المسار',
+ 'tours.hike.sac.t1': 'المشي الطويل',
+ 'tours.hike.sac.t2': 'المشي الجبلي',
+ 'tours.hike.sac.t3': 'المشي الجبلي الشاق',
+ 'tours.hike.sac.t4': 'المشي في التضاريس الألبية',
+ 'tours.hike.sac.t5': 'المشي الشاق في التضاريس الألبية',
+ 'tours.hike.sac.t6': 'المشي الصعب في التضاريس الألبية',
+ 'tours.hike.sac.untagged': 'لا درجة في OpenStreetMap',
+ 'tours.hike.sac.unclear': 'درجة غير واضحة',
+ 'tours.hike.sac.unmatched': 'الطريق غير مطابق بوضوح',
+ 'tours.hike.sac.unasked': 'تعذّر الاستعلام هذه المرة',
+ 'tours.hike.sac.unknown': 'غير معروف',
+ 'tours.hike.sac.stepTip': 'مقياس المشي SAC {step}، من OpenStreetMap: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip': 'لا يعطي OpenStreetMap هذه الطرق قيمة sac_scale. غير معروف لا يعني سهلًا.',
+ 'tours.hike.sac.unclearTip': 'يكتب OpenStreetMap هنا {tags}، وهذا لا يحدد درجة واحدة.',
+ 'tours.hike.sac.unmatchedTip': 'لا يقع هنا أي طريق بوضوح تحت خط الجولة، لذلك لا تظهر درجة.',
+ 'tours.hike.sac.known': 'معروف',
+ 'tours.hike.sac.knownTip': 'جزء المسار الذي يعطي OpenStreetMap درجته في SAC. الباقي غير معروف.',
+ 'tours.hike.sac.highest': 'الأعلى المعروف',
+ 'tours.hike.sac.routingLimit': 'حد التوجيه',
+ 'tours.hike.sac.routingLimitTip': 'أقصى ما يجوز لبحث المسار استخدامه: إعداد، وليس صعوبة الطرق.',
+ 'tours.hike.sac.aboveLimit': 'فوق الحد',
+ 'tours.hike.sac.loading': 'جارٍ قراءة درجات SAC…',
+ 'tours.hike.sac.offline': 'تحتاج درجات SAC إلى اتصال.',
+ 'tours.hike.sac.failed': 'تعذّرت قراءة درجات SAC. الجولة لم تتغير.',
+ 'tours.hike.sac.source': 'من OpenStreetMap. قد توجد فجوات؛ وليس تقييمًا للسلامة.',
'tours.copy.action': 'حفظ كجولة جديدة',
'tours.copy.hint': 'ينسخ هذه الجولة كجولة جديدة مستقلة دون أيامها. يبقى الأصل كما هو.',
'tours.copy.name': '{name} (نسخة)',
diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts
index 9693bd0b9..a6f181de8 100644
--- a/shared/src/i18n/az/tours.ts
+++ b/shared/src/i18n/az/tours.ts
@@ -512,6 +512,34 @@ const tours: TranslationStrings = {
'Dərəcələr OpenStreetMap redaktorlarının verdiyi kimidir. Dərəcəsiz yol asan deyil, naməlumdur. Cığırın sənə uyğun olub-olmadığını demir.',
'tours.bike.trail.loading': 'Cığır çətinlikləri oxunur…',
'tours.bike.trail.failed': 'Cığır çətinlikləri bu dəfə oxunmadı.',
+ 'tours.hike.sac.title': 'SAC dərəcələri',
+ 'tours.hike.sac.show': 'Marşrut boyunca göstər',
+ 'tours.hike.sac.t1': 'Yürüş',
+ 'tours.hike.sac.t2': 'Dağ yürüşü',
+ 'tours.hike.sac.t3': 'Yüksək hazırlıq tələb edən dağ yürüşü',
+ 'tours.hike.sac.t4': 'Alp yürüşü',
+ 'tours.hike.sac.t5': 'Yüksək hazırlıq tələb edən alp yürüşü',
+ 'tours.hike.sac.t6': 'Çətin alp yürüşü',
+ 'tours.hike.sac.untagged': 'OpenStreetMap-də dərəcə yoxdur',
+ 'tours.hike.sac.unclear': 'Dərəcə aydın deyil',
+ 'tours.hike.sac.unmatched': 'Yol birmənalı uyğunlaşdırılmayıb',
+ 'tours.hike.sac.unasked': 'Bu dəfə sorğu alınmadı',
+ 'tours.hike.sac.unknown': 'Naməlum',
+ 'tours.hike.sac.stepTip': 'SAC piyada şkalası {step}, OpenStreetMap-dən: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip': 'OpenStreetMap bu yollara sac_scale vermir. Naməlum asan demək deyil.',
+ 'tours.hike.sac.unclearTip': 'OpenStreetMap burada {tags} yazır, bu isə tək bir dərəcə bildirmir.',
+ 'tours.hike.sac.unmatchedTip': 'Burada turun xətti altında aydın bir yol yoxdur, ona görə dərəcə göstərilmir.',
+ 'tours.hike.sac.known': 'Məlum',
+ 'tours.hike.sac.knownTip': 'Marşrutun OpenStreetMap-in SAC dərəcəsi verdiyi hissəsi. Qalanı naməlumdur.',
+ 'tours.hike.sac.highest': 'Ən yüksək məlum',
+ 'tours.hike.sac.routingLimit': 'Marşrut həddi',
+ 'tours.hike.sac.routingLimitTip':
+ 'Marşrut axtarışının istifadə edə biləcəyi ən çox: ayardır, yolların çətinliyi deyil.',
+ 'tours.hike.sac.aboveLimit': 'Həddən yuxarı',
+ 'tours.hike.sac.loading': 'SAC dərəcələri oxunur…',
+ 'tours.hike.sac.offline': 'SAC dərəcələri üçün bağlantı lazımdır.',
+ 'tours.hike.sac.failed': 'SAC dərəcələri oxuna bilmədi. Tur dəyişməyib.',
+ 'tours.hike.sac.source': 'OpenStreetMap-dən. Boşluqlar ola bilər; təhlükəsizlik qiymətləndirməsi deyil.',
'tours.copy.action': 'Yeni tur kimi saxla',
'tours.copy.hint': 'Bu turu günləri olmadan yeni, müstəqil tur kimi kopyalayır. Orijinal olduğu kimi qalır.',
'tours.copy.name': '{name} (surət)',
diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts
index 482cd5ce9..769fce899 100644
--- a/shared/src/i18n/br/tours.ts
+++ b/shared/src/i18n/br/tours.ts
@@ -518,6 +518,34 @@ const tours: TranslationStrings = {
'Graus como os mapeadores do OpenStreetMap os deram. Uma via sem grau é desconhecida, não fácil. Eles não dizem se a trilha combina com você.',
'tours.bike.trail.loading': 'Lendo os graus das trilhas…',
'tours.bike.trail.failed': 'Não foi possível ler os graus das trilhas agora.',
+ 'tours.hike.sac.title': 'Graus SAC',
+ 'tours.hike.sac.show': 'Mostrar ao longo da rota',
+ 'tours.hike.sac.t1': 'Caminhada',
+ 'tours.hike.sac.t2': 'Caminhada em montanha',
+ 'tours.hike.sac.t3': 'Caminhada exigente em montanha',
+ 'tours.hike.sac.t4': 'Caminhada alpina',
+ 'tours.hike.sac.t5': 'Caminhada alpina exigente',
+ 'tours.hike.sac.t6': 'Caminhada alpina difícil',
+ 'tours.hike.sac.untagged': 'Sem grau no OpenStreetMap',
+ 'tours.hike.sac.unclear': 'Grau incerto',
+ 'tours.hike.sac.unmatched': 'Trilha sem correspondência clara',
+ 'tours.hike.sac.unasked': 'Não foi possível consultar desta vez',
+ 'tours.hike.sac.unknown': 'Desconhecido',
+ 'tours.hike.sac.stepTip': 'Escala de caminhada SAC {step}, do OpenStreetMap: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip': 'O OpenStreetMap não dá sac_scale a estas trilhas. Desconhecido não quer dizer fácil.',
+ 'tours.hike.sac.unclearTip': 'O OpenStreetMap indica aqui {tags}, que não nomeia um único grau.',
+ 'tours.hike.sac.unmatchedTip':
+ 'Aqui nenhuma trilha fica claramente sob a linha do passeio, então nenhum grau é mostrado.',
+ 'tours.hike.sac.known': 'Conhecido',
+ 'tours.hike.sac.knownTip': 'A parte da rota cujo grau SAC o OpenStreetMap informa. O resto é desconhecido.',
+ 'tours.hike.sac.highest': 'Maior conhecido',
+ 'tours.hike.sac.routingLimit': 'Limite de roteamento',
+ 'tours.hike.sac.routingLimitTip': 'O máximo que a busca de rota pode usar: uma configuração, não o grau das trilhas.',
+ 'tours.hike.sac.aboveLimit': 'Acima do limite',
+ 'tours.hike.sac.loading': 'Lendo os graus SAC…',
+ 'tours.hike.sac.offline': 'Os graus SAC precisam de conexão.',
+ 'tours.hike.sac.failed': 'Não foi possível ler os graus SAC. O passeio não foi alterado.',
+ 'tours.hike.sac.source': 'Do OpenStreetMap. Pode haver lacunas; não é uma avaliação de segurança.',
'tours.copy.action': 'Salvar como novo passeio',
'tours.copy.hint':
'Copia este passeio como um novo passeio independente, sem os seus dias. O original fica como está.',
diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts
index 1a244bff4..214b96039 100644
--- a/shared/src/i18n/ca/tours.ts
+++ b/shared/src/i18n/ca/tours.ts
@@ -519,6 +519,34 @@ const tours: TranslationStrings = {
"Graus tal com els han posat els mapadors d'OpenStreetMap. Un camí sense grau és desconegut, no fàcil. No diuen res de si un corriol et convé.",
'tours.bike.trail.loading': "S'estan llegint els graus dels corriols…",
'tours.bike.trail.failed': "Aquesta vegada no s'han pogut llegir els graus dels corriols.",
+ 'tours.hike.sac.title': 'Graus SAC',
+ 'tours.hike.sac.show': 'Mostra al llarg de la ruta',
+ 'tours.hike.sac.t1': 'Senderisme',
+ 'tours.hike.sac.t2': 'Senderisme de muntanya',
+ 'tours.hike.sac.t3': 'Senderisme de muntanya exigent',
+ 'tours.hike.sac.t4': 'Senderisme alpí',
+ 'tours.hike.sac.t5': 'Senderisme alpí exigent',
+ 'tours.hike.sac.t6': 'Senderisme alpí difícil',
+ 'tours.hike.sac.untagged': 'Sense grau a OpenStreetMap',
+ 'tours.hike.sac.unclear': 'Grau poc clar',
+ 'tours.hike.sac.unmatched': 'Camí sense assignació clara',
+ 'tours.hike.sac.unasked': 'No s’ha pogut consultar aquesta vegada',
+ 'tours.hike.sac.unknown': 'Desconegut',
+ 'tours.hike.sac.stepTip': 'Escala d’excursionisme SAC {step}, segons OpenStreetMap: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip': 'OpenStreetMap no dona sac_scale a aquests camins. Desconegut no vol dir fàcil.',
+ 'tours.hike.sac.unclearTip': 'OpenStreetMap hi indica {tags}, que no anomena cap grau concret.',
+ 'tours.hike.sac.unmatchedTip':
+ 'Aquí cap camí queda clarament sota la línia de la ruta, així que no es mostra cap grau.',
+ 'tours.hike.sac.known': 'Conegut',
+ 'tours.hike.sac.knownTip': 'La part de la ruta de la qual OpenStreetMap dona el grau SAC. La resta és desconeguda.',
+ 'tours.hike.sac.highest': 'Màxim conegut',
+ 'tours.hike.sac.routingLimit': 'Límit de càlcul',
+ 'tours.hike.sac.routingLimitTip': 'El màxim que pot fer servir la cerca de ruta: un ajust, no el grau dels camins.',
+ 'tours.hike.sac.aboveLimit': 'Per sobre del límit',
+ 'tours.hike.sac.loading': 'S’estan llegint els graus SAC…',
+ 'tours.hike.sac.offline': 'Els graus SAC necessiten connexió.',
+ 'tours.hike.sac.failed': 'No s’han pogut llegir els graus SAC. La ruta no ha canviat.',
+ 'tours.hike.sac.source': 'Segons OpenStreetMap. Hi pot haver buits; no és una valoració de seguretat.',
'tours.copy.action': 'Desa com a excursió nova',
'tours.copy.hint':
'Copia aquesta excursió com una excursió nova i independent, sense els seus dies. L’original es queda com està.',
diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts
index 7c3ee6558..8d84d1a7b 100644
--- a/shared/src/i18n/cs/tours.ts
+++ b/shared/src/i18n/cs/tours.ts
@@ -510,6 +510,33 @@ const tours: TranslationStrings = {
'Stupně, jak je zadali mapovači OpenStreetMap. Cesta bez stupně je neznámá, ne snadná. Neříkají nic o tom, zda se trať hodí pro tebe.',
'tours.bike.trail.loading': 'Načítá se obtížnost tratí…',
'tours.bike.trail.failed': 'Obtížnost tratí se tentokrát nepodařilo načíst.',
+ 'tours.hike.sac.title': 'Obtížnost SAC',
+ 'tours.hike.sac.show': 'Zobrazit podél trasy',
+ 'tours.hike.sac.t1': 'Pěší turistika',
+ 'tours.hike.sac.t2': 'Horská turistika',
+ 'tours.hike.sac.t3': 'Náročná horská turistika',
+ 'tours.hike.sac.t4': 'Vysokohorská turistika',
+ 'tours.hike.sac.t5': 'Náročná vysokohorská turistika',
+ 'tours.hike.sac.t6': 'Obtížná vysokohorská turistika',
+ 'tours.hike.sac.untagged': 'Bez stupně v OpenStreetMap',
+ 'tours.hike.sac.unclear': 'Stupeň nejasný',
+ 'tours.hike.sac.unmatched': 'Cesta není jednoznačně přiřazena',
+ 'tours.hike.sac.unasked': 'Tentokrát se nepodařilo zjistit',
+ 'tours.hike.sac.unknown': 'Neznámé',
+ 'tours.hike.sac.stepTip': 'Turistická stupnice SAC {step}, z OpenStreetMap: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip': 'OpenStreetMap těmto cestám nedává sac_scale. Neznámé neznamená snadné.',
+ 'tours.hike.sac.unclearTip': 'OpenStreetMap zde uvádí {tags}, což neoznačuje jediný stupeň.',
+ 'tours.hike.sac.unmatchedTip': 'Zde pod linií túry jednoznačně neleží žádná cesta, proto se stupeň nezobrazuje.',
+ 'tours.hike.sac.known': 'Známé',
+ 'tours.hike.sac.knownTip': 'Podíl trasy, pro který OpenStreetMap uvádí stupeň SAC. Zbytek je neznámý.',
+ 'tours.hike.sac.highest': 'Nejvyšší známý',
+ 'tours.hike.sac.routingLimit': 'Limit trasování',
+ 'tours.hike.sac.routingLimitTip': 'Nejvíc, co smí hledání trasy použít: nastavení, ne obtížnost cest.',
+ 'tours.hike.sac.aboveLimit': 'Nad limitem',
+ 'tours.hike.sac.loading': 'Načítají se stupně SAC…',
+ 'tours.hike.sac.offline': 'Stupně SAC potřebují připojení.',
+ 'tours.hike.sac.failed': 'Stupně SAC se nepodařilo načíst. Túra zůstává beze změny.',
+ 'tours.hike.sac.source': 'Z OpenStreetMap. Mohou chybět údaje; nejde o hodnocení bezpečnosti.',
'tours.copy.action': 'Uložit jako novou túru',
'tours.copy.hint': 'Zkopíruje tuto túru jako novou, samostatnou túru bez jejích dnů. Originál zůstane, jak je.',
'tours.copy.name': '{name} (kopie)',
diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts
index 584d3a7b3..ff7192582 100644
--- a/shared/src/i18n/de/tours.ts
+++ b/shared/src/i18n/de/tours.ts
@@ -522,6 +522,36 @@ const tours: TranslationStrings = {
'Schwierigkeiten, wie OpenStreetMap-Mapper sie vergeben haben. Ein Weg ohne Angabe ist unbekannt, nicht leicht. Sie sagen nichts darüber, ob ein Trail zu dir passt.',
'tours.bike.trail.loading': 'Trail-Schwierigkeiten werden gelesen…',
'tours.bike.trail.failed': 'Die Trail-Schwierigkeiten konnten diesmal nicht gelesen werden.',
+ 'tours.hike.sac.title': 'SAC-Schwierigkeit',
+ 'tours.hike.sac.show': 'Entlang der Route zeigen',
+ 'tours.hike.sac.t1': 'Wandern',
+ 'tours.hike.sac.t2': 'Bergwandern',
+ 'tours.hike.sac.t3': 'Anspruchsvolles Bergwandern',
+ 'tours.hike.sac.t4': 'Alpinwandern',
+ 'tours.hike.sac.t5': 'Anspruchsvolles Alpinwandern',
+ 'tours.hike.sac.t6': 'Schwieriges Alpinwandern',
+ 'tours.hike.sac.untagged': 'Keine Angabe in OpenStreetMap',
+ 'tours.hike.sac.unclear': 'Angabe unklar',
+ 'tours.hike.sac.unmatched': 'Weg nicht eindeutig zugeordnet',
+ 'tours.hike.sac.unasked': 'Diesmal nicht abfragbar',
+ 'tours.hike.sac.unknown': 'Unbekannt',
+ 'tours.hike.sac.stepTip': 'SAC-Wanderskala {step}, aus OpenStreetMap: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip': 'OpenStreetMap gibt diesen Wegen kein sac_scale. Unbekannt heißt nicht leicht.',
+ 'tours.hike.sac.unclearTip': 'OpenStreetMap schreibt hier {tags}, das benennt keine einzelne Stufe.',
+ 'tours.hike.sac.unmatchedTip':
+ 'Hier liegt kein Weg eindeutig unter der Linie der Tour, daher wird keine Stufe gezeigt.',
+ 'tours.hike.sac.known': 'Bekannt',
+ 'tours.hike.sac.knownTip':
+ 'Der Anteil der Route, für den OpenStreetMap eine SAC-Stufe angibt. Der Rest ist unbekannt.',
+ 'tours.hike.sac.highest': 'Höchste bekannte',
+ 'tours.hike.sac.routingLimit': 'Routing-Grenze',
+ 'tours.hike.sac.routingLimitTip':
+ 'Das Höchste, was die Routensuche nutzen darf: eine Einstellung, nicht die Schwierigkeit der Wege.',
+ 'tours.hike.sac.aboveLimit': 'Über der Grenze',
+ 'tours.hike.sac.loading': 'SAC-Stufen werden gelesen…',
+ 'tours.hike.sac.offline': 'Die SAC-Stufen brauchen eine Verbindung.',
+ 'tours.hike.sac.failed': 'Die SAC-Stufen konnten nicht gelesen werden. Die Tour bleibt unverändert.',
+ 'tours.hike.sac.source': 'Aus OpenStreetMap. Lücken sind möglich; keine Sicherheitsbewertung.',
'tours.copy.action': 'Als neue Tour speichern',
'tours.copy.hint': 'Kopiert diese Tour als neue, eigenständige Tour ohne ihre Tage. Das Original bleibt, wie es ist.',
'tours.copy.name': '{name} (Kopie)',
diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts
index a6093ce12..fd4aa9378 100644
--- a/shared/src/i18n/en/tours.ts
+++ b/shared/src/i18n/en/tours.ts
@@ -510,6 +510,33 @@ const tours: TranslationStrings = {
'Grades as OpenStreetMap mappers gave them. A way without one is unknown, not easy. They say nothing about whether a trail suits you.',
'tours.bike.trail.loading': 'Reading the trail grades…',
'tours.bike.trail.failed': 'The trail grades could not be read this time.',
+ 'tours.hike.sac.title': 'SAC grades',
+ 'tours.hike.sac.show': 'Show along the route',
+ 'tours.hike.sac.t1': 'Hiking',
+ 'tours.hike.sac.t2': 'Mountain hiking',
+ 'tours.hike.sac.t3': 'Demanding mountain hiking',
+ 'tours.hike.sac.t4': 'Alpine hiking',
+ 'tours.hike.sac.t5': 'Demanding alpine hiking',
+ 'tours.hike.sac.t6': 'Difficult alpine hiking',
+ 'tours.hike.sac.untagged': 'No grade in OpenStreetMap',
+ 'tours.hike.sac.unclear': 'Grade unclear',
+ 'tours.hike.sac.unmatched': 'Way not clearly matched',
+ 'tours.hike.sac.unasked': 'Could not be asked this time',
+ 'tours.hike.sac.unknown': 'Unknown',
+ 'tours.hike.sac.stepTip': 'SAC hiking scale {step}, from OpenStreetMap: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip': 'OpenStreetMap gives these ways no sac_scale. Unknown does not mean easy.',
+ 'tours.hike.sac.unclearTip': 'OpenStreetMap writes {tags} here, which names no single step.',
+ 'tours.hike.sac.unmatchedTip': 'No way lies clearly under the line of the tour here, so no grade is shown.',
+ 'tours.hike.sac.known': 'Known',
+ 'tours.hike.sac.knownTip': 'The share of the route whose SAC grade OpenStreetMap gives. The rest is unknown.',
+ 'tours.hike.sac.highest': 'Highest known',
+ 'tours.hike.sac.routingLimit': 'Routing limit',
+ 'tours.hike.sac.routingLimitTip': 'The most the route search may use: a setting, not the grade of the ways.',
+ 'tours.hike.sac.aboveLimit': 'Above the limit',
+ 'tours.hike.sac.loading': 'Reading the SAC grades…',
+ 'tours.hike.sac.offline': 'The SAC grades need a connection.',
+ 'tours.hike.sac.failed': 'The SAC grades could not be read. The tour is unchanged.',
+ 'tours.hike.sac.source': 'From OpenStreetMap. Gaps are possible; not a safety rating.',
'tours.copy.action': 'Save as new tour',
'tours.copy.hint': 'Copies this tour as a new, independent tour without its days. The original stays as it is.',
'tours.copy.name': '{name} (copy)',
diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts
index 15738b901..3063a06c1 100644
--- a/shared/src/i18n/es/tours.ts
+++ b/shared/src/i18n/es/tours.ts
@@ -518,6 +518,35 @@ const tours: TranslationStrings = {
'Grados tal como los dieron los mapeadores de OpenStreetMap. Una vía sin grado es desconocida, no fácil. No dicen nada sobre si un sendero te conviene.',
'tours.bike.trail.loading': 'Leyendo los grados de los senderos…',
'tours.bike.trail.failed': 'Esta vez no se pudieron leer los grados de los senderos.',
+ 'tours.hike.sac.title': 'Grados SAC',
+ 'tours.hike.sac.show': 'Mostrar a lo largo de la ruta',
+ 'tours.hike.sac.t1': 'Senderismo',
+ 'tours.hike.sac.t2': 'Senderismo de montaña',
+ 'tours.hike.sac.t3': 'Senderismo de montaña exigente',
+ 'tours.hike.sac.t4': 'Senderismo alpino',
+ 'tours.hike.sac.t5': 'Senderismo alpino exigente',
+ 'tours.hike.sac.t6': 'Senderismo alpino difícil',
+ 'tours.hike.sac.untagged': 'Sin grado en OpenStreetMap',
+ 'tours.hike.sac.unclear': 'Grado poco claro',
+ 'tours.hike.sac.unmatched': 'Camino sin asignación clara',
+ 'tours.hike.sac.unasked': 'No se pudo consultar esta vez',
+ 'tours.hike.sac.unknown': 'Desconocido',
+ 'tours.hike.sac.stepTip': 'Escala de senderismo SAC {step}, según OpenStreetMap: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip': 'OpenStreetMap no da sac_scale a estos caminos. Desconocido no significa fácil.',
+ 'tours.hike.sac.unclearTip': 'OpenStreetMap indica aquí {tags}, que no nombra un único grado.',
+ 'tours.hike.sac.unmatchedTip':
+ 'Aquí ningún camino queda claramente bajo la línea de la ruta, así que no se muestra grado.',
+ 'tours.hike.sac.known': 'Conocido',
+ 'tours.hike.sac.knownTip': 'La parte de la ruta cuyo grado SAC da OpenStreetMap. El resto es desconocido.',
+ 'tours.hike.sac.highest': 'Máximo conocido',
+ 'tours.hike.sac.routingLimit': 'Límite de cálculo',
+ 'tours.hike.sac.routingLimitTip':
+ 'Lo máximo que puede usar la búsqueda de ruta: un ajuste, no el grado de los caminos.',
+ 'tours.hike.sac.aboveLimit': 'Por encima del límite',
+ 'tours.hike.sac.loading': 'Leyendo los grados SAC…',
+ 'tours.hike.sac.offline': 'Los grados SAC necesitan conexión.',
+ 'tours.hike.sac.failed': 'No se pudieron leer los grados SAC. La ruta no ha cambiado.',
+ 'tours.hike.sac.source': 'Según OpenStreetMap. Puede haber huecos; no es una valoración de seguridad.',
'tours.copy.action': 'Guardar como nueva excursión',
'tours.copy.hint':
'Copia esta excursión como una nueva excursión independiente, sin sus días. El original queda como está.',
diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts
index 9d4b1ca16..a50fbf415 100644
--- a/shared/src/i18n/et/tours.ts
+++ b/shared/src/i18n/et/tours.ts
@@ -513,6 +513,33 @@ const tours: TranslationStrings = {
'Raskused nii, nagu OpenStreetMapi kaardistajad need andsid. Rada ilma raskuseta on teadmata, mitte kerge. Need ei ütle midagi selle kohta, kas rada sulle sobib.',
'tours.bike.trail.loading': 'Loen radade raskust…',
'tours.bike.trail.failed': 'Radade raskust ei õnnestunud seekord lugeda.',
+ 'tours.hike.sac.title': 'SAC raskusastmed',
+ 'tours.hike.sac.show': 'Näita mööda marsruuti',
+ 'tours.hike.sac.t1': 'Matkamine',
+ 'tours.hike.sac.t2': 'Mägimatkamine',
+ 'tours.hike.sac.t3': 'Nõudlik mägimatkamine',
+ 'tours.hike.sac.t4': 'Alpimatkamine',
+ 'tours.hike.sac.t5': 'Nõudlik alpimatkamine',
+ 'tours.hike.sac.t6': 'Raske alpimatkamine',
+ 'tours.hike.sac.untagged': 'OpenStreetMapis raskusaste puudub',
+ 'tours.hike.sac.unclear': 'Raskusaste ebaselge',
+ 'tours.hike.sac.unmatched': 'Rada pole üheselt sobitatud',
+ 'tours.hike.sac.unasked': 'Seekord ei õnnestunud pärida',
+ 'tours.hike.sac.unknown': 'Teadmata',
+ 'tours.hike.sac.stepTip': 'SAC matkaskaala {step}, OpenStreetMapist: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip': 'OpenStreetMap ei anna neile radadele sac_scale väärtust. Teadmata ei tähenda kerget.',
+ 'tours.hike.sac.unclearTip': 'OpenStreetMap märgib siin {tags}, mis ei nimeta ühte astet.',
+ 'tours.hike.sac.unmatchedTip': 'Siin ei asu ükski rada selgelt matka joone all, seega raskusastet ei näidata.',
+ 'tours.hike.sac.known': 'Teada',
+ 'tours.hike.sac.knownTip': 'Marsruudi osa, mille SAC raskusastme OpenStreetMap annab. Ülejäänu on teadmata.',
+ 'tours.hike.sac.highest': 'Kõrgeim teadaolev',
+ 'tours.hike.sac.routingLimit': 'Marsruutimise piir',
+ 'tours.hike.sac.routingLimitTip': 'Kõige rohkem, mida marsruudiotsing tohib kasutada: seade, mitte radade raskus.',
+ 'tours.hike.sac.aboveLimit': 'Üle piiri',
+ 'tours.hike.sac.loading': 'SAC raskusastmete lugemine…',
+ 'tours.hike.sac.offline': 'SAC raskusastmed vajavad ühendust.',
+ 'tours.hike.sac.failed': 'SAC raskusastmeid ei õnnestunud lugeda. Matk on muutmata.',
+ 'tours.hike.sac.source': 'OpenStreetMapist. Lüngad on võimalikud; see pole ohutushinnang.',
'tours.copy.action': 'Salvesta uue matkana',
'tours.copy.hint': 'Kopeerib selle matka uue, iseseisva matkana ilma päevadeta. Originaal jääb samaks.',
'tours.copy.name': '{name} (koopia)',
diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts
index bd91598e1..726236f97 100644
--- a/shared/src/i18n/fr/tours.ts
+++ b/shared/src/i18n/fr/tours.ts
@@ -526,6 +526,36 @@ const tours: TranslationStrings = {
"Difficultés telles que les contributeurs d'OpenStreetMap les ont notées. Une voie sans note est inconnue, pas facile. Elles ne disent rien de ce qui te convient.",
'tours.bike.trail.loading': 'Lecture des difficultés des sentiers…',
'tours.bike.trail.failed': "Les difficultés des sentiers n'ont pas pu être lues cette fois.",
+ 'tours.hike.sac.title': 'Cotations SAC',
+ 'tours.hike.sac.show': 'Afficher le long de l’itinéraire',
+ 'tours.hike.sac.t1': 'Randonnée',
+ 'tours.hike.sac.t2': 'Randonnée en montagne',
+ 'tours.hike.sac.t3': 'Randonnée en montagne exigeante',
+ 'tours.hike.sac.t4': 'Randonnée alpine',
+ 'tours.hike.sac.t5': 'Randonnée alpine exigeante',
+ 'tours.hike.sac.t6': 'Randonnée alpine difficile',
+ 'tours.hike.sac.untagged': 'Aucune cotation dans OpenStreetMap',
+ 'tours.hike.sac.unclear': 'Cotation incertaine',
+ 'tours.hike.sac.unmatched': 'Chemin non attribué avec certitude',
+ 'tours.hike.sac.unasked': 'Interrogation impossible cette fois',
+ 'tours.hike.sac.unknown': 'Inconnu',
+ 'tours.hike.sac.stepTip': 'Échelle de randonnée SAC {step}, d’après OpenStreetMap : sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip':
+ 'OpenStreetMap ne donne aucun sac_scale à ces chemins. Inconnu ne veut pas dire facile.',
+ 'tours.hike.sac.unclearTip': 'OpenStreetMap indique ici {tags}, ce qui ne désigne aucun degré précis.',
+ 'tours.hike.sac.unmatchedTip':
+ 'Aucun chemin ne se trouve clairement sous le tracé ici, donc aucune cotation n’est affichée.',
+ 'tours.hike.sac.known': 'Connu',
+ 'tours.hike.sac.knownTip': 'La part de l’itinéraire dont OpenStreetMap donne la cotation SAC. Le reste est inconnu.',
+ 'tours.hike.sac.highest': 'Plus haute connue',
+ 'tours.hike.sac.routingLimit': 'Limite de calcul',
+ 'tours.hike.sac.routingLimitTip':
+ 'Le maximum que la recherche d’itinéraire peut emprunter : un réglage, pas la difficulté des chemins.',
+ 'tours.hike.sac.aboveLimit': 'Au-dessus de la limite',
+ 'tours.hike.sac.loading': 'Lecture des cotations SAC…',
+ 'tours.hike.sac.offline': 'Les cotations SAC nécessitent une connexion.',
+ 'tours.hike.sac.failed': 'Les cotations SAC n’ont pas pu être lues. La randonnée reste inchangée.',
+ 'tours.hike.sac.source': 'D’après OpenStreetMap. Des lacunes sont possibles ; pas une évaluation de sécurité.',
'tours.copy.action': 'Enregistrer comme nouvelle sortie',
'tours.copy.hint':
'Copie cette sortie en une nouvelle sortie indépendante, sans ses jours. L’original reste tel quel.',
diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts
index 2385d7058..cad4214cc 100644
--- a/shared/src/i18n/gr/tours.ts
+++ b/shared/src/i18n/gr/tours.ts
@@ -528,6 +528,37 @@ const tours: TranslationStrings = {
'Βαθμοί όπως τους έδωσαν οι χαρτογράφοι του OpenStreetMap. Δρόμος χωρίς βαθμό είναι άγνωστος, όχι εύκολος. Δεν λένε αν ένα μονοπάτι σου ταιριάζει.',
'tours.bike.trail.loading': 'Διαβάζονται οι βαθμοί δυσκολίας…',
'tours.bike.trail.failed': 'Οι βαθμοί δυσκολίας δεν διαβάστηκαν αυτή τη φορά.',
+ 'tours.hike.sac.title': 'Βαθμοί SAC',
+ 'tours.hike.sac.show': 'Εμφάνιση κατά μήκος της διαδρομής',
+ 'tours.hike.sac.t1': 'Πεζοπορία',
+ 'tours.hike.sac.t2': 'Ορεινή πεζοπορία',
+ 'tours.hike.sac.t3': 'Απαιτητική ορεινή πεζοπορία',
+ 'tours.hike.sac.t4': 'Αλπική πεζοπορία',
+ 'tours.hike.sac.t5': 'Απαιτητική αλπική πεζοπορία',
+ 'tours.hike.sac.t6': 'Δύσκολη αλπική πεζοπορία',
+ 'tours.hike.sac.untagged': 'Χωρίς βαθμό στο OpenStreetMap',
+ 'tours.hike.sac.unclear': 'Ασαφής βαθμός',
+ 'tours.hike.sac.unmatched': 'Το μονοπάτι δεν αντιστοιχίστηκε σαφώς',
+ 'tours.hike.sac.unasked': 'Δεν ήταν δυνατή η αναζήτηση αυτή τη φορά',
+ 'tours.hike.sac.unknown': 'Άγνωστο',
+ 'tours.hike.sac.stepTip': 'Κλίμακα πεζοπορίας SAC {step}, από το OpenStreetMap: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip':
+ 'Το OpenStreetMap δεν δίνει sac_scale σε αυτά τα μονοπάτια. Άγνωστο δεν σημαίνει εύκολο.',
+ 'tours.hike.sac.unclearTip': 'Το OpenStreetMap γράφει εδώ {tags}, που δεν ορίζει έναν μόνο βαθμό.',
+ 'tours.hike.sac.unmatchedTip':
+ 'Εδώ κανένα μονοπάτι δεν βρίσκεται σαφώς κάτω από τη γραμμή της διαδρομής, οπότε δεν εμφανίζεται βαθμός.',
+ 'tours.hike.sac.known': 'Γνωστό',
+ 'tours.hike.sac.knownTip':
+ 'Το μέρος της διαδρομής για το οποίο το OpenStreetMap δίνει βαθμό SAC. Το υπόλοιπο είναι άγνωστο.',
+ 'tours.hike.sac.highest': 'Υψηλότερος γνωστός',
+ 'tours.hike.sac.routingLimit': 'Όριο δρομολόγησης',
+ 'tours.hike.sac.routingLimitTip':
+ 'Το μέγιστο που επιτρέπεται στην αναζήτηση διαδρομής: μια ρύθμιση, όχι η δυσκολία των μονοπατιών.',
+ 'tours.hike.sac.aboveLimit': 'Πάνω από το όριο',
+ 'tours.hike.sac.loading': 'Ανάγνωση βαθμών SAC…',
+ 'tours.hike.sac.offline': 'Οι βαθμοί SAC χρειάζονται σύνδεση.',
+ 'tours.hike.sac.failed': 'Οι βαθμοί SAC δεν διαβάστηκαν. Η διαδρομή δεν άλλαξε.',
+ 'tours.hike.sac.source': 'Από το OpenStreetMap. Μπορεί να λείπουν στοιχεία· δεν είναι αξιολόγηση ασφάλειας.',
'tours.copy.action': 'Αποθήκευση ως νέα εκδρομή',
'tours.copy.hint':
'Αντιγράφει αυτή την εκδρομή ως νέα, ανεξάρτητη εκδρομή χωρίς τις μέρες της. Το πρωτότυπο μένει ως έχει.',
diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts
index 3a51286c2..21e068898 100644
--- a/shared/src/i18n/hu/tours.ts
+++ b/shared/src/i18n/hu/tours.ts
@@ -520,6 +520,35 @@ const tours: TranslationStrings = {
'A fokok az OpenStreetMap-térképezők szerint. A fok nélküli út ismeretlen, nem könnyű. Semmit sem mondanak arról, hogy egy ösvény neked való-e.',
'tours.bike.trail.loading': 'Nehézségi fokok beolvasása…',
'tours.bike.trail.failed': 'A nehézségi fokokat ezúttal nem sikerült beolvasni.',
+ 'tours.hike.sac.title': 'SAC-fokozatok',
+ 'tours.hike.sac.show': 'Megjelenítés az útvonal mentén',
+ 'tours.hike.sac.t1': 'Gyalogtúra',
+ 'tours.hike.sac.t2': 'Hegyi túra',
+ 'tours.hike.sac.t3': 'Megerőltető hegyi túra',
+ 'tours.hike.sac.t4': 'Magashegyi túra',
+ 'tours.hike.sac.t5': 'Megerőltető magashegyi túra',
+ 'tours.hike.sac.t6': 'Nehéz magashegyi túra',
+ 'tours.hike.sac.untagged': 'Nincs fokozat az OpenStreetMapben',
+ 'tours.hike.sac.unclear': 'Bizonytalan fokozat',
+ 'tours.hike.sac.unmatched': 'Az út nem egyértelműen illesztett',
+ 'tours.hike.sac.unasked': 'Ezúttal nem sikerült lekérdezni',
+ 'tours.hike.sac.unknown': 'Ismeretlen',
+ 'tours.hike.sac.stepTip': 'SAC túraskála {step}, az OpenStreetMapből: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip':
+ 'Az OpenStreetMap nem ad sac_scale értéket ezeknek az utaknak. Az ismeretlen nem jelent könnyűt.',
+ 'tours.hike.sac.unclearTip': 'Az OpenStreetMap itt ezt írja: {tags}, ami nem egyetlen fokozatot jelöl.',
+ 'tours.hike.sac.unmatchedTip':
+ 'Itt egyetlen út sem fut egyértelműen a túra vonala alatt, ezért nincs megjelenített fokozat.',
+ 'tours.hike.sac.known': 'Ismert',
+ 'tours.hike.sac.knownTip': 'Az útvonal azon része, amelyhez az OpenStreetMap SAC-fokozatot ad. A többi ismeretlen.',
+ 'tours.hike.sac.highest': 'Legmagasabb ismert',
+ 'tours.hike.sac.routingLimit': 'Útvonaltervezési határ',
+ 'tours.hike.sac.routingLimitTip': 'A legtöbb, amit az útvonalkeresés használhat: beállítás, nem az utak nehézsége.',
+ 'tours.hike.sac.aboveLimit': 'A határ felett',
+ 'tours.hike.sac.loading': 'SAC-fokozatok beolvasása…',
+ 'tours.hike.sac.offline': 'A SAC-fokozatokhoz kapcsolat kell.',
+ 'tours.hike.sac.failed': 'A SAC-fokozatokat nem sikerült beolvasni. A túra változatlan.',
+ 'tours.hike.sac.source': 'Az OpenStreetMapből. Hiányok lehetnek; nem biztonsági értékelés.',
'tours.copy.action': 'Mentés új túraként',
'tours.copy.hint': 'Ezt a túrát új, önálló túraként másolja a napjai nélkül. Az eredeti változatlan marad.',
'tours.copy.name': '{name} (másolat)',
diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts
index 7cdecb267..7152052bc 100644
--- a/shared/src/i18n/id/tours.ts
+++ b/shared/src/i18n/id/tours.ts
@@ -513,6 +513,36 @@ const tours: TranslationStrings = {
'Tingkat sebagaimana diberikan pemeta OpenStreetMap. Jalan tanpa tingkat tidak diketahui, bukan mudah. Ini tidak menyatakan apakah jalur cocok untukmu.',
'tours.bike.trail.loading': 'Membaca tingkat jalur…',
'tours.bike.trail.failed': 'Tingkat jalur tidak dapat dibaca kali ini.',
+ 'tours.hike.sac.title': 'Tingkat SAC',
+ 'tours.hike.sac.show': 'Tampilkan di sepanjang rute',
+ 'tours.hike.sac.t1': 'Pendakian',
+ 'tours.hike.sac.t2': 'Pendakian gunung',
+ 'tours.hike.sac.t3': 'Pendakian gunung menantang',
+ 'tours.hike.sac.t4': 'Pendakian alpin',
+ 'tours.hike.sac.t5': 'Pendakian alpin menantang',
+ 'tours.hike.sac.t6': 'Pendakian alpin sulit',
+ 'tours.hike.sac.untagged': 'Tidak ada tingkat di OpenStreetMap',
+ 'tours.hike.sac.unclear': 'Tingkat tidak jelas',
+ 'tours.hike.sac.unmatched': 'Jalur tidak dicocokkan dengan jelas',
+ 'tours.hike.sac.unasked': 'Tidak dapat ditanyakan kali ini',
+ 'tours.hike.sac.unknown': 'Tidak diketahui',
+ 'tours.hike.sac.stepTip': 'Skala pendakian SAC {step}, dari OpenStreetMap: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip':
+ 'OpenStreetMap tidak memberi sac_scale pada jalur ini. Tidak diketahui bukan berarti mudah.',
+ 'tours.hike.sac.unclearTip': 'OpenStreetMap menulis {tags} di sini, yang tidak menyebut satu tingkat.',
+ 'tours.hike.sac.unmatchedTip':
+ 'Di sini tidak ada jalur yang jelas berada di bawah garis tur, jadi tidak ada tingkat yang ditampilkan.',
+ 'tours.hike.sac.known': 'Diketahui',
+ 'tours.hike.sac.knownTip': 'Bagian rute yang tingkat SAC-nya diberikan OpenStreetMap. Sisanya tidak diketahui.',
+ 'tours.hike.sac.highest': 'Tertinggi diketahui',
+ 'tours.hike.sac.routingLimit': 'Batas perutean',
+ 'tours.hike.sac.routingLimitTip':
+ 'Batas terbanyak yang boleh dipakai pencarian rute: sebuah pengaturan, bukan tingkat jalurnya.',
+ 'tours.hike.sac.aboveLimit': 'Di atas batas',
+ 'tours.hike.sac.loading': 'Membaca tingkat SAC…',
+ 'tours.hike.sac.offline': 'Tingkat SAC memerlukan koneksi.',
+ 'tours.hike.sac.failed': 'Tingkat SAC tidak dapat dibaca. Tur tidak berubah.',
+ 'tours.hike.sac.source': 'Dari OpenStreetMap. Mungkin ada celah; bukan penilaian keselamatan.',
'tours.copy.action': 'Simpan sebagai tur baru',
'tours.copy.hint': 'Menyalin tur ini sebagai tur baru yang mandiri tanpa hari-harinya. Aslinya tetap seperti semula.',
'tours.copy.name': '{name} (salinan)',
diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts
index 3e8987adf..6260e65c9 100644
--- a/shared/src/i18n/it/tours.ts
+++ b/shared/src/i18n/it/tours.ts
@@ -521,6 +521,36 @@ const tours: TranslationStrings = {
"Gradi come li hanno assegnati i mappatori di OpenStreetMap. Un percorso senza grado è sconosciuto, non facile. Non dicono nulla sull'adattezza del sentiero a te.",
'tours.bike.trail.loading': 'Lettura delle difficoltà dei sentieri…',
'tours.bike.trail.failed': 'Questa volta non è stato possibile leggere le difficoltà dei sentieri.',
+ 'tours.hike.sac.title': 'Gradi SAC',
+ 'tours.hike.sac.show': 'Mostra lungo il percorso',
+ 'tours.hike.sac.t1': 'Escursionismo',
+ 'tours.hike.sac.t2': 'Escursionismo di montagna',
+ 'tours.hike.sac.t3': 'Escursionismo di montagna impegnativo',
+ 'tours.hike.sac.t4': 'Escursionismo alpino',
+ 'tours.hike.sac.t5': 'Escursionismo alpino impegnativo',
+ 'tours.hike.sac.t6': 'Escursionismo alpino difficile',
+ 'tours.hike.sac.untagged': 'Nessun grado in OpenStreetMap',
+ 'tours.hike.sac.unclear': 'Grado non chiaro',
+ 'tours.hike.sac.unmatched': 'Sentiero non abbinato con certezza',
+ 'tours.hike.sac.unasked': 'Non interrogabile questa volta',
+ 'tours.hike.sac.unknown': 'Sconosciuto',
+ 'tours.hike.sac.stepTip': 'Scala escursionistica SAC {step}, da OpenStreetMap: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip':
+ 'OpenStreetMap non assegna sac_scale a questi sentieri. Sconosciuto non significa facile.',
+ 'tours.hike.sac.unclearTip': 'Qui OpenStreetMap riporta {tags}, che non indica un singolo grado.',
+ 'tours.hike.sac.unmatchedTip':
+ 'Qui nessun sentiero sta chiaramente sotto la linea del percorso, quindi non viene mostrato alcun grado.',
+ 'tours.hike.sac.known': 'Noto',
+ 'tours.hike.sac.knownTip': 'La parte del percorso di cui OpenStreetMap indica il grado SAC. Il resto è sconosciuto.',
+ 'tours.hike.sac.highest': 'Massimo noto',
+ 'tours.hike.sac.routingLimit': 'Limite di calcolo',
+ 'tours.hike.sac.routingLimitTip':
+ 'Il massimo che la ricerca del percorso può usare: un’impostazione, non il grado dei sentieri.',
+ 'tours.hike.sac.aboveLimit': 'Oltre il limite',
+ 'tours.hike.sac.loading': 'Lettura dei gradi SAC…',
+ 'tours.hike.sac.offline': 'I gradi SAC richiedono una connessione.',
+ 'tours.hike.sac.failed': 'Non è stato possibile leggere i gradi SAC. Il percorso non è cambiato.',
+ 'tours.hike.sac.source': 'Da OpenStreetMap. Possibili lacune; non è una valutazione di sicurezza.',
'tours.copy.action': 'Salva come nuovo tour',
'tours.copy.hint': 'Copia questo tour come nuovo tour indipendente, senza i suoi giorni. L’originale resta com’è.',
'tours.copy.name': '{name} (copia)',
diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts
index 3261fe948..bce7a8a10 100644
--- a/shared/src/i18n/ja/tours.ts
+++ b/shared/src/i18n/ja/tours.ts
@@ -499,6 +499,33 @@ const tours: TranslationStrings = {
'OpenStreetMap のマッパーが付けた難度です。難度のない道は簡単ではなく不明です。そのトレイルがあなたに合うかどうかは示しません。',
'tours.bike.trail.loading': 'トレイル難度を読み込み中…',
'tours.bike.trail.failed': '今回はトレイル難度を読み込めませんでした。',
+ 'tours.hike.sac.title': 'SAC難易度',
+ 'tours.hike.sac.show': 'ルートに沿って表示',
+ 'tours.hike.sac.t1': 'ハイキング',
+ 'tours.hike.sac.t2': '山岳ハイキング',
+ 'tours.hike.sac.t3': '上級山岳ハイキング',
+ 'tours.hike.sac.t4': 'アルパインハイキング',
+ 'tours.hike.sac.t5': '上級アルパインハイキング',
+ 'tours.hike.sac.t6': '難度の高いアルパインハイキング',
+ 'tours.hike.sac.untagged': 'OpenStreetMapに難易度なし',
+ 'tours.hike.sac.unclear': '難易度が不明確',
+ 'tours.hike.sac.unmatched': '道が明確に一致しない',
+ 'tours.hike.sac.unasked': '今回は問い合わせできませんでした',
+ 'tours.hike.sac.unknown': '不明',
+ 'tours.hike.sac.stepTip': 'SACハイキングスケール {step}、OpenStreetMapより: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip': 'OpenStreetMapはこれらの道にsac_scaleを付けていません。不明は易しいという意味ではありません。',
+ 'tours.hike.sac.unclearTip': 'OpenStreetMapはここに{tags}と記載しており、単一の段階を示していません。',
+ 'tours.hike.sac.unmatchedTip': 'ここではツアーの線の下に明確に道がないため、難易度は表示されません。',
+ 'tours.hike.sac.known': '判明',
+ 'tours.hike.sac.knownTip': 'OpenStreetMapがSAC難易度を示しているルートの割合です。残りは不明です。',
+ 'tours.hike.sac.highest': '判明している最高',
+ 'tours.hike.sac.routingLimit': 'ルート探索の上限',
+ 'tours.hike.sac.routingLimitTip': 'ルート探索が使える上限です。設定であり、道の難易度ではありません。',
+ 'tours.hike.sac.aboveLimit': '上限を超えています',
+ 'tours.hike.sac.loading': 'SAC難易度を読み込み中…',
+ 'tours.hike.sac.offline': 'SAC難易度には接続が必要です。',
+ 'tours.hike.sac.failed': 'SAC難易度を読み込めませんでした。ツアーは変更されていません。',
+ 'tours.hike.sac.source': 'OpenStreetMapより。欠けがある場合があり、安全性の評価ではありません。',
'tours.copy.action': '新しいツアーとして保存',
'tours.copy.hint': 'このツアーを日程なしの独立した新しいツアーとしてコピーします。元のツアーはそのままです。',
'tours.copy.name': '{name}(コピー)',
diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts
index dc767af57..28f358362 100644
--- a/shared/src/i18n/ko/tours.ts
+++ b/shared/src/i18n/ko/tours.ts
@@ -497,6 +497,33 @@ const tours: TranslationStrings = {
'OpenStreetMap 매퍼가 매긴 난이도입니다. 난이도가 없는 길은 쉬운 것이 아니라 알 수 없는 것입니다. 그 트레일이 당신에게 맞는지는 알려 주지 않습니다.',
'tours.bike.trail.loading': '트레일 난이도를 읽는 중…',
'tours.bike.trail.failed': '이번에는 트레일 난이도를 읽지 못했습니다.',
+ 'tours.hike.sac.title': 'SAC 등급',
+ 'tours.hike.sac.show': '경로를 따라 표시',
+ 'tours.hike.sac.t1': '하이킹',
+ 'tours.hike.sac.t2': '산악 하이킹',
+ 'tours.hike.sac.t3': '고난도 산악 하이킹',
+ 'tours.hike.sac.t4': '고산 하이킹',
+ 'tours.hike.sac.t5': '고난도 고산 하이킹',
+ 'tours.hike.sac.t6': '매우 어려운 고산 하이킹',
+ 'tours.hike.sac.untagged': 'OpenStreetMap에 등급 없음',
+ 'tours.hike.sac.unclear': '등급 불분명',
+ 'tours.hike.sac.unmatched': '길이 명확히 일치하지 않음',
+ 'tours.hike.sac.unasked': '이번에는 조회할 수 없음',
+ 'tours.hike.sac.unknown': '알 수 없음',
+ 'tours.hike.sac.stepTip': 'SAC 하이킹 등급 {step}, OpenStreetMap 기준: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip': 'OpenStreetMap은 이 길들에 sac_scale을 주지 않습니다. 알 수 없음은 쉬움이 아닙니다.',
+ 'tours.hike.sac.unclearTip': 'OpenStreetMap은 여기에 {tags}라고 적어 하나의 등급을 가리키지 않습니다.',
+ 'tours.hike.sac.unmatchedTip': '여기서는 투어 선 아래에 명확히 놓인 길이 없어 등급을 표시하지 않습니다.',
+ 'tours.hike.sac.known': '알려짐',
+ 'tours.hike.sac.knownTip': 'OpenStreetMap이 SAC 등급을 제공하는 경로의 비율입니다. 나머지는 알 수 없습니다.',
+ 'tours.hike.sac.highest': '알려진 최고',
+ 'tours.hike.sac.routingLimit': '경로 탐색 한도',
+ 'tours.hike.sac.routingLimitTip': '경로 탐색이 사용할 수 있는 최대치입니다. 설정이며 길의 난이도가 아닙니다.',
+ 'tours.hike.sac.aboveLimit': '한도 초과',
+ 'tours.hike.sac.loading': 'SAC 등급을 읽는 중…',
+ 'tours.hike.sac.offline': 'SAC 등급에는 연결이 필요합니다.',
+ 'tours.hike.sac.failed': 'SAC 등급을 읽을 수 없습니다. 투어는 그대로입니다.',
+ 'tours.hike.sac.source': 'OpenStreetMap 기준. 빈 곳이 있을 수 있으며 안전 평가가 아닙니다.',
'tours.copy.action': '새 투어로 저장',
'tours.copy.hint': '이 투어를 날짜 없이 독립된 새 투어로 복사합니다. 원본은 그대로입니다.',
'tours.copy.name': '{name} (사본)',
diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts
index 3b2f70ab2..75822f491 100644
--- a/shared/src/i18n/nl/tours.ts
+++ b/shared/src/i18n/nl/tours.ts
@@ -520,6 +520,35 @@ const tours: TranslationStrings = {
'Graden zoals OpenStreetMap-mappers ze gaven. Een weg zonder graad is onbekend, niet makkelijk. Ze zeggen niets over of een trail bij je past.',
'tours.bike.trail.loading': 'Trailgraden worden gelezen…',
'tours.bike.trail.failed': 'De trailgraden konden deze keer niet worden gelezen.',
+ 'tours.hike.sac.title': 'SAC-graden',
+ 'tours.hike.sac.show': 'Langs de route tonen',
+ 'tours.hike.sac.t1': 'Wandelen',
+ 'tours.hike.sac.t2': 'Bergwandelen',
+ 'tours.hike.sac.t3': 'Veeleisend bergwandelen',
+ 'tours.hike.sac.t4': 'Alpien wandelen',
+ 'tours.hike.sac.t5': 'Veeleisend alpien wandelen',
+ 'tours.hike.sac.t6': 'Moeilijk alpien wandelen',
+ 'tours.hike.sac.untagged': 'Geen graad in OpenStreetMap',
+ 'tours.hike.sac.unclear': 'Graad onduidelijk',
+ 'tours.hike.sac.unmatched': 'Pad niet eenduidig gekoppeld',
+ 'tours.hike.sac.unasked': 'Kon deze keer niet worden opgevraagd',
+ 'tours.hike.sac.unknown': 'Onbekend',
+ 'tours.hike.sac.stepTip': 'SAC-wandelschaal {step}, uit OpenStreetMap: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip': 'OpenStreetMap geeft deze paden geen sac_scale. Onbekend betekent niet makkelijk.',
+ 'tours.hike.sac.unclearTip': 'OpenStreetMap vermeldt hier {tags}, wat geen enkele graad aanduidt.',
+ 'tours.hike.sac.unmatchedTip':
+ 'Hier ligt geen pad duidelijk onder de lijn van de tocht, dus er wordt geen graad getoond.',
+ 'tours.hike.sac.known': 'Bekend',
+ 'tours.hike.sac.knownTip': 'Het deel van de route waarvoor OpenStreetMap een SAC-graad geeft. De rest is onbekend.',
+ 'tours.hike.sac.highest': 'Hoogste bekende',
+ 'tours.hike.sac.routingLimit': 'Routeringsgrens',
+ 'tours.hike.sac.routingLimitTip':
+ 'Het hoogste dat de routezoeker mag gebruiken: een instelling, niet de graad van de paden.',
+ 'tours.hike.sac.aboveLimit': 'Boven de grens',
+ 'tours.hike.sac.loading': 'SAC-graden worden gelezen…',
+ 'tours.hike.sac.offline': 'De SAC-graden hebben een verbinding nodig.',
+ 'tours.hike.sac.failed': 'De SAC-graden konden niet worden gelezen. De tocht is ongewijzigd.',
+ 'tours.hike.sac.source': 'Uit OpenStreetMap. Hiaten zijn mogelijk; geen veiligheidsoordeel.',
'tours.copy.action': 'Opslaan als nieuwe tocht',
'tours.copy.hint':
'Kopieert deze tocht als nieuwe, zelfstandige tocht zonder zijn dagen. Het origineel blijft zoals het is.',
diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts
index 875c79b67..3f7265209 100644
--- a/shared/src/i18n/pl/tours.ts
+++ b/shared/src/i18n/pl/tours.ts
@@ -527,6 +527,34 @@ const tours: TranslationStrings = {
'Stopnie nadane przez maperów OpenStreetMap. Droga bez stopnia jest nieznana, a nie łatwa. Nie mówią, czy szlak jest dla ciebie odpowiedni.',
'tours.bike.trail.loading': 'Odczytywanie stopni trudności…',
'tours.bike.trail.failed': 'Tym razem nie udało się odczytać stopni trudności.',
+ 'tours.hike.sac.title': 'Stopnie SAC',
+ 'tours.hike.sac.show': 'Pokaż wzdłuż trasy',
+ 'tours.hike.sac.t1': 'Turystyka piesza',
+ 'tours.hike.sac.t2': 'Turystyka górska',
+ 'tours.hike.sac.t3': 'Wymagająca turystyka górska',
+ 'tours.hike.sac.t4': 'Turystyka wysokogórska',
+ 'tours.hike.sac.t5': 'Wymagająca turystyka wysokogórska',
+ 'tours.hike.sac.t6': 'Trudna turystyka wysokogórska',
+ 'tours.hike.sac.untagged': 'Brak stopnia w OpenStreetMap',
+ 'tours.hike.sac.unclear': 'Stopień niejasny',
+ 'tours.hike.sac.unmatched': 'Droga nie jest jednoznacznie dopasowana',
+ 'tours.hike.sac.unasked': 'Tym razem nie udało się sprawdzić',
+ 'tours.hike.sac.unknown': 'Nieznane',
+ 'tours.hike.sac.stepTip': 'Skala turystyczna SAC {step}, z OpenStreetMap: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip': 'OpenStreetMap nie podaje dla tych dróg sac_scale. Nieznane nie znaczy łatwe.',
+ 'tours.hike.sac.unclearTip': 'OpenStreetMap podaje tu {tags}, co nie wskazuje jednego stopnia.',
+ 'tours.hike.sac.unmatchedTip':
+ 'Tutaj żadna droga nie leży wyraźnie pod linią wycieczki, więc stopień nie jest pokazywany.',
+ 'tours.hike.sac.known': 'Znane',
+ 'tours.hike.sac.knownTip': 'Część trasy, dla której OpenStreetMap podaje stopień SAC. Reszta jest nieznana.',
+ 'tours.hike.sac.highest': 'Najwyższy znany',
+ 'tours.hike.sac.routingLimit': 'Limit wyznaczania trasy',
+ 'tours.hike.sac.routingLimitTip': 'Najwięcej, ile może użyć wyszukiwanie trasy: ustawienie, a nie trudność dróg.',
+ 'tours.hike.sac.aboveLimit': 'Powyżej limitu',
+ 'tours.hike.sac.loading': 'Odczytywanie stopni SAC…',
+ 'tours.hike.sac.offline': 'Stopnie SAC wymagają połączenia.',
+ 'tours.hike.sac.failed': 'Nie udało się odczytać stopni SAC. Wycieczka pozostaje bez zmian.',
+ 'tours.hike.sac.source': 'Z OpenStreetMap. Możliwe luki; to nie jest ocena bezpieczeństwa.',
'tours.copy.action': 'Zapisz jako nową trasę',
'tours.copy.hint': 'Kopiuje tę trasę jako nową, niezależną trasę bez jej dni. Oryginał zostaje bez zmian.',
'tours.copy.name': '{name} (kopia)',
diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts
index fac9e4dd9..8ee63766c 100644
--- a/shared/src/i18n/ru/tours.ts
+++ b/shared/src/i18n/ru/tours.ts
@@ -531,6 +531,35 @@ const tours: TranslationStrings = {
'Сложность, как её указали участники OpenStreetMap. Дорога без оценки неизвестна, а не проста. Это ничего не говорит о том, подходит ли тебе трейл.',
'tours.bike.trail.loading': 'Читаем сложность троп…',
'tours.bike.trail.failed': 'На этот раз не удалось прочитать сложность троп.',
+ 'tours.hike.sac.title': 'Категории SAC',
+ 'tours.hike.sac.show': 'Показать вдоль маршрута',
+ 'tours.hike.sac.t1': 'Пеший поход',
+ 'tours.hike.sac.t2': 'Горный поход',
+ 'tours.hike.sac.t3': 'Сложный горный поход',
+ 'tours.hike.sac.t4': 'Высокогорный поход',
+ 'tours.hike.sac.t5': 'Сложный высокогорный поход',
+ 'tours.hike.sac.t6': 'Очень сложный высокогорный поход',
+ 'tours.hike.sac.untagged': 'Нет категории в OpenStreetMap',
+ 'tours.hike.sac.unclear': 'Категория неясна',
+ 'tours.hike.sac.unmatched': 'Путь не сопоставлен однозначно',
+ 'tours.hike.sac.unasked': 'В этот раз не удалось запросить',
+ 'tours.hike.sac.unknown': 'Неизвестно',
+ 'tours.hike.sac.stepTip': 'Шкала пеших маршрутов SAC {step}, из OpenStreetMap: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip': 'OpenStreetMap не указывает sac_scale для этих путей. Неизвестно не значит легко.',
+ 'tours.hike.sac.unclearTip': 'OpenStreetMap указывает здесь {tags}, что не означает одну категорию.',
+ 'tours.hike.sac.unmatchedTip':
+ 'Здесь ни один путь однозначно не лежит под линией маршрута, поэтому категория не показана.',
+ 'tours.hike.sac.known': 'Известно',
+ 'tours.hike.sac.knownTip': 'Доля маршрута, для которой OpenStreetMap указывает категорию SAC. Остальное неизвестно.',
+ 'tours.hike.sac.highest': 'Высшая известная',
+ 'tours.hike.sac.routingLimit': 'Предел маршрутизации',
+ 'tours.hike.sac.routingLimitTip':
+ 'Максимум, который может использовать поиск маршрута: настройка, а не сложность путей.',
+ 'tours.hike.sac.aboveLimit': 'Выше предела',
+ 'tours.hike.sac.loading': 'Загрузка категорий SAC…',
+ 'tours.hike.sac.offline': 'Для категорий SAC нужно подключение.',
+ 'tours.hike.sac.failed': 'Не удалось загрузить категории SAC. Маршрут не изменён.',
+ 'tours.hike.sac.source': 'Из OpenStreetMap. Возможны пробелы; это не оценка безопасности.',
'tours.copy.action': 'Сохранить как новый поход',
'tours.copy.hint': 'Копирует этот поход как новый, самостоятельный поход без его дней. Оригинал остаётся как есть.',
'tours.copy.name': '{name} (копия)',
diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts
index 56199e441..53813795a 100644
--- a/shared/src/i18n/sk/tours.ts
+++ b/shared/src/i18n/sk/tours.ts
@@ -512,6 +512,33 @@ const tours: TranslationStrings = {
'Stupne, ako ich zadali mapovači OpenStreetMap. Cesta bez stupňa je neznáma, nie ľahká. Nehovoria nič o tom, či sa trail hodí pre teba.',
'tours.bike.trail.loading': 'Načítava sa obtiažnosť trailov…',
'tours.bike.trail.failed': 'Obtiažnosť trailov sa tentoraz nepodarilo načítať.',
+ 'tours.hike.sac.title': 'Obtiažnosť SAC',
+ 'tours.hike.sac.show': 'Zobraziť pozdĺž trasy',
+ 'tours.hike.sac.t1': 'Pešia turistika',
+ 'tours.hike.sac.t2': 'Horská turistika',
+ 'tours.hike.sac.t3': 'Náročná horská turistika',
+ 'tours.hike.sac.t4': 'Vysokohorská turistika',
+ 'tours.hike.sac.t5': 'Náročná vysokohorská turistika',
+ 'tours.hike.sac.t6': 'Veľmi náročná vysokohorská turistika',
+ 'tours.hike.sac.untagged': 'Bez stupňa v OpenStreetMap',
+ 'tours.hike.sac.unclear': 'Stupeň nejasný',
+ 'tours.hike.sac.unmatched': 'Cesta nie je jednoznačne priradená',
+ 'tours.hike.sac.unasked': 'Tentoraz sa nepodarilo zistiť',
+ 'tours.hike.sac.unknown': 'Neznáme',
+ 'tours.hike.sac.stepTip': 'Turistická stupnica SAC {step}, z OpenStreetMap: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip': 'OpenStreetMap týmto cestám nedáva sac_scale. Neznáme neznamená ľahké.',
+ 'tours.hike.sac.unclearTip': 'OpenStreetMap tu uvádza {tags}, čo neoznačuje jediný stupeň.',
+ 'tours.hike.sac.unmatchedTip': 'Tu pod líniou túry jednoznačne neleží žiadna cesta, preto sa stupeň nezobrazuje.',
+ 'tours.hike.sac.known': 'Známe',
+ 'tours.hike.sac.knownTip': 'Podiel trasy, pre ktorý OpenStreetMap uvádza stupeň SAC. Zvyšok je neznámy.',
+ 'tours.hike.sac.highest': 'Najvyšší známy',
+ 'tours.hike.sac.routingLimit': 'Limit trasovania',
+ 'tours.hike.sac.routingLimitTip': 'Najviac, čo smie hľadanie trasy použiť: nastavenie, nie obtiažnosť ciest.',
+ 'tours.hike.sac.aboveLimit': 'Nad limitom',
+ 'tours.hike.sac.loading': 'Načítavajú sa stupne SAC…',
+ 'tours.hike.sac.offline': 'Stupne SAC potrebujú pripojenie.',
+ 'tours.hike.sac.failed': 'Stupne SAC sa nepodarilo načítať. Túra zostáva bez zmeny.',
+ 'tours.hike.sac.source': 'Z OpenStreetMap. Môžu chýbať údaje; nejde o hodnotenie bezpečnosti.',
'tours.copy.action': 'Uložiť ako novú túru',
'tours.copy.hint': 'Skopíruje túto túru ako novú, samostatnú túru bez jej dní. Originál zostane, ako je.',
'tours.copy.name': '{name} (kópia)',
diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts
index 729d2ed99..846358c6e 100644
--- a/shared/src/i18n/sv/tours.ts
+++ b/shared/src/i18n/sv/tours.ts
@@ -509,6 +509,33 @@ const tours: TranslationStrings = {
'Grader som OpenStreetMaps kartläggare satt dem. En väg utan grad är okänd, inte lätt. De säger inget om huruvida en stig passar dig.',
'tours.bike.trail.loading': 'Läser svårighetsgraderna…',
'tours.bike.trail.failed': 'Svårighetsgraderna kunde inte läsas den här gången.',
+ 'tours.hike.sac.title': 'SAC-grader',
+ 'tours.hike.sac.show': 'Visa längs rutten',
+ 'tours.hike.sac.t1': 'Vandring',
+ 'tours.hike.sac.t2': 'Bergsvandring',
+ 'tours.hike.sac.t3': 'Krävande bergsvandring',
+ 'tours.hike.sac.t4': 'Alpin vandring',
+ 'tours.hike.sac.t5': 'Krävande alpin vandring',
+ 'tours.hike.sac.t6': 'Svår alpin vandring',
+ 'tours.hike.sac.untagged': 'Ingen grad i OpenStreetMap',
+ 'tours.hike.sac.unclear': 'Graden oklar',
+ 'tours.hike.sac.unmatched': 'Vägen inte entydigt matchad',
+ 'tours.hike.sac.unasked': 'Kunde inte frågas den här gången',
+ 'tours.hike.sac.unknown': 'Okänt',
+ 'tours.hike.sac.stepTip': 'SAC-vandringsskala {step}, från OpenStreetMap: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip': 'OpenStreetMap ger dessa vägar ingen sac_scale. Okänt betyder inte lätt.',
+ 'tours.hike.sac.unclearTip': 'OpenStreetMap anger här {tags}, vilket inte pekar ut en enda grad.',
+ 'tours.hike.sac.unmatchedTip': 'Här ligger ingen väg tydligt under turens linje, så ingen grad visas.',
+ 'tours.hike.sac.known': 'Känt',
+ 'tours.hike.sac.knownTip': 'Den del av rutten där OpenStreetMap anger en SAC-grad. Resten är okänd.',
+ 'tours.hike.sac.highest': 'Högsta kända',
+ 'tours.hike.sac.routingLimit': 'Ruttgräns',
+ 'tours.hike.sac.routingLimitTip': 'Det mesta ruttsökningen får använda: en inställning, inte vägarnas svårighet.',
+ 'tours.hike.sac.aboveLimit': 'Över gränsen',
+ 'tours.hike.sac.loading': 'Läser SAC-graderna…',
+ 'tours.hike.sac.offline': 'SAC-graderna behöver en anslutning.',
+ 'tours.hike.sac.failed': 'SAC-graderna kunde inte läsas. Turen är oförändrad.',
+ 'tours.hike.sac.source': 'Från OpenStreetMap. Luckor kan finnas; ingen säkerhetsbedömning.',
'tours.copy.action': 'Spara som ny tur',
'tours.copy.hint': 'Kopierar turen som en ny, fristående tur utan dess dagar. Originalet förblir som det är.',
'tours.copy.name': '{name} (kopia)',
diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts
index d62a5dd54..e10f720e9 100644
--- a/shared/src/i18n/th/tours.ts
+++ b/shared/src/i18n/th/tours.ts
@@ -490,6 +490,33 @@ const tours: TranslationStrings = {
'ระดับตามที่ผู้ทำแผนที่ OpenStreetMap กำหนด ทางที่ไม่มีระดับคือไม่ทราบ ไม่ใช่ง่าย และไม่ได้บอกว่าเส้นทางเหมาะกับคุณหรือไม่',
'tours.bike.trail.loading': 'กำลังอ่านระดับความยาก…',
'tours.bike.trail.failed': 'ครั้งนี้อ่านระดับความยากไม่ได้',
+ 'tours.hike.sac.title': 'ระดับ SAC',
+ 'tours.hike.sac.show': 'แสดงตามเส้นทาง',
+ 'tours.hike.sac.t1': 'เดินป่า',
+ 'tours.hike.sac.t2': 'เดินป่าบนภูเขา',
+ 'tours.hike.sac.t3': 'เดินป่าบนภูเขาระดับท้าทาย',
+ 'tours.hike.sac.t4': 'เดินป่าแบบอัลไพน์',
+ 'tours.hike.sac.t5': 'เดินป่าแบบอัลไพน์ระดับท้าทาย',
+ 'tours.hike.sac.t6': 'เดินป่าแบบอัลไพน์ระดับยาก',
+ 'tours.hike.sac.untagged': 'ไม่มีระดับใน OpenStreetMap',
+ 'tours.hike.sac.unclear': 'ระดับไม่ชัดเจน',
+ 'tours.hike.sac.unmatched': 'ทางไม่ตรงกันอย่างชัดเจน',
+ 'tours.hike.sac.unasked': 'ครั้งนี้สอบถามไม่ได้',
+ 'tours.hike.sac.unknown': 'ไม่ทราบ',
+ 'tours.hike.sac.stepTip': 'มาตราการเดินป่า SAC {step} จาก OpenStreetMap: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip': 'OpenStreetMap ไม่ได้ระบุ sac_scale ให้ทางเหล่านี้ ไม่ทราบไม่ได้แปลว่าง่าย',
+ 'tours.hike.sac.unclearTip': 'OpenStreetMap เขียนไว้ที่นี่ว่า {tags} ซึ่งไม่ได้ระบุระดับเดียว',
+ 'tours.hike.sac.unmatchedTip': 'ที่นี่ไม่มีทางใดอยู่ใต้เส้นของทัวร์อย่างชัดเจน จึงไม่แสดงระดับ',
+ 'tours.hike.sac.known': 'ทราบแล้ว',
+ 'tours.hike.sac.knownTip': 'สัดส่วนของเส้นทางที่ OpenStreetMap ระบุระดับ SAC ส่วนที่เหลือไม่ทราบ',
+ 'tours.hike.sac.highest': 'สูงสุดที่ทราบ',
+ 'tours.hike.sac.routingLimit': 'ขีดจำกัดการหาเส้นทาง',
+ 'tours.hike.sac.routingLimitTip': 'ค่าสูงสุดที่การค้นหาเส้นทางใช้ได้ เป็นการตั้งค่า ไม่ใช่ระดับความยากของทาง',
+ 'tours.hike.sac.aboveLimit': 'เกินขีดจำกัด',
+ 'tours.hike.sac.loading': 'กำลังอ่านระดับ SAC…',
+ 'tours.hike.sac.offline': 'ระดับ SAC ต้องใช้การเชื่อมต่อ',
+ 'tours.hike.sac.failed': 'อ่านระดับ SAC ไม่ได้ ทัวร์ไม่เปลี่ยนแปลง',
+ 'tours.hike.sac.source': 'จาก OpenStreetMap อาจมีช่องว่าง ไม่ใช่การประเมินความปลอดภัย',
'tours.copy.action': 'บันทึกเป็นทัวร์ใหม่',
'tours.copy.hint': 'คัดลอกทัวร์นี้เป็นทัวร์ใหม่ที่แยกเป็นอิสระโดยไม่รวมวัน ต้นฉบับจะคงเดิม',
'tours.copy.name': '{name} (สำเนา)',
diff --git a/shared/src/i18n/tours-locales.spec.ts b/shared/src/i18n/tours-locales.spec.ts
index 9a32f7cd0..d4ad1dc02 100644
--- a/shared/src/i18n/tours-locales.spec.ts
+++ b/shared/src/i18n/tours-locales.spec.ts
@@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match)
// placeholder spec; here only the general keys are.
const groups = pluralGroups(Object.keys(english));
const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups));
-const GENERAL_KEY_COUNT = 959;
+const GENERAL_KEY_COUNT = 986;
function literalKeys(source: string): string[] {
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts
index c37286410..5cdaed5ff 100644
--- a/shared/src/i18n/tr/tours.ts
+++ b/shared/src/i18n/tr/tours.ts
@@ -513,6 +513,33 @@ const tours: TranslationStrings = {
'Zorluklar OpenStreetMap haritacılarının verdiği gibidir. Zorluğu olmayan bir yol kolay değil, bilinmezdir. Parkurun sana uygun olup olmadığını söylemez.',
'tours.bike.trail.loading': 'Parkur zorlukları okunuyor…',
'tours.bike.trail.failed': 'Parkur zorlukları bu kez okunamadı.',
+ 'tours.hike.sac.title': 'SAC dereceleri',
+ 'tours.hike.sac.show': 'Rota boyunca göster',
+ 'tours.hike.sac.t1': 'Doğa yürüyüşü',
+ 'tours.hike.sac.t2': 'Dağ yürüyüşü',
+ 'tours.hike.sac.t3': 'Zorlu dağ yürüyüşü',
+ 'tours.hike.sac.t4': 'Yüksek dağ yürüyüşü',
+ 'tours.hike.sac.t5': 'Zorlu yüksek dağ yürüyüşü',
+ 'tours.hike.sac.t6': 'Çok zor yüksek dağ yürüyüşü',
+ 'tours.hike.sac.untagged': 'OpenStreetMap’te derece yok',
+ 'tours.hike.sac.unclear': 'Derece belirsiz',
+ 'tours.hike.sac.unmatched': 'Yol net eşleşmedi',
+ 'tours.hike.sac.unasked': 'Bu sefer sorgulanamadı',
+ 'tours.hike.sac.unknown': 'Bilinmiyor',
+ 'tours.hike.sac.stepTip': 'SAC yürüyüş ölçeği {step}, OpenStreetMap’ten: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip': 'OpenStreetMap bu yollara sac_scale vermiyor. Bilinmiyor kolay demek değildir.',
+ 'tours.hike.sac.unclearTip': 'OpenStreetMap burada {tags} yazıyor; bu tek bir derece belirtmiyor.',
+ 'tours.hike.sac.unmatchedTip': 'Burada turun çizgisinin altında net bir yol yok, bu yüzden derece gösterilmiyor.',
+ 'tours.hike.sac.known': 'Bilinen',
+ 'tours.hike.sac.knownTip': 'Rotanın, OpenStreetMap’in SAC derecesi verdiği payı. Gerisi bilinmiyor.',
+ 'tours.hike.sac.highest': 'En yüksek bilinen',
+ 'tours.hike.sac.routingLimit': 'Rota sınırı',
+ 'tours.hike.sac.routingLimitTip': 'Rota aramasının kullanabileceği en fazla değer: bir ayar, yolların zorluğu değil.',
+ 'tours.hike.sac.aboveLimit': 'Sınırın üstünde',
+ 'tours.hike.sac.loading': 'SAC dereceleri okunuyor…',
+ 'tours.hike.sac.offline': 'SAC dereceleri için bağlantı gerekir.',
+ 'tours.hike.sac.failed': 'SAC dereceleri okunamadı. Tur değişmedi.',
+ 'tours.hike.sac.source': 'OpenStreetMap’ten. Boşluklar olabilir; bir güvenlik değerlendirmesi değildir.',
'tours.copy.action': 'Yeni tur olarak kaydet',
'tours.copy.hint': 'Bu turu günleri olmadan yeni ve bağımsız bir tur olarak kopyalar. Asıl tur olduğu gibi kalır.',
'tours.copy.name': '{name} (kopya)',
diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts
index 5cc176c74..4dd986177 100644
--- a/shared/src/i18n/uk/tours.ts
+++ b/shared/src/i18n/uk/tours.ts
@@ -526,6 +526,34 @@ const tours: TranslationStrings = {
'Складність, як її вказали учасники OpenStreetMap. Дорога без оцінки невідома, а не легка. Це нічого не каже про те, чи підходить тобі трейл.',
'tours.bike.trail.loading': 'Читаємо складність трейлів…',
'tours.bike.trail.failed': 'Цього разу не вдалося прочитати складність трейлів.',
+ 'tours.hike.sac.title': 'Категорії SAC',
+ 'tours.hike.sac.show': 'Показати вздовж маршруту',
+ 'tours.hike.sac.t1': 'Піший похід',
+ 'tours.hike.sac.t2': 'Гірський похід',
+ 'tours.hike.sac.t3': 'Складний гірський похід',
+ 'tours.hike.sac.t4': 'Високогірний похід',
+ 'tours.hike.sac.t5': 'Складний високогірний похід',
+ 'tours.hike.sac.t6': 'Дуже складний високогірний похід',
+ 'tours.hike.sac.untagged': 'Немає категорії в OpenStreetMap',
+ 'tours.hike.sac.unclear': 'Категорія неясна',
+ 'tours.hike.sac.unmatched': 'Шлях не зіставлено однозначно',
+ 'tours.hike.sac.unasked': 'Цього разу не вдалося запитати',
+ 'tours.hike.sac.unknown': 'Невідомо',
+ 'tours.hike.sac.stepTip': 'Шкала пішохідних маршрутів SAC {step}, з OpenStreetMap: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip': 'OpenStreetMap не вказує sac_scale для цих шляхів. Невідомо не означає легко.',
+ 'tours.hike.sac.unclearTip': 'OpenStreetMap вказує тут {tags}, що не означає одну категорію.',
+ 'tours.hike.sac.unmatchedTip': 'Тут жоден шлях однозначно не лежить під лінією маршруту, тому категорія не показана.',
+ 'tours.hike.sac.known': 'Відомо',
+ 'tours.hike.sac.knownTip': 'Частка маршруту, для якої OpenStreetMap вказує категорію SAC. Решта невідома.',
+ 'tours.hike.sac.highest': 'Найвища відома',
+ 'tours.hike.sac.routingLimit': 'Межа маршрутизації',
+ 'tours.hike.sac.routingLimitTip':
+ 'Найбільше, що може використати пошук маршруту: налаштування, а не складність шляхів.',
+ 'tours.hike.sac.aboveLimit': 'Вище межі',
+ 'tours.hike.sac.loading': 'Завантаження категорій SAC…',
+ 'tours.hike.sac.offline': 'Для категорій SAC потрібне з’єднання.',
+ 'tours.hike.sac.failed': 'Не вдалося завантажити категорії SAC. Маршрут не змінено.',
+ 'tours.hike.sac.source': 'З OpenStreetMap. Можливі прогалини; це не оцінка безпеки.',
'tours.copy.action': 'Зберегти як новий похід',
'tours.copy.hint': 'Копіює цей похід як новий, самостійний похід без його днів. Оригінал лишається як є.',
'tours.copy.name': '{name} (копія)',
diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts
index f3ef7eb85..d3d737c79 100644
--- a/shared/src/i18n/vi/tours.ts
+++ b/shared/src/i18n/vi/tours.ts
@@ -510,6 +510,34 @@ const tours: TranslationStrings = {
'Cấp độ theo những người lập bản đồ OpenStreetMap ghi. Đường không có cấp độ là chưa rõ, không phải dễ. Chúng không cho biết đường mòn có hợp với bạn không.',
'tours.bike.trail.loading': 'Đang đọc cấp độ đường mòn…',
'tours.bike.trail.failed': 'Lần này không đọc được cấp độ đường mòn.',
+ 'tours.hike.sac.title': 'Cấp độ SAC',
+ 'tours.hike.sac.show': 'Hiển thị dọc tuyến đường',
+ 'tours.hike.sac.t1': 'Đi bộ đường dài',
+ 'tours.hike.sac.t2': 'Đi bộ đường dài trên núi',
+ 'tours.hike.sac.t3': 'Đi bộ đường dài trên núi đòi hỏi kỹ năng cao',
+ 'tours.hike.sac.t4': 'Đi bộ đường dài trên núi cao',
+ 'tours.hike.sac.t5': 'Đi bộ đường dài trên núi cao đòi hỏi kỹ năng cao',
+ 'tours.hike.sac.t6': 'Đi bộ đường dài trên núi cao rất khó',
+ 'tours.hike.sac.untagged': 'Không có cấp độ trong OpenStreetMap',
+ 'tours.hike.sac.unclear': 'Cấp độ không rõ',
+ 'tours.hike.sac.unmatched': 'Đường chưa khớp rõ ràng',
+ 'tours.hike.sac.unasked': 'Lần này không truy vấn được',
+ 'tours.hike.sac.unknown': 'Không rõ',
+ 'tours.hike.sac.stepTip': 'Thang leo núi SAC {step}, từ OpenStreetMap: sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip': 'OpenStreetMap không gán sac_scale cho các đường này. Không rõ không có nghĩa là dễ.',
+ 'tours.hike.sac.unclearTip': 'OpenStreetMap ghi {tags} ở đây, không chỉ một cấp độ duy nhất.',
+ 'tours.hike.sac.unmatchedTip': 'Ở đây không có đường nào nằm rõ dưới tuyến của chuyến đi, nên không hiển thị cấp độ.',
+ 'tours.hike.sac.known': 'Đã biết',
+ 'tours.hike.sac.knownTip': 'Phần tuyến đường mà OpenStreetMap cho biết cấp độ SAC. Phần còn lại không rõ.',
+ 'tours.hike.sac.highest': 'Cao nhất đã biết',
+ 'tours.hike.sac.routingLimit': 'Giới hạn định tuyến',
+ 'tours.hike.sac.routingLimitTip':
+ 'Mức cao nhất mà tìm đường được phép dùng: một thiết lập, không phải độ khó của đường.',
+ 'tours.hike.sac.aboveLimit': 'Vượt giới hạn',
+ 'tours.hike.sac.loading': 'Đang đọc cấp độ SAC…',
+ 'tours.hike.sac.offline': 'Cấp độ SAC cần kết nối mạng.',
+ 'tours.hike.sac.failed': 'Không đọc được cấp độ SAC. Chuyến đi không thay đổi.',
+ 'tours.hike.sac.source': 'Từ OpenStreetMap. Có thể thiếu dữ liệu; không phải đánh giá an toàn.',
'tours.copy.action': 'Lưu thành chuyến đi bộ mới',
'tours.copy.hint': 'Sao chép chuyến này thành một chuyến đi bộ mới, độc lập, không kèm các ngày. Bản gốc giữ nguyên.',
'tours.copy.name': '{name} (bản sao)',
diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts
index 01ff457e5..6742bfbd0 100644
--- a/shared/src/i18n/zh-TW/tours.ts
+++ b/shared/src/i18n/zh-TW/tours.ts
@@ -466,6 +466,33 @@ const tours: TranslationStrings = {
'tours.bike.trail.note': '難度為 OpenStreetMap 繪圖者所標註。無難度的道路為未知,而非容易。它不說明路徑是否適合你。',
'tours.bike.trail.loading': '正在讀取路徑難度…',
'tours.bike.trail.failed': '本次無法讀取路徑難度。',
+ 'tours.hike.sac.title': 'SAC 難度',
+ 'tours.hike.sac.show': '沿路線顯示',
+ 'tours.hike.sac.t1': '健行',
+ 'tours.hike.sac.t2': '山區健行',
+ 'tours.hike.sac.t3': '具挑戰性的山區健行',
+ 'tours.hike.sac.t4': '高山健行',
+ 'tours.hike.sac.t5': '具挑戰性的高山健行',
+ 'tours.hike.sac.t6': '高難度高山健行',
+ 'tours.hike.sac.untagged': 'OpenStreetMap 中無難度',
+ 'tours.hike.sac.unclear': '難度不明確',
+ 'tours.hike.sac.unmatched': '道路未明確對應',
+ 'tours.hike.sac.unasked': '本次無法查詢',
+ 'tours.hike.sac.unknown': '未知',
+ 'tours.hike.sac.stepTip': 'SAC 健行難度 {step},來自 OpenStreetMap:sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip': 'OpenStreetMap 未替這些道路標註 sac_scale。未知並不代表容易。',
+ 'tours.hike.sac.unclearTip': 'OpenStreetMap 在此處寫的是 {tags},並未指出單一等級。',
+ 'tours.hike.sac.unmatchedTip': '此處沒有道路明確位於路線下方,因此不顯示難度。',
+ 'tours.hike.sac.known': '已知',
+ 'tours.hike.sac.knownTip': 'OpenStreetMap 提供 SAC 難度的路線比例。其餘為未知。',
+ 'tours.hike.sac.highest': '已知最高',
+ 'tours.hike.sac.routingLimit': '路線規劃上限',
+ 'tours.hike.sac.routingLimitTip': '路線搜尋最多可使用的難度:這是一項設定,不是道路的難度。',
+ 'tours.hike.sac.aboveLimit': '超出上限',
+ 'tours.hike.sac.loading': '正在讀取 SAC 難度…',
+ 'tours.hike.sac.offline': 'SAC 難度需要網路連線。',
+ 'tours.hike.sac.failed': '無法讀取 SAC 難度。路線未改變。',
+ 'tours.hike.sac.source': '來自 OpenStreetMap。可能有缺漏;並非安全評估。',
'tours.copy.action': '另存為新遊程',
'tours.copy.hint': '將此遊程複製為不含日程的獨立新遊程。原遊程保持不變。',
'tours.copy.name': '{name}(副本)',
diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts
index 37d5c65f4..a031ff853 100644
--- a/shared/src/i18n/zh/tours.ts
+++ b/shared/src/i18n/zh/tours.ts
@@ -467,6 +467,33 @@ const tours: TranslationStrings = {
'tours.bike.trail.note': '难度为 OpenStreetMap 绘图者所标注。无难度的道路为未知,而非容易。它不说明路径是否适合你。',
'tours.bike.trail.loading': '正在读取路径难度…',
'tours.bike.trail.failed': '本次无法读取路径难度。',
+ 'tours.hike.sac.title': 'SAC 难度',
+ 'tours.hike.sac.show': '沿路线显示',
+ 'tours.hike.sac.t1': '徒步',
+ 'tours.hike.sac.t2': '山地徒步',
+ 'tours.hike.sac.t3': '挑战性山地徒步',
+ 'tours.hike.sac.t4': '高山徒步',
+ 'tours.hike.sac.t5': '挑战性高山徒步',
+ 'tours.hike.sac.t6': '高难度高山徒步',
+ 'tours.hike.sac.untagged': 'OpenStreetMap 中无难度',
+ 'tours.hike.sac.unclear': '难度不明确',
+ 'tours.hike.sac.unmatched': '道路未明确匹配',
+ 'tours.hike.sac.unasked': '本次无法查询',
+ 'tours.hike.sac.unknown': '未知',
+ 'tours.hike.sac.stepTip': 'SAC 徒步难度 {step},来自 OpenStreetMap:sac_scale={tag}',
+ 'tours.hike.sac.untaggedTip': 'OpenStreetMap 未给这些道路标注 sac_scale。未知并不代表容易。',
+ 'tours.hike.sac.unclearTip': 'OpenStreetMap 在此处写的是 {tags},并未指明单一等级。',
+ 'tours.hike.sac.unmatchedTip': '此处没有道路明确位于路线下方,因此不显示难度。',
+ 'tours.hike.sac.known': '已知',
+ 'tours.hike.sac.knownTip': 'OpenStreetMap 给出 SAC 难度的路线比例。其余为未知。',
+ 'tours.hike.sac.highest': '已知最高',
+ 'tours.hike.sac.routingLimit': '路线规划上限',
+ 'tours.hike.sac.routingLimitTip': '路线搜索最多可使用的难度:这是一项设置,不是道路的难度。',
+ 'tours.hike.sac.aboveLimit': '超出上限',
+ 'tours.hike.sac.loading': '正在读取 SAC 难度…',
+ 'tours.hike.sac.offline': 'SAC 难度需要网络连接。',
+ 'tours.hike.sac.failed': '无法读取 SAC 难度。路线未改变。',
+ 'tours.hike.sac.source': '来自 OpenStreetMap。可能有缺失;并非安全评估。',
'tours.copy.action': '另存为新路线',
'tours.copy.hint': '将此路线复制为不含日程的独立新路线。原路线保持不变。',
'tours.copy.name': '{name}(副本)',
diff --git a/shared/src/tours/tour-trail-grade.schema.spec.ts b/shared/src/tours/tour-trail-grade.schema.spec.ts
new file mode 100644
index 000000000..ac791d309
--- /dev/null
+++ b/shared/src/tours/tour-trail-grade.schema.spec.ts
@@ -0,0 +1,49 @@
+import {
+ SAC_SCALE_UNCLEAR_MAX,
+ SAC_SCALE_VALUES,
+ sacStepOf,
+ tourTrailGradeRequestSchema,
+ tourTrailGradeResponseSchema,
+ tourTrailGradeSchema,
+} from './tour-trail-grade.schema';
+
+import { describe, expect, it } from 'vitest';
+
+const grade = {
+ id: 1,
+ mtb_scale: null,
+ mtb_scale_uphill: null,
+ mtb_scale_imba: null,
+ sac_scale: null,
+ sac_scale_unclear: null,
+};
+
+describe('tour trail grades (BIK-05, MAP-03)', () => {
+ it('TRAIL-GRADE-SCHEMA-001: reads the six sac_scale values to T1 to T6 in order', () => {
+ expect(SAC_SCALE_VALUES.map(sacStepOf)).toEqual([1, 2, 3, 4, 5, 6]);
+ expect(sacStepOf('hiking')).toBe(1);
+ expect(sacStepOf('difficult_alpine_hiking')).toBe(6);
+ });
+
+ it('TRAIL-GRADE-SCHEMA-002: takes a way with an SAC grade, or with one written unclearly, and nothing else', () => {
+ expect(tourTrailGradeSchema.parse({ ...grade, sac_scale: 'alpine_hiking' }).sac_scale).toBe('alpine_hiking');
+ expect(tourTrailGradeSchema.parse({ ...grade, sac_scale_unclear: 'T3;T4' }).sac_scale_unclear).toBe('T3;T4');
+ expect(tourTrailGradeSchema.safeParse({ ...grade, sac_scale: 'T3' }).success).toBe(false);
+ expect(tourTrailGradeSchema.safeParse({ ...grade, sac_scale_unclear: '' }).success).toBe(false);
+ expect(
+ tourTrailGradeSchema.safeParse({ ...grade, sac_scale_unclear: 'x'.repeat(SAC_SCALE_UNCLEAR_MAX + 1) }).success,
+ ).toBe(false);
+ });
+
+ it('TRAIL-GRADE-SCHEMA-003: an answer lists graded ways and the ways it could not ask about', () => {
+ const answer = { ways: [{ ...grade, mtb_scale: '2+' }], unanswered: [7] };
+ expect(tourTrailGradeResponseSchema.parse(answer)).toEqual(answer);
+ });
+
+ it('TRAIL-GRADE-SCHEMA-004: a question names at least one way, by a positive id, and nothing more', () => {
+ expect(tourTrailGradeRequestSchema.safeParse({ way_ids: [1, 2] }).success).toBe(true);
+ expect(tourTrailGradeRequestSchema.safeParse({ way_ids: [] }).success).toBe(false);
+ expect(tourTrailGradeRequestSchema.safeParse({ way_ids: [0] }).success).toBe(false);
+ expect(tourTrailGradeRequestSchema.safeParse({ way_ids: [1], extra: true }).success).toBe(false);
+ });
+});
diff --git a/shared/src/tours/tour-trail-grade.schema.ts b/shared/src/tours/tour-trail-grade.schema.ts
index 32451de63..1e6a37956 100644
--- a/shared/src/tours/tour-trail-grade.schema.ts
+++ b/shared/src/tours/tour-trail-grade.schema.ts
@@ -1,8 +1,9 @@
import { z } from 'zod';
/**
- * BIK-05: the trail grades OpenStreetMap carries for the ways a mountain bike
- * tour runs on, asked for by the ways' ids. A read: nothing about the tour
+ * BIK-05 and MAP-03: the trail grades OpenStreetMap carries for the ways a
+ * tour runs on, asked for by the ways' ids: the mountain bike scales for a
+ * bike tour, the SAC hiking scale for a hike. A read: nothing about the tour
* changes. A way without a grade is unknown, never easy; a way that could not
* be asked about is named, so the planner can say so.
*/
@@ -17,11 +18,38 @@ export const tourTrailGradeRequestSchema = z
.strict();
export type TourTrailGradeRequest = z.infer;
+/**
+ * MAP-03: the six steps of the SAC hiking scale as OpenStreetMap writes them
+ * in `sac_scale`, from T1 to T6 in this order.
+ */
+export const SAC_SCALE_VALUES = [
+ 'hiking',
+ 'mountain_hiking',
+ 'demanding_mountain_hiking',
+ 'alpine_hiking',
+ 'demanding_alpine_hiking',
+ 'difficult_alpine_hiking',
+] as const;
+export const sacScaleSchema = z.enum(SAC_SCALE_VALUES);
+export type SacScale = z.infer;
+export type SacStep = 1 | 2 | 3 | 4 | 5 | 6;
+
+/** The step of the scale a `sac_scale` value names: `hiking` is T1, `difficult_alpine_hiking` T6. */
+export function sacStepOf(value: SacScale): SacStep {
+ return (SAC_SCALE_VALUES.indexOf(value) + 1) as SacStep;
+}
+
+/** The most of an unclear `sac_scale` tag that is handed on, so a stray long value cannot bloat the answer. */
+export const SAC_SCALE_UNCLEAR_MAX = 64;
+
/**
* One way's grades as OpenStreetMap tags them, read to the scale's own steps:
* `mtb:scale` 0 to 6 (a + or - kept), `mtb:scale:uphill` 0 to 5,
- * `mtb:scale:imba` 0 to 4. Null where the way has no such tag or one that is
- * no step of the scale.
+ * `mtb:scale:imba` 0 to 4, and `sac_scale` as one of its six values. Null
+ * where the way has no such tag or one that is no step of the scale. A
+ * `sac_scale` that is there but names no single step (`T3`, two values joined
+ * by `;`) is handed on as written in `sac_scale_unclear`, so the planner can
+ * say the grade is unclear rather than missing; it is never read to a step.
*/
export const tourTrailGradeSchema = z.object({
id: z.number().int().positive(),
@@ -37,6 +65,8 @@ export const tourTrailGradeSchema = z.object({
.string()
.regex(/^[0-4]$/)
.nullable(),
+ sac_scale: sacScaleSchema.nullable(),
+ sac_scale_unclear: z.string().min(1).max(SAC_SCALE_UNCLEAR_MAX).nullable(),
});
export type TourTrailGrade = z.infer;