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

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

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

The routing limit of a planned hike sits apart as a setting with its
own badge, and a known step above it is marked. Nothing about the tour,
its places or its days changes; switched off, the map is as it was.
No regional colour categories and no grade derived from slope, height
or trail markings.
This commit is contained in:
Maurice
2026-10-20 12:25:20 +02:00
parent ad2d653e9a
commit ea9e997338
63 changed files with 2327 additions and 108 deletions
@@ -557,7 +557,7 @@ describe('valhallaTraceAttributes (BIK-01)', () => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ shape: SHAPE, edges: [] })
}))
await valhallaTraceAttributes(SHAPE_POINTS, undefined, 'pedestrian')
await valhallaTraceAttributes(SHAPE_POINTS, undefined, 'run')
expect(bodies[0]).toMatchObject({
costing: 'pedestrian',
costing_options: { pedestrian: { max_hiking_difficulty: 6 } },
@@ -589,6 +589,21 @@ describe('valhallaTraceAttributes (BIK-01)', () => {
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toBeNull()
})
it('VALHALLA-TRACE-006: a hike is matched on foot with every grade of path open, never by its routing limit (MAP-03)', async () => {
const bodies: Record<string, unknown>[] = []
server.use(http.post(TRACE, async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ shape: SHAPE, edges: [] })
}))
await valhallaTraceAttributes(SHAPE_POINTS, undefined, 'foot')
expect(bodies[0]).toMatchObject({
costing: 'pedestrian',
costing_options: { pedestrian: { max_hiking_difficulty: 6 } },
shape_match: 'map_snap',
})
expect(JSON.stringify(bodies[0])).not.toContain('sac_scale')
})
it('VALHALLA-TRACE-004: a caller gone before its turn asks nothing', async () => {
let asked = false
server.use(http.post(TRACE, () => { asked = true; return HttpResponse.json({}) }))
+32 -18
View File
@@ -369,30 +369,46 @@ const TRACE_TIMEOUT_MS = 30_000
/**
* How a line is matched to the ways under it: as a mountain bike rides, the
* bicycle costing that refuses the fewest ways, or on foot with no hiking
* limit, so steps and mountain paths a run takes stay on the line (matched as
* a bike, a trail run over steps was described as the street beside them).
* bicycle costing that refuses the fewest ways, so a road bike's route and a
* gravel one are described alike; or on foot with every grade of path open,
* since a hike's line (MAP-03) may run on a T6 ridge the matcher must not
* refuse, and steps and mountain paths a run takes (RUN-02) stay on the line
* (matched as a bike, a trail run over steps was described as the street
* beside them). Matching is no routing: the limit a hike was planned with
* plays no part. A hike and a run are matched alike; they differ in whether
* the router's own hiking grade is asked for.
*/
export type TraceMatch = 'bicycle' | 'pedestrian'
const TRACE_COSTING = {
bike: { costing: 'bicycle', costing_options: { bicycle: { bicycle_type: 'Mountain' } } },
foot: { costing: 'pedestrian', costing_options: { pedestrian: { max_hiking_difficulty: 6 } } },
run: { costing: 'pedestrian', costing_options: { pedestrian: { max_hiking_difficulty: 6 } } },
} as const
const TRACE_COSTING: Record<TraceMatch, Readonly<Record<string, CostingOptions>>> = {
bicycle: { bicycle: { bicycle_type: 'Mountain' } },
pedestrian: { pedestrian: { max_hiking_difficulty: 6 } },
export type TraceMatching = keyof typeof TRACE_COSTING
const TRACE_ATTRIBUTES = ['shape', 'edge.surface', 'edge.use', 'edge.road_class', 'edge.cycle_lane', 'edge.length',
'edge.way_id', 'edge.begin_shape_index', 'edge.end_shape_index']
/** The router's hiking grade is asked along a bike tour's and a run's line, never a hike's (MAP-03 reads OpenStreetMap). */
function traceAttributesFor(matching: TraceMatching): string[] {
return matching === 'foot' ? TRACE_ATTRIBUTES : [...TRACE_ATTRIBUTES, 'edge.sac_scale']
}
/**
* BIK-01, RUN-02: how the router describes the ways under a line
* BIK-01, RUN-02 and MAP-03: how the router describes the ways under a line
* (`trace_attributes`): the surface, the use and road class, the cycle lane,
* the hiking grade (`sac_scale`, 0 where the way has none) and the
* OpenStreetMap way of every edge, with the line as matched. A bike tour's
* line is matched as a mountain bike would ride it, so a road bike's route and
* a gravel one are described alike; a run's on foot. Null for every failure:
* the description is an addition beside a route that already exists.
* the OpenStreetMap way of every edge and, but for a hike, the router's
* hiking grade (`sac_scale`, 0 where the way has none), with the line as
* matched. A run's grade shares (RUN-02) read the router's grade; the grade a
* hike shows (MAP-03) comes from OpenStreetMap itself, since the router's
* reading may be normalised or derived, so it is not asked for there. Null for
* every failure: the description is an addition beside a route that already
* exists.
*/
export async function valhallaTraceAttributes(
line: readonly [number, number][],
signal?: AbortSignal,
matchAs: TraceMatch = 'bicycle',
matching: TraceMatching = 'bike'
): Promise<unknown> {
const base = valhallaBase()
if (!base || line.length < 2) return null
@@ -407,12 +423,10 @@ export async function valhallaTraceAttributes(
headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
body: JSON.stringify({
shape,
costing: matchAs,
costing_options: TRACE_COSTING[matchAs],
...TRACE_COSTING[matching],
shape_match: 'map_snap',
filters: {
attributes: ['shape', 'edge.surface', 'edge.use', 'edge.road_class', 'edge.cycle_lane', 'edge.sac_scale', 'edge.length',
'edge.way_id', 'edge.begin_shape_index', 'edge.end_shape_index'],
attributes: traceAttributesFor(matching),
action: 'include',
},
}),
@@ -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 (
<span role="status" className="inline-flex items-center gap-1.5 text-content-muted" style={fs(11.5, 'body')}>
<Loader2 size={12} strokeWidth={2} className="animate-spin" />
{messages.loading}
</span>
);
if (status === 'offline') return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{messages.offline}</TourNotice>;
if (status !== 'failed') return null;
return (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{messages.failed}</span>
<CardAction icon={<RefreshCw size={12} strokeWidth={2} />} onClick={onRetry}>
{t('common.errorRetry')}
</CardAction>
</span>
</TourNotice>
);
}
@@ -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<RouteWaysState>, overrides: Partial<TourPlannerController> = {}) {
@@ -147,11 +149,11 @@ describe('BikeWaysCard (BIK-01 to BIK-05)', () => {
hike.unmount();
show({}, { routeCurrent: false, mode: { type: 'view-gpx' } } as Partial<TourPlannerController>);
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<TourPlannerController>);
expect(hook.input).toMatchObject({ active: true, route: LINE, matchAs: 'bicycle', withGrades: true });
expect(hook.input).toMatchObject({ active: true, route: LINE, matching: 'bike', withGrades: true });
});
});
@@ -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;
@@ -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<RouteWaysState, 'status'> & { retry: () => void } }) {
const { t } = useTranslation();
if (state.status === 'loading')
return (
<span className="inline-flex items-center gap-1.5 text-content-muted" style={fs(11.5, 'body')}>
<Loader2 size={12} strokeWidth={2} className="animate-spin" />
{t('tours.bike.ways.loading')}
</span>
);
if (state.status === 'offline')
return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.bike.ways.offline')}</TourNotice>;
if (state.status !== 'failed') return null;
return (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{t('tours.bike.ways.failed')}</span>
<CardAction icon={<RefreshCw size={12} strokeWidth={2} />} onClick={state.retry}>
{t('common.errorRetry')}
</CardAction>
</span>
</TourNotice>
<ReadStatusNotice
status={state.status}
messages={{
loading: t('tours.bike.ways.loading'),
offline: t('tours.bike.ways.offline'),
failed: t('tours.bike.ways.failed'),
}}
onRetry={state.retry}
/>
);
}
@@ -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<Record<string, string>> & { 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<BikePaintKey, string>
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))],
};
}
@@ -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' },
]);
});
});
@@ -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());
});
});
@@ -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<typeof withRouteHighlight>[2];
type Colors = Parameters<typeof withRouteHighlight>[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);
}
@@ -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> = {}): 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');
});
});
@@ -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<number, TourTrailGrade>(), unasked: new Set<number>() };
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<RouteWaysInput, 'tripId' | 'matchAs' | 'withGrades'> & { route: [number, number][] },
{ tripId, route, matching, withGrades }: Question,
signal: AbortSignal,
onWays: (ways: RouteWays) => void
): Promise<RouteWaysState> {
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<Answer | null>(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 };
@@ -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> = {}): 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(<SacGradesCard tripId={4} route={LINE} routingLimit={routingLimit} />);
}
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');
});
});
@@ -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 (
<span className="inline-flex h-[18px] w-[34px] flex-none items-center justify-center gap-1 rounded-full bg-surface-card font-geist font-semibold tabular-nums text-content shadow-sm ring-1 ring-inset ring-edge-faint">
<span aria-hidden="true" className="h-2 w-2 flex-none rounded-full" style={{ background: paintOf(grade) }} />
<span className={step === null ? 'text-content-faint' : ''} style={fs(10.5)}>
{step === null ? '?' : stepName(step)}
</span>
</span>
);
}
/** 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 (
<li>
<Tooltip label={source} disabled={!source} placement="top">
<button
type="button"
aria-pressed={pressed}
data-testid={`sac-row-${row.key}`}
onClick={() => grades.focusOn(row.key)}
className={`flex w-full min-w-0 items-center gap-2 rounded-lg px-1.5 py-1 text-start transition-colors hover:bg-surface-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${pressed ? 'bg-surface-tertiary' : ''}`}
>
<GradeChip grade={row.key} />
<span
className={`min-w-0 flex-1 truncate ${step === null ? 'text-content-muted' : 'text-content'}`}
style={fs(12, 'body')}
>
{t(`tours.hike.sac.${row.key}`)}
</span>
{step !== null && limit !== null && step > limit && (
<SoftPill tone="warning">{t('tours.hike.sac.aboveLimit')}</SoftPill>
)}
<span className="flex-none font-geist tabular-nums text-content-faint" style={fs(11)}>
{formatDistance(row.lengthM / 1000, unit)}
</span>
<span className="w-10 flex-none text-end font-geist tabular-nums text-content-secondary" style={fs(11)}>
{percent(row.share)}
</span>
</button>
</Tooltip>
</li>
);
}
/** 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 (
<span aria-hidden="true" className="flex h-2 w-full overflow-hidden rounded-full bg-surface-tertiary">
{rows.map((row) => (
<span key={row.key} className="h-full" style={{ width: `${row.share * 100}%`, background: paintOf(row.key) }} />
))}
</span>
);
}
/**
* 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 (
<div className="flex flex-wrap items-center gap-1.5">
<FigureBadge
label={t('tours.hike.sac.known')}
value={percent(grades.knownShare)}
info={t('tours.hike.sac.knownTip')}
testId="sac-known"
/>
{grades.highest !== null && (
<FigureBadge label={t('tours.hike.sac.highest')} value={stepName(grades.highest)} testId="sac-highest" />
)}
{limit !== null && (
<FigureBadge
label={t('tours.hike.sac.routingLimit')}
value={stepName(limit)}
icon={<SlidersHorizontal size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}
info={t('tours.hike.sac.routingLimitTip')}
testId="sac-routing-limit"
/>
)}
</div>
);
}
function GradeList({ grades, limit }: { grades: SacGrades; limit: number | null }) {
const { t } = useTranslation();
const known = grades.rows.filter((row) => isSacStep(row.key));
const unknown = grades.rows.filter((row) => !isSacStep(row.key));
return (
<>
<GradeSummary grades={grades} limit={limit} />
<GradeBar rows={grades.rows} />
{known.length > 0 && (
<ul className="m-0 flex list-none flex-col p-0">
{known.map((row) => (
<GradeRow key={row.key} row={row} grades={grades} limit={limit} />
))}
</ul>
)}
{unknown.length > 0 && (
<div className="flex min-w-0 flex-col gap-1">
<span
className="font-geist font-bold uppercase tracking-[.08em] text-content-faint"
style={fs(9.5, 'caption')}
>
{t('tours.hike.sac.unknown')}
</span>
<ul className="m-0 flex list-none flex-col p-0">
{unknown.map((row) => (
<GradeRow key={row.key} row={row} grades={grades} limit={limit} />
))}
</ul>
</div>
)}
<span className="inline-flex gap-1.5 text-content-faint" style={fs(11, 'body')}>
<Info size={12} strokeWidth={2} className="mt-0.5 flex-none" />
{t('tours.hike.sac.source')}
</span>
</>
);
}
/**
* 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 (
<RailCard icon={<MountainSnow size={14} strokeWidth={2} />} title={t('tours.hike.sac.title')} foldId="hike-sac">
<div data-testid="sac-grades-card" className="flex min-w-0 flex-col gap-3">
<div className="flex items-center gap-3">
<span aria-hidden="true" className="min-w-0 flex-1 text-content-secondary" style={fs(12, 'body')}>
{t('tours.hike.sac.show')}
</span>
<ToggleSwitch on={grades.shown} onToggle={grades.toggle} label={t('tours.hike.sac.show')} />
</div>
{grades.shown && (
<ReadStatusNotice
status={grades.status}
messages={{
loading: t('tours.hike.sac.loading'),
offline: t('tours.hike.sac.offline'),
failed: t('tours.hike.sac.failed'),
}}
onRetry={grades.retry}
/>
)}
{grades.shown && grades.status === 'ready' && <GradeList grades={grades} limit={routingLimit} />}
</div>
</RailCard>
);
}
@@ -0,0 +1,29 @@
import { useMapPaint } from '../../Map/mapPaint';
/**
* MAP-03: the colours of the SAC grades along a hike (styles/hike.css), by
* name. The card takes the CSS variable, which follows the theme on its own;
* the map reads them resolved (`useHikePaint`). A way without a known grade
* is drawn in the bike paints' grey of the unknown, the one grey the route
* paints use for what the data does not say.
*/
const HIKE_PAINT_TOKENS = {
t1: '--hike-sac-t1',
t2: '--hike-sac-t2',
t3: '--hike-sac-t3',
t4: '--hike-sac-t4',
t5: '--hike-sac-t5',
t6: '--hike-sac-t6',
} as const;
export type HikePaintKey = keyof typeof HIKE_PAINT_TOKENS;
/** A colour as a CSS variable, for markup on the page. */
export function hikePaintCss(key: HikePaintKey): string {
return `var(${HIKE_PAINT_TOKENS[key]})`;
}
/** The colours resolved for map paint, again whenever the theme changes. */
export function useHikePaint(): Record<HikePaintKey, string> {
return useMapPaint(HIKE_PAINT_TOKENS);
}
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';
import type { RouteWayEdge, RouteWays } from '../bike/routeWays';
import { MATCH_TOLERANCE_M, nearLine, straysFrom } from './routeMatch';
// FE-HIKE-MATCH-001 to FE-HIKE-MATCH-006
// At 47° N a ten-thousandth of a degree is about 11 m north to south and 7.6 m east to west.
const NORTH: [number, number][] = [
[47.0, 11.0],
[47.002, 11.0],
[47.004, 11.0],
];
const edge = (from: number, to: number, wayId = 1): RouteWayEdge => ({
from,
to,
lengthM: 100,
surface: 'unknown',
way: 'path',
wayId,
});
describe('nearLine (MAP-03)', () => {
it('FE-HIKE-MATCH-001: a point on the line or within the tolerance is near, one further off is not', () => {
const near = nearLine(NORTH, MATCH_TOLERANCE_M);
expect(near([47.001, 11.0])).toBe(true);
// About 15 m east of the line.
expect(near([47.003, 11.0002])).toBe(true);
// About 38 m east of it.
expect(near([47.003, 11.0005])).toBe(false);
// Past the northern end by about 33 m.
expect(near([47.0043, 11.0])).toBe(false);
expect(near([48, 12])).toBe(false);
});
it('FE-HIKE-MATCH-002: finds the middle of a long straight segment, drawn by hand between two clicks', () => {
const near = nearLine(
[
[47.0, 11.0],
[47.02, 11.0],
],
MATCH_TOLERANCE_M
);
expect(near([47.01, 11.0001])).toBe(true);
expect(near([47.01, 11.001])).toBe(false);
});
it('FE-HIKE-MATCH-003: a line of one point is near only around that point; no line is near nothing', () => {
const one = nearLine([[47.0, 11.0]], MATCH_TOLERANCE_M);
expect(one([47.0001, 11.0])).toBe(true);
expect(one([47.001, 11.0])).toBe(false);
expect(nearLine([], MATCH_TOLERANCE_M)([47.0, 11.0])).toBe(false);
});
});
describe('straysFrom (MAP-03)', () => {
it('FE-HIKE-MATCH-004: a matched line that keeps to the tour has no strays', () => {
const ways: RouteWays = { line: NORTH, edges: [edge(0, 1), edge(1, 2, 2)] };
expect(straysFrom(ways, NORTH).size).toBe(0);
});
it('FE-HIKE-MATCH-005: an edge on a neighbouring path strays, the ones on the line do not', () => {
const line: [number, number][] = [...NORTH, [47.004, 11.001], [47.006, 11.001]];
const ways: RouteWays = { line, edges: [edge(0, 2), edge(2, 4, 2)] };
const strays = straysFrom(ways, NORTH);
expect([...strays]).toEqual([ways.edges[1]]);
});
it('FE-HIKE-MATCH-006: an edge whose ends touch the line but whose middle cuts across a bend strays', () => {
const bend: [number, number][] = [
[47.0, 11.0],
[47.002, 11.0],
[47.002, 11.003],
];
const ways: RouteWays = {
line: [
[47.0, 11.0],
[47.002, 11.003],
],
edges: [edge(0, 1)],
};
expect(straysFrom(ways, bend).size).toBe(1);
});
});
@@ -0,0 +1,98 @@
import type { RouteWayEdge, RouteWays } from '../bike/routeWays';
/**
* MAP-03: whether the ways the router matched a line to really lie under it.
* A planned route runs on its ways exactly; an imported track or a stretch
* drawn by hand only roughly, and the matcher then picks the way it finds
* nearest, which may be the wrong one. A grade is only shown for a way the
* tour's own line keeps to, so an edge that strays from it counts as not
* clearly matched. The line is laid flat around its own latitude, exact to
* well under a metre over the span of a tour.
*/
/** How far a matched way may lie from the tour's line and still be its way: GPS noise, not a neighbouring path. */
export const MATCH_TOLERANCE_M = 25;
const EARTH_M = 6_371_000;
const RAD = Math.PI / 180;
type LatLng = readonly [number, number];
type Flat = readonly [number, number];
function flattener(line: readonly LatLng[]): (point: LatLng) => Flat {
const meanLat = line.reduce((sum, [lat]) => sum + lat, 0) / line.length;
const kx = Math.cos(meanLat * RAD) * EARTH_M * RAD;
const ky = EARTH_M * RAD;
return ([lat, lng]) => [lng * kx, lat * ky];
}
function distanceToSegment([px, py]: Flat, [ax, ay]: Flat, [bx, by]: Flat): number {
const dx = bx - ax;
const dy = by - ay;
const length2 = dx * dx + dy * dy;
const t = length2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / length2));
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
}
/**
* A test whether a point lies within `toleranceM` of a line, answered from a
* grid of the line's segments rather than by walking the whole line for each
* point. Each segment is entered in the cells it passes, sampled at most
* `toleranceM` apart; a cell is twice the tolerance wide, so a point near a
* segment always finds it among its own cell and the eight around it.
*/
export function nearLine(line: readonly LatLng[], toleranceM: number): (point: LatLng) => boolean {
if (line.length === 0) return () => false;
const flat = flattener(line);
const points = line.map(flat);
const cell = toleranceM * 2;
const keyOf = (x: number, y: number) => `${Math.floor(x / cell)}:${Math.floor(y / cell)}`;
const grid = new Map<string, number[]>();
// Segment i runs from point i - 1 to point i; a line of one point is a segment of no length.
const segment = (i: number): [Flat, Flat] => [points[Math.max(0, i - 1)], points[i]];
for (let i = points.length === 1 ? 0 : 1; i < points.length; i += 1) {
const [[ax, ay], [bx, by]] = segment(i);
const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, by - ay) / toleranceM));
for (let s = 0; s <= steps; s += 1) {
const key = keyOf(ax + ((bx - ax) * s) / steps, ay + ((by - ay) * s) / steps);
const entries = grid.get(key) ?? [];
if (entries[entries.length - 1] !== i) entries.push(i);
grid.set(key, entries);
}
}
return (point) => {
const p = flat(point);
const cx = Math.floor(p[0] / cell);
const cy = Math.floor(p[1] / cell);
for (let dx = -1; dx <= 1; dx += 1) {
for (let dy = -1; dy <= 1; dy += 1) {
const entries = grid.get(`${cx + dx}:${cy + dy}`) ?? [];
if (entries.some((i) => distanceToSegment(p, ...segment(i)) <= toleranceM)) return true;
}
}
return false;
};
}
const midpoint = ([aLat, aLng]: LatLng, [bLat, bLng]: LatLng): LatLng => [(aLat + bLat) / 2, (aLng + bLng) / 2];
/** The matched edges that stray from the tour's own line somewhere along them: their way is not clearly the tour's. */
export function straysFrom(
ways: RouteWays,
route: readonly LatLng[],
toleranceM = MATCH_TOLERANCE_M
): ReadonlySet<RouteWayEdge> {
const near = nearLine(route, toleranceM);
const strays = new Set<RouteWayEdge>();
for (const edge of ways.edges) {
for (let i = edge.from; i <= edge.to; i += 1) {
const point = ways.line[i];
const next = i < edge.to ? ways.line[i + 1] : undefined;
if (!near(point) || (next && !near(midpoint(point, next)))) {
strays.add(edge);
break;
}
}
}
return strays;
}
@@ -0,0 +1,115 @@
import type { TourTrailGrade } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import type { RouteWayEdge, RouteWays } from '../bike/routeWays';
import {
highestStep,
isSacStep,
sacClassifier,
sacOverlay,
sacPaintKey,
sacShares,
sacStepOfClass,
sacTagOf,
unclearTags,
} from './sacGrades';
// FE-HIKE-SAC-001 to FE-HIKE-SAC-007
const LINE: [number, number][] = Array.from({ length: 8 }, (_, index): [number, number] => [47 + index / 1000, 11]);
const edge = (from: number, wayId: number | null, lengthM = 100): RouteWayEdge => ({
from,
to: from + 1,
lengthM,
surface: 'unknown',
way: 'path',
wayId,
});
const grade = (id: number, sac: Partial<Pick<TourTrailGrade, 'sac_scale' | 'sac_scale_unclear'>>): TourTrailGrade => ({
id,
mtb_scale: null,
mtb_scale_uphill: null,
mtb_scale_imba: null,
sac_scale: null,
sac_scale_unclear: null,
...sac,
});
// One stretch of each kind: T3 (twice as long), T1, unclear, untagged, unasked, no way, a T6 way that strays.
const WAYS: RouteWays = {
line: LINE,
edges: [
edge(0, 1, 200),
edge(1, 2),
edge(2, 3),
edge(3, 4),
edge(4, 5),
edge(5, null),
edge(6, 7),
],
};
const GRADES = {
byWay: new Map([
[1, grade(1, { sac_scale: 'demanding_mountain_hiking' })],
[2, grade(2, { sac_scale: 'hiking' })],
[3, grade(3, { sac_scale_unclear: 'T3;T4' })],
[7, grade(7, { sac_scale: 'difficult_alpine_hiking' })],
]),
unasked: new Set([5]),
};
const STRAYS = new Set([WAYS.edges[6]]);
describe('sacGrades (MAP-03)', () => {
it('FE-HIKE-SAC-001: reads each stretch to its step, and every unknown to its own reason, never to T1', () => {
const classOf = sacClassifier(GRADES, STRAYS);
expect(WAYS.edges.map(classOf)).toEqual(['t3', 't1', 'unclear', 'untagged', 'unasked', 'unmatched', 'unmatched']);
});
it('FE-HIKE-SAC-002: the shares come in the scale order, the steps first, then the unknown', () => {
const rows = sacShares(WAYS, sacClassifier(GRADES, STRAYS));
expect(rows.map((row) => row.key)).toEqual(['t1', 't3', 'untagged', 'unclear', 'unmatched', 'unasked']);
expect(rows.find((row) => row.key === 't3')).toMatchObject({ lengthM: 200, share: 0.25 });
expect(rows.find((row) => row.key === 'unmatched')?.lengthM).toBe(200);
});
it('FE-HIKE-SAC-003: names a step by its class, its value in OpenStreetMap and its paint', () => {
expect(isSacStep('t4')).toBe(true);
expect(isSacStep('unclear')).toBe(false);
expect(isSacStep('untagged')).toBe(false);
expect(sacStepOfClass('t6')).toBe(6);
expect(sacStepOfClass('untagged')).toBeNull();
expect(sacTagOf('t1')).toBe('hiking');
expect(sacTagOf('t6')).toBe('difficult_alpine_hiking');
expect(sacPaintKey('t2')).toBe('t2');
expect(sacPaintKey('unasked')).toBe('unknown');
});
it('FE-HIKE-SAC-004: hands on an unclear grade as OpenStreetMap wrote it, each value once', () => {
const twice: RouteWays = { line: LINE, edges: [edge(0, 3), edge(1, 3), edge(2, 1)] };
expect(unclearTags(twice, GRADES, () => true)).toEqual(['T3;T4']);
expect(unclearTags(twice, GRADES, () => false)).toEqual([]);
expect(unclearTags({ line: LINE, edges: [edge(0, null)] }, GRADES, () => true)).toEqual([]);
});
it('FE-HIKE-SAC-005: the highest step known, or none where no step is', () => {
const rows = sacShares(WAYS, sacClassifier(GRADES, STRAYS));
expect(highestStep(rows)).toBe(3);
expect(highestStep(rows.filter((row) => !isSacStep(row.key)))).toBeNull();
});
it('FE-HIKE-SAC-006: draws every class along the line in its paint, neighbours of a class joined', () => {
const classOf = sacClassifier(GRADES, STRAYS);
const overlay = sacOverlay(WAYS, sacShares(WAYS, classOf), classOf);
expect(overlay.paints).toEqual(['t1', 't3', 'unknown', 'unknown', 'unknown', 'unknown']);
expect(overlay.lines).toHaveLength(6);
// The no-way stretch and the stray one sit side by side and are one stretch of their class.
expect(overlay.lines[4]).toEqual(LINE.slice(5, 8));
});
it('FE-HIKE-SAC-007: draws one class alone when it is picked', () => {
const classOf = sacClassifier(GRADES, STRAYS);
const overlay = sacOverlay(WAYS, sacShares(WAYS, classOf), classOf, 't3');
expect(overlay).toEqual({ lines: [LINE.slice(0, 2)], paints: ['t3'] });
});
});
@@ -0,0 +1,122 @@
import { SAC_SCALE_VALUES, sacStepOf, type SacScale, type TourTrailGrade } from '@trek/shared';
import { sharesBy, stretchesOf, type ClassShare, type RouteWayEdge, type RouteWays } from '../bike/routeWays';
import type { TrailGrades } from '../bike/useRouteWays';
import type { HikePaintKey } from './hikePaint';
/**
* MAP-03: the SAC grades along a hike, read stretch by stretch from the
* `sac_scale` OpenStreetMap gives the ways under it. Pure, so the classes,
* their shares and their stretches are tested apart from the requests.
*
* A step (`t1` to `t6`) is only ever what OpenStreetMap wrote for that way.
* Everything else is unknown, and the reason stays apart: the way has no
* grade (`untagged`), a grade that names no single step (`unclear`), no way
* clearly under the tour's line (`unmatched`), or OpenStreetMap could not be
* asked this time (`unasked`). None of them is ever read as T1.
*/
type SacStepClass = 't1' | 't2' | 't3' | 't4' | 't5' | 't6';
type SacUnknownClass = 'untagged' | 'unclear' | 'unmatched' | 'unasked';
export type SacClass = SacStepClass | SacUnknownClass;
/** The order the card lists the classes in: the steps from easy to hard, then the unknown. */
const SAC_CLASSES: readonly SacClass[] = [
't1',
't2',
't3',
't4',
't5',
't6',
'untagged',
'unclear',
'unmatched',
'unasked',
];
export function isSacStep(key: SacClass): key is SacStepClass {
return /^t[1-6]$/.test(key);
}
/** The step a class stands for, 1 to 6; null for the unknown. */
export function sacStepOfClass(key: SacClass): number | null {
return isSacStep(key) ? Number(key.slice(1)) : null;
}
/** The `sac_scale` value OpenStreetMap writes for a step's class. */
export function sacTagOf(key: SacStepClass): SacScale {
return SAC_SCALE_VALUES[Number(key.slice(1)) - 1];
}
/** The paint of a class: the step's own, and the grey of the unknown for every kind of unknown. */
export function sacPaintKey(key: SacClass): HikePaintKey | 'unknown' {
return isSacStep(key) ? key : 'unknown';
}
function stepClassOf(grade: TourTrailGrade | undefined): SacClass {
if (grade?.sac_scale) return `t${sacStepOf(grade.sac_scale)}`;
return grade?.sac_scale_unclear ? 'unclear' : 'untagged';
}
/** How each stretch of the matched line reads, given the grades and the edges that stray from the tour's own line. */
export function sacClassifier(
grades: Pick<TrailGrades, 'byWay' | 'unasked'>,
strays: ReadonlySet<RouteWayEdge>
): (edge: RouteWayEdge) => SacClass {
return (edge) => {
if (edge.wayId === null || strays.has(edge)) return 'unmatched';
if (grades.unasked.has(edge.wayId)) return 'unasked';
return stepClassOf(grades.byWay.get(edge.wayId));
};
}
/** The share of each class along the line, in the card's order; classes the line has none of are left out. */
export function sacShares(ways: RouteWays, classOf: (edge: RouteWayEdge) => SacClass): ClassShare<SacClass>[] {
return sharesBy(ways, classOf).sort((a, b) => SAC_CLASSES.indexOf(a.key) - SAC_CLASSES.indexOf(b.key));
}
/** The grade's tags as OpenStreetMap wrote them where they name no single step, each once. */
export function unclearTags(
ways: RouteWays,
grades: Pick<TrailGrades, 'byWay'>,
inClass: (edge: RouteWayEdge) => boolean
): string[] {
const tags = new Set<string>();
for (const edge of ways.edges) {
const written = edge.wayId === null ? null : grades.byWay.get(edge.wayId)?.sac_scale_unclear;
if (written && inClass(edge)) tags.add(written);
}
return [...tags];
}
/** The highest step known anywhere along the line, or null where none is. */
export function highestStep(rows: readonly ClassShare<SacClass>[]): number | null {
const steps = rows.map((row) => sacStepOfClass(row.key)).filter((step): step is number => step !== null);
return steps.length ? Math.max(...steps) : null;
}
interface SacOverlay {
lines: [number, number][][];
paints: (HikePaintKey | 'unknown')[];
}
/**
* Every class's stretches with their paints, for drawing the grades along the
* whole line at once; `only` draws one class alone.
*/
export function sacOverlay(
ways: RouteWays,
rows: readonly ClassShare<SacClass>[],
classOf: (edge: RouteWayEdge) => SacClass,
only: SacClass | null = null
): SacOverlay {
const lines: [number, number][][] = [];
const paints: (HikePaintKey | 'unknown')[] = [];
for (const row of rows) {
if (only && row.key !== only) continue;
for (const line of stretchesOf(ways, (edge) => classOf(edge) === row.key)) {
lines.push(line);
paints.push(sacPaintKey(row.key));
}
}
return { lines, paints };
}
@@ -0,0 +1,186 @@
import type { TourTrailGrade } from '@trek/shared';
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import type { RouteWayEdge, RouteWays } from '../bike/routeWays';
import type { RouteWaysState } from '../bike/useRouteWays';
import { useSacGrades } from './useSacGrades';
const hook = vi.hoisted(() => ({
state: null as unknown,
input: null as unknown,
retry: vi.fn(),
}));
vi.mock('../bike/useRouteWays', () => ({
useRouteWays: (input: unknown) => {
hook.input = input;
return { ...(hook.state as object), retry: hook.retry };
},
}));
// FE-HIKE-SAC-HOOK-001 to FE-HIKE-SAC-HOOK-008
const ROUTE: [number, number][] = [
[47.0, 11.0],
[47.002, 11.0],
[47.004, 11.0],
[47.006, 11.0],
];
const edge = (from: number, wayId: number): RouteWayEdge => ({
from,
to: from + 1,
lengthM: 100,
surface: 'unknown',
way: 'path',
wayId,
});
const WAYS: RouteWays = { line: ROUTE, edges: [edge(0, 1), edge(1, 2), edge(2, 3)] };
const grade = (id: number, sac: Partial<TourTrailGrade>): TourTrailGrade => ({
id,
mtb_scale: null,
mtb_scale_uphill: null,
mtb_scale_imba: null,
sac_scale: null,
sac_scale_unclear: null,
...sac,
});
const READY: RouteWaysState = {
status: 'ready',
ways: WAYS,
grades: {
status: 'ready',
byWay: new Map([
[1, grade(1, { sac_scale: 'mountain_hiking' })],
[2, grade(2, { sac_scale: 'alpine_hiking' })],
]),
unasked: new Set(),
},
};
function shownHook(state: RouteWaysState, route: [number, number][] | null = ROUTE) {
hook.state = state;
const view = renderHook((props: { route: [number, number][] | null }) => useSacGrades({ tripId: 5, ...props }), {
initialProps: { route },
});
act(() => view.result.current.toggle());
return view;
}
beforeEach(() => {
hook.state = { status: 'idle', ways: null, grades: null };
hook.input = null;
hook.retry.mockReset();
useTourRouteHighlightStore.getState().clear();
});
describe('useSacGrades (MAP-03)', () => {
it('FE-HIKE-SAC-HOOK-001: without a line of two points there is nothing to grade and nothing is asked', () => {
for (const route of [null, ROUTE.slice(0, 1)]) {
const { result } = renderHook(() => useSacGrades({ tripId: 5, route }));
expect(result.current.available).toBe(false);
expect(result.current.status).toBe('off');
expect(hook.input).toMatchObject({ active: false });
}
});
it('FE-HIKE-SAC-HOOK-002: off until switched on, then asks for the ways on foot with their grades', () => {
const { result } = renderHook(() => useSacGrades({ tripId: 5, route: ROUTE }));
expect(result.current).toMatchObject({ available: true, shown: false, status: 'off', rows: [] });
expect(hook.input).toMatchObject({ tripId: 5, active: false, route: ROUTE, matching: 'foot', withGrades: true });
hook.state = { status: 'loading', ways: null, grades: null };
act(() => result.current.toggle());
expect(result.current.shown).toBe(true);
expect(result.current.status).toBe('loading');
expect(hook.input).toMatchObject({ active: true });
});
it('FE-HIKE-SAC-HOOK-003: says when the ways or the grades are on their way, offline or failed', () => {
const cases: [RouteWaysState, string][] = [
[{ status: 'offline', ways: null, grades: null }, 'offline'],
[{ status: 'failed', ways: null, grades: null }, 'failed'],
[{ ...READY, grades: { status: 'loading', byWay: new Map(), unasked: new Set() } }, 'loading'],
[{ ...READY, grades: { status: 'offline', byWay: new Map(), unasked: new Set() } }, 'offline'],
[{ ...READY, grades: null }, 'failed'],
];
for (const [state, status] of cases) {
const { result, unmount } = shownHook(state);
expect(result.current.status).toBe(status);
expect(result.current.rows).toEqual([]);
unmount();
}
expect(useTourRouteHighlightStore.getState().highlight).toBeNull();
});
it('FE-HIKE-SAC-HOOK-004: lists the known steps and the unknown, and draws them all along the line', () => {
const { result } = shownHook(READY);
expect(result.current.status).toBe('ready');
expect(result.current.rows.map((row) => [row.key, row.tags])).toEqual([
['t2', ['mountain_hiking']],
['t4', ['alpine_hiking']],
['untagged', []],
]);
expect(result.current.knownShare).toBeCloseTo(2 / 3);
expect(result.current.highest).toBe(4);
expect(useTourRouteHighlightStore.getState().highlight).toMatchObject({
id: 'sac',
paint: 'unknown',
paints: ['t2', 't4', 'unknown'],
});
});
it('FE-HIKE-SAC-HOOK-005: a picked class is drawn alone, picked again every class is drawn again', () => {
const { result } = shownHook(READY);
act(() => result.current.focusOn('t4'));
expect(result.current.focus).toBe('t4');
expect(useTourRouteHighlightStore.getState().highlight).toMatchObject({
id: 'sac-t4',
lines: [ROUTE.slice(1, 3)],
paints: ['t4'],
});
act(() => result.current.focusOn('t4'));
expect(result.current.focus).toBeNull();
expect(useTourRouteHighlightStore.getState().highlight?.id).toBe('sac');
});
it('FE-HIKE-SAC-HOOK-006: a hike nobody could be asked about fails once with a retry rather than one unknown row', () => {
const { result } = shownHook({
...READY,
grades: { status: 'ready', byWay: new Map(), unasked: new Set([1, 2, 3]) },
});
expect(result.current.status).toBe('failed');
expect(result.current.rows).toEqual([]);
expect(useTourRouteHighlightStore.getState().highlight).toBeNull();
result.current.retry();
expect(hook.retry).toHaveBeenCalledTimes(1);
});
it('FE-HIKE-SAC-HOOK-007: switched off or left, the map is as it was; a way off the line is never graded', () => {
const { result, unmount } = shownHook(READY);
act(() => result.current.focusOn('t2'));
act(() => result.current.toggle());
expect(result.current.shown).toBe(false);
expect(result.current.focus).toBeNull();
expect(useTourRouteHighlightStore.getState().highlight).toBeNull();
act(() => result.current.toggle());
expect(useTourRouteHighlightStore.getState().highlight?.id).toBe('sac');
unmount();
expect(useTourRouteHighlightStore.getState().highlight).toBeNull();
// The tour's own line runs a kilometre east of the ways the router matched: none of them is its way.
const elsewhere = ROUTE.map(([lat, lng]): [number, number] => [lat, lng + 0.013]);
const off = shownHook(READY, elsewhere);
expect(off.result.current.rows.map((row) => row.key)).toEqual(['unmatched']);
expect(off.result.current.highest).toBeNull();
});
it('FE-HIKE-SAC-HOOK-008: while the line is planned anew the card waits for it and grades nothing of the old one', () => {
hook.state = READY;
const { result } = renderHook(() => useSacGrades({ tripId: 5, route: ROUTE, current: false }));
act(() => result.current.toggle());
expect(result.current.available).toBe(true);
expect(result.current.status).toBe('loading');
expect(result.current.rows).toEqual([]);
expect(hook.input).toMatchObject({ active: true, route: null });
expect(useTourRouteHighlightStore.getState().highlight).toBeNull();
});
});
@@ -0,0 +1,124 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import type { ClassShare, RouteWayEdge, RouteWays } from '../bike/routeWays';
import { useRouteWays, type RouteWaysState } from '../bike/useRouteWays';
import { straysFrom } from './routeMatch';
import {
highestStep,
isSacStep,
sacClassifier,
sacOverlay,
sacShares,
sacTagOf,
unclearTags,
type SacClass,
} from './sacGrades';
type SacGradesStatus = 'off' | 'loading' | 'offline' | 'failed' | 'ready';
export interface SacGradeRow extends ClassShare<SacClass> {
/** What OpenStreetMap wrote in `sac_scale` for this class's ways: the step's value, or the unclear ones. */
tags: string[];
}
export interface SacGrades {
/** Whether the card has a line to describe at all. */
available: boolean;
/** Whether the grades are switched on: only then are they asked for and drawn on the map. */
shown: boolean;
toggle: () => void;
status: SacGradesStatus;
rows: SacGradeRow[];
/** Of the whole line, 0 to 1, the share whose step is known. */
knownShare: number;
/** The highest step known along the line, or null. */
highest: number | null;
/** The class drawn alone on the map, or null while every class is. */
focus: SacClass | null;
focusOn: (key: SacClass) => void;
retry: () => void;
}
const NO_STRAYS: ReadonlySet<RouteWayEdge> = new Set();
function statusOf(state: RouteWaysState, nothingAnswered: boolean): SacGradesStatus {
if (state.status === 'idle') return 'off';
if (state.status !== 'ready') return state.status;
const grades = state.grades?.status ?? 'failed';
if (grades === 'ready') return nothingAnswered ? 'failed' : 'ready';
return grades;
}
function rowsOf(
ways: RouteWays,
grades: NonNullable<RouteWaysState['grades']>,
classOf: (edge: RouteWayEdge) => SacClass
): SacGradeRow[] {
return sacShares(ways, classOf).map((row) => ({
...row,
tags: isSacStep(row.key) ? [sacTagOf(row.key)] : unclearTags(ways, grades, (edge) => classOf(edge) === row.key),
}));
}
/**
* MAP-03: the SAC grades along a hike, switched on by the traveller. Only
* then are the ways under the line asked of the router and their grades of
* OpenStreetMap, and the grades are drawn along the line on the planner map:
* every class at once, or the one picked alone. Nothing here changes the
* tour, its places or its days; switched off, the map is as it was.
*/
export function useSacGrades(input: {
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: the card stays, and waits for the new one. */
current?: boolean;
}): SacGrades {
const { tripId, route, current = true } = input;
const available = !!route && route.length >= 2;
const [shown, setShown] = useState(false);
const [picked, setPicked] = useState<SacClass | null>(null);
const on = available && shown;
const line = current ? route : null;
const state = useRouteWays({ tripId, active: on, route: line, matching: 'foot', withGrades: true });
const { ways, grades } = state;
const strays = useMemo(() => (ways && line ? straysFrom(ways, line) : NO_STRAYS), [ways, line]);
const classOf = useMemo(() => (grades ? sacClassifier(grades, strays) : null), [grades, strays]);
const rows = useMemo(
() => (ways && grades?.status === 'ready' && classOf ? rowsOf(ways, grades, classOf) : []),
[ways, grades, classOf]
);
// Not a single way could be asked about: said once with a way to ask again, not as a row covering the whole hike.
const nothingAnswered = rows.length > 0 && rows.every((row) => row.key === 'unasked');
const status = !on ? 'off' : current ? statusOf(state, nothingAnswered) : 'loading';
const focus = rows.some((row) => row.key === picked) ? picked : null;
useEffect(() => {
if (status !== 'ready' || !ways || !classOf) return;
const overlay = sacOverlay(ways, rows, classOf, focus);
const highlight = useTourRouteHighlightStore.getState();
const id = focus ? `sac-${focus}` : 'sac';
highlight.show({ id, lines: overlay.lines, paint: 'unknown', paints: overlay.paints });
return () => highlight.clear();
}, [status, ways, rows, classOf, focus]);
const toggle = useCallback(() => {
setShown((value) => !value);
setPicked(null);
}, []);
const focusOn = useCallback((key: SacClass) => setPicked((current) => (current === key ? null : key)), []);
const shownRows = status === 'ready' ? rows : [];
return {
available,
shown: on,
toggle,
status,
rows: shownRows,
knownShare: shownRows.filter((row) => isSacStep(row.key)).reduce((sum, row) => sum + row.share, 0),
highest: highestStep(shownRows),
focus,
focusOn,
retry: state.retry,
};
}
@@ -23,6 +23,14 @@ vi.mock('../paddle/PaddleCheckCard', () => ({
),
}))
// MAP-03: the SAC card is its own suite's; here it only has to sit where the rails put it, with what they hand it.
vi.mock('../hike/SacGradesCard', () => ({
SacGradesCard: ({ route, routeCurrent, routingLimit }: { route: [number, number][] | null; routeCurrent?: boolean; routingLimit: number | null }) => (
<div data-testid="sac-grades-card" data-route={route ? route.length : 'none'} data-current={String(routeCurrent ?? true)}
data-limit={String(routingLimit)} />
),
}))
const tour: TourListItem = {
place_id: 42,
name: 'Saved ridge walk',
@@ -1015,3 +1023,33 @@ describe('a run in the planner rails (RUN-01)', () => {
expect(screen.getByTestId('tour-planner-unreachable')).toHaveTextContent('900 m')
})
})
describe('SAC grades in the planner rails (MAP-03)', () => {
it('MAP03-RAIL-001: a hike being edited carries the card with its line, whether it is planned now, and its routing limit', () => {
const { unmount } = render(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, routeCurrent: true, maxHikingDifficulty: 3 })} />)
const card = screen.getByTestId('sac-grades-card')
expect(card).toHaveAttribute('data-route', '2')
expect(card).toHaveAttribute('data-current', 'true')
expect(card).toHaveAttribute('data-limit', '3')
unmount()
render(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, routeCurrent: false })} />)
expect(screen.getByTestId('sac-grades-card')).toHaveAttribute('data-current', 'false')
})
it('MAP03-RAIL-002: a hike import carries it without a routing limit; a bike tour and a bike import do not', () => {
const geometry = JSON.stringify([[48, 11, 500], [48, 11.01, 550], [48, 11.02, 520]])
const imported = (tourType: 'hike' | 'bike') => planner({
readOnlyGpxTour: { tour: { ...tour, tour_type: tourType, has_waypoints: false }, routeGeometry: geometry },
readOnlyGpxAnalysis: analyzeRouteGeometry(geometry),
})
const hike = render(<TourPlannerRail planner={imported('hike')} />)
expect(screen.getByTestId('sac-grades-card')).toHaveAttribute('data-route', '3')
expect(screen.getByTestId('sac-grades-card')).toHaveAttribute('data-limit', 'null')
hike.unmount()
const bikeImport = render(<TourPlannerRail planner={imported('bike')} />)
expect(screen.queryByTestId('sac-grades-card')).toBeNull()
bikeImport.unmount()
render(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, tourType: 'bike', isBike: true, routeCurrent: false })} />)
expect(screen.queryByTestId('sac-grades-card')).toBeNull()
})
})
@@ -53,6 +53,7 @@ import { BikeWaysCard } from '../bike/BikeWaysCard'
import { RunWaysCard } from '../run/RunWaysCard'
import { HorseRoutesCard } from '../horse/HorseRoutesCard'
import { RecordedRideCard } from '../horse/RecordedRideFacts'
import { SacGradesCard } from '../hike/SacGradesCard'
import { walkTimeFields } from '../walk/walkTimeFields'
const STATUS_KEY: Partial<Record<TourPlannerStatus, string>> = {
@@ -349,6 +350,9 @@ function GpxViewRail({
</p>
)}
</RailCard>
{tour.tour_type === 'hike' && (
<SacGradesCard tripId={planner.tripId} route={analysis?.routeCoordinates ?? null} routingLimit={null} />
)}
</div>
{tour.tour_type === 'kayak' && (
<div className="flex-none border-t border-edge-faint px-3 py-3">
@@ -470,6 +474,11 @@ export function TourPlannerRail({ planner,
<HorseRoutesCard planner={planner} />
{planner.tourType === 'hike' && (
<SacGradesCard tripId={planner.tripId} route={planner.route} routeCurrent={planner.routeCurrent}
routingLimit={planner.maxHikingDifficulty} />
)}
<TourPoiSearchCard planner={planner} />
<PaddleEditorCards planner={planner} locked={locked} checklist={paddleChecklist} />
@@ -116,7 +116,7 @@ describe('RunWaysCard (RUN-02)', () => {
tripId: 4,
active: true,
route: LINE,
matchAs: 'pedestrian',
matching: 'run',
withGrades: false,
});
run.unmount();
@@ -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;