mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
feat(tours): plan a trail run on forest tracks and natural paths
RUN-02. Trail running joins Running as a run profile, chosen on purpose in the planner and in the GPX import. It asks Valhalla's pedestrian costing for tracks wherever they serve (use_tracks 1) and admits mountain paths up to T2 (max_hiking_difficulty 2), never T3 and above, with ferries only where no way on land exists. Checked with trace_attributes against the public instance: above Freiburg Guenterstal up to the Schauinsland the route went from 82 % roads and 83 % paved (running) to 78 % forest tracks and 81 % compacted, gravel and dirt; Rathen to the Bastei took 28 % of T2 path that running leaves out; Heidelberg's old town stayed as it was. Leaning on footways (walkway_factor) was tried and left out, it moved runs onto paved footways. While a run is planned, a card says what it runs on: its surfaces, the hiking grades of its paths (sac_scale, T1 to T6, as mappers gave them) and its kinds of way, each as shares with lengths that point at their stretches on the map. A way without a grade or a surface is its own row, unknown and never easy, and an info dot says the grades say nothing about whether a way can be run. The head carries the unpaved share and the hardest graded path as badges. The line is matched on foot for this, so steps and mountain paths stay on it; a bike tour's is matched as before. Reading fails, goes offline or is retried as for a bike tour, and nothing of it changes the run. Pace and time are RUN-01's: the running time stays the distance at the runner's pace, with climbs, descents and difficulty left out, whatever the profile. A profile change plans the route again and keeps the pace; a failed trail route keeps the last route and holds Save back. The card's building blocks moved out of the bike card into RouteWaySections, so both cards share one set of rows, bars and status lines. The run_profile column already allowed trail (RUN-01), so no migration. GPX export names a trail run trail_running, list_tours describes the profile as a routing preference, never a statement that a way can be run.
This commit is contained in:
@@ -551,6 +551,21 @@ describe('valhallaTraceAttributes (BIK-01)', () => {
|
||||
expect(bodies[0].shape).toEqual(SHAPE_POINTS.map(([lat, lon]) => ({ lat, lon })))
|
||||
})
|
||||
|
||||
it('VALHALLA-TRACE-005: a run is matched on foot without a hiking limit, and every line is asked its hiking grades (RUN-02)', 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, 'pedestrian')
|
||||
expect(bodies[0]).toMatchObject({
|
||||
costing: 'pedestrian',
|
||||
costing_options: { pedestrian: { max_hiking_difficulty: 6 } },
|
||||
shape_match: 'map_snap',
|
||||
})
|
||||
expect((bodies[0].filters as { attributes: string[] }).attributes).toContain('edge.sac_scale')
|
||||
})
|
||||
|
||||
it('VALHALLA-TRACE-002: a long line is thinned evenly to what one request carries, its ends kept', async () => {
|
||||
let shape: unknown[] = []
|
||||
server.use(http.post(TRACE, async ({ request }) => {
|
||||
|
||||
@@ -368,14 +368,32 @@ const TRACE_MAX_POINTS = 4000
|
||||
const TRACE_TIMEOUT_MS = 30_000
|
||||
|
||||
/**
|
||||
* BIK-01: how the router describes the ways under a line (`trace_attributes`):
|
||||
* the surface, the use and road class, the cycle lane and the OpenStreetMap way
|
||||
* of every edge, with the line as matched. The edges are matched as a mountain
|
||||
* bike would ride them, the costing that refuses the fewest ways, so a road
|
||||
* bike's route and a gravel one are described alike. Null for every failure:
|
||||
* 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).
|
||||
*/
|
||||
export type TraceMatch = 'bicycle' | 'pedestrian'
|
||||
|
||||
const TRACE_COSTING: Record<TraceMatch, Readonly<Record<string, CostingOptions>>> = {
|
||||
bicycle: { bicycle: { bicycle_type: 'Mountain' } },
|
||||
pedestrian: { pedestrian: { max_hiking_difficulty: 6 } },
|
||||
}
|
||||
|
||||
/**
|
||||
* BIK-01, RUN-02: 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.
|
||||
*/
|
||||
export async function valhallaTraceAttributes(line: readonly [number, number][], signal?: AbortSignal): Promise<unknown> {
|
||||
export async function valhallaTraceAttributes(
|
||||
line: readonly [number, number][],
|
||||
signal?: AbortSignal,
|
||||
matchAs: TraceMatch = 'bicycle',
|
||||
): Promise<unknown> {
|
||||
const base = valhallaBase()
|
||||
if (!base || line.length < 2) return null
|
||||
const step = Math.max(1, Math.ceil(line.length / TRACE_MAX_POINTS))
|
||||
@@ -389,12 +407,12 @@ export async function valhallaTraceAttributes(line: readonly [number, number][],
|
||||
headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
|
||||
body: JSON.stringify({
|
||||
shape,
|
||||
costing: 'bicycle',
|
||||
costing_options: { bicycle: { bicycle_type: 'Mountain' } },
|
||||
costing: matchAs,
|
||||
costing_options: TRACE_COSTING[matchAs],
|
||||
shape_match: 'map_snap',
|
||||
filters: {
|
||||
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'],
|
||||
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'],
|
||||
action: 'include',
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -15,7 +15,7 @@ vi.mock('./useRouteWays', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// FE-TOUR-BIKECARD-001 to FE-TOUR-BIKECARD-008
|
||||
// FE-TOUR-BIKECARD-001 to FE-TOUR-BIKECARD-009
|
||||
|
||||
const LINE: [number, number][] = [
|
||||
[47.0, 11.0],
|
||||
@@ -26,9 +26,9 @@ const LINE: [number, number][] = [
|
||||
const WAYS: RouteWays = {
|
||||
line: LINE,
|
||||
edges: [
|
||||
{ from: 0, to: 1, lengthM: 600, surface: 'paved', way: 'cyclePath', wayId: 1 },
|
||||
{ from: 1, to: 2, lengthM: 300, surface: 'gravel', way: 'track', wayId: 2 },
|
||||
{ from: 2, to: 3, lengthM: 100, surface: 'ground', way: 'path', wayId: 3 },
|
||||
{ from: 0, to: 1, lengthM: 600, surface: 'paved', way: 'cyclePath', sac: 'none', wayId: 1 },
|
||||
{ from: 1, to: 2, lengthM: 300, surface: 'gravel', way: 'track', sac: 'none', wayId: 2 },
|
||||
{ from: 2, to: 3, lengthM: 100, surface: 'ground', way: 'path', sac: 'none', wayId: 3 },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -66,16 +66,16 @@ describe('BikeWaysCard (BIK-01 to BIK-05)', () => {
|
||||
it('FE-TOUR-BIKECARD-001: the surfaces and kinds of way of the route, by share, with their lengths', () => {
|
||||
show({});
|
||||
const card = screen.getByTestId('bike-ways-card');
|
||||
expect(within(screen.getByTestId('bike-ways-row-surface-paved')).getByText('60 %')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('bike-ways-row-surface-gravel')).toHaveTextContent('300 m');
|
||||
expect(screen.getByTestId('bike-ways-row-way-cyclePath')).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('route-ways-row-surface-paved')).getByText('60 %')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('route-ways-row-surface-gravel')).toHaveTextContent('300 m');
|
||||
expect(screen.getByTestId('route-ways-row-way-cyclePath')).toBeInTheDocument();
|
||||
expect(screen.getByText('40 % unpaved')).toBeInTheDocument();
|
||||
expect(card).not.toHaveTextContent('Trail grade');
|
||||
});
|
||||
|
||||
it('FE-TOUR-BIKECARD-002: a row points at its stretches on the map, and lets go when pressed again', () => {
|
||||
show({});
|
||||
const row = screen.getByTestId('bike-ways-row-surface-gravel');
|
||||
const row = screen.getByTestId('route-ways-row-surface-gravel');
|
||||
fireEvent.click(row);
|
||||
expect(row).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(useTourRouteHighlightStore.getState().highlight).toEqual({
|
||||
@@ -105,15 +105,15 @@ describe('BikeWaysCard (BIK-01 to BIK-05)', () => {
|
||||
unasked: new Set([3]),
|
||||
},
|
||||
});
|
||||
expect(screen.getByTestId('bike-ways-row-trail-s0')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('bike-ways-row-trail-s2')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('bike-ways-row-trail-unasked')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('bike-ways-row-uphill-u3')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('route-ways-row-trail-s0')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('route-ways-row-trail-s2')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('route-ways-row-trail-unasked')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('route-ways-row-uphill-u3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-BIKECARD-005: grades nobody could be asked for are one notice with a retry, not a row for the whole tour', () => {
|
||||
show({ grades: { status: 'ready', byWay: new Map(), unasked: new Set([1, 2, 3]) } });
|
||||
expect(screen.queryByTestId('bike-ways-row-trail-unasked')).toBeNull();
|
||||
expect(screen.queryByTestId('route-ways-row-trail-unasked')).toBeNull();
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /try again/i })[0]);
|
||||
expect(hook.retry).toHaveBeenCalled();
|
||||
});
|
||||
@@ -132,7 +132,7 @@ describe('BikeWaysCard (BIK-01 to BIK-05)', () => {
|
||||
|
||||
it('FE-TOUR-BIKECARD-007: grades on their way or offline say so in their own section', () => {
|
||||
const { unmount } = show({ grades: { status: 'loading', byWay: new Map(), unasked: new Set() } });
|
||||
expect(screen.queryByTestId('bike-ways-row-trail-s0')).toBeNull();
|
||||
expect(screen.queryByTestId('route-ways-row-trail-s0')).toBeNull();
|
||||
unmount();
|
||||
show({ grades: { status: 'offline', byWay: new Map(), unasked: new Set() } });
|
||||
expect(screen.queryByRole('button', { name: /try again/i })).toBeNull();
|
||||
@@ -147,6 +147,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, profile: 'touring' });
|
||||
expect(hook.input).toMatchObject({ tripId: 4, active: false, route: null, matchAs: 'bicycle', 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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AlertTriangle, Info, Layers, Loader2, RefreshCw, WifiOff } from 'lucide-react';
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { AlertTriangle, Layers, Loader2, RefreshCw, WifiOff } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
|
||||
@@ -8,144 +8,29 @@ import { SoftPill } from '../../Planner/planParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { CardAction, RailCard, TourNotice } from '../tourParts';
|
||||
import { bikePaintCss, surfacePaintKey, trailPaintKey, type BikePaintKey } from './bikePaint';
|
||||
import { trailPaintKey } from './bikePaint';
|
||||
import {
|
||||
percent,
|
||||
RouteWaysProgress,
|
||||
RouteWaysSource,
|
||||
ShareBar,
|
||||
ShareList,
|
||||
SurfaceSection,
|
||||
WaySection,
|
||||
WaysSection,
|
||||
} from './RouteWaySections';
|
||||
import {
|
||||
sharesBy,
|
||||
stretchesOf,
|
||||
trailClassOf,
|
||||
unpavedShareOf,
|
||||
UNPAVED,
|
||||
type ClassShare,
|
||||
type RouteWayEdge,
|
||||
type RouteWays,
|
||||
type SurfaceClass,
|
||||
type TrailClass,
|
||||
type WayClass,
|
||||
} from './routeWays';
|
||||
import { useRouteWays, type TrailGrades } from './useRouteWays';
|
||||
|
||||
const percent = (share: number) => `${Math.round(share * 100)} %`;
|
||||
|
||||
interface ShareRowProps<C extends string> {
|
||||
id: string;
|
||||
row: ClassShare<C>;
|
||||
label: string;
|
||||
paint: BikePaintKey;
|
||||
/** A dot in the class's colour; a kind of way has none. */
|
||||
dot: boolean;
|
||||
ways: RouteWays;
|
||||
inClass: (edge: RouteWayEdge) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One class of the route: its share and length, and a switch that points at
|
||||
* its stretches on the map. Pressed again, or another row pressed, it lets go.
|
||||
*/
|
||||
function ShareRow<C extends string>({ id, row, label, paint, dot, ways, inClass }: ShareRowProps<C>) {
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const shown = useTourRouteHighlightStore((state) => state.highlight?.id === id);
|
||||
const toggle = () =>
|
||||
useTourRouteHighlightStore.getState().show(shown ? null : { id, lines: stretchesOf(ways, inClass), paint });
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={shown}
|
||||
data-testid={`bike-ways-row-${id}`}
|
||||
onClick={toggle}
|
||||
className={`flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1 text-start hover:bg-surface-tertiary ${shown ? 'bg-surface-tertiary' : ''}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-2.5 w-2.5 flex-none rounded-full ${dot ? '' : 'border border-edge'}`}
|
||||
style={dot ? { background: bikePaintCss(paint) } : undefined}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-content" style={fs(12, 'body')}>
|
||||
{label}
|
||||
</span>
|
||||
<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>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** The shares as one bar, in the classes' colours, so the mix reads at a glance. */
|
||||
function ShareBar<C extends string>({ rows, paintOf }: { rows: ClassShare<C>[]; paintOf: (key: C) => BikePaintKey }) {
|
||||
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: bikePaintCss(paintOf(row.key)) }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<span className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5, 'caption')}>
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SurfaceSection({ ways }: { ways: RouteWays }) {
|
||||
const { t } = useTranslation();
|
||||
const rows = useMemo(() => sharesBy<SurfaceClass>(ways, (edge) => edge.surface), [ways]);
|
||||
return (
|
||||
<Section label={t('tours.bike.ways.surface')}>
|
||||
<ShareBar rows={rows} paintOf={surfacePaintKey} />
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{rows.map((row) => (
|
||||
<ShareRow
|
||||
key={row.key}
|
||||
id={`surface-${row.key}`}
|
||||
row={row}
|
||||
label={t(`tours.bike.ways.surface.${row.key}`)}
|
||||
paint={surfacePaintKey(row.key)}
|
||||
dot
|
||||
ways={ways}
|
||||
inClass={(edge) => edge.surface === row.key}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function WaySection({ ways }: { ways: RouteWays }) {
|
||||
const { t } = useTranslation();
|
||||
const rows = useMemo(() => sharesBy<WayClass>(ways, (edge) => edge.way), [ways]);
|
||||
return (
|
||||
<Section label={t('tours.bike.ways.way')}>
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{rows.map((row) => (
|
||||
<ShareRow
|
||||
key={row.key}
|
||||
id={`way-${row.key}`}
|
||||
row={row}
|
||||
label={t(`tours.bike.ways.way.${row.key}`)}
|
||||
paint="way"
|
||||
dot={false}
|
||||
ways={ways}
|
||||
inClass={(edge) => edge.way === row.key}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/** BIK-05: the trail grades along a mountain bike tour, downhill and uphill apart, the unknown said. */
|
||||
function TrailSection({ ways, grades, onRetry }: { ways: RouteWays; grades: TrailGrades; onRetry: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -173,46 +58,33 @@ function TrailSection({ ways, grades, onRetry }: { ways: RouteWays; grades: Trai
|
||||
const unread = noneAnswered ? 'failed' : grades.status;
|
||||
if (unread !== 'ready')
|
||||
return (
|
||||
<Section label={t('tours.bike.trail.title')}>
|
||||
<WaysSection label={t('tours.bike.trail.title')}>
|
||||
<GradesStatus status={unread} onRetry={onRetry} />
|
||||
</Section>
|
||||
</WaysSection>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Section label={t('tours.bike.trail.title')}>
|
||||
<WaysSection label={t('tours.bike.trail.title')}>
|
||||
<ShareBar rows={rows} paintOf={trailPaintKey} />
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{rows.map((row) => (
|
||||
<ShareRow
|
||||
key={row.key}
|
||||
id={`trail-${row.key}`}
|
||||
row={row}
|
||||
label={t(`tours.bike.trail.${row.key}`)}
|
||||
paint={trailPaintKey(row.key)}
|
||||
dot
|
||||
ways={ways}
|
||||
inClass={(edge) => classOf(edge) === row.key}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
<ShareList
|
||||
ways={ways}
|
||||
rows={rows}
|
||||
idPrefix="trail"
|
||||
classOf={classOf}
|
||||
labelOf={(key) => t(`tours.bike.trail.${key}`)}
|
||||
paintOf={trailPaintKey}
|
||||
/>
|
||||
</WaysSection>
|
||||
{uphill.length > 0 && (
|
||||
<Section label={t('tours.bike.trail.uphillTitle')}>
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{uphill.map((row) => (
|
||||
<ShareRow
|
||||
key={row.key}
|
||||
id={`uphill-${row.key}`}
|
||||
row={row}
|
||||
label={t('tours.bike.trail.uphillStep', { step: row.key.slice(1) })}
|
||||
paint="way"
|
||||
dot={false}
|
||||
ways={ways}
|
||||
inClass={(edge) => uphillOf(edge) === row.key}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
<WaysSection label={t('tours.bike.trail.uphillTitle')}>
|
||||
<ShareList
|
||||
ways={ways}
|
||||
rows={uphill}
|
||||
idPrefix="uphill"
|
||||
classOf={uphillOf}
|
||||
labelOf={(key) => t('tours.bike.trail.uphillStep', { step: key.slice(1) })}
|
||||
/>
|
||||
</WaysSection>
|
||||
)}
|
||||
<span className="text-content-faint" style={fs(11, 'body')}>
|
||||
{t('tours.bike.trail.note')}
|
||||
@@ -283,15 +155,12 @@ export function BikeWaysCard({ planner }: { planner: TourPlannerController }) {
|
||||
tripId: planner.tripId,
|
||||
active: planner.isBike && editing,
|
||||
route,
|
||||
profile: planner.bikeProfile,
|
||||
matchAs: 'bicycle',
|
||||
withGrades: planner.bikeProfile === 'mtb',
|
||||
});
|
||||
const { ways } = state;
|
||||
const unpavedShare = useMemo(
|
||||
() => (ways ? sharesBy(ways, (edge) => (UNPAVED.has(edge.surface) ? 'unpaved' : 'other')) : []),
|
||||
[ways]
|
||||
);
|
||||
const unpaved = useMemo(() => (ways ? unpavedShareOf(ways) : null), [ways]);
|
||||
if (!planner.isBike || !editing || state.status === 'idle') return null;
|
||||
const unpaved = unpavedShare.find((row) => row.key === 'unpaved');
|
||||
return (
|
||||
<RailCard
|
||||
icon={<Layers size={14} strokeWidth={2} />}
|
||||
@@ -299,40 +168,19 @@ export function BikeWaysCard({ planner }: { planner: TourPlannerController }) {
|
||||
foldId="bike-ways"
|
||||
aside={
|
||||
unpaved ? (
|
||||
<SoftPill tone="warning">{t('tours.bike.ways.unpavedBadge', { share: percent(unpaved.share) })}</SoftPill>
|
||||
<SoftPill tone="warning">{t('tours.bike.ways.unpavedBadge', { share: percent(unpaved) })}</SoftPill>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div data-testid="bike-ways-card" className="flex min-w-0 flex-col gap-3">
|
||||
{state.status === 'loading' && (
|
||||
<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>
|
||||
)}
|
||||
{state.status === 'offline' && (
|
||||
<TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.bike.ways.offline')}</TourNotice>
|
||||
)}
|
||||
{state.status === 'failed' && (
|
||||
<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>
|
||||
)}
|
||||
<RouteWaysProgress state={state} />
|
||||
{ways && (
|
||||
<>
|
||||
{planner.bikeProfile === 'road' && <UnpavedNotice ways={ways} />}
|
||||
<SurfaceSection ways={ways} />
|
||||
<WaySection ways={ways} />
|
||||
{state.grades && <TrailSection ways={ways} grades={state.grades} onRetry={state.retry} />}
|
||||
<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.bike.ways.source')}
|
||||
</span>
|
||||
<RouteWaysSource />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { AlertTriangle, Info, Loader2, RefreshCw, WifiOff } 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 { bikePaintCss, surfacePaintKey, type BikePaintKey } from './bikePaint';
|
||||
import {
|
||||
sharesBy,
|
||||
stretchesOf,
|
||||
type ClassShare,
|
||||
type RouteWayEdge,
|
||||
type RouteWays,
|
||||
type SurfaceClass,
|
||||
type WayClass,
|
||||
} from './routeWays';
|
||||
import type { RouteWaysState } from './useRouteWays';
|
||||
|
||||
/**
|
||||
* BIK-01 to BIK-05, RUN-02: the pieces a card about what a route runs on is
|
||||
* built from, shared by the bike tour's card and the run's: a class's share
|
||||
* as a bar and as rows that point at its stretches on the map, the surfaces
|
||||
* and kinds of way, and how the reading went.
|
||||
*/
|
||||
|
||||
export const percent = (share: number) => `${Math.round(share * 100)} %`;
|
||||
|
||||
interface ShareRowProps<C extends string> {
|
||||
id: string;
|
||||
row: ClassShare<C>;
|
||||
label: string;
|
||||
paint: BikePaintKey;
|
||||
/** A dot in the class's colour; a kind of way has none. */
|
||||
dot: boolean;
|
||||
ways: RouteWays;
|
||||
inClass: (edge: RouteWayEdge) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One class of the route: its share and length, and a switch that points at
|
||||
* its stretches on the map. Pressed again, or another row pressed, it lets go.
|
||||
*/
|
||||
function ShareRow<C extends string>({ id, row, label, paint, dot, ways, inClass }: ShareRowProps<C>) {
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const shown = useTourRouteHighlightStore((state) => state.highlight?.id === id);
|
||||
const toggle = () =>
|
||||
useTourRouteHighlightStore.getState().show(shown ? null : { id, lines: stretchesOf(ways, inClass), paint });
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={shown}
|
||||
data-testid={`route-ways-row-${id}`}
|
||||
onClick={toggle}
|
||||
className={`flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1 text-start hover:bg-surface-tertiary ${shown ? 'bg-surface-tertiary' : ''}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-2.5 w-2.5 flex-none rounded-full ${dot ? '' : 'border border-edge'}`}
|
||||
style={dot ? { background: bikePaintCss(paint) } : undefined}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-content" style={fs(12, 'body')}>
|
||||
{label}
|
||||
</span>
|
||||
<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>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The classes of the route as rows, the largest first. With `paintOf` each
|
||||
* row has a dot in its class's colour and points at its stretches in it; a
|
||||
* kind of way has no colour and is pointed at in the accent.
|
||||
*/
|
||||
export function ShareList<C extends string>({
|
||||
ways,
|
||||
rows,
|
||||
idPrefix,
|
||||
classOf,
|
||||
labelOf,
|
||||
paintOf,
|
||||
}: {
|
||||
ways: RouteWays;
|
||||
rows: ClassShare<C>[];
|
||||
idPrefix: string;
|
||||
classOf: (edge: RouteWayEdge) => C;
|
||||
labelOf: (key: C) => string;
|
||||
paintOf?: (key: C) => BikePaintKey;
|
||||
}) {
|
||||
return (
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{rows.map((row) => (
|
||||
<ShareRow
|
||||
key={row.key}
|
||||
id={`${idPrefix}-${row.key}`}
|
||||
row={row}
|
||||
label={labelOf(row.key)}
|
||||
paint={paintOf ? paintOf(row.key) : 'way'}
|
||||
dot={!!paintOf}
|
||||
ways={ways}
|
||||
inClass={(edge) => classOf(edge) === row.key}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** The shares as one bar, in the classes' colours, so the mix reads at a glance. */
|
||||
export function ShareBar<C extends string>({
|
||||
rows,
|
||||
paintOf,
|
||||
}: {
|
||||
rows: ClassShare<C>[];
|
||||
paintOf: (key: C) => BikePaintKey;
|
||||
}) {
|
||||
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: bikePaintCss(paintOf(row.key)) }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A labelled part of the card; `info` puts what the part means behind a dot rather than in a line of its own. */
|
||||
export function WaysSection({
|
||||
label,
|
||||
info,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
info?: { label: string; description: string };
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<span className="flex min-h-5 min-w-0 items-center gap-1">
|
||||
<span
|
||||
className="min-w-0 truncate font-geist font-bold uppercase tracking-[.08em] text-content-faint"
|
||||
style={fs(9.5, 'caption')}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{info && <InfoDot label={info.label} description={info.description} />}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const surfaceOf = (edge: RouteWayEdge) => edge.surface;
|
||||
const wayClassOf = (edge: RouteWayEdge) => edge.way;
|
||||
|
||||
/** The surfaces under the route, the unknown named as such. */
|
||||
export function SurfaceSection({ ways }: { ways: RouteWays }) {
|
||||
const { t } = useTranslation();
|
||||
const rows = useMemo(() => sharesBy<SurfaceClass>(ways, surfaceOf), [ways]);
|
||||
return (
|
||||
<WaysSection label={t('tours.bike.ways.surface')}>
|
||||
<ShareBar rows={rows} paintOf={surfacePaintKey} />
|
||||
<ShareList
|
||||
ways={ways}
|
||||
rows={rows}
|
||||
idPrefix="surface"
|
||||
classOf={surfaceOf}
|
||||
labelOf={(key) => t(`tours.bike.ways.surface.${key}`)}
|
||||
paintOf={surfacePaintKey}
|
||||
/>
|
||||
</WaysSection>
|
||||
);
|
||||
}
|
||||
|
||||
/** The kinds of way the route takes. */
|
||||
export function WaySection({ ways }: { ways: RouteWays }) {
|
||||
const { t } = useTranslation();
|
||||
const rows = useMemo(() => sharesBy<WayClass>(ways, wayClassOf), [ways]);
|
||||
return (
|
||||
<WaysSection label={t('tours.bike.ways.way')}>
|
||||
<ShareList
|
||||
ways={ways}
|
||||
rows={rows}
|
||||
idPrefix="way"
|
||||
classOf={wayClassOf}
|
||||
labelOf={(key) => t(`tours.bike.ways.way.${key}`)}
|
||||
/>
|
||||
</WaysSection>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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>
|
||||
);
|
||||
}
|
||||
|
||||
/** Where what the card shows comes from, and that a gap in it is unknown, not fine. */
|
||||
export function RouteWaysSource() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<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.bike.ways.source')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMapPaint } from '../../Map/mapPaint';
|
||||
import type { SurfaceClass, TrailClass } from './routeWays';
|
||||
import type { SacClass, SurfaceClass, TrailClass } from './routeWays';
|
||||
|
||||
/**
|
||||
* The colours of a bike tour's surfaces and trail grades (styles/bike.css),
|
||||
* The colours of a bike tour's or a run's surfaces and grades (styles/bike.css),
|
||||
* by name. The cards take the CSS variable, which follows the theme on its
|
||||
* own; the map reads them resolved (`useBikePaint`). A kind of way has no
|
||||
* colour of its own: it is pointed at in the accent.
|
||||
@@ -30,6 +30,25 @@ export function trailPaintKey(key: TrailClass): BikePaintKey {
|
||||
return key === 'none' || key === 'unasked' ? 'unknown' : key;
|
||||
}
|
||||
|
||||
/**
|
||||
* RUN-02: the paint of a hiking grade, on the same scale from easy to hard as
|
||||
* the trail grades (T1 as S0, T5 and up as S4), so a map never shows two
|
||||
* colour scales for one idea; a way without a grade in the grey of the unknown.
|
||||
*/
|
||||
const SAC_PAINT: Record<SacClass, BikePaintKey> = {
|
||||
t1: 's0',
|
||||
t2: 's1',
|
||||
t3: 's2',
|
||||
t4: 's3',
|
||||
t5: 's4',
|
||||
t6: 's4',
|
||||
none: 'unknown',
|
||||
};
|
||||
|
||||
export function sacPaintKey(key: SacClass): BikePaintKey {
|
||||
return SAC_PAINT[key];
|
||||
}
|
||||
|
||||
/** The paint of a surface class. */
|
||||
export function surfacePaintKey(key: SurfaceClass): BikePaintKey {
|
||||
return key;
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { encodePolyline } from '../../../../tests/helpers/polyline';
|
||||
import { bikePaintCss, surfacePaintKey, trailPaintKey } from './bikePaint';
|
||||
import { bikePaintCss, sacPaintKey, surfacePaintKey, trailPaintKey } from './bikePaint';
|
||||
import { withRouteHighlight } from './routeHighlight';
|
||||
import { routeWaysFrom, sharesBy, stretchesOf, trailClassOf, wayIdsOf, type RouteWays } from './routeWays';
|
||||
import {
|
||||
routeWaysFrom,
|
||||
sacClassOf,
|
||||
sharesBy,
|
||||
stretchesOf,
|
||||
trailClassOf,
|
||||
unpavedShareOf,
|
||||
wayIdsOf,
|
||||
type RouteWays,
|
||||
} from './routeWays';
|
||||
|
||||
// FE-TOUR-WAYS-001 to FE-TOUR-WAYS-010
|
||||
// FE-TOUR-WAYS-001 to FE-TOUR-WAYS-013
|
||||
|
||||
const LINE: [number, number][] = [
|
||||
[47.0, 11.0],
|
||||
@@ -65,6 +74,20 @@ describe('routeWaysFrom (BIK-01 to BIK-04)', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYS-011: reads the hiking grade of each edge, and no grade, a 0 or anything else as none (RUN-02)', () => {
|
||||
const ways = routeWaysFrom(
|
||||
answer([
|
||||
edge(0, 1, { use: 'path', sac_scale: 1 }),
|
||||
edge(1, 2, { use: 'path', sac_scale: 2 }),
|
||||
edge(2, 3, { use: 'road', sac_scale: 0 }),
|
||||
edge(3, 4, { use: 'road' }),
|
||||
])
|
||||
);
|
||||
expect(ways?.edges.map((e) => e.sac)).toEqual(['t1', 't2', 'none', 'none']);
|
||||
expect([3, 4, 5, 6].map(sacClassOf)).toEqual(['t3', 't4', 't5', 't6']);
|
||||
for (const value of [7, -1, 2.5, '2', null]) expect(sacClassOf(value)).toBe('none');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYS-003: drops an edge without its place on the line, and is null for no answer of that kind', () => {
|
||||
const ways = routeWaysFrom(
|
||||
answer([edge(0, 1), { ...edge(1, 2), end_shape_index: 9 }, { ...edge(2, 1) }, { ...edge(2, 3), length: -1 }])
|
||||
@@ -80,10 +103,10 @@ describe('routeWaysFrom (BIK-01 to BIK-04)', () => {
|
||||
const WAYS: RouteWays = {
|
||||
line: LINE,
|
||||
edges: [
|
||||
{ from: 0, to: 1, lengthM: 300, surface: 'paved', way: 'road', wayId: 1 },
|
||||
{ from: 1, to: 2, lengthM: 100, surface: 'gravel', way: 'track', wayId: 2 },
|
||||
{ from: 2, to: 3, lengthM: 100, surface: 'gravel', way: 'track', wayId: 2 },
|
||||
{ from: 3, to: 4, lengthM: 0, surface: 'ground', way: 'path', wayId: null },
|
||||
{ from: 0, to: 1, lengthM: 300, surface: 'paved', way: 'road', sac: 'none', wayId: 1 },
|
||||
{ from: 1, to: 2, lengthM: 100, surface: 'gravel', way: 'track', sac: 'none', wayId: 2 },
|
||||
{ from: 2, to: 3, lengthM: 100, surface: 'gravel', way: 'track', sac: 'none', wayId: 2 },
|
||||
{ from: 3, to: 4, lengthM: 0, surface: 'ground', way: 'path', sac: 'none', wayId: null },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -96,6 +119,11 @@ describe('what the line runs on (BIK-01 to BIK-05)', () => {
|
||||
expect(sharesBy({ line: LINE, edges: [] }, (e) => e.surface)).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYS-013: the share known to be unpaved, and none where nothing is', () => {
|
||||
expect(unpavedShareOf(WAYS)).toBe(0.4);
|
||||
expect(unpavedShareOf({ line: LINE, edges: [WAYS.edges[0]] })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYS-005: the stretches of a class, neighbours joined into one line each', () => {
|
||||
expect(stretchesOf(WAYS, (e) => e.surface === 'gravel')).toEqual([LINE.slice(1, 4)]);
|
||||
expect(stretchesOf(WAYS, (e) => e.surface !== 'gravel')).toEqual([LINE.slice(0, 2), LINE.slice(3, 5)]);
|
||||
@@ -126,6 +154,12 @@ describe('the paints and the highlight (BIK-01 to BIK-05)', () => {
|
||||
expect(bikePaintCss('s1')).toBe('var(--bike-trail-s1)');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYS-012: a hiking grade takes the paint of the trail grade it matches, T5 and up the hardest (RUN-02)', () => {
|
||||
const grades = ['t1', 't2', 't3', 't4', 't5', 't6'] as const;
|
||||
expect(grades.map(sacPaintKey)).toEqual(['s0', 's1', 's2', 's3', 's4', 's4']);
|
||||
expect(sacPaintKey('none')).toBe('unknown');
|
||||
});
|
||||
|
||||
const paint = { gravel: '#a0522d', way: '#111', casing: '#fff' } as Parameters<typeof withRouteHighlight>[4];
|
||||
|
||||
it('FE-TOUR-WAYS-009: lays the highlighted stretches over the route in their paint, solid', () => {
|
||||
|
||||
@@ -2,10 +2,11 @@ import type { TourTrailGrade } from '@trek/shared';
|
||||
import { decodePolyline } from '../../Map/transitGeometry';
|
||||
|
||||
/**
|
||||
* BIK-01 to BIK-05: what a bike tour runs on, read from the router's own
|
||||
* description of the ways along its line (Valhalla's trace attributes): the
|
||||
* surface, the kind of way, and the OpenStreetMap way behind each stretch.
|
||||
* Pure, so the classes and their shares are tested apart from the request.
|
||||
* BIK-01 to BIK-05, RUN-02: what a bike tour or a run runs on, read from the
|
||||
* router's own description of the ways along its line (Valhalla's trace
|
||||
* attributes): the surface, the kind of way, the hiking grade, and the
|
||||
* OpenStreetMap way behind each stretch. Pure, so the classes and their
|
||||
* shares are tested apart from the request.
|
||||
*/
|
||||
|
||||
/** Surfaces from smooth to rough; `unknown` where the data names none. */
|
||||
@@ -21,6 +22,13 @@ export type WayClass = 'cyclePath' | 'cycleLane' | 'quiet' | 'road' | 'track' |
|
||||
*/
|
||||
export type TrailClass = 's0' | 's1' | 's2' | 's3' | 's4' | 'none' | 'unasked';
|
||||
|
||||
/**
|
||||
* RUN-02: sac_scale steps, T1 to T6, as OpenStreetMap mappers gave them to a
|
||||
* way; `none` where the way has no grade (the router's 0), which is unknown,
|
||||
* never easy.
|
||||
*/
|
||||
export type SacClass = 't1' | 't2' | 't3' | 't4' | 't5' | 't6' | 'none';
|
||||
|
||||
/** Surfaces a road bike's rider calls unpaved (BIK-02): what is no paving of any kind. */
|
||||
export const UNPAVED: ReadonlySet<SurfaceClass> = new Set(['compacted', 'gravel', 'ground']);
|
||||
|
||||
@@ -31,6 +39,7 @@ export interface RouteWayEdge {
|
||||
lengthM: number;
|
||||
surface: SurfaceClass;
|
||||
way: WayClass;
|
||||
sac: SacClass;
|
||||
/** The OpenStreetMap way, where the router names it. */
|
||||
wayId: number | null;
|
||||
}
|
||||
@@ -89,6 +98,7 @@ interface TraceEdge {
|
||||
use?: unknown;
|
||||
road_class?: unknown;
|
||||
cycle_lane?: unknown;
|
||||
sac_scale?: unknown;
|
||||
length?: unknown;
|
||||
way_id?: unknown;
|
||||
begin_shape_index?: unknown;
|
||||
@@ -98,6 +108,13 @@ interface TraceEdge {
|
||||
const text = (value: unknown) => (typeof value === 'string' ? value : undefined);
|
||||
const count = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null);
|
||||
|
||||
/** The router's sac_scale, 1 to 6, as its step; 0, no value or anything else is no grade. */
|
||||
export function sacClassOf(value: unknown): SacClass {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 6
|
||||
? (`t${value}` as SacClass)
|
||||
: 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* The router's answer read into stretches, or null when it is no answer of
|
||||
* that kind. Edge lengths come in kilometres; an edge without its place on the
|
||||
@@ -122,6 +139,7 @@ export function routeWaysFrom(answer: unknown): RouteWays | null {
|
||||
lengthM: km * 1000,
|
||||
surface,
|
||||
way: wayOf(text(raw.use), text(raw.road_class), text(raw.cycle_lane)),
|
||||
sac: sacClassOf(raw.sac_scale),
|
||||
wayId: wayId && wayId > 0 ? wayId : null,
|
||||
});
|
||||
}
|
||||
@@ -153,6 +171,14 @@ export function sharesBy<C extends string>(ways: RouteWays, classOf: (edge: Rout
|
||||
.sort((a, b) => b.lengthM - a.lengthM);
|
||||
}
|
||||
|
||||
/** The share of the line known to be unpaved, 0 to 1, or null when none of it is. */
|
||||
export function unpavedShareOf(ways: RouteWays): number | null {
|
||||
const row = sharesBy(ways, (edge) => (UNPAVED.has(edge.surface) ? 'unpaved' : 'other')).find(
|
||||
(share) => share.key === 'unpaved'
|
||||
);
|
||||
return row ? row.share : null;
|
||||
}
|
||||
|
||||
/** The stretches of the line in one class, neighbours joined, each as its own line for the map. */
|
||||
export function stretchesOf(ways: RouteWays, inClass: (edge: RouteWayEdge) => boolean): [number, number][][] {
|
||||
const stretches: [number, number][][] = [];
|
||||
|
||||
@@ -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-008
|
||||
// FE-TOUR-WAYS-HOOK-001 to FE-TOUR-WAYS-HOOK-009
|
||||
|
||||
const ROUTE: [number, number][] = [
|
||||
[47.0, 11.0],
|
||||
@@ -32,7 +32,8 @@ const input = (overrides: Partial<Input> = {}): Input => ({
|
||||
tripId: 3,
|
||||
active: true,
|
||||
route: ROUTE,
|
||||
profile: 'touring',
|
||||
matchAs: 'bicycle',
|
||||
withGrades: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -51,7 +52,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('useRouteWays (BIK-01 to BIK-05)', () => {
|
||||
describe('useRouteWays (BIK-01 to BIK-05, RUN-02)', () => {
|
||||
it('FE-TOUR-WAYS-HOOK-001: asks nothing while inactive or without a route of two points', () => {
|
||||
for (const overrides of [{ active: false }, { route: null }, { route: ROUTE.slice(0, 1) }]) {
|
||||
const { result } = renderHook(() => useRouteWays(input(overrides)));
|
||||
@@ -65,7 +66,7 @@ describe('useRouteWays (BIK-01 to BIK-05)', () => {
|
||||
expect(result.current.status).toBe('loading');
|
||||
expect(trace.valhallaTraceAttributes).not.toHaveBeenCalled();
|
||||
await settle();
|
||||
expect(trace.valhallaTraceAttributes).toHaveBeenCalledWith(ROUTE, expect.any(AbortSignal));
|
||||
expect(trace.valhallaTraceAttributes).toHaveBeenCalledWith(ROUTE, expect.any(AbortSignal), 'bicycle');
|
||||
expect(result.current.status).toBe('ready');
|
||||
expect(result.current.ways?.edges.map((edge) => edge.surface)).toEqual(['gravel', 'ground']);
|
||||
expect(result.current.grades).toBeNull();
|
||||
@@ -77,7 +78,7 @@ describe('useRouteWays (BIK-01 to BIK-05)', () => {
|
||||
ways: [{ id: 11, mtb_scale: '2', mtb_scale_uphill: null, mtb_scale_imba: null }],
|
||||
unanswered: [12],
|
||||
});
|
||||
const { result } = renderHook(() => useRouteWays(input({ profile: 'mtb' })));
|
||||
const { result } = renderHook(() => useRouteWays(input({ withGrades: true })));
|
||||
await settle();
|
||||
expect(gradesRepo.grades).toHaveBeenCalledWith(3, { way_ids: [11, 12] }, expect.any(AbortSignal));
|
||||
expect(result.current.grades?.status).toBe('ready');
|
||||
@@ -87,14 +88,14 @@ describe('useRouteWays (BIK-01 to BIK-05)', () => {
|
||||
|
||||
it('FE-TOUR-WAYS-HOOK-004: grades that cannot be had say why: offline, or failed', async () => {
|
||||
gradesRepo.grades.mockRejectedValueOnce(new OnlineOnlyError());
|
||||
const offline = renderHook(() => useRouteWays(input({ profile: 'mtb' })));
|
||||
const offline = renderHook(() => useRouteWays(input({ withGrades: true })));
|
||||
await settle();
|
||||
expect(offline.result.current.status).toBe('ready');
|
||||
expect(offline.result.current.grades?.status).toBe('offline');
|
||||
offline.unmount();
|
||||
|
||||
gradesRepo.grades.mockRejectedValueOnce(new Error('overpass'));
|
||||
const failed = renderHook(() => useRouteWays(input({ profile: 'mtb' })));
|
||||
const failed = renderHook(() => useRouteWays(input({ withGrades: true })));
|
||||
await settle();
|
||||
expect(failed.result.current.grades?.status).toBe('failed');
|
||||
});
|
||||
@@ -126,6 +127,19 @@ describe('useRouteWays (BIK-01 to BIK-05)', () => {
|
||||
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 () => {
|
||||
const { result, rerender } = renderHook((props: Input) => useRouteWays(props), {
|
||||
initialProps: input({ matchAs: 'pedestrian' }),
|
||||
});
|
||||
await settle();
|
||||
expect(trace.valhallaTraceAttributes).toHaveBeenLastCalledWith(ROUTE, expect.any(AbortSignal), 'pedestrian');
|
||||
expect(result.current.status).toBe('ready');
|
||||
rerender(input({ matchAs: 'bicycle' }));
|
||||
expect(result.current.status).toBe('loading');
|
||||
await settle();
|
||||
expect(trace.valhallaTraceAttributes).toHaveBeenLastCalledWith(ROUTE, expect.any(AbortSignal), 'bicycle');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYS-HOOK-008: a new route drops the highlight and the old answer, and leaving takes the highlight down', async () => {
|
||||
const { result, rerender, unmount } = renderHook((props: Input) => useRouteWays(props), { initialProps: input() });
|
||||
await settle();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { TOUR_TRAIL_GRADE_MAX_WAYS, type BikeProfile, type TourTrailGrade } from '@trek/shared';
|
||||
import { TOUR_TRAIL_GRADE_MAX_WAYS, type TourTrailGrade } from '@trek/shared';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNetworkMode } from '../../../hooks/useNetworkMode';
|
||||
import { OnlineOnlyError } from '../../../repo/onlineOnly';
|
||||
import { tourTrailGradeRepo } from '../../../repo/tourTrailGradeRepo';
|
||||
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
|
||||
import { valhallaTraceAttributes } from '../../Map/valhallaRoute';
|
||||
import { valhallaTraceAttributes, type TraceMatch } 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. */
|
||||
@@ -30,21 +30,31 @@ interface Answer extends RouteWaysState {
|
||||
attempt: number;
|
||||
/** The route and the question it answers; another one makes it stale. */
|
||||
route: [number, number][];
|
||||
matchAs: TraceMatch;
|
||||
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 {
|
||||
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. */
|
||||
withGrades: boolean;
|
||||
}
|
||||
|
||||
/** The ways of a route, and for a mountain bike tour their grades; the grades' outcome as it came. */
|
||||
async function askRouteWays(
|
||||
tripId: number | string,
|
||||
route: [number, number][],
|
||||
withGrades: boolean,
|
||||
question: Pick<RouteWaysInput, 'tripId' | 'matchAs' | 'withGrades'> & { route: [number, number][] },
|
||||
signal: AbortSignal,
|
||||
onWays: (ways: RouteWays) => void
|
||||
): Promise<RouteWaysState> {
|
||||
const ways = routeWaysFrom(await valhallaTraceAttributes(route, signal));
|
||||
const { tripId, route, matchAs, withGrades } = question;
|
||||
const ways = routeWaysFrom(await valhallaTraceAttributes(route, signal, matchAs));
|
||||
if (!ways) return { status: 'failed', ways: null, grades: null };
|
||||
if (!withGrades) return { status: 'ready', ways, grades: null };
|
||||
onWays(ways);
|
||||
@@ -61,23 +71,17 @@ async function askRouteWays(
|
||||
}
|
||||
|
||||
/**
|
||||
* BIK-01 to BIK-05: what the bike tour 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, 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.
|
||||
*/
|
||||
export function useRouteWays(input: {
|
||||
tripId: number | string;
|
||||
active: boolean;
|
||||
route: [number, number][] | null;
|
||||
profile: BikeProfile;
|
||||
}): RouteWaysState & { retry: () => void } {
|
||||
const { tripId, active, route, profile } = input;
|
||||
export function useRouteWays(input: RouteWaysInput): RouteWaysState & { retry: () => void } {
|
||||
const { tripId, active, route, matchAs, withGrades } = input;
|
||||
const { offline } = useNetworkMode();
|
||||
const [answer, setAnswer] = useState<Answer | null>(null);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const retry = useCallback(() => setAttempt((value) => value + 1), []);
|
||||
const withGrades = profile === 'mtb';
|
||||
const asking = active && !offline && !!route && route.length >= 2;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -85,10 +89,10 @@ export function useRouteWays(input: {
|
||||
if (!asking || !route) return;
|
||||
const controller = new AbortController();
|
||||
const settle = (state: RouteWaysState) => {
|
||||
if (!controller.signal.aborted) setAnswer({ ...state, route, withGrades, attempt });
|
||||
if (!controller.signal.aborted) setAnswer({ ...state, route, matchAs, withGrades, attempt });
|
||||
};
|
||||
const timer = window.setTimeout(() => {
|
||||
askRouteWays(tripId, route, withGrades, controller.signal, (ways) =>
|
||||
askRouteWays({ tripId, route, matchAs, withGrades }, controller.signal, (ways) =>
|
||||
settle({ status: 'ready', ways, grades: { status: 'loading', ...NO_GRADES } })
|
||||
)
|
||||
.then(settle)
|
||||
@@ -99,13 +103,17 @@ export function useRouteWays(input: {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [asking, attempt, route, tripId, withGrades]);
|
||||
}, [asking, attempt, matchAs, route, tripId, withGrades]);
|
||||
|
||||
// The highlight belongs to the route it was drawn on.
|
||||
useEffect(() => () => useTourRouteHighlightStore.getState().clear(), []);
|
||||
|
||||
if (!active || !route || route.length < 2) return { ...IDLE, retry };
|
||||
if (offline) return { status: 'offline', ways: null, grades: null, retry };
|
||||
const current = answer?.route === route && answer.withGrades === withGrades && answer.attempt === attempt;
|
||||
const current =
|
||||
answer?.route === route &&
|
||||
answer.matchAs === matchAs &&
|
||||
answer.withGrades === withGrades &&
|
||||
answer.attempt === attempt;
|
||||
return current ? { ...answer, retry } : { status: 'loading', ways: null, grades: null, retry };
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import { TourScopeSummary } from '../TourScopeSummary'
|
||||
import { useTourListFilters } from '../useTourListFilters'
|
||||
import { TourPoiSearchCard } from '../poi/TourPoiSearchCard'
|
||||
import { BikeWaysCard } from '../bike/BikeWaysCard'
|
||||
import { RunWaysCard } from '../run/RunWaysCard'
|
||||
import { walkTimeFields } from '../walk/walkTimeFields'
|
||||
|
||||
const STATUS_KEY: Partial<Record<TourPlannerStatus, string>> = {
|
||||
@@ -454,6 +455,8 @@ export function TourPlannerRail({ planner,
|
||||
|
||||
<BikeWaysCard planner={planner} />
|
||||
|
||||
<RunWaysCard planner={planner} />
|
||||
|
||||
<TourPoiSearchCard planner={planner} />
|
||||
|
||||
<PaddleEditorCards planner={planner} locked={locked} checklist={paddleChecklist} />
|
||||
|
||||
@@ -245,4 +245,14 @@ describe('routing on land, bike profiles and unreachable points (BIK-01)', () =>
|
||||
valhallaRun.mockResolvedValueOnce(run([3, 900]))
|
||||
expect(await routeRun(waypoints, 'running')).toEqual({ unreachable: 1, snappedMeters: 900 })
|
||||
})
|
||||
|
||||
it('TOUR-ROUTING-035: a trail run asks for tracks and natural paths, up to mountain paths, and still no ferry (RUN-02)', async () => {
|
||||
valhallaRun.mockResolvedValueOnce(run([3, 4]))
|
||||
expect(await routeRun(waypoints, 'trail')).toMatchObject({ distanceMeters: 1500 })
|
||||
expect(valhallaRun).toHaveBeenCalledWith(waypoints, 'walking', [], undefined, {
|
||||
use_tracks: 1,
|
||||
use_ferry: 0,
|
||||
max_hiking_difficulty: 2,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -120,24 +120,36 @@ export async function routeCityWalk(
|
||||
const CITY_WALK_COSTING: Readonly<CostingOptions> = { use_ferry: 0 }
|
||||
|
||||
/**
|
||||
* RUN-01: what each run profile asks Valhalla's pedestrian costing for.
|
||||
* Running keeps to runnable, mostly paved ways: farm and forest tracks are
|
||||
* taken only where there is no other way (`use_tracks` 0), and a run is run,
|
||||
* so a ferry only where no way on land exists (`use_ferry` 0). Checked with
|
||||
* trace_attributes against the public instance on 20.10.2026 (shares of the
|
||||
* length): Berlin S Grunewald into the forest went from 95 % tracks with 3 %
|
||||
* paved to 67 % smooth paved paths over 3.58 km instead of 3.50 km; Munich
|
||||
* Großhesseloher Brücke to Pullach from 79 % to 88 % smooth asphalt and no
|
||||
* track left; Marienplatz to the Englischer Garten stayed as it was. No hiking
|
||||
* limit is sent, so the router keeps to the ways it takes for walking anyway
|
||||
* (T1). The router's own time is a walker's and is never used: a run's time is
|
||||
* its distance at the runner's pace. Nothing here says a way can be run.
|
||||
* RUN-01, RUN-02: what each run profile asks Valhalla's pedestrian costing
|
||||
* for. A run is run, so a ferry only where no way on land exists
|
||||
* (`use_ferry` 0). Checked with trace_attributes against the public instance
|
||||
* on 20.10.2026 (shares of the length):
|
||||
* - running keeps to runnable, mostly paved ways: farm and forest tracks only
|
||||
* where there is no other way (`use_tracks` 0), and no hiking limit, so the
|
||||
* router keeps to the ways it takes for walking anyway (T1). Berlin S
|
||||
* Grunewald into the forest went from 95 % tracks with 3 % paved to 67 %
|
||||
* smooth paved paths over 3.58 km instead of 3.50 km; Munich Großhesseloher
|
||||
* Brücke to Pullach from 79 % to 88 % smooth asphalt and no track left;
|
||||
* Marienplatz to the Englischer Garten stayed as it was.
|
||||
* - trail takes tracks wherever they serve (`use_tracks` 1) and admits
|
||||
* mountain paths up to T2 (`max_hiking_difficulty` 2), never T3 and above.
|
||||
* Above Freiburg Günterstal up to the Schauinsland it went from 82 % roads
|
||||
* and 83 % paved (running) to 78 % forest tracks and 81 % compacted, gravel
|
||||
* and dirt over 9.57 km instead of 10.15 km; Rathen to the Bastei took 28 % of T2
|
||||
* path that running leaves out; Heidelberg's old town, all streets, stayed
|
||||
* as it was. Leaning on footways (`walkway_factor`) was tried and left out:
|
||||
* along the Isar it moved the run onto paved footways, from 33 % to 18 %
|
||||
* unpaved.
|
||||
* The router's own time is a walker's and is never used: a run's time is its
|
||||
* distance at the runner's pace, whatever the profile. Nothing here says a
|
||||
* way can be run.
|
||||
*/
|
||||
const RUN_COSTING: Record<RunProfile, Readonly<CostingOptions>> = {
|
||||
running: { use_tracks: 0, use_ferry: 0 },
|
||||
trail: { use_tracks: 1, use_ferry: 0, max_hiking_difficulty: 2 },
|
||||
}
|
||||
|
||||
/** RUN-01: a run, on foot for its profile; a leg drawn by hand is kept as drawn. */
|
||||
/** RUN-01, RUN-02: a run, on foot for its profile; a leg drawn by hand is kept as drawn. */
|
||||
export async function routeRun(
|
||||
waypoints: LandWaypoint[],
|
||||
profile: RunProfile,
|
||||
|
||||
@@ -16,7 +16,7 @@ vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit:
|
||||
|
||||
import { useTourPlanner, type DerivedTourDraft } from './useTourPlanner';
|
||||
|
||||
// FE-TOUR-RUN-001 to FE-TOUR-RUN-008
|
||||
// FE-TOUR-RUN-001 to FE-TOUR-RUN-010
|
||||
|
||||
const routed = {
|
||||
coordinates: [
|
||||
@@ -242,4 +242,38 @@ describe("useTourPlanner, a run at the runner's own pace (RUN-01)", () => {
|
||||
expect(result.current.runPace.paceSecondsPerKm).toBeNull();
|
||||
expect(result.current.runPaceMissing).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-TOUR-RUN-009: trail running plans the route again for trails, keeps the pace and its sum, and saves as trail (RUN-02)', async () => {
|
||||
createTour.mockResolvedValue({ tour: { ...savedRun, run_profile: 'trail' }, waypoints: savedPoints });
|
||||
const { result } = await plannedRun(79);
|
||||
act(() => result.current.runPace.setPace(450));
|
||||
routeRun.mockClear();
|
||||
act(() => result.current.setRunProfile('trail'));
|
||||
await settle();
|
||||
expect(routeRun).toHaveBeenCalledWith(expect.any(Array), 'trail', expect.any(AbortSignal));
|
||||
expect(result.current.runProfile).toBe('trail');
|
||||
const distanceKm = result.current.routeAnalysis?.distanceKm ?? 0;
|
||||
// The profile changes the ways, never the time: still the distance at the pace, nothing for the terrain.
|
||||
expect(result.current.walkingDurationMinutes).toBe(Math.round(distanceKm * 7.5));
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(createTour).toHaveBeenCalledWith(
|
||||
79,
|
||||
expect.objectContaining({ tour_type: 'run', run_profile: 'trail', run_pace_seconds_per_km: 450 })
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-TOUR-RUN-010: a trail route the router cannot plan keeps the last route, and the run waits rather than saving it (RUN-02)', async () => {
|
||||
const { result } = await plannedRun(80);
|
||||
act(() => result.current.runPace.setPace(450));
|
||||
routeRun.mockResolvedValueOnce(null);
|
||||
act(() => result.current.setRunProfile('trail'));
|
||||
await settle();
|
||||
expect(result.current.status).toBe('routing-failed');
|
||||
expect(result.current.route).toEqual(routed.coordinates);
|
||||
expect(result.current.tourType).toBe('run');
|
||||
expect(result.current.runPace.paceSecondsPerKm).toBe(450);
|
||||
expect(result.current.canSave).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { RunFields, RunProfileField } from './RunFields';
|
||||
import { runPaceInvalid } from './runPace';
|
||||
|
||||
// FE-TOUR-RUN-FIELDS-001 to FE-TOUR-RUN-FIELDS-006
|
||||
// FE-TOUR-RUN-FIELDS-001 to FE-TOUR-RUN-FIELDS-008
|
||||
|
||||
type Planner = ComponentProps<typeof RunFields>['planner'];
|
||||
|
||||
@@ -14,14 +14,16 @@ const analysis = { distanceKm: 8 } as Planner['routeAnalysis'];
|
||||
function Stateful({
|
||||
initial = null,
|
||||
distance = analysis,
|
||||
profile = 'running',
|
||||
}: {
|
||||
initial?: number | null;
|
||||
distance?: Planner['routeAnalysis'];
|
||||
profile?: Planner['runProfile'];
|
||||
}) {
|
||||
const [pace, setPace] = useState<number | null>(initial);
|
||||
const invalid = runPaceInvalid(pace);
|
||||
const planner: Planner = {
|
||||
runProfile: 'running',
|
||||
runProfile: profile,
|
||||
setRunProfile: vi.fn(),
|
||||
runPace: { paceSecondsPerKm: pace, setPace, load: setPace },
|
||||
runPaceMissing: pace === null,
|
||||
@@ -127,4 +129,23 @@ describe('RunFields (RUN-01)', () => {
|
||||
expect(paceField()).toHaveValue('5:00');
|
||||
expect(screen.queryByTestId('tour-run-time-sum')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-RUN-FIELDS-007: offers trail running beside running, with the ways it asks for (RUN-02)', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<RunProfileField value="trail" onChange={onChange} />);
|
||||
expect(screen.getByRole('button', { name: 'tours.run.profile.trail' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByText('tours.run.profileHint.trail')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'tours.run.profile.running' }));
|
||||
expect(onChange).toHaveBeenCalledWith('running');
|
||||
});
|
||||
|
||||
it('FE-TOUR-RUN-FIELDS-008: a trail run is timed as any run, distance times pace, nothing added for the terrain (RUN-02)', () => {
|
||||
render(<Stateful initial={450} profile="trail" />);
|
||||
const sum = screen.getByTestId('tour-run-time-sum');
|
||||
expect(sum).toHaveTextContent('7:30 /km');
|
||||
expect(sum).toHaveTextContent('1 h');
|
||||
expect(screen.getByRole('button', { name: 'tours.run.paceInfoLabel' })).toHaveAccessibleDescription(
|
||||
'tours.run.paceInfo'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
RUN_PROFILES,
|
||||
type RunProfile,
|
||||
} from '@trek/shared';
|
||||
import { Activity, Clock, Gauge, Info, Ruler, type LucideIcon } from 'lucide-react';
|
||||
import { Clock, Gauge, Info, Ruler } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
@@ -15,13 +15,12 @@ import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { InfoDot, TourNotice } from '../tourParts';
|
||||
import { formatPlannedTourDuration } from '../tourPresentation';
|
||||
import { formatRunPace, parseRunPace, RUN_PACE_INPUT_MAX_LENGTH, runPaceInvalid } from './runPace';
|
||||
|
||||
const PROFILE_ICON: Record<RunProfile, LucideIcon> = { running: Activity };
|
||||
import { RUN_PROFILE_ICON } from './runProfileIcon';
|
||||
|
||||
/**
|
||||
* RUN-01: what a run is planned as, chosen on purpose like a bike tour's
|
||||
* profile, with a line on the ways it asks the router for. The planner and
|
||||
* the GPX import share it.
|
||||
* RUN-01, RUN-02: what a run is planned as, running or trail running, chosen
|
||||
* on purpose like a bike tour's profile, with a line on the ways it asks the
|
||||
* router for. The planner and the GPX import share it.
|
||||
*/
|
||||
export function RunProfileField({ value, onChange }: { value: RunProfile; onChange: (profile: RunProfile) => void }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -34,7 +33,7 @@ export function RunProfileField({ value, onChange }: { value: RunProfile; onChan
|
||||
label={t('tours.run.profile')}
|
||||
fill
|
||||
options={RUN_PROFILES.map((profile) => {
|
||||
const Icon = PROFILE_ICON[profile];
|
||||
const Icon = RUN_PROFILE_ICON[profile];
|
||||
return {
|
||||
value: profile,
|
||||
label: t(`tours.run.profile.${profile}`),
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { Activity, Gauge } from 'lucide-react';
|
||||
import { Gauge } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { runPaceText } from './runPaceText';
|
||||
import { RUN_PROFILE_ICON } from './runProfileIcon';
|
||||
|
||||
/**
|
||||
* RUN-01: a run's profile and planned pace as pills of a tour's head, beside
|
||||
* RUN-01, RUN-02: a run's profile and planned pace as pills of a tour's head, beside
|
||||
* its type. The pace's tooltip says the running time is that pace over the
|
||||
* distance and nothing more; a run without a pace shows no pace pill.
|
||||
*/
|
||||
@@ -20,11 +21,12 @@ export function RunHeadPills({
|
||||
const { t } = useTranslation();
|
||||
if (tour.tour_type !== 'run') return null;
|
||||
const pace = runPaceText(tour, t);
|
||||
const ProfileIcon = RUN_PROFILE_ICON[tour.run_profile ?? 'running'];
|
||||
return (
|
||||
<>
|
||||
{tour.run_profile && (
|
||||
<span data-testid="tour-detail-run-profile" className={className} style={fs(10.5)}>
|
||||
<Activity size={11} strokeWidth={2} className="flex-none text-content-faint" aria-hidden="true" />
|
||||
<ProfileIcon size={11} strokeWidth={2} className="flex-none text-content-faint" aria-hidden="true" />
|
||||
{t(`tours.run.profile.${tour.run_profile}`)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
|
||||
import type { RouteWays } from '../bike/routeWays';
|
||||
import type { RouteWaysState } from '../bike/useRouteWays';
|
||||
import { hardestSac, RunWaysCard } from './RunWaysCard';
|
||||
|
||||
const hook = vi.hoisted(() => ({ state: null as unknown, retry: vi.fn(), input: null as unknown }));
|
||||
vi.mock('../bike/useRouteWays', () => ({
|
||||
useRouteWays: (input: unknown) => {
|
||||
hook.input = input;
|
||||
return { ...(hook.state as object), retry: hook.retry };
|
||||
},
|
||||
}));
|
||||
|
||||
// FE-TOUR-RUNWAYS-001 to FE-TOUR-RUNWAYS-007
|
||||
|
||||
type Planner = Parameters<typeof RunWaysCard>[0]['planner'];
|
||||
|
||||
const LINE: [number, number][] = [
|
||||
[47.0, 11.0],
|
||||
[47.001, 11.0],
|
||||
[47.002, 11.0],
|
||||
[47.003, 11.0],
|
||||
];
|
||||
const WAYS: RouteWays = {
|
||||
line: LINE,
|
||||
edges: [
|
||||
{ from: 0, to: 1, lengthM: 600, surface: 'paved', way: 'road', sac: 'none', wayId: 1 },
|
||||
{ from: 1, to: 2, lengthM: 300, surface: 'gravel', way: 'track', sac: 't1', wayId: 2 },
|
||||
{ from: 2, to: 3, lengthM: 100, surface: 'ground', way: 'path', sac: 't2', wayId: 3 },
|
||||
],
|
||||
};
|
||||
|
||||
function planner(overrides: Partial<Planner> = {}): Planner {
|
||||
return {
|
||||
tripId: 4,
|
||||
mode: { type: 'new-draft' },
|
||||
isRun: true,
|
||||
route: LINE,
|
||||
routeCurrent: true,
|
||||
...overrides,
|
||||
} as Planner;
|
||||
}
|
||||
|
||||
function show(state: Partial<RouteWaysState>, overrides: Partial<Planner> = {}) {
|
||||
hook.state = { status: 'ready', ways: WAYS, grades: null, ...state };
|
||||
return render(<RunWaysCard planner={planner(overrides)} />);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useTourRouteHighlightStore.getState().clear();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('RunWaysCard (RUN-02)', () => {
|
||||
it('FE-TOUR-RUNWAYS-001: shows the surfaces, the hiking grades and the kinds of way, the ungraded named as unknown', () => {
|
||||
show({});
|
||||
expect(within(screen.getByTestId('route-ways-row-surface-paved')).getByText('60 %')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('route-ways-row-surface-gravel')).toHaveTextContent('300 m');
|
||||
expect(screen.getByTestId('route-ways-row-grade-t1')).toHaveTextContent('T1');
|
||||
expect(screen.getByTestId('route-ways-row-grade-t2')).toHaveTextContent('10 %');
|
||||
expect(screen.getByTestId('route-ways-row-grade-none')).toHaveTextContent('No grade in the data');
|
||||
expect(screen.getByTestId('route-ways-row-way-track')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'About the path difficulty' })).toHaveAccessibleDescription(
|
||||
/unknown, not easy/
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-TOUR-RUNWAYS-002: the head says how much is unpaved and the hardest graded path, in words for assistive tech', () => {
|
||||
show({});
|
||||
expect(screen.getByText('40 % unpaved')).toBeInTheDocument();
|
||||
const hardest = screen.getByTestId('run-ways-hardest');
|
||||
expect(hardest).toHaveTextContent('T2');
|
||||
expect(hardest).toHaveTextContent(/Hardest graded path: T2/);
|
||||
});
|
||||
|
||||
it('FE-TOUR-RUNWAYS-003: a grade row points at its stretches on the map in the grade colour, and lets go', () => {
|
||||
show({});
|
||||
const row = screen.getByTestId('route-ways-row-grade-t2');
|
||||
fireEvent.click(row);
|
||||
expect(useTourRouteHighlightStore.getState().highlight).toEqual({
|
||||
id: 'grade-t2',
|
||||
lines: [LINE.slice(2, 4)],
|
||||
paint: 's1',
|
||||
});
|
||||
fireEvent.click(row);
|
||||
expect(useTourRouteHighlightStore.getState().highlight).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-RUNWAYS-004: a route all paved and ungraded shows no head badges, and its grades as unknown', () => {
|
||||
const paved: RouteWays = { line: LINE, edges: [WAYS.edges[0]] };
|
||||
show({ ways: paved });
|
||||
expect(screen.queryByText(/unpaved/)).toBeNull();
|
||||
expect(screen.queryByTestId('run-ways-hardest')).toBeNull();
|
||||
expect(screen.getByTestId('route-ways-row-grade-none')).toHaveTextContent('100 %');
|
||||
});
|
||||
|
||||
it('FE-TOUR-RUNWAYS-005: says when it is still asking, offline, or failed with a way to ask again', () => {
|
||||
const loading = show({ status: 'loading', ways: null });
|
||||
expect(screen.getByTestId('run-ways-card')).toHaveTextContent(/./);
|
||||
loading.unmount();
|
||||
const offline = show({ status: 'offline', ways: null });
|
||||
expect(screen.queryByRole('button', { name: /try again/i })).toBeNull();
|
||||
offline.unmount();
|
||||
show({ status: 'failed', ways: null });
|
||||
fireEvent.click(screen.getByRole('button', { name: /try again/i }));
|
||||
expect(hook.retry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-TOUR-RUNWAYS-006: matched on foot without trail grades, and only for a run being edited with its route as planned now', () => {
|
||||
const run = show({});
|
||||
expect(hook.input).toMatchObject({
|
||||
tripId: 4,
|
||||
active: true,
|
||||
route: LINE,
|
||||
matchAs: 'pedestrian',
|
||||
withGrades: false,
|
||||
});
|
||||
run.unmount();
|
||||
const idle = show({ status: 'idle', ways: null });
|
||||
expect(screen.queryByTestId('run-ways-card')).toBeNull();
|
||||
idle.unmount();
|
||||
const bike = show({}, { isRun: false });
|
||||
expect(screen.queryByTestId('run-ways-card')).toBeNull();
|
||||
bike.unmount();
|
||||
show({}, { routeCurrent: false, mode: { type: 'view-gpx' } as Planner['mode'] });
|
||||
expect(screen.queryByTestId('run-ways-card')).toBeNull();
|
||||
expect(hook.input).toMatchObject({ active: false, route: null });
|
||||
});
|
||||
|
||||
it('FE-TOUR-RUNWAYS-007: the hardest graded step along the route, a stretch of no length left out', () => {
|
||||
expect(hardestSac(WAYS)).toBe('t2');
|
||||
expect(hardestSac({ line: LINE, edges: [{ ...WAYS.edges[2], sac: 't5', lengthM: 0 }, WAYS.edges[1]] })).toBe('t1');
|
||||
expect(hardestSac({ line: LINE, edges: [WAYS.edges[0]] })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Layers, Mountain } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { sacPaintKey } from '../bike/bikePaint';
|
||||
import {
|
||||
percent,
|
||||
RouteWaysProgress,
|
||||
RouteWaysSource,
|
||||
ShareBar,
|
||||
ShareList,
|
||||
SurfaceSection,
|
||||
WaySection,
|
||||
WaysSection,
|
||||
} from '../bike/RouteWaySections';
|
||||
import { sharesBy, unpavedShareOf, type RouteWayEdge, type RouteWays, type SacClass } from '../bike/routeWays';
|
||||
import { useRouteWays } from '../bike/useRouteWays';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { RailCard } from '../tourParts';
|
||||
|
||||
type GradedClass = Exclude<SacClass, 'none'>;
|
||||
|
||||
const sacOf = (edge: RouteWayEdge) => edge.sac;
|
||||
|
||||
/** The steps from easy to hard, so the hardest one along the route is the last one found. */
|
||||
const SAC_STEPS: readonly GradedClass[] = ['t1', 't2', 't3', 't4', 't5', 't6'];
|
||||
|
||||
/** A hiking grade in the words the hiking limit uses for it, a way without one as unknown. */
|
||||
function useSacLabel() {
|
||||
const { t } = useTranslation();
|
||||
return (key: SacClass) => (key === 'none' ? t('tours.bike.trail.none') : t(`tours.planner.difficulty.${key}`));
|
||||
}
|
||||
|
||||
/** The hardest graded step along the route, or null when no way on it carries a grade. */
|
||||
export function hardestSac(ways: RouteWays): GradedClass | null {
|
||||
const found = new Set(ways.edges.filter((edge) => edge.lengthM > 0).map((edge) => edge.sac));
|
||||
return [...SAC_STEPS].reverse().find((step) => found.has(step)) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* RUN-02: the hiking grades along a run (sac_scale) as OpenStreetMap mappers
|
||||
* gave them, by share, each pointing at its stretches on the map. A way
|
||||
* without a grade is its own row: unknown, never easy. What the grades are
|
||||
* and are not sits behind the info dot, not in a line of its own.
|
||||
*/
|
||||
function PathGradeSection({ ways }: { ways: RouteWays }) {
|
||||
const { t } = useTranslation();
|
||||
const labelOf = useSacLabel();
|
||||
const rows = useMemo(() => sharesBy<SacClass>(ways, sacOf), [ways]);
|
||||
return (
|
||||
<WaysSection
|
||||
label={t('tours.run.ways.grade')}
|
||||
info={{ label: t('tours.run.ways.gradeInfoLabel'), description: t('tours.run.ways.gradeInfo') }}
|
||||
>
|
||||
<ShareBar rows={rows} paintOf={sacPaintKey} />
|
||||
<ShareList ways={ways} rows={rows} idPrefix="grade" classOf={sacOf} labelOf={labelOf} paintOf={sacPaintKey} />
|
||||
</WaysSection>
|
||||
);
|
||||
}
|
||||
|
||||
/** The card's head: how much of the run is known to be unpaved, and the hardest graded path on it. */
|
||||
function RunWaysBadges({ ways }: { ways: RouteWays | null }) {
|
||||
const { t } = useTranslation();
|
||||
const labelOf = useSacLabel();
|
||||
const unpaved = useMemo(() => (ways ? unpavedShareOf(ways) : null), [ways]);
|
||||
const hardest = useMemo(() => (ways ? hardestSac(ways) : null), [ways]);
|
||||
if (unpaved === null && hardest === null) return null;
|
||||
const hardestText = hardest ? t('tours.run.ways.gradeTop', { grade: labelOf(hardest) }) : '';
|
||||
return (
|
||||
<span className="flex flex-none items-center gap-1">
|
||||
{unpaved !== null && <SoftPill>{t('tours.bike.ways.unpavedBadge', { share: percent(unpaved) })}</SoftPill>}
|
||||
{hardest && (
|
||||
<Tooltip label={hardestText}>
|
||||
<span data-testid="run-ways-hardest" className="inline-flex">
|
||||
<SoftPill icon={<Mountain size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
<span aria-hidden="true">{hardest.toUpperCase()}</span>
|
||||
<span className="sr-only">{hardestText}</span>
|
||||
</SoftPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type RunWaysPlanner = Pick<TourPlannerController, 'tripId' | 'mode' | 'isRun' | 'route' | 'routeCurrent'>;
|
||||
|
||||
/**
|
||||
* RUN-02: what the run being planned runs on: its surfaces, the hiking grades
|
||||
* of its paths and its kinds of way, read off the router's description of the
|
||||
* route matched on foot. Where the data says nothing the card says unknown,
|
||||
* and nothing here grades the run, changes its time or changes the tour.
|
||||
*/
|
||||
export function RunWaysCard({ planner }: { planner: RunWaysPlanner }) {
|
||||
const { t } = useTranslation();
|
||||
const editing = planner.mode.type === 'new-draft' || planner.mode.type === 'edit-saved';
|
||||
const state = useRouteWays({
|
||||
tripId: planner.tripId,
|
||||
active: planner.isRun && editing,
|
||||
route: planner.routeCurrent ? planner.route : null,
|
||||
matchAs: 'pedestrian',
|
||||
withGrades: false,
|
||||
});
|
||||
const { ways } = state;
|
||||
if (!planner.isRun || !editing || state.status === 'idle') return null;
|
||||
return (
|
||||
<RailCard
|
||||
icon={<Layers size={14} strokeWidth={2} />}
|
||||
title={t('tours.bike.ways.title')}
|
||||
foldId="run-ways"
|
||||
aside={<RunWaysBadges ways={ways} />}
|
||||
>
|
||||
<div data-testid="run-ways-card" className="flex min-w-0 flex-col gap-3">
|
||||
<RouteWaysProgress state={state} />
|
||||
{ways && (
|
||||
<>
|
||||
<SurfaceSection ways={ways} />
|
||||
<PathGradeSection ways={ways} />
|
||||
<WaySection ways={ways} />
|
||||
<RouteWaysSource />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</RailCard>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { TourGradeTile } from '../tourParts';
|
||||
import { RunHeadPills } from './RunHeadPills';
|
||||
import { runPaceText } from './runPaceText';
|
||||
|
||||
// FE-TOUR-RUN-MARK-001 to FE-TOUR-RUN-MARK-006
|
||||
// FE-TOUR-RUN-MARK-001 to FE-TOUR-RUN-MARK-007
|
||||
|
||||
const run: TourListItem = {
|
||||
place_id: 7,
|
||||
@@ -87,4 +87,13 @@ describe('a run wherever a tour shows (RUN-01)', () => {
|
||||
rerender(<RunHeadPills tour={{ ...run, tour_type: 'hike', run_profile: null }} className="head-pill" />);
|
||||
expect(screen.queryByTestId('tour-detail-run-profile')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-RUN-MARK-007: a trail run goes by its own profile, and its time and pace read as any run's (RUN-02)', () => {
|
||||
const trail = { ...run, run_profile: 'trail' as const, run_pace_seconds_per_km: 450, duration: 60 };
|
||||
render(<RunHeadPills tour={trail} className="head-pill" />);
|
||||
expect(screen.getByTestId('tour-detail-run-profile')).toHaveTextContent('Trail running');
|
||||
expect(screen.getByTestId('tour-detail-run-pace')).toHaveTextContent('7:30 min/km');
|
||||
render(<TourDayRowFacts tour={trail} />);
|
||||
expect(screen.getByTestId('plan-tour-run-pace')).toHaveAccessibleName('Planned pace: 7:30 min/km');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { RunProfile } from '@trek/shared';
|
||||
import { Activity, Trees, type LucideIcon } from 'lucide-react';
|
||||
|
||||
/** RUN-01, RUN-02: the icon a run profile goes by, in its picker and on a run's head. */
|
||||
export const RUN_PROFILE_ICON: Record<RunProfile, LucideIcon> = { running: Activity, trail: Trees };
|
||||
Reference in New Issue
Block a user