Files
TREK/client/src/components/Tours/run/RunWaysCard.tsx
T
Maurice 51bfb5abd1 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.
2026-10-20 15:05:51 +02:00

128 lines
5.0 KiB
TypeScript

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>
);
}