feat(tours): plan rides on horseback from signposted horse routes

A tour can now be a ride (HOR-01). The planner offers "Horse ride" as a
tour type with its own horseshoe, and a Horse routes card beside the
waypoints looks, when asked, for the route=horse relations OpenStreetMap
knows in the map view. A route opens in a dialog that shows it on a map in
the sections it is made of, split at forks, gaps and crossings, so nothing
is ever joined across a gap. The rider picks a section and its direction,
sees what the horse=* tags along it say (open, limited, closed, or unknown
where untagged) and whether it is signposted the other way, and takes it as
one undoable step. A section that does not start where the ride ends is
only joined by a straight manual stretch once the rider switched that on.

Nothing of a ride is routed: every leg follows a section or is drawn by
hand, and the waypoint list and the map say which (a section as a solid
line with its route, a manual stretch dashed and marked unchecked). A point
at a section stays where the route has it, a ride works out no riding time,
and distance, heights, day assignment, GPX import and export, the 3D view
and the reversed ride keep working with each leg's origin saved and opened
again. A failed lookup leaves the ride and the routes already found as they
were.

The server reads the routes through the shared Overpass client
(GET /api/trips/:tripId/tours/horse-routes, Tours addon, trip access),
keeps an answer twelve hours per grid-aligned view, and the tour contract
refuses a routed leg, a via ferrata or a riding time on a ride and a horse
route section on any other type. A migration enables the horse tour type.
This commit is contained in:
Maurice
2026-10-20 12:48:41 +02:00
parent dfe11e9c4c
commit 0331d9950f
103 changed files with 5470 additions and 82 deletions
@@ -416,6 +416,24 @@ describe('ToursSidebar', () => {
vi.restoreAllMocks()
})
it('HOR-01: a recorded ride imports as a ride, with nothing of another type', async () => {
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] })
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
const file = new File(['<gpx/>'], 'ride.gpx', { type: 'application/gpx+xml' })
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])
await screen.findByRole('dialog')
fireEvent.click(screen.getByTestId('tour-type-select'))
fireEvent.click(screen.getByTestId('tour-type-option-horse'))
fireEvent.click(screen.getByTestId('gpx-preview-import'))
await waitFor(() => expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'horse' }))
vi.restoreAllMocks()
})
it('RC-04: refuses files that are not GPX before uploading anything', async () => {
const importCall = vi.spyOn(tourGpxApi, 'import')
render(
@@ -128,8 +128,14 @@ export function useGpxConversion(planner: TourPlannerController) {
/**
* The import shown can be planned from: it has a track and the user may add tours. A ski
* track (SKI-02) never: TREK routes no ski track, and a hike's or a walk's route is no stand-in.
* A ride (HOR-01) neither: no router plans one, and walking it again on foot would be no ride.
*/
available: tour !== null && tour.tour_type !== 'ski' && (track?.length ?? 0) >= 2 && planner.canEdit,
available:
tour !== null &&
tour.tour_type !== 'ski' &&
tour.tour_type !== 'horse' &&
(track?.length ?? 0) >= 2 &&
planner.canEdit,
difficulty,
/** The import is a paddle tour: its version is drawn with straight legs, not routed. */
paddle,
@@ -47,6 +47,8 @@ export function importFields(chosen: GpxImportChoices): TourGpxImportFields {
if (type === 'bike') return { tour_type: 'bike', bike_profile: chosen.bikeProfile };
// RUN-01: a run comes in with its profile and no pace; its time stays unknown until one is planned from it.
if (type === 'run') return { tour_type: 'run', run_profile: chosen.runProfile };
// HOR-01: a recorded ride comes in as a ride, with nothing of another type.
if (type === 'horse') return { tour_type: 'horse' };
// SKI-02: a ski track comes in with its discipline and, for cross-country, its technique, and no time.
const ski = type === 'ski' ? skiFieldsOf(chosen.ski) : null;
if (ski) return { tour_type: 'ski', ...ski };
@@ -0,0 +1,103 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen, within } from '../../../../tests/helpers/render';
import { TourDayRowIcon } from '../TourDayRowFacts';
import { tourDayRowIcon } from '../tourDayRowIcon';
import { movingTimeLabel } from '../tourFacts';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import { useShapingClick } from '../planner/useShapingClick';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { WaypointLegRows } from '../planner/WaypointLegRows';
import { HorseLegRow } from './HorseLegRow';
import { Horseshoe } from './HorseshoeIcon';
import type { HorseSectionLeg } from './horseSection';
// FE-HORSE-LEG-001 to FE-HORSE-LEG-005
const section: HorseSectionLeg = {
kind: 'horse_route',
ref: 'relation/42',
name: 'Reitweg Isartal',
geometry: [
[48, 11],
[48.01, 11],
],
signposted: 'against',
access: [
{ value: 'designated', length_m: 800 },
{ value: 'no', length_m: 312 },
],
};
function legRows(point: TourPlannerWaypoint, isHorse: boolean) {
const planner = { isHorse, toggleWaypointFreeLeg: vi.fn() } as unknown as TourPlannerController;
return (
<ul>
<WaypointLegRows
planner={planner}
point={point}
index={0}
routedLegs={null}
paddleLayout={null}
editable
portageRouting={{} as never}
regionLoad={{ byLeg: new Map(), retry: vi.fn() } as never}
/>
</ul>
);
}
describe('a ride in the waypoint list (HOR-01)', () => {
it('FE-HORSE-LEG-001: a section shows its route, its length, the other signposted way and what its tags say', () => {
render(
<ul>
<HorseLegRow leg={section} />
</ul>
);
const row = screen.getByTestId('tour-planner-horse-leg');
expect(row).toHaveTextContent('Reitweg Isartal');
expect(row).toHaveTextContent('1.1 km');
expect(within(row).getByTestId('tour-planner-horse-leg-against')).toHaveAccessibleName(
'This section is signposted the other way.'
);
expect(within(row).getByTestId('tour-planner-horse-leg-access')).toHaveAccessibleName('Closed in part');
});
it('FE-HORSE-LEG-002: a section without a name or tags is said to have neither', () => {
render(
<ul>
<HorseLegRow leg={{ ...section, name: null, access: undefined, signposted: null }} />
</ul>
);
const row = screen.getByTestId('tour-planner-horse-leg');
expect(row).toHaveTextContent('Unnamed horse route');
expect(within(row).queryByTestId('tour-planner-horse-leg-against')).not.toBeInTheDocument();
expect(within(row).getByTestId('tour-planner-horse-leg-access')).toHaveAccessibleName('Access not in the data');
});
it('FE-HORSE-LEG-003: a ride names its legs drawn by hand manual and unchecked, its sections as sections', () => {
const drawn = { kind: 'free' as const, geometry: section.geometry };
const { rerender } = render(legRows({ id: 'a', lat: 48, lng: 11, role: 'start', fixedLeg: drawn }, true));
expect(screen.getByTestId('tour-planner-free-leg')).toHaveTextContent('Manual, unchecked');
rerender(legRows({ id: 'a', lat: 48, lng: 11, role: 'start', fixedLeg: drawn }, false));
expect(screen.getByTestId('tour-planner-free-leg')).not.toHaveTextContent('Manual, unchecked');
rerender(legRows({ id: 'a', lat: 48, lng: 11, role: 'start', fixedLeg: section }, true));
expect(screen.getByTestId('tour-planner-horse-leg')).toBeInTheDocument();
});
it('FE-HORSE-LEG-004: a ride wears the horseshoe, is named a horse ride and its time riding time', () => {
expect(tourDayRowIcon('horse')).toBe(Horseshoe);
render(<TourDayRowIcon type="horse" />);
expect(screen.getByTestId('plan-tour-icon')).toHaveAttribute('aria-label', 'Horse ride');
expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-horseshoe')).toBeTruthy();
expect(movingTimeLabel({ tour_type: 'horse' }, 'Walking time', (key) => key)).toBe('tours.horse.time');
});
it('FE-HORSE-LEG-005: a click on a ride sets no shaping point', () => {
const planner = { waypoints: [], route: null, insertShapingPoint: vi.fn(), setSelectedWaypointId: vi.fn() };
const ride = renderHook(() => useShapingClick({ ...planner, isHorse: true }, { editable: true, picking: false }));
expect(ride.result.current).toBeUndefined();
const hike = renderHook(() => useShapingClick(planner, { editable: true, picking: false }));
expect(hike.result.current).toBeTypeOf('function');
});
});
@@ -0,0 +1,66 @@
import { ArrowLeftRight } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance } from '../../../utils/units';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { lineLengthM } from '../planner/tourFixedLegs';
import { ACCESS_TONE_DOT, accessText, accessToneOf } from './horseAccess';
import { Horseshoe } from './HorseshoeIcon';
import type { HorseSectionLeg } from './horseSection';
/**
* HOR-01: a section of a horse route the ride follows, as a badge on its leg
* in the brown the map draws it in: the route's name, a dot for what its
* `horse=*` tags say (hollow where they say nothing), a mark where the route
* is signposted the other way, and its length. The tooltip spells the tags out.
*/
export function HorseLegRow({ leg }: { leg: HorseSectionLeg }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
// Read as plain numbers: this workspace compiles without strict mode, where the schema's tuples are "maybe two".
const line = leg.geometry.map(([lat, lng]): [number, number] => [Number(lat), Number(lng)]);
const length = formatDistance(lineLengthM(line) / 1000, unit);
const tone = accessToneOf(leg.access) ?? 'unknown';
const access = accessText(leg.access, unit, t) || t('tours.horse.access.value.unknown');
const name = leg.name ?? t('tours.horse.routes.unnamed');
return (
<li data-testid="tour-planner-horse-leg" className="flex min-w-0 py-0.5 ps-[34px]">
<Tooltip label={t('tours.horse.leg.info', { access })}>
<span className="flex h-[26px] w-full min-w-0 items-center gap-1.5 rounded-full bg-surface-card pe-1 ps-1.5 shadow-sm ring-1 ring-inset ring-[color:var(--horse-route)]">
<Horseshoe
size={14}
strokeWidth={2.2}
aria-hidden="true"
className="flex-none text-[color:var(--horse-route)]"
/>
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(11.5, 'body')}>
{name}
</span>
{leg.signposted === 'against' && (
<ArrowLeftRight
size={12}
strokeWidth={2.2}
data-testid="tour-planner-horse-leg-against"
role="img"
aria-label={t('tours.horse.dialog.signedAgainst')}
className="flex-none text-warning"
/>
)}
<span
data-testid="tour-planner-horse-leg-access"
role="img"
aria-label={t(`tours.horse.access.summary.${tone}`)}
className={`h-2 w-2 flex-none rounded-full ${ACCESS_TONE_DOT[tone]}`}
/>
<span
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist tabular-nums leading-[18px] text-content-secondary"
style={fs(10.5)}
>
{length}
</span>
</span>
</Tooltip>
</li>
);
}
@@ -0,0 +1,129 @@
import type { HorseRoute } from '@trek/shared';
import { ArrowRight, ExternalLink, Plus } from 'lucide-react';
import { useId } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { SoftPill } from '../../Planner/planParts';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogSection,
DialogShell,
DialogTile,
FooterSpacer,
NEUTRAL_TINT,
PILL,
fs,
} from '../../shared/DialogShell';
import { FigureBadge } from '../paddle/FigureBadge';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { AccessLabel, AccessPills, ConnectionField, DirectionField, SectionChips } from './HorseRouteDialogParts';
import { HorseRouteMap } from './HorseRouteMap';
import { Horseshoe } from './HorseshoeIcon';
import { useHorseSectionPick } from './useHorseSectionPick';
type DialogPlanner = Pick<TourPlannerController, 'waypoints' | 'editWaypoints' | 'isSaving' | 'canEdit' | 'route'>;
/** A route's name as the rider reads it: its own, else its reference, else said to have none. */
export function horseRouteTitle(route: HorseRoute, t: (key: string) => string): string {
return route.name ?? route.ref ?? t('tours.horse.routes.unnamed');
}
/** What the route is at a glance: its reference, its sections, whether it goes on, and a way to OpenStreetMap. */
function RoutePills({ route }: { route: HorseRoute }) {
const { t } = useTranslation();
return (
<>
{route.ref && route.name && <SoftPill>{route.ref}</SoftPill>}
<FigureBadge label={t('tours.horse.routes.sections')} value={String(route.sections.length)} />
{route.continues && (
<SoftPill icon={<ArrowRight size={11} strokeWidth={2.4} className="rtl:rotate-180" />}>
{t('tours.horse.routes.continues')}
</SoftPill>
)}
<a href={route.osmUrl} target="_blank" rel="noopener noreferrer" className={PILL} style={fs(11, 'body')}>
<ExternalLink size={11} strokeWidth={2.2} aria-hidden="true" />
{t('tours.horse.dialog.osm')}
</a>
</>
);
}
/**
* HOR-01: a signposted horse route, and the one place a section of it goes
* onto the ride. The rider sees the route on a map, picks a section and the
* direction, reads what the tags along it say and how it joins the ride, and
* only then takes it: one step of the planner's history. Being on the route
* is never read as a permission to ride it.
*/
export function HorseRouteDialog({
planner,
route,
onClose,
}: {
planner: DialogPlanner;
route: HorseRoute;
onClose: () => void;
}) {
const { t } = useTranslation();
const labelId = useId();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const pick = useHorseSectionPick(planner, route, onClose);
return (
<DialogShell
onClose={onClose}
labelledBy={labelId}
width="editor"
align="top"
header={
<DialogHeader
tile={
<DialogTile>
<Horseshoe size={20} strokeWidth={2} className="text-[color:var(--horse-route)]" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
eyebrow={t('tours.horse.dialog.eyebrow')}
title={horseRouteTitle(route, t)}
pills={<RoutePills route={route} />}
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
<DialogButton
variant="primary"
data-testid="horse-section-adopt"
icon={<Plus size={14} strokeWidth={2} />}
disabled={!pick.canAdopt}
onClick={pick.adopt}
>
{t('tours.horse.dialog.adopt')}
</DialogButton>
</DialogFooter>
}
>
<HorseRouteMap route={route} chosen={pick.leg} ride={planner.route} />
<DialogSection label={t('tours.horse.routes.sections')}>
<SectionChips route={route} chosen={pick.section?.number} onPick={pick.pick} unit={unit} />
</DialogSection>
{pick.leg && (
<>
<DialogSection label={t('tours.horse.dialog.direction')}>
<DirectionField pick={pick} />
</DialogSection>
<DialogSection label={<AccessLabel />}>
<AccessPills leg={pick.leg} unit={unit} />
</DialogSection>
<DialogSection label={t('tours.horse.dialog.connection')}>
<ConnectionField pick={pick} unit={unit} />
</DialogSection>
</>
)}
</DialogShell>
);
}
@@ -0,0 +1,219 @@
import { horseAccessTone, type HorseRoute } from '@trek/shared';
import { AlertTriangle, ArrowLeft, ArrowLeftRight, ArrowRight, Link2, Spline } from 'lucide-react';
import { useId } from 'react';
import { useTranslation } from '../../../i18n';
import type { DistanceUnit } from '../../../types';
import { formatDistance } from '../../../utils/units';
import { SoftPill } from '../../Planner/planParts';
import ToggleSwitch from '../../Settings/ToggleSwitch';
import { Segmented } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { InfoDot, TourNotice } from '../tourParts';
import { ACCESS_TONE_DOT, ACCESS_TONE_PILL, accessToneOf } from './horseAccess';
import type { HorseSectionLeg } from './horseSection';
import type { HorseSectionPick } from './useHorseSectionPick';
/**
* HOR-01: the parts of the dialog that takes a section of a horse route onto
* a ride: the sections to choose from, the direction, what the `horse=*` tags
* say along the one chosen, and how it joins the ride.
*/
/**
* The route's sections as numbered chips, in the order the route lists its
* ways: the number carries a dot in its corner for what the tags say along the
* section, hollow where they say nothing, and the chip its length.
*/
export function SectionChips({
route,
chosen,
onPick,
unit,
}: {
route: HorseRoute;
chosen: number | undefined;
onPick: (number: number) => void;
unit: DistanceUnit;
}) {
const { t } = useTranslation();
return (
<ul className="m-0 flex max-h-[176px] list-none flex-wrap gap-1.5 overflow-y-auto p-0">
{route.sections.map((section) => {
const on = section.number === chosen;
const tone = accessToneOf(section.access) ?? 'unknown';
const length = formatDistance(section.length_m / 1000, unit);
const label = t('tours.horse.dialog.section', { number: section.number });
return (
<li key={section.number}>
<Tooltip label={`${label}: ${t(`tours.horse.access.summary.${tone}`)}`}>
<button
type="button"
aria-pressed={on}
aria-label={`${label}, ${length}`}
data-testid={`horse-section-${section.number}`}
onClick={() => onPick(section.number)}
className={`inline-flex h-8 items-center gap-1.5 rounded-full pe-2.5 ps-1 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${on ? 'bg-surface-card shadow-sm ring-1 ring-inset ring-accent' : 'bg-surface-secondary hover:bg-surface-tertiary'}`}
>
<span
className={`relative grid h-6 w-6 place-items-center rounded-full font-geist font-semibold tabular-nums ${on ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'}`}
style={fs(11)}
>
{section.number}
<span
aria-hidden="true"
className={`absolute -end-0.5 -top-0.5 h-2.5 w-2.5 rounded-full ring-2 ring-surface-card ${ACCESS_TONE_DOT[tone]}`}
/>
</span>
<span className="font-geist tabular-nums text-content-secondary" style={fs(11)}>
{length}
</span>
{section.signposted && (
<ArrowRight size={11} strokeWidth={2.4} aria-hidden="true" className="text-content-faint" />
)}
</button>
</Tooltip>
</li>
);
})}
</ul>
);
}
/** As mapped or the other way round, with what the route's own signposting says about the choice. */
export function DirectionField({ pick }: { pick: HorseSectionPick }) {
const { t } = useTranslation();
const signposted = pick.leg?.signposted ?? null;
return (
<div className="flex flex-col gap-2">
<Segmented<'mapped' | 'reversed'>
value={pick.reversed ? 'reversed' : 'mapped'}
onChange={(value) => pick.turn(value === 'reversed')}
label={t('tours.horse.dialog.direction')}
fill
options={[
{
value: 'mapped',
label: t('tours.horse.dialog.asMapped'),
icon: <ArrowRight size={13} strokeWidth={2.2} />,
},
{
value: 'reversed',
label: t('tours.horse.dialog.reversed'),
icon: <ArrowLeft size={13} strokeWidth={2.2} />,
},
]}
/>
{signposted === 'with' && (
<span className="inline-flex">
<SoftPill tone="success" icon={<ArrowRight size={11} strokeWidth={2.4} />}>
{t('tours.horse.dialog.signedWith')}
</SoftPill>
</span>
)}
{signposted === 'against' && (
<TourNotice tone="warning" icon={<ArrowLeftRight size={13} strokeWidth={2} />}>
{t('tours.horse.dialog.signedAgainst')}
</TourNotice>
)}
</div>
);
}
/** The label of the access block, with what it can and cannot say behind its info dot. */
export function AccessLabel() {
const { t } = useTranslation();
return (
<span className="inline-flex items-center gap-1">
{t('tours.horse.access.title')}
<InfoDot label={t('tours.horse.routes.infoLabel')} description={t('tours.horse.access.note')} />
</span>
);
}
/**
* What the `horse=*` tags say along the section chosen, one pill per value
* with the metres it covers, the most restrictive first: the tags as they
* are, unknown where there are none. A tag that closes part of it is said.
*/
export function AccessPills({ leg, unit }: { leg: HorseSectionLeg; unit: DistanceUnit }) {
const { t } = useTranslation();
const shares = leg.access ?? [];
const closed = accessToneOf(shares) === 'closed';
return (
<div className="flex flex-col gap-2">
<span data-testid="horse-section-access" className="flex flex-wrap gap-1.5">
{shares.map((share) => {
const tone = horseAccessTone(share.value);
return (
<SoftPill
key={share.value}
tone={ACCESS_TONE_PILL[tone]}
icon={<span aria-hidden="true" className={`h-2 w-2 rounded-full ${ACCESS_TONE_DOT[tone]}`} />}
>
{t(`tours.horse.access.value.${share.value}`)}
<span className="font-geist tabular-nums text-content-faint">
{formatDistance(share.length_m / 1000, unit)}
</span>
</SoftPill>
);
})}
</span>
{closed && (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
{t('tours.horse.access.closedNotice')}
</TourNotice>
)}
</div>
);
}
/**
* How the section joins the ride: as its first leg, from its end, or across a
* gap no horse route covers. A gap is only drawn by hand, straight and
* unchecked, once the rider switched that on; it is never closed by itself.
*/
export function ConnectionField({ pick, unit }: { pick: HorseSectionPick; unit: DistanceUnit }) {
const { t } = useTranslation();
const hintId = useId();
if (pick.gapM === null)
return (
<span className="inline-flex">
<SoftPill tone="info" icon={<Link2 size={11} strokeWidth={2.4} />}>
{t('tours.horse.dialog.firstSection')}
</SoftPill>
</span>
);
if (!pick.gapOpen)
return (
<span className="inline-flex">
<SoftPill tone="success" icon={<Link2 size={11} strokeWidth={2.4} />}>
{t('tours.horse.dialog.joins')}
</SoftPill>
</span>
);
return (
<div data-testid="horse-section-gap" className="flex flex-col gap-2">
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
{t('tours.horse.dialog.gap', { distance: formatDistance(pick.gapM / 1000, unit) })}
</TourNotice>
<div className="flex items-center gap-3 rounded-[10px] border border-dashed border-edge px-3 py-2">
<Spline size={14} strokeWidth={2} aria-hidden="true" className="flex-none text-content-muted" />
<span className="min-w-0 flex-1">
<span className="block font-semibold text-content" style={fs(12.5, 'body')}>
{t('tours.horse.dialog.joinGap')}
</span>
<span id={hintId} className="block text-content-faint" style={fs(11, 'body')}>
{t('tours.horse.dialog.joinGapHint')}
</span>
</span>
<ToggleSwitch
on={pick.joinGap}
onToggle={pick.toggleJoinGap}
label={t('tours.horse.dialog.joinGap')}
describedBy={hintId}
/>
</div>
</div>
);
}
@@ -0,0 +1,82 @@
import type { HorseRoute } from '@trek/shared';
import { useMemo } from 'react';
import { OFM_POSITRON } from '../../../constants/mapDefaults';
import { useTileUrl } from '../../../hooks/useTileUrl';
import { useTranslation } from '../../../i18n';
import { MapViewAuto } from '../../Map/MapViewAuto';
import { useHorsePaint } from './horsePaint';
import type { HorseSectionLeg } from './horseSection';
type Line = [number, number][];
/**
* HOR-01: the route in the dialog that takes a section of it: the ride so far
* in the planner's blue, every section of the route in its brown, and the one
* chosen over them in the accent, from A to B in the direction chosen. The map
* frames the chosen section and the end of the ride, so a gap between them
* shows before anything is taken.
*/
export function HorseRouteMap({
route,
chosen,
ride,
}: {
route: HorseRoute;
chosen: HorseSectionLeg | null;
/** The ride as the planner draws it now, or null while it has no line. */
ride: Line | null;
}) {
const { t } = useTranslation();
const tileUrl = useTileUrl(OFM_POSITRON);
const paint = useHorsePaint();
const drawn = useMemo(() => {
const sections = route.sections.map((section) =>
section.geometry.map(([lat, lng]): [number, number] => [lat, lng])
);
const chosenLine = chosen?.geometry.map(([lat, lng]): [number, number] => [Number(lat), Number(lng)]) ?? null;
const sectionColor = { line: paint.section, casing: paint.casing };
return {
lines: [...(ride ? [ride] : []), ...sections, ...(chosenLine ? [chosenLine] : [])],
colors: [
...(ride ? [undefined] : []),
...sections.map(() => sectionColor),
...(chosenLine ? [{ line: paint.chosen, casing: paint.casing }] : []),
],
focus: [...(chosenLine ?? sections.flat()), ...(ride ? ride.slice(-1) : [])],
};
}, [chosen, paint, ride, route]);
const chosenGeometry = chosen?.geometry ?? [];
const first = chosenGeometry[0];
const last = chosenGeometry[chosenGeometry.length - 1];
const pin = (id: string, at: readonly unknown[], label: string, detail: string) => ({
id,
lat: Number(at[0]),
lng: Number(at[1]),
label,
detail,
});
const ends =
first && last
? [
pin('horse-section-start', first, 'A', t('tours.planner.start')),
pin('horse-section-end', last, 'B', t('tours.planner.end')),
]
: [];
return (
<div
data-testid="horse-route-map"
className="relative h-[240px] flex-none overflow-hidden rounded-[14px] border border-edge-faint"
>
<MapViewAuto
places={[]}
route={drawn.lines}
routeColors={drawn.colors}
tileUrl={tileUrl}
focusPoints={drawn.focus}
focusKey={`${route.id}:${first?.join(',') ?? ''}`}
followSelection={false}
plannerWaypoints={ends}
/>
</div>
);
}
@@ -0,0 +1,184 @@
import { HORSE_ROUTES_ATTRIBUTION, type HorseRoute } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor, within } from '../../../../tests/helpers/render';
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub';
import { publishTourMapViewport } from '../../../store/tourMapViewportStore';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { HorseRoutesCard } from './HorseRoutesCard';
const repo = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock('../../../repo/horseRouteRepo', () => ({ horseRouteRepo: repo }));
vi.mock('../../Map/MapViewAuto', () => ({
MapViewAuto: ({ route, plannerWaypoints }: { route?: unknown[]; plannerWaypoints?: Array<{ label?: string }> }) => (
<div data-testid="horse-map-stub" data-lines={route?.length ?? 0}>
{plannerWaypoints?.map((pin) => pin.label).join('')}
</div>
),
}));
// FE-HORSE-CARD-001 to FE-HORSE-CARD-007
const VIEW = { south: 47.1, west: 11.2, north: 47.3, east: 11.5 };
const ROUTE: HorseRoute = {
id: 'relation/42',
name: 'Reitweg Isartal',
ref: 'R1',
osmUrl: 'https://www.openstreetmap.org/relation/42',
continues: true,
sections: [
{
number: 1,
geometry: [
[47.15, 11.25],
[47.16, 11.25],
],
length_m: 1112,
access: [
{ value: 'designated', length_m: 800 },
{ value: 'private', length_m: 312 },
],
signposted: 'with',
},
{
number: 2,
geometry: [
[47.17, 11.25],
[47.18, 11.25],
],
length_m: 1112,
access: [],
signposted: null,
},
],
};
const reply = (routes: HorseRoute[]) => ({
routes,
truncated: false,
source: 'openstreetmap' as const,
attribution: HORSE_ROUTES_ATTRIBUTION,
});
function planner(overrides: Partial<TourPlannerController> = {}) {
return {
...hikePlannerDefaults(),
tourType: 'horse',
isHorse: true,
mode: { type: 'new-draft' },
waypoints: [] as TourPlannerWaypoint[],
canEdit: true,
isSaving: false,
route: null,
editWaypoints: vi.fn(),
...overrides,
} as unknown as TourPlannerController;
}
async function openRoute(stub: TourPlannerController) {
repo.list.mockResolvedValue(reply([ROUTE]));
render(<HorseRoutesCard planner={stub} />);
fireEvent.click(screen.getByRole('button', { name: 'Find in map view' }));
fireEvent.click(await screen.findByTestId('horse-route-relation/42'));
return screen.getByRole('dialog');
}
beforeEach(() => {
repo.list.mockReset();
publishTourMapViewport(VIEW);
localStorage.clear();
});
describe('HorseRoutesCard (HOR-01)', () => {
it('FE-HORSE-CARD-001: is there only while a ride is edited', () => {
const { rerender } = render(<HorseRoutesCard planner={planner({ isHorse: false })} />);
expect(screen.queryByTestId('horse-routes-card')).not.toBeInTheDocument();
rerender(<HorseRoutesCard planner={planner({ mode: { type: 'neutral' } })} />);
expect(screen.queryByTestId('horse-routes-card')).not.toBeInTheDocument();
rerender(<HorseRoutesCard planner={planner()} />);
expect(screen.getByTestId('horse-routes-card')).toBeInTheDocument();
expect(repo.list).not.toHaveBeenCalled();
});
it('FE-HORSE-CARD-002: lists the routes of the view with their length, sections and where they go on', async () => {
repo.list.mockResolvedValue(reply([ROUTE]));
render(<HorseRoutesCard planner={planner()} />);
fireEvent.click(screen.getByRole('button', { name: 'Find in map view' }));
const row = await screen.findByTestId('horse-route-relation/42');
expect(row).toHaveTextContent('Reitweg Isartal');
expect(row).toHaveTextContent('2.2 km');
expect(row).toHaveTextContent('R1');
expect(row).toHaveTextContent('Goes on beyond the view');
expect(within(row).getByText('Sections: 2')).toBeInTheDocument();
expect(screen.getByText(HORSE_ROUTES_ATTRIBUTION)).toBeInTheDocument();
// The map moved: the routes shown are from another view, and can be looked for again.
act(() => publishTourMapViewport({ ...VIEW, north: 47.31 }));
expect(await screen.findByRole('button', { name: 'Search this view' })).toBeInTheDocument();
});
it('FE-HORSE-CARD-003: says why it found nothing, and lets a failed search be asked again', async () => {
repo.list.mockResolvedValueOnce(reply([])).mockRejectedValueOnce(new Error('502'));
render(<HorseRoutesCard planner={planner()} />);
fireEvent.click(screen.getByRole('button', { name: 'Find in map view' }));
expect(await screen.findByText('No signposted horse routes in this view.')).toBeInTheDocument();
act(() => publishTourMapViewport({ ...VIEW, north: 47.31 }));
fireEvent.click(await screen.findByRole('button', { name: 'Search this view' }));
expect(await screen.findByText('Horse routes could not be read. Your ride is unchanged.')).toBeInTheDocument();
act(() => publishTourMapViewport({ south: 46, west: 11, north: 47, east: 11.4 }));
fireEvent.click(screen.getByRole('button', { name: 'Search this view' }));
expect(await screen.findByText('Zoom in further to look for horse routes.')).toBeInTheDocument();
});
it('FE-HORSE-CARD-004: takes a section as the first leg of an empty ride, in one step', async () => {
const stub = planner();
const dialog = await openRoute(stub);
expect(within(dialog).getByTestId('horse-map-stub')).toHaveAttribute('data-lines', '3');
expect(within(dialog).getByTestId('horse-map-stub')).toHaveTextContent('AB');
expect(within(dialog).getByText('Starts your ride')).toBeInTheDocument();
fireEvent.click(within(dialog).getByTestId('horse-section-adopt'));
expect(stub.editWaypoints).toHaveBeenCalledTimes(1);
type Change = (points: TourPlannerWaypoint[]) => TourPlannerWaypoint[];
const change = vi.mocked(stub.editWaypoints).mock.calls[0]?.[0] as Change;
const points = change([]);
expect(points.map((point) => point.fixedLeg?.kind ?? null)).toEqual(['horse_route', null]);
expect(points[0].fixedLeg).toMatchObject({ ref: 'relation/42', name: 'Reitweg Isartal', signposted: 'with' });
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
});
it('FE-HORSE-CARD-005: shows what the tags say, and says a closed part and the other signposted way', async () => {
const dialog = await openRoute(planner());
const access = within(dialog).getByTestId('horse-section-access');
expect(access).toHaveTextContent('Designated for riders');
expect(access).toHaveTextContent('800 m');
expect(access).toHaveTextContent('Private');
const closed = 'Part of this section is tagged as closed or private for horses.';
expect(within(dialog).getByText(closed)).toBeInTheDocument();
expect(within(dialog).getByText('Signposted this way')).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: 'Reversed' }));
expect(within(dialog).getByText('This section is signposted the other way.')).toBeInTheDocument();
// A section the data says nothing about reads as unknown, not as open.
fireEvent.click(within(dialog).getByTestId('horse-section-2'));
expect(within(dialog).queryByText('Signposted this way')).not.toBeInTheDocument();
expect(within(dialog).getByRole('button', { name: 'As mapped' })).toHaveAttribute('aria-pressed', 'true');
});
it('FE-HORSE-CARD-006: never closes a gap by itself: the manual join has to be switched on first', async () => {
const stub = planner({ waypoints: [{ id: 'a', lat: 47.1, lng: 11.25, role: 'start' }] });
const dialog = await openRoute(stub);
expect(within(dialog).getByTestId('horse-section-gap')).toHaveTextContent('5.6 km');
const adopt = within(dialog).getByTestId('horse-section-adopt');
expect(adopt).toBeDisabled();
fireEvent.click(within(dialog).getByRole('button', { name: 'Join with a manual section' }));
expect(adopt).toBeEnabled();
// Another direction is another gap: the switch goes off again.
fireEvent.click(within(dialog).getByRole('button', { name: 'Reversed' }));
expect(adopt).toBeDisabled();
});
it('FE-HORSE-CARD-007: goes on from the end of the ride where the section starts there', async () => {
const stub = planner({ waypoints: [{ id: 'a', lat: 47.15, lng: 11.25, role: 'start' }] });
const dialog = await openRoute(stub);
expect(within(dialog).getByText('Joins the end of your ride')).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
expect(stub.editWaypoints).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,156 @@
import { HORSE_ROUTES_ATTRIBUTION, type HorseRoute } from '@trek/shared';
import { AlertTriangle, ArrowRight, ChevronRight, Loader2, RefreshCw, Search, WifiOff, ZoomIn } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance } from '../../../utils/units';
import { SoftPill, WhiteBadge } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { CardAction, InfoDot, RailCard, TourNotice } from '../tourParts';
import { HorseRouteDialog, horseRouteTitle } from './HorseRouteDialog';
import { Horseshoe } from './HorseshoeIcon';
import { useHorseRoutes, type HorseRoutes } from './useHorseRoutes';
/**
* One route found in the view: its horseshoe with the number of its sections
* in the corner, its name, its length in the view, and a mark where it goes
* on beyond. Pressed, it opens the route to take a section of it.
*/
function RouteRow({ route, onOpen }: { route: HorseRoute; onOpen: () => void }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const lengthM = route.sections.reduce((sum, section) => sum + section.length_m, 0);
return (
<li>
<button
type="button"
data-testid={`horse-route-${route.id}`}
onClick={onOpen}
className="flex w-full min-w-0 items-center gap-2.5 rounded-[10px] px-1.5 py-1.5 text-start transition-colors hover:bg-surface-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<span className="relative grid h-8 w-8 flex-none place-items-center rounded-[9px] bg-surface-tertiary">
<Horseshoe size={16} strokeWidth={2.1} aria-hidden="true" className="text-[color:var(--horse-route)]" />
<span
aria-hidden="true"
className="absolute -end-1.5 -top-1.5 grid h-4 min-w-4 place-items-center rounded-full bg-surface-card px-1 font-geist font-semibold tabular-nums leading-none text-content shadow-sm ring-1 ring-inset ring-edge-faint"
style={fs(9.5)}
>
{route.sections.length}
</span>
<span className="sr-only">{`${t('tours.horse.routes.sections')}: ${route.sections.length}`}</span>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={fs(12.5, 'body')}>
{horseRouteTitle(route, t)}
</span>
<span className="mt-0.5 flex flex-wrap items-center gap-1">
<WhiteBadge>{formatDistance(lengthM / 1000, unit)}</WhiteBadge>
{route.ref && route.name && <SoftPill>{route.ref}</SoftPill>}
{route.continues && (
<SoftPill icon={<ArrowRight size={10} strokeWidth={2.4} className="rtl:rotate-180" />}>
{t('tours.horse.routes.continues')}
</SoftPill>
)}
</span>
</span>
<ChevronRight
size={14}
strokeWidth={2.2}
aria-hidden="true"
className="flex-none text-content-faint rtl:rotate-180"
/>
</button>
</li>
);
}
/** What the last search came to, when that is not a list: loading, nothing found, or why it could not look. */
function SearchStatus({ found }: { found: HorseRoutes }) {
const { t } = useTranslation();
if (found.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.horse.routes.loading')}
</span>
);
if (found.status === 'zoom')
return <TourNotice icon={<ZoomIn size={13} strokeWidth={2} />}>{t('tours.horse.routes.zoomIn')}</TourNotice>;
if (found.status === 'offline')
return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.horse.routes.offline')}</TourNotice>;
if (found.status === 'failed')
return (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
{t('tours.horse.routes.failed')}
</TourNotice>
);
if (found.status === 'ready' && found.routes.length === 0)
return <TourNotice>{t('tours.horse.routes.none')}</TourNotice>;
if (found.status === 'ready' && found.truncated)
return <TourNotice icon={<ZoomIn size={13} strokeWidth={2} />}>{t('tours.horse.routes.truncated')}</TourNotice>;
return null;
}
function HorseRoutesBody({ planner }: { planner: TourPlannerController }) {
const { t } = useTranslation();
const found = useHorseRoutes(planner.tripId);
const [open, setOpen] = useState<HorseRoute | null>(null);
const searched = found.searched !== null;
const again = searched && (found.stale || found.status === 'failed');
return (
<RailCard
icon={<Horseshoe size={14} strokeWidth={2} />}
title={t('tours.horse.routes.title')}
foldId="horse-routes"
aside={
<>
{found.status === 'ready' && found.routes.length > 0 && <SoftPill>{found.routes.length}</SoftPill>}
<InfoDot label={t('tours.horse.routes.infoLabel')} description={t('tours.horse.routes.info')} />
</>
}
>
<div data-testid="horse-routes-card" className="flex min-w-0 flex-col gap-2.5">
{(!searched || again) && (
<span className="flex">
<CardAction
icon={again ? <RefreshCw size={12} strokeWidth={2} /> : <Search size={12} strokeWidth={2} />}
onClick={() => void found.search()}
disabled={found.status === 'loading'}
>
{t(again ? 'tours.horse.routes.searchAgain' : 'tours.horse.routes.search')}
</CardAction>
</span>
)}
<SearchStatus found={found} />
{found.routes.length > 0 && (
<ul
data-testid="horse-routes-list"
className={`m-0 flex list-none flex-col gap-0.5 p-0 transition-opacity ${found.status === 'ready' && !found.stale ? '' : 'opacity-60'}`}
>
{found.routes.map((route) => (
<RouteRow key={route.id} route={route} onOpen={() => setOpen(route)} />
))}
</ul>
)}
{searched && (
<span className="text-content-faint" style={fs(10.5, 'body')}>
{HORSE_ROUTES_ATTRIBUTION}
</span>
)}
</div>
{open && <HorseRouteDialog key={open.id} planner={planner} route={open} onClose={() => setOpen(null)} />}
</RailCard>
);
}
/**
* HOR-01: the signposted horse routes of the map view, beside a ride being
* planned. Looked for when the rider asks; a route opens in a dialog to take
* a section of it. Shown only while a ride is edited.
*/
export function HorseRoutesCard({ planner }: { planner: TourPlannerController }) {
const editing = planner.mode.type === 'new-draft' || planner.mode.type === 'edit-saved';
if (!planner.isHorse || !editing) return null;
return <HorseRoutesBody planner={planner} />;
}
@@ -0,0 +1,19 @@
import { createLucideIcon } from 'lucide-react';
/**
* HOR-01: a horseshoe, the ride's own symbol, drawn on lucide's grid (24,
* stroke 2, round caps) so it sits beside its icons without a seam: the shoe
* open to the ground, its two arcs joined at the heels and four nail holes.
* Lucide has no horse; built with its own factory, it is a LucideIcon like
* any other and goes wherever one does.
*/
export const Horseshoe = createLucideIcon('horseshoe', [
['path', { d: 'M5.5 20C3.9 18 3 15.3 3 12.5a9 9 0 0 1 18 0c0 2.8-.9 5.5-2.5 7.5', key: 'outer' }],
['path', { d: 'M9.5 18c-.9-1.3-1.5-3.3-1.5-5.5a4 4 0 0 1 8 0c0 2.2-.6 4.2-1.5 5.5', key: 'inner' }],
['path', { d: 'm5.5 20 4-2', key: 'heel-start' }],
['path', { d: 'm18.5 20-4-2', key: 'heel-end' }],
['path', { d: 'M5.5 12.5h.01', key: 'nail-1' }],
['path', { d: 'M7.4 7.9h.01', key: 'nail-2' }],
['path', { d: 'M16.6 7.9h.01', key: 'nail-3' }],
['path', { d: 'M18.5 12.5h.01', key: 'nail-4' }],
]);
@@ -0,0 +1,45 @@
import { horseAccessTone, type HorseAccessShare, type HorseAccessTone } from '@trek/shared';
import type { DistanceUnit } from '../../../types';
import { formatDistance } from '../../../utils/units';
/**
* HOR-01: how the `horse=*` tags along a section read for a rider. What is
* tagged is said as it is, step by step; what is not tagged stays unknown.
* None of it is a permission: being on a signposted route is not either.
*/
/** The step a whole line reads as: the most restrictive one tagged along it, unknown before open. */
const TONE_ORDER: readonly HorseAccessTone[] = ['closed', 'limited', 'unknown', 'open'];
export function accessToneOf(shares: readonly HorseAccessShare[] | undefined): HorseAccessTone | null {
if (!shares?.length) return null;
const tones = new Set(shares.map((share) => horseAccessTone(share.value)));
return TONE_ORDER.find((tone) => tones.has(tone)) ?? null;
}
/** The dot a step is marked with: a status colour, unknown as a hollow ring. */
export const ACCESS_TONE_DOT: Readonly<Record<HorseAccessTone, string>> = {
open: 'bg-success',
limited: 'bg-warning',
closed: 'bg-danger',
unknown: 'border border-content-faint bg-surface-card',
};
/** The pill tone a step is written in. */
export const ACCESS_TONE_PILL: Readonly<Record<HorseAccessTone, 'success' | 'warning' | 'danger' | 'neutral'>> = {
open: 'success',
limited: 'warning',
closed: 'danger',
unknown: 'neutral',
};
/** The tags along a line as words, the longest first: "Designated for riders 900 m, Not in the data 450 m". */
export function accessText(
shares: readonly HorseAccessShare[] | undefined,
unit: DistanceUnit,
t: (key: string) => string
): string {
return (shares ?? [])
.map((share) => `${t(`tours.horse.access.value.${share.value}`)} ${formatDistance(share.length_m / 1000, unit)}`)
.join(', ');
}
@@ -0,0 +1,29 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useGpxConversion } from '../conversion/useGpxConversion';
import type { TourPlannerController } from '../planner/useTourPlanner';
// FE-HORSE-IMPORT-001
function viewing(tourType: string) {
const tour = { place_id: 5, tour_type: tourType, name: 'Recorded', max_hiking_difficulty: null };
return {
mode: { type: 'view-gpx', placeId: 5, tour },
readOnlyGpxTour: { tour, routeGeometry: null },
readOnlyGpxAnalysis: {
routeCoordinates: [
[48, 11],
[48.01, 11],
],
},
canEdit: true,
startDerivedTour: vi.fn(),
} as unknown as TourPlannerController;
}
describe('a recorded ride (HOR-01)', () => {
it('FE-HORSE-IMPORT-001: offers no editable version planned on foot, where a hike does', () => {
expect(renderHook(() => useGpxConversion(viewing('horse'))).result.current.available).toBe(false);
expect(renderHook(() => useGpxConversion(viewing('hike'))).result.current.available).toBe(true);
});
});
@@ -0,0 +1,41 @@
import { freeLegBetween, type RoutePoint } from '../planner/tourRouteActions';
/**
* HOR-01: the legs of a ride. Nothing of a ride is routed: a leg either
* follows a section of a signposted horse route or is drawn by hand, straight
* and unchecked. These keep a draft to that, whatever was done to it.
*/
/**
* Every leg of a ride that follows no horse route drawn by hand: a leg the
* router would have planned, a via ferrata, a section torn off its points by a
* move. The last point has no leg.
*/
export function rideLegs<T extends RoutePoint>(points: readonly T[]): T[] {
return points.map((point, index) => {
const next = points[index + 1];
const kind = point.fixedLeg?.kind;
if (!next || kind === 'horse_route' || kind === 'free') return point;
return { ...point, fixedLeg: freeLegBetween(point, next) };
});
}
/** Any other type takes no section of a horse route: such a leg goes back to the router. */
export function withoutHorseRoutes<T extends RoutePoint>(points: readonly T[]): T[] {
return points.map((point) => {
if (point.fixedLeg?.kind !== 'horse_route') return point;
const { fixedLeg: _section, ...routed } = point;
return routed as T;
});
}
/**
* Whether a section of a horse route starts or ends at this point. Such a
* point stays where the route has it: dragged away, the section would no
* longer reach it and would have to give way to a straight line.
*/
export function holdsHorseSection(points: readonly RoutePoint[], id: string): boolean {
const index = points.findIndex((point) => point.id === id);
if (index < 0) return false;
return points[index]?.fixedLeg?.kind === 'horse_route' || points[index - 1]?.fixedLeg?.kind === 'horse_route';
}
@@ -0,0 +1,144 @@
import type { HorseRoute } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import { accessText, accessToneOf } from './horseAccess';
import { holdsHorseSection, rideLegs, withoutHorseRoutes } from './horseLegs';
import { preferReversed, SECTION_JOINS_M, sectionGapM, sectionLeg, withSectionAppended } from './horseSection';
// FE-HORSE-MODEL-001 to FE-HORSE-MODEL-009
const route: HorseRoute = {
id: 'relation/42',
name: null,
ref: 'R1',
osmUrl: 'https://www.openstreetmap.org/relation/42',
continues: true,
sections: [
{
number: 1,
geometry: [
[48, 11],
[48.005, 11],
[48.01, 11],
],
length_m: 1112,
access: [
{ value: 'designated', length_m: 800 },
{ value: 'unknown', length_m: 312 },
],
signposted: 'with',
},
],
};
const section = route.sections[0];
const point = (id: string, lat: number, lng: number, extra: Partial<TourPlannerWaypoint> = {}) =>
({ id, lat, lng, role: 'via', ...extra }) as TourPlannerWaypoint;
const t = (key: string) => key.split('.').pop() ?? key;
describe('horse route sections on a ride (HOR-01)', () => {
it('FE-HORSE-MODEL-001: a section as a leg keeps its line, its route and its access, and turns round whole', () => {
expect(sectionLeg(route, section, false)).toEqual({
kind: 'horse_route',
ref: 'relation/42',
name: 'R1',
geometry: section.geometry,
signposted: 'with',
access: section.access,
});
const turned = sectionLeg({ ...route, name: 'Reitweg', ref: null }, section, true);
expect(turned.name).toBe('Reitweg');
expect(turned.geometry).toEqual([...section.geometry].reverse());
expect(turned.signposted).toBe('against');
expect(sectionLeg({ ...route, ref: null }, { ...section, signposted: null }, true)).toMatchObject({
name: null,
signposted: null,
});
});
it('FE-HORSE-MODEL-002: measures the gap from the end of the ride, and has none on an empty ride', () => {
const leg = sectionLeg(route, section, false);
expect(sectionGapM([], leg)).toBeNull();
expect(sectionGapM([point('a', 48, 11)], leg)).toBe(0);
expect(sectionGapM([point('a', 47.99, 11)], leg)).toBeCloseTo(1112, -1);
});
it('FE-HORSE-MODEL-003: offers first the direction that goes on from the ride, else the signposted one', () => {
expect(preferReversed([point('a', 48.011, 11)], section)).toBe(true);
expect(preferReversed([point('a', 47.99, 11)], section)).toBe(false);
expect(preferReversed([], section)).toBe(false);
expect(preferReversed([], { ...section, signposted: 'against' })).toBe(true);
});
it('FE-HORSE-MODEL-004: a section starts an empty ride, goes on from its end, or starts a point of its own', () => {
const leg = sectionLeg(route, section, false);
let next = 0;
const newId = () => `n${(next += 1)}`;
expect(withSectionAppended([], leg, newId)).toEqual([
{ id: 'n2', lat: 48, lng: 11, role: 'via', fixedLeg: leg },
{ id: 'n1', lat: 48.01, lng: 11, role: 'end' },
]);
const near = point('a', 48 + SECTION_JOINS_M / 200_000, 11);
expect(withSectionAppended([near], leg, newId)).toEqual([
{ ...near, fixedLeg: leg },
{ id: 'n3', lat: 48.01, lng: 11, role: 'end' },
]);
const far = point('b', 47.99, 11);
expect(withSectionAppended([far], leg, newId).map((entry) => [entry.id, entry.fixedLeg?.kind ?? null])).toEqual([
['b', null],
['n5', 'horse_route'],
['n4', null],
]);
});
it('FE-HORSE-MODEL-005: every leg of a ride is a section or drawn by hand, the last point has none', () => {
const ferrata = {
kind: 'ferrata' as const,
ref: 'way/1',
name: null,
geometry: [[48, 11] as [number, number], [48.01, 11] as [number, number]],
};
const leg = sectionLeg(route, section, false);
const legs = rideLegs([
point('a', 47.99, 11),
point('b', 48, 11, { fixedLeg: leg }),
point('c', 48.01, 11, { fixedLeg: ferrata }),
point('d', 48.02, 11),
]);
expect(legs.map((entry) => entry.fixedLeg?.kind ?? null)).toEqual(['free', 'horse_route', 'free', null]);
expect(legs[0].fixedLeg?.geometry).toEqual([
[47.99, 11],
[48, 11],
]);
});
it('FE-HORSE-MODEL-006: any other type gives a section back to the router and keeps the rest', () => {
const leg = sectionLeg(route, section, false);
const drawn = { kind: 'free' as const, geometry: [[47.99, 11] as [number, number], [48, 11] as [number, number]] };
const points = withoutHorseRoutes([
point('a', 47.99, 11, { fixedLeg: drawn }),
point('b', 48, 11, { fixedLeg: leg }),
]);
expect(points.map((entry) => entry.fixedLeg?.kind ?? null)).toEqual(['free', null]);
expect('fixedLeg' in points[1]).toBe(false);
});
it('FE-HORSE-MODEL-007: a point that starts or ends a section is held where the route has it', () => {
const leg = sectionLeg(route, section, false);
const points = [point('a', 48, 11, { fixedLeg: leg }), point('b', 48.01, 11), point('c', 48.02, 11)];
expect(['a', 'b', 'c', 'gone'].map((id) => holdsHorseSection(points, id))).toEqual([true, true, false, false]);
});
it('FE-HORSE-MODEL-008: reads a line as its most restrictive tag, unknown before open, nothing as nothing', () => {
expect(accessToneOf(undefined)).toBeNull();
expect(accessToneOf([])).toBeNull();
expect(accessToneOf([{ value: 'yes', length_m: 10 }])).toBe('open');
expect(accessToneOf([{ value: 'yes', length_m: 10 }, { value: 'unknown', length_m: 1 }])).toBe('unknown');
expect(accessToneOf([{ value: 'unknown', length_m: 10 }, { value: 'destination', length_m: 1 }])).toBe('limited');
expect(accessToneOf([{ value: 'designated', length_m: 10 }, { value: 'private', length_m: 1 }])).toBe('closed');
});
it('FE-HORSE-MODEL-009: spells the tags out with the metres each covers', () => {
expect(accessText(section.access, 'metric', t)).toBe('designated 800 m, unknown 312 m');
expect(accessText(undefined, 'metric', t)).toBe('');
});
});
@@ -0,0 +1,16 @@
import { useMapPaint } from '../../Map/mapPaint';
/**
* The colours a ride's horse routes are drawn in on a map (styles/horse.css):
* the sections of a route in its brown, the one chosen in the accent, both on
* a rim. The cards take the CSS variables directly; a map needs them resolved.
*/
const HORSE_PAINT_TOKENS = {
section: '--horse-route',
chosen: '--accent',
casing: '--horse-route-casing',
} as const;
export function useHorsePaint(): Record<keyof typeof HORSE_PAINT_TOKENS, string> {
return useMapPaint(HORSE_PAINT_TOKENS);
}
@@ -0,0 +1,85 @@
import type { TourFixedLeg } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { tourMapLayersFor } from '../tourMapLayers';
import { normalizePlannerWaypoints, tourWaypointKey, type TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import { toggleFreeLeg } from '../planner/tourRouteActions';
import { rideTour, routeWalkingTour } from '../planner/tourRouting';
// FE-HORSE-ROUTE-001 to FE-HORSE-ROUTE-006
const section: TourFixedLeg = {
kind: 'horse_route',
ref: 'relation/42',
name: 'Reitweg',
geometry: [
[48, 11],
[48.005, 11],
[48.01, 11],
],
signposted: null,
access: [],
};
const drawn: TourFixedLeg = {
kind: 'free',
geometry: [
[48.01, 11],
[48.011, 11],
],
};
const at = (id: string, lat: number, lng: number, fixedLeg?: TourFixedLeg): TourPlannerWaypoint => ({
id,
lat,
lng,
role: 'via',
...(fixedLeg ? { fixedLeg } : {}),
});
describe('a ride on the planner (HOR-01)', () => {
it('FE-HORSE-ROUTE-001: puts a ride together from its legs, a section as it is, a drawn leg sampled, no time', () => {
const ride = rideTour([at('a', 48, 11, section), at('b', 48.01, 11, drawn), at('c', 48.011, 11)]);
expect(ride).not.toBeNull();
expect(ride?.coordinates.slice(0, 3)).toEqual(section.geometry);
expect(ride?.coordinates[ride.coordinates.length - 1]).toEqual([48.011, 11]);
expect(ride?.parts.map((part) => part.free)).toEqual([false, true]);
// The drawn leg of about 111 m gets points along it, so its heights follow the ground.
expect(ride?.parts[1]?.coordinates.length).toBeGreaterThan(2);
expect(ride?.distanceMeters).toBeCloseTo(1223, -1);
expect(ride?.durationSeconds).toBeNull();
});
it('FE-HORSE-ROUTE-002: has no line while a leg has none of its own, or before there is a leg', () => {
expect(rideTour([at('a', 48, 11), at('b', 48.01, 11)])).toBeNull();
expect(rideTour([at('a', 48, 11)])).toBeNull();
});
it('FE-HORSE-ROUTE-003: no router plans a section of a horse route on another type', async () => {
await expect(routeWalkingTour([at('a', 48, 11, section), at('b', 48.01, 11)])).resolves.toBeNull();
});
it('FE-HORSE-ROUTE-004: keeps a section when its leg is asked to be drawn by hand', () => {
const points = [at('a', 48, 11, section), at('b', 48.01, 11)];
expect(toggleFreeLeg(points, 'a')).toEqual(points);
});
it('FE-HORSE-ROUTE-005: normalises a draft for its type, a ride all fixed, another type without sections', () => {
const points = [at('a', 47.99, 11), at('b', 48, 11, section), at('c', 48.01, 11)];
const ride = normalizePlannerWaypoints(points, 'horse');
expect(ride.map((point) => [point.role, point.fixedLeg?.kind ?? null])).toEqual([
['start', 'free'],
['via', 'horse_route'],
['end', null],
]);
expect(normalizePlannerWaypoints(points).map((point) => point.fixedLeg?.kind ?? null)).toEqual([null, null, null]);
// A section taken the other way round is another route for the planner.
const turned = { ...section, geometry: [...section.geometry].reverse() };
expect(tourWaypointKey([at('x', 48, 11, section)])).not.toBe(tourWaypointKey([at('x', 48, 11, turned)]));
});
it('FE-HORSE-ROUTE-006: a ride offers the standard map, topo and satellite, and no via ferratas', () => {
expect(tourMapLayersFor('horse')).toEqual({
bases: ['default', 'topo', 'satellite'],
defaultBase: 'default',
overlays: [],
});
});
});
@@ -0,0 +1,81 @@
import {
haversineMetres,
turnedSignposted,
type HorseRoute,
type HorseRouteSection,
type TourFixedLeg,
} from '@trek/shared';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
/**
* HOR-01: a section of a horse route taken onto a ride. Pure, so the dialog's
* preview and the step that changes the tour read the same answer.
*/
export type HorseSectionLeg = Extract<TourFixedLeg, { kind: 'horse_route' }>;
/**
* How close a section has to start to the end of the ride to go on from that
* point itself, in metres: the gap between two ways that meet in the data, not
* a stretch of its own. Well inside the 60 metres a saved leg may lie off its
* point.
*/
export const SECTION_JOINS_M = 25;
/** The section as a leg of the ride, in the direction chosen, with its route's name and its access. */
export function sectionLeg(route: HorseRoute, section: HorseRouteSection, reversed: boolean): HorseSectionLeg {
const geometry = section.geometry.map(([lat, lng]): [number, number] => [lat, lng]);
return {
kind: 'horse_route',
ref: route.id,
name: (route.name ?? route.ref)?.slice(0, 255) ?? null,
geometry: reversed ? geometry.reverse() : geometry,
signposted: reversed ? turnedSignposted(section.signposted) : section.signposted,
access: section.access,
};
}
type Point = Pick<TourPlannerWaypoint, 'lat' | 'lng'>;
/** Metres from the ride's last point to where the leg starts; null while the ride has no point. */
export function sectionGapM(points: readonly Point[], leg: HorseSectionLeg): number | null {
const last = points[points.length - 1];
const start = leg.geometry[0];
if (!last || !start) return null;
return haversineMetres(last.lat, last.lng, Number(start[0]), Number(start[1]));
}
/**
* The direction a section is offered in first: the one that starts nearer the
* end of the ride, so it goes on from there; on an empty ride the one the
* route is signposted in.
*/
export function preferReversed(points: readonly Point[], section: HorseRouteSection): boolean {
const last = points[points.length - 1];
const first = section.geometry[0];
const end = section.geometry[section.geometry.length - 1];
if (!last || !first || !end) return section.signposted === 'against';
return haversineMetres(last.lat, last.lng, end[0], end[1]) < haversineMetres(last.lat, last.lng, first[0], first[1]);
}
/**
* The ride with the section on its end, as one change. On an empty ride the
* section is its first leg; where it starts at the ride's end it goes on from
* that point; anywhere else it starts at a point of its own, and the gap to it
* is left to the planner, which draws it by hand once the rider has agreed.
*/
export function withSectionAppended(
points: readonly TourPlannerWaypoint[],
leg: HorseSectionLeg,
newId: () => string
): TourPlannerWaypoint[] {
const [startLat, startLng] = leg.geometry[0] as [number, number];
const [endLat, endLng] = leg.geometry[leg.geometry.length - 1] as [number, number];
const end: TourPlannerWaypoint = { id: newId(), lat: endLat, lng: endLng, role: 'end' };
const gap = sectionGapM(points, leg);
if (gap !== null && gap <= SECTION_JOINS_M) {
const last = points[points.length - 1] as TourPlannerWaypoint;
return [...points.slice(0, -1), { ...last, fixedLeg: leg }, end];
}
return [...points, { id: newId(), lat: startLat, lng: startLng, role: 'via', fixedLeg: leg }, end];
}
@@ -0,0 +1,129 @@
import { act, renderHook } from '@testing-library/react';
import { HORSE_ROUTES_ATTRIBUTION, type HorseRoute } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { publishTourMapViewport } from '../../../store/tourMapViewportStore';
import { horseRouteBbox, useHorseRoutes } from './useHorseRoutes';
const repo = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock('../../../repo/horseRouteRepo', () => ({ horseRouteRepo: repo }));
// FE-HORSE-ROUTES-HOOK-001 to FE-HORSE-ROUTES-HOOK-006
const VIEW = { south: 47.1, west: 11.2, north: 47.3, east: 11.5 };
const ROUTE: HorseRoute = {
id: 'relation/42',
name: 'Reitweg',
ref: null,
osmUrl: 'https://www.openstreetmap.org/relation/42',
continues: false,
sections: [
{
number: 1,
geometry: [
[47.15, 11.25],
[47.16, 11.25],
],
length_m: 1112,
access: [],
signposted: null,
},
],
};
const answer = (routes: HorseRoute[], truncated = false) => ({
routes,
truncated,
source: 'openstreetmap' as const,
attribution: HORSE_ROUTES_ATTRIBUTION,
});
beforeEach(() => {
repo.list.mockReset();
publishTourMapViewport(VIEW);
});
describe('useHorseRoutes (HOR-01)', () => {
it('FE-HORSE-ROUTES-HOOK-001: looks only when asked, in the view the map reported', async () => {
repo.list.mockResolvedValue(answer([ROUTE], true));
const { result } = renderHook(() => useHorseRoutes(7));
expect(result.current.status).toBe('idle');
expect(repo.list).not.toHaveBeenCalled();
await act(async () => {
await result.current.search();
});
expect(repo.list).toHaveBeenCalledWith(7, '47.10000,11.20000,47.30000,11.50000', expect.any(AbortSignal));
expect(result.current).toMatchObject({ status: 'ready', routes: [ROUTE], truncated: true, stale: false });
});
it('FE-HORSE-ROUTES-HOOK-002: asks to zoom in rather than send a view too wide, or none at all', async () => {
const { result } = renderHook(() => useHorseRoutes(7));
act(() => publishTourMapViewport({ south: 46, west: 11, north: 47, east: 11.4 }));
await act(async () => {
await result.current.search();
});
expect(result.current.status).toBe('zoom');
act(() => publishTourMapViewport(null));
await act(async () => {
await result.current.search();
});
expect(result.current.status).toBe('zoom');
expect(repo.list).not.toHaveBeenCalled();
});
it('FE-HORSE-ROUTES-HOOK-003: a failed search keeps the routes found before', async () => {
repo.list.mockResolvedValueOnce(answer([ROUTE])).mockRejectedValueOnce(new Error('502'));
const { result } = renderHook(() => useHorseRoutes(7));
await act(async () => {
await result.current.search();
});
await act(async () => {
await result.current.search();
});
expect(result.current).toMatchObject({ status: 'failed', routes: [ROUTE] });
});
it('FE-HORSE-ROUTES-HOOK-004: offline it says so instead of failing', async () => {
repo.list.mockRejectedValue(new OnlineOnlyError());
const { result } = renderHook(() => useHorseRoutes(7));
await act(async () => {
await result.current.search();
});
expect(result.current.status).toBe('offline');
});
it('FE-HORSE-ROUTES-HOOK-005: says the routes shown are from another view once the map moved', async () => {
repo.list.mockResolvedValue(answer([]));
const { result } = renderHook(() => useHorseRoutes(7));
await act(async () => {
await result.current.search();
});
expect(result.current.stale).toBe(false);
act(() => publishTourMapViewport({ ...VIEW, north: 47.31 }));
expect(result.current.stale).toBe(true);
});
it('FE-HORSE-ROUTES-HOOK-006: an answer to a search given up on is dropped', async () => {
let first: (value: unknown) => void = () => undefined;
repo.list
.mockReturnValueOnce(
new Promise((resolve) => {
first = resolve;
})
)
.mockResolvedValueOnce(answer([]));
const { result } = renderHook(() => useHorseRoutes(7));
let earlier: Promise<void> = Promise.resolve();
act(() => {
earlier = result.current.search();
});
await act(async () => {
await result.current.search();
});
await act(async () => {
first(answer([ROUTE]));
await earlier;
});
expect(result.current.routes).toEqual([]);
expect(horseRouteBbox(VIEW)).toBe('47.10000,11.20000,47.30000,11.50000');
});
});
@@ -0,0 +1,71 @@
import { ferrataBboxSpanOk, type FerrataBbox, type HorseRoute } from '@trek/shared';
import { useCallback, useEffect, useRef, useState } from 'react';
import { horseRouteRepo } from '../../../repo/horseRouteRepo';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { useTourMapViewportStore } from '../../../store/tourMapViewportStore';
type HorseRoutesStatus = 'idle' | 'loading' | 'ready' | 'failed' | 'offline' | 'zoom';
interface HorseRoutesState {
status: HorseRoutesStatus;
/** What the last search that answered found; kept while a later one loads or fails. */
routes: HorseRoute[];
truncated: boolean;
/** The view that search looked in, as it was sent. */
searched: string | null;
}
const IDLE: HorseRoutesState = { status: 'idle', routes: [], truncated: false, searched: null };
/** A view as it is sent: four edges rounded to five places, which is a metre and keeps the cache key short. */
export function horseRouteBbox(bounds: FerrataBbox): string {
return [bounds.south, bounds.west, bounds.north, bounds.east].map((edge) => edge.toFixed(5)).join(',');
}
/** The rounded view, or null where it spans more than one search may ask for. */
function askableBbox(bounds: FerrataBbox | null): string | null {
if (!bounds) return null;
const bbox = horseRouteBbox(bounds);
const [south, west, north, east] = bbox.split(',').map(Number) as [number, number, number, number];
return ferrataBboxSpanOk({ south, west, north, east }) ? bbox : null;
}
/**
* HOR-01: the signposted horse routes in the planner map's view, looked for
* when the rider asks, never on every pan: Overpass is shared and slow. The
* last answer stays on screen while a new search loads or fails, so a failed
* search changes neither the list nor the ride. `stale` says the map has moved
* since the routes shown were found.
*/
export function useHorseRoutes(tripId: number | string) {
const [state, setState] = useState<HorseRoutesState>(IDLE);
const abortRef = useRef<AbortController | null>(null);
const view = useTourMapViewportStore((store) => store.bounds);
useEffect(() => () => abortRef.current?.abort(), []);
const search = useCallback(async () => {
const bbox = askableBbox(useTourMapViewportStore.getState().bounds);
abortRef.current?.abort();
if (!bbox) {
setState((current) => ({ ...current, status: 'zoom' }));
return;
}
const controller = new AbortController();
abortRef.current = controller;
setState((current) => ({ ...current, status: 'loading' }));
try {
const answer = await horseRouteRepo.list(tripId, bbox, controller.signal);
if (controller.signal.aborted) return;
setState({ status: 'ready', routes: answer.routes, truncated: answer.truncated, searched: bbox });
} catch (failure) {
if (controller.signal.aborted) return;
setState((current) => ({ ...current, status: failure instanceof OnlineOnlyError ? 'offline' : 'failed' }));
}
}, [tripId]);
const stale = state.searched !== null && view !== null && horseRouteBbox(view) !== state.searched;
return { ...state, stale, search };
}
export type HorseRoutes = ReturnType<typeof useHorseRoutes>;
@@ -0,0 +1,58 @@
import type { HorseRoute } from '@trek/shared';
import { useMemo, useState } from 'react';
import { randomId } from '../../../utils/randomId';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { preferReversed, SECTION_JOINS_M, sectionGapM, sectionLeg, withSectionAppended } from './horseSection';
type PickPlanner = Pick<TourPlannerController, 'waypoints' | 'editWaypoints' | 'isSaving' | 'canEdit'>;
/**
* HOR-01: one route's sections in the dialog that takes one onto the ride:
* which section, in which direction, and whether a gap before it may be drawn
* by hand. Nothing changes until `adopt`, and then in one step of the
* planner's history, so one undo takes the whole section back. A gap is never
* closed unless the rider switched that on for this section and direction.
*/
export function useHorseSectionPick(planner: PickPlanner, route: HorseRoute, onDone: () => void) {
const [chosen, setChosen] = useState(route.sections[0]?.number ?? 1);
const [direction, setDirection] = useState<boolean | null>(null);
const [joinGap, setJoinGap] = useState(false);
const section = route.sections.find((candidate) => candidate.number === chosen) ?? route.sections[0];
const reversed = direction ?? (section ? preferReversed(planner.waypoints, section) : false);
// One leg per choice, so the map in the dialog draws again only when the choice changes.
const leg = useMemo(() => (section ? sectionLeg(route, section, reversed) : null), [reversed, route, section]);
const gapM = leg ? sectionGapM(planner.waypoints, leg) : null;
const gapOpen = gapM !== null && gapM > SECTION_JOINS_M;
const canAdopt = leg !== null && planner.canEdit && !planner.isSaving && (!gapOpen || joinGap);
const pick = (number: number) => {
setChosen(number);
setDirection(null);
setJoinGap(false);
};
const turn = (next: boolean) => {
setDirection(next);
setJoinGap(false);
};
const adopt = () => {
if (!canAdopt || !leg) return;
planner.editWaypoints((points) => withSectionAppended(points, leg, randomId));
onDone();
};
return {
section,
reversed,
leg,
gapM,
gapOpen,
joinGap,
canAdopt,
pick,
turn,
toggleJoinGap: () => setJoinGap((on) => !on),
adopt,
};
}
export type HorseSectionPick = ReturnType<typeof useHorseSectionPick>;
@@ -82,6 +82,7 @@ export function TourNameField({ planner, locked }: TourFieldProps) {
kayak: t('tours.planner.namePlaceholder.kayak'),
walk: t('tours.planner.namePlaceholder.walk'),
run: t('tours.planner.namePlaceholder.run'),
horse: t('tours.planner.namePlaceholder.horse'),
}[planner.tourType]
}
className={INPUT}
@@ -51,6 +51,7 @@ import { useTourListFilters } from '../useTourListFilters'
import { TourPoiSearchCard } from '../poi/TourPoiSearchCard'
import { BikeWaysCard } from '../bike/BikeWaysCard'
import { RunWaysCard } from '../run/RunWaysCard'
import { HorseRoutesCard } from '../horse/HorseRoutesCard'
import { walkTimeFields } from '../walk/walkTimeFields'
const STATUS_KEY: Partial<Record<TourPlannerStatus, string>> = {
@@ -464,6 +465,8 @@ export function TourPlannerRail({ planner,
<RunWaysCard planner={planner} />
<HorseRoutesCard planner={planner} />
<TourPoiSearchCard planner={planner} />
<PaddleEditorCards planner={planner} locked={locked} checklist={paddleChecklist} />
@@ -622,20 +625,23 @@ interface TourPlannerToursRailProps extends TourPermissionProps {
onTakenOver?: (tour: TourListItem) => Promise<void>
}
/** The words of the time estimate: walking, riding (BIK-01), running (RUN-01) or paddling, each saying how it is reckoned. */
/** The words of the time estimate: walking, riding (BIK-01), running (RUN-01) or paddling, each saying how it is reckoned, and a ride none. */
const ESTIMATE_KEYS = {
hike: { labelKey: 'tours.planner.walkingEstimated', shortKey: 'tours.planner.walkingEstimatedShort', infoLabelKey: 'tours.planner.walkingEstimatedInfoLabel', infoKey: 'tours.planner.walkingEstimatedInfo' },
bike: { labelKey: 'tours.bike.timeEstimated', shortKey: 'tours.bike.timeEstimatedShort', infoLabelKey: 'tours.bike.timeEstimatedInfoLabel', infoKey: 'tours.bike.timeEstimatedInfo' },
kayak: { labelKey: 'tours.paddle.timeEstimated', shortKey: 'tours.paddle.timeEstimatedShort', infoLabelKey: 'tours.paddle.timeEstimatedInfoLabel', infoKey: 'tours.paddle.timeEstimatedInfo' },
walk: { labelKey: 'tours.walk.timeEstimated', shortKey: 'tours.walk.timeEstimatedShort', infoLabelKey: 'tours.walk.timeEstimatedInfoLabel', infoKey: 'tours.walk.timeEstimatedInfo' },
run: { labelKey: 'tours.run.timeEstimated', shortKey: 'tours.run.timeEstimatedShort', infoLabelKey: 'tours.run.timeEstimatedInfoLabel', infoKey: 'tours.run.timeEstimatedInfo' },
// HOR-01: a ride has no estimate; the field says why, and the planned total is the rider's own.
horse: { labelKey: 'tours.horse.time', shortKey: 'tours.horse.time', infoLabelKey: 'tours.horse.timeInfoLabel', infoKey: 'tours.horse.timeInfo' },
} as const
/** Which ways a point is out of reach of: paths for hikes and city walks, ways for bikes, runnable ways. */
/** Which ways a point is out of reach of: paths for hikes, city walks and rides, ways for bikes, runnable ways. */
const UNREACHABLE_WAYS: Record<PlannableTourType, 'hike' | 'bike' | 'run'> = {
hike: 'hike',
walk: 'hike',
kayak: 'hike',
horse: 'hike',
bike: 'bike',
run: 'run',
}
@@ -24,9 +24,10 @@ function TypeTile({ type, active = false }: { type: ImportableTourType; active?:
/**
* What the tour is, as one field that opens the types with what each one
* plans: a hike on footpaths within its limit, a bike tour on its profile, a
* paddle tour on the water. Room for the types still to come without the row
* of buttons it was growing too long for. The planner offers the types it
* plans; the GPX import offers ski as well (SKI-02), a track it only keeps.
* paddle tour on the water, a ride along horse routes. Room for the types
* still to come without the row of buttons it was growing too long for. The
* planner offers the types it plans; the GPX import offers ski as well
* (SKI-02), a track it only keeps.
*/
export function TourTypeSelect<T extends ImportableTourType = PlannableTourType>({
value,
@@ -355,7 +355,8 @@ type WaypointPoint = TourPlannerController['waypoints'][number];
/**
* The tools for the leg after a point: drawn by hand or routed (on a paddle
* tour: on the water), planned again (P2A-05), and the point a stop or a
* shaping point (P2A-03; never the first or the last).
* shaping point (P2A-03; never the first or the last). A ride (HOR-01) has
* none: no router plans any of its legs, so there is nothing to switch.
*/
function routeMenuItems(
planner: TourPlannerController,
@@ -364,6 +365,7 @@ function routeMenuItems(
lastIndex: number,
t: Translate
): MenuItems {
if (planner.isHorse) return [];
const hasLeg = index < lastIndex;
const free = point.fixedLeg?.kind === 'free';
const routed = hasLeg && !point.fixedLeg;
@@ -5,6 +5,7 @@ import { useSettingsStore } from '../../../store/settingsStore';
import type { TourFerrataCrossing } from '../../../store/tourFerrataCrossingStore';
import { formatDistance } from '../../../utils/units';
import { FerrataGlyph } from '../../Ferratas/FerrataGlyph';
import { HorseLegRow } from '../horse/HorseLegRow';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { PaddleLegFailureRow, PaddleLegNotes } from '../paddle/PaddleLegParts';
@@ -23,9 +24,10 @@ type WaypointPoint = TourPlannerController['waypoints'][number];
/**
* A leg drawn by hand, as a badge on that leg in the dashed look the map draws
* it in: no router planned it, and the badge says so. Its length is the line
* the user drew, the only measure it has.
* the user drew, the only measure it has. On a ride (HOR-01) it is the manual
* stretch between horse routes, said to be unchecked.
*/
function FreeLegRow({ leg }: { leg: TourFixedLeg }) {
function FreeLegRow({ leg, ride }: { leg: TourFixedLeg; ride: boolean }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit);
// Read as plain numbers: this workspace compiles without strict mode, where the schema's tuples are "maybe two".
@@ -33,11 +35,11 @@ function FreeLegRow({ leg }: { leg: TourFixedLeg }) {
const distance = formatDistance(lineLengthM(line) / 1000, unit ?? 'metric');
return (
<li data-testid="tour-planner-free-leg" className="flex min-w-0 py-0.5 ps-[34px]">
<Tooltip label={t('tours.planner.freeLegInfo')}>
<Tooltip label={t(ride ? 'tours.horse.leg.manualInfo' : 'tours.planner.freeLegInfo')}>
<span className="flex h-[26px] w-full min-w-0 items-center gap-1.5 rounded-full border border-dashed border-edge bg-surface-card pe-1 ps-2">
<Spline size={13} strokeWidth={2} className="flex-none text-content-muted" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(11.5, 'body')}>
{t('tours.planner.freeLegBadge')}
{t(ride ? 'tours.horse.leg.manual' : 'tours.planner.freeLegBadge')}
</span>
<span
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist tabular-nums leading-[18px] text-content-secondary"
@@ -175,8 +177,9 @@ function LegFailure({
/**
* The rows under a waypoint for the leg that starts there: the via ferrata it
* crosses, a line drawn by hand, and on a paddle tour what the router found on
* the way (PAD-11) or why it could not plan it, plus the portage and lock rows.
* crosses, a line drawn by hand, a ride's horse route section (HOR-01), and
* on a paddle tour what the router found on the way (PAD-11) or why it could
* not plan it, plus the portage and lock rows.
*/
export function WaypointLegRows({
planner,
@@ -205,7 +208,8 @@ export function WaypointLegRows({
{crossings.map((crossing) => (
<FerrataLegRow key={`${crossing.id}-${crossing.fromM}`} crossing={crossing} />
))}
{point.fixedLeg?.kind === 'free' && <FreeLegRow leg={point.fixedLeg} />}
{point.fixedLeg?.kind === 'free' && <FreeLegRow leg={point.fixedLeg} ride={planner.isHorse} />}
{point.fixedLeg?.kind === 'horse_route' && <HorseLegRow leg={point.fixedLeg} />}
{routedLegs && <PaddleLegNotes leg={routedLegs.legs[index]} />}
{failure && (
<LegFailure
@@ -93,9 +93,11 @@ function runMinutes({ runPaceSecondsPerKm: pace, routeAnalysis }: TourPlannerFac
* pace, a bike tour by the router, which rides the profile at its speed and
* weighs the climbs, a hike by the signposts' rule over distance, climb and
* descent once the route has its heights, and by the router's own time before that.
* A ride (HOR-01) has none: its pace is the horse's and the rider's, so nothing is made up.
*/
function movingMinutes(input: TourPlannerFactsInput, routerMinutes: number | null): number | null {
const { paddleEstimate, routeAnalysis } = input;
if (input.tourType === 'horse') return null;
if (paddleEstimate) return paddleEstimate.waterMinutes === null ? null : paddleEstimate.estimateMinutes;
if (input.tourType === 'run') return runMinutes(input);
// A bike tour is timed by the router for its profile, a city walk by the router on foot.
@@ -192,9 +194,13 @@ export function tourPlannerFacts(input: TourPlannerFactsInput) {
automaticPlannedTotalMinutes > MAX_PLANNED_TOUR_DURATION_MINUTES,
// A city walk's stops are its stays: it saves no breaks, so a cafe never counts as a stay and a break at once.
savedBreakAdditionalMinutes: input.tourType === 'walk' ? null : breakAdditionalMinutes,
// The router's own time stands in for a hike without heights; a paddle tour has only its speed, a run its pace.
// The router's own time stands in for a hike without heights; a paddle tour has only its speed, a run its pace, a ride none.
savedDurationSeconds:
walkingDurationMinutes === null ? (isPaddle || isRun ? null : durationSeconds) : walkingDurationMinutes * 60,
walkingDurationMinutes === null
? isPaddle || isRun || input.tourType === 'horse'
? null
: durationSeconds
: walkingDurationMinutes * 60,
typeFields: typeFieldsOf(input),
};
}
@@ -7,6 +7,7 @@ import type {
TourWaypoint,
TourWaypointRole,
} from '@trek/shared';
import { rideLegs, withoutHorseRoutes } from '../horse/horseLegs';
import type { AutoBase } from './paddleRouting';
import { keepJoinedFixedLegs } from './tourFixedLegs';
import { alignFreeLegs } from './tourRouteActions';
@@ -100,7 +101,6 @@ export function unlinkedPoint<T extends TourPlannerWaypoint>(point: T): T {
return free as T;
}
/** Roles from the order; free legs follow their points, and a paddle tour keeps its portages and locks. */
/** BIK-01: a bike tour rides no via ferrata; a leg that followed one is planned by the router again. */
export function ridablePoints(points: TourPlannerWaypoint[]): TourPlannerWaypoint[] {
return points.some((point) => point.fixedLeg?.kind === 'ferrata')
@@ -108,8 +108,18 @@ export function ridablePoints(points: TourPlannerWaypoint[]): TourPlannerWaypoin
: points;
}
export function normalizePlannerWaypoints(points: TourPlannerWaypoint[], paddle = false): TourPlannerWaypoint[] {
return keepJoinedFixedLegs(alignFreeLegs(points, paddle)).map((point, index) => ({
/**
* Roles from the order; free legs follow their points, and a paddle tour keeps
* its portages and locks. A ride (HOR-01) has every leg on a horse route or
* drawn by hand; any other type gives a horse route section back to the router.
*/
export function normalizePlannerWaypoints(
points: TourPlannerWaypoint[],
tourType: PlannableTourType = 'hike'
): TourPlannerWaypoint[] {
const joined = keepJoinedFixedLegs(alignFreeLegs(points, tourType === 'kayak'));
const typed = tourType === 'horse' ? rideLegs(joined) : withoutHorseRoutes(joined);
return typed.map((point, index) => ({
...point,
role: index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'via',
}));
@@ -117,7 +127,10 @@ export function normalizePlannerWaypoints(points: TourPlannerWaypoint[], paddle
function fixedLegKey(leg: TourFixedLeg | null | undefined): string {
if (!leg) return '';
return leg.kind === 'ferrata' ? `:${leg.ref}` : `:${leg.kind}:${leg.geometry.length}`;
if (leg.kind === 'ferrata') return `:${leg.ref}`;
// A section taken the other way round has the same ref and length: its first point tells them apart.
if (leg.kind === 'horse_route') return `:${leg.ref}:${leg.geometry.length}:${leg.geometry[0]?.join(',')}`;
return `:${leg.kind}:${leg.geometry.length}`;
}
/** What the route depends on: every point, where it is, the fixed line after it and whether it only shapes. */
@@ -1,4 +1,4 @@
import { isClosedLoop, type PaddleLock, type TourFixedLeg } from '@trek/shared';
import { isClosedLoop, turnedSignposted, type PaddleLock, type TourFixedLeg } from '@trek/shared';
/**
* The route tools of the tour planner as plain list operations: reversing the
@@ -67,12 +67,20 @@ export function closeLoop<T extends RoutePoint>(points: readonly T[], newId: str
return [...points, { ...start, id: newId } as T];
}
/** A fixed leg run the other way: its line turned round, and a horse route section's signposting with it. */
function turnedLeg(leg: TourFixedLeg): TourFixedLeg {
const geometry = [...leg.geometry].reverse();
if (leg.kind !== 'horse_route') return { ...leg, geometry };
return { ...leg, geometry, signposted: turnedSignposted(leg.signposted) };
}
/**
* The same tour the other way round: the last point starts it, the stops run
* backwards, and a fixed leg (a via ferrata, a hand-drawn stretch) stays on the
* stretch it covered, its line turned round with it. A loop stays a loop from
* the same spot, and its start stays the station there: the way back is no
* station, so the start's key, name and stay stay first (CW-02, CW-03).
* backwards, and a fixed leg (a via ferrata, a hand-drawn stretch, a section of
* a horse route) stays on the stretch it covered, its line turned round with
* it. A loop stays a loop from the same spot, and its start stays the station
* there: the way back is no station, so the start's key, name and stay stay
* first (CW-02, CW-03).
*/
export function reverseRoute<T extends RoutePoint>(points: readonly T[]): T[] {
const reversed = points.map((_, index) => {
@@ -82,7 +90,7 @@ export function reverseRoute<T extends RoutePoint>(points: readonly T[]): T[] {
const { fixedLeg: _old, ...rest } = point;
const leg = before?.fixedLeg;
if (!leg || index === points.length - 1) return rest as T;
return { ...rest, fixedLeg: { ...leg, geometry: [...leg.geometry].reverse() } } as T;
return { ...rest, fixedLeg: turnedLeg(leg) } as T;
});
if (!isClosedLoop(points)) return reversed;
const start = stationSplit(reversed[0]);
@@ -252,14 +260,15 @@ export function setWaypointLock<T extends RoutePoint>(points: readonly T[], id:
/**
* The leg after this waypoint drawn by hand, or routed again (on a paddle
* tour: on the water). A via ferrata and a portage keep their line; the last
* point has no leg to change.
* tour: on the water). A via ferrata, a portage and a horse route section keep
* their line; the last point has no leg to change.
*/
export function toggleFreeLeg<T extends RoutePoint>(points: readonly T[], id: string): T[] {
const index = points.findIndex((point) => point.id === id);
const point = points[index];
const next = points[index + 1];
if (!point || !next || point.fixedLeg?.kind === 'ferrata' || isPortage(point.fixedLeg)) return [...points];
const kind = point?.fixedLeg?.kind;
if (!point || !next || kind === 'ferrata' || kind === 'horse_route' || isPortage(point.fixedLeg)) return [...points];
const copy = [...points];
if (point.fixedLeg?.kind === 'free') {
const { fixedLeg: _dropped, ...rest } = point;
@@ -23,7 +23,8 @@ export interface TourRouteResult {
/** The same line cut where it changes between routed and hand-drawn, for the map to tell them apart. */
parts: TourRoutePart[]
distanceMeters: number
durationSeconds: number
/** Null where no time is worked out at all: a ride (HOR-01). */
durationSeconds: number | null
}
/**
@@ -167,6 +168,28 @@ export async function routeBikeTour(
return routeOnLand(waypoints, 'cycling', BIKE_COSTING[profile], signal)
}
/**
* HOR-01: a ride, put together from its legs as they are: a section of a horse
* route keeps its own line, a leg drawn by hand is sampled along its length so
* its heights follow the ground. No router is asked, nothing between the legs
* is filled in, and no riding time is worked out. Null while a leg has no line
* of its own, which a ride never keeps.
*/
export function rideTour(waypoints: LandWaypoint[]): TourRouteResult | null {
const lines: TourRoutePart[] = []
for (const point of waypoints.slice(0, -1)) {
const leg = point.fixedLeg
if (!leg) return null
const drawn = leg.geometry.map(([lat, lng]): [number, number] => [lat, lng])
const section = leg.kind === 'horse_route'
lines.push({ coordinates: section ? drawn : densifyLine(drawn), free: !section })
}
if (lines.length === 0) return null
const coordinates = lines.flatMap((line, index) => (index === 0 ? line.coordinates : line.coordinates.slice(1)))
const distanceMeters = lines.reduce((sum, line) => sum + lineLengthM(line.coordinates), 0)
return { coordinates, parts: mergeParts(lines), distanceMeters, durationSeconds: null }
}
async function routeOnLand(
waypoints: LandWaypoint[],
mode: 'walking' | 'cycling',
@@ -201,6 +224,8 @@ async function routeOnLand(
run.push(waypoints[i + 1])
continue
}
// A section of a horse route is a ride's (HOR-01), which no router plans and no clock times.
if (leg.kind === 'horse_route') return null
if (!(await routeRun(run, runStart))) return unreachable
const drawn = leg.geometry.map(([lat, lng]): [number, number] => [lat, lng])
// A hand-drawn leg is sampled along its length, so its heights follow the ground between its ends.
@@ -6,10 +6,13 @@ import type { TourPlannerController } from './useTourPlanner';
* P2A-03: a click on the route being edited sets a shaping point where it
* landed, in the leg that runs there, and selects it, ready to be dragged
* where the route should go. Undefined while the points cannot be moved, or
* while a click on the line picks a spot for something else.
* while a click on the line picks a spot for something else, and on a ride
* (HOR-01): no router shapes it, and a point set into a horse route section
* would cut the section off its line.
*/
export function useShapingClick(
planner: Pick<TourPlannerController, 'waypoints' | 'route' | 'insertShapingPoint' | 'setSelectedWaypointId'>,
planner: Pick<TourPlannerController, 'waypoints' | 'route' | 'insertShapingPoint' | 'setSelectedWaypointId'> &
Partial<Pick<TourPlannerController, 'isHorse'>>,
{ editable, picking }: { editable: boolean; picking: boolean }
): ((lat: number, lng: number) => void) | undefined {
const { waypoints, route, insertShapingPoint, setSelectedWaypointId } = planner;
@@ -22,5 +25,5 @@ export function useShapingClick(
},
[insertShapingPoint, route, setSelectedWaypointId, waypoints]
);
return editable && !picking ? shapeAt : undefined;
return editable && !picking && !planner.isHorse ? shapeAt : undefined;
}
@@ -14,7 +14,7 @@ export function useTourDraftStorage(tripId: number | string) {
try {
const parsed = JSON.parse(localStorage.getItem(storageKey) || 'null') as unknown;
return validDraft(parsed)
? { ...parsed, waypoints: normalizePlannerWaypoints(parsed.waypoints, parsed.tourType === 'kayak') }
? { ...parsed, waypoints: normalizePlannerWaypoints(parsed.waypoints, parsed.tourType) }
: null;
} catch {
return null;
@@ -0,0 +1,179 @@
import { act, renderHook } from '@testing-library/react';
import type { HorseRoute } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { routeWalkingTour, routeBikeTour, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(
() => ({
routeWalkingTour: vi.fn(),
routeBikeTour: vi.fn(),
enrichTourElevations: vi.fn(),
createTour: vi.fn(),
detailTour: vi.fn(),
updateTour: vi.fn(),
})
);
// The ride is put together by the real rideTour; only a router asked by mistake and the heights are stand-ins.
vi.mock('./tourRouting', async (importOriginal) => ({
...(await importOriginal<typeof import('./tourRouting')>()),
routeWalkingTour,
routeBikeTour,
enrichTourElevations,
}));
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit: true, canAssign: true }) }));
import { sectionLeg, withSectionAppended } from '../horse/horseSection';
import { useTourPlanner } from './useTourPlanner';
// FE-TOUR-HORSE-001 to FE-TOUR-HORSE-005
const route: HorseRoute = {
id: 'relation/42',
name: 'Reitweg Isartal',
ref: 'R1',
osmUrl: 'https://www.openstreetmap.org/relation/42',
continues: false,
sections: [
{
number: 1,
geometry: [
[48, 11],
[48.005, 11.005],
[48.01, 11.01],
],
length_m: 1360,
access: [{ value: 'designated', length_m: 1360 }],
signposted: 'with',
},
],
};
const section = sectionLeg(route, route.sections[0], false);
const heights = (line: [number, number][]) =>
line.map(([lat, lng], index): [number, number, number] => [lat, lng, 500 + index]);
async function settle() {
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
}
function rideWithSection(tripId: number) {
const hook = renderHook(() => useTourPlanner({ tripId }));
let next = 0;
const newId = () => `section-point-${(next += 1)}`;
act(() => hook.result.current.setTourType('horse'));
act(() => hook.result.current.editWaypoints((points) => withSectionAppended(points, section, newId)));
return hook;
}
describe('useTourPlanner, rides on horseback (HOR-01)', () => {
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
routeWalkingTour.mockReset();
routeBikeTour.mockReset();
enrichTourElevations.mockReset().mockImplementation(async (line: [number, number][]) => heights(line));
createTour.mockReset();
detailTour.mockReset();
updateTour.mockReset();
});
afterEach(() => vi.useRealTimers());
it('FE-TOUR-HORSE-001: a ride is its sections and manual legs as drawn, with heights and no riding time', async () => {
const { result } = rideWithSection(61);
act(() => result.current.addWaypoint(48.02, 11.02));
expect(result.current.isHorse).toBe(true);
expect(result.current.waypoints.map((point) => point.fixedLeg?.kind ?? null)).toEqual([
'horse_route',
'free',
null,
]);
await settle();
expect(routeWalkingTour).not.toHaveBeenCalled();
expect(result.current.status).toBe('ready');
expect(result.current.routeParts?.map((part) => part.free)).toEqual([false, true]);
expect(result.current.route?.[0]).toEqual([48, 11]);
expect(result.current.durationSeconds).toBeNull();
expect(result.current.walkingDurationMinutes).toBeNull();
expect(result.current.plannedTotalMinutes).toBeNull();
});
it('FE-TOUR-HORSE-002: saves the ride with the origin of each leg and no riding time worked out', async () => {
createTour.mockResolvedValue({ tour: { place_id: 9, tour_type: 'horse', revision: 1 }, waypoints: [] });
const { result } = rideWithSection(62);
act(() => result.current.addWaypoint(48.02, 11.02));
act(() => result.current.setName('Isar ride'));
await settle();
expect(result.current.canSave).toBe(true);
await act(async () => {
await result.current.save();
});
const body = createTour.mock.calls[0]?.[1];
expect(body).toMatchObject({
tour_type: 'horse',
duration_seconds: null,
max_hiking_difficulty: null,
bike_profile: null,
});
expect(body.waypoints.map((point: { fixed_leg?: { kind: string } }) => point.fixed_leg?.kind ?? null)).toEqual([
'horse_route',
'free',
null,
]);
expect(body.waypoints[0].fixed_leg).toMatchObject({ ref: 'relation/42', signposted: 'with' });
});
it('FE-TOUR-HORSE-003: a routed leg turns manual on a ride, a section goes back to the router on a hike', () => {
const { result } = renderHook(() => useTourPlanner({ tripId: 63 }));
act(() => {
result.current.addWaypoint(47.99, 10.99);
result.current.addWaypoint(48, 11);
});
expect(result.current.waypoints[0].fixedLeg).toBeUndefined();
act(() => result.current.setTourType('horse'));
expect(result.current.waypoints[0].fixedLeg?.kind).toBe('free');
// The hike ended where the section starts: the section goes on from that point.
act(() => result.current.editWaypoints((points) => withSectionAppended(points, section, () => 'section-end')));
expect(result.current.waypoints.map((point) => point.fixedLeg?.kind ?? null)).toEqual([
'free',
'horse_route',
null,
]);
act(() => result.current.setTourType('hike'));
expect(result.current.waypoints[1].fixedLeg ?? null).toBeNull();
act(() => result.current.undo());
expect(result.current.waypoints[1].fixedLeg?.kind).toBe('horse_route');
});
it('FE-TOUR-HORSE-004: a point at a section stays put, one between manual legs moves', () => {
const { result } = rideWithSection(64);
act(() => result.current.addWaypoint(48.02, 11.02));
const [start, end, last] = result.current.waypoints;
let moved = true;
act(() => {
moved = result.current.setWaypointPosition(end.id, 48.011, 11.011);
});
expect(moved).toBe(false);
expect(result.current.waypoints[1]).toMatchObject({ lat: 48.01, lng: 11.01 });
expect(result.current.setWaypointPosition(start.id, 47.9, 11)).toBe(false);
act(() => {
moved = result.current.setWaypointPosition(last.id, 48.03, 11.03);
});
expect(moved).toBe(true);
expect(result.current.waypoints[1].fixedLeg?.geometry).toEqual([
[48.01, 11.01],
[48.03, 11.03],
]);
});
it('FE-TOUR-HORSE-005: turning the ride round turns its section and its signposting round with it', () => {
const { result } = rideWithSection(65);
act(() => result.current.reverse());
const leg = result.current.waypoints[0].fixedLeg;
expect(leg?.kind).toBe('horse_route');
expect(leg?.geometry[0]).toEqual([48.01, 11.01]);
expect(leg?.kind === 'horse_route' ? leg.signposted : null).toBe('against');
});
});
@@ -5,6 +5,7 @@ import { tourRepo } from '../../../repo/tourRepo'
import { randomId } from '../../../utils/randomId'
import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/routeGeometry'
import { closeLoop, reverseRoute, toggleFreeLeg, withPointAppended } from './tourRouteActions'
import { holdsHorseSection } from '../horse/horseLegs'
import { walkTimes } from './stationModel'
import { hasCompleteElevation, useTourRoute } from './useTourRoute'
import { normalizePlannerWaypoints, plannerWaypointsOf, ridablePoints, routeInputsKey, sameWaypointSnapshot, savedWaypointsOf, unlinkedPoint, withSavedKeys, type TourPlannerWaypoint } from './tourPlannerWaypoints'
@@ -255,7 +256,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const changed = typeof nextValue === 'function' ? nextValue(current) : nextValue
const paddle = tourTypeRef.current === 'kayak'
// A point the router set that the paddler moves or edits is theirs from then on.
const next = normalizePlannerWaypoints(paddle ? adoptChangedPoints(current, changed) : changed, paddle)
const next = normalizePlannerWaypoints(paddle ? adoptChangedPoints(current, changed) : changed, tourTypeRef.current)
if (sameWaypointSnapshot(current, next)) return
recordStep(snapshot())
waypointsRef.current = next
@@ -281,7 +282,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
if (changed === waypointsRef.current) return
interruptRouting()
draftRevisionRef.current += 1
waypointsRef.current = normalizePlannerWaypoints(changed, tourTypeRef.current === 'kayak')
waypointsRef.current = normalizePlannerWaypoints(changed, tourTypeRef.current)
setWaypoints(waypointsRef.current)
setHasUnsavedChanges(true)
setStatus('dirty')
@@ -355,6 +356,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
if (!current) return false
if (current.lat === lat && current.lng === lng) return true
const points = waypointsRef.current
// HOR-01: a ride's point at a horse route section stays where the route has it.
if (tourTypeRef.current === 'horse' && holdsHorseSection(points, id)) return false
// In a loop the start and the way back are one spot: moving either moves both.
const loopEnds = isClosedLoop(points) && (id === points[0].id || id === points[points.length - 1].id)
? new Set([points[0].id, points[points.length - 1].id])
@@ -492,9 +495,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
/**
* PAD-01, BIK-01, RUN-01: the type, as one step of the history with the
* route, since a paddle tour draws every leg by hand and neither a bike tour
* nor a run takes a via ferrata. Nothing is put on a day by the choice itself.
* PAD-01, BIK-01, RUN-01, HOR-01: the type, as one step of the history with
* the route, since a paddle tour draws every leg by hand, neither a bike tour
* nor a run takes a via ferrata and a ride takes every leg from a horse route
* or by hand. Nothing is put on a day by the choice itself.
*/
const setTourType = useCallback((value: PlannableTourType) => {
if (!editPermissionRef.current || saveInFlightRef.current || value === tourTypeRef.current) return
@@ -505,7 +509,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
tourTypeRef.current = value
setTourTypeState(value)
const noFerrata = value === 'bike' || value === 'run'
const next = normalizePlannerWaypoints(noFerrata ? ridablePoints(waypointsRef.current) : waypointsRef.current, value === 'kayak')
const next = normalizePlannerWaypoints(noFerrata ? ridablePoints(waypointsRef.current) : waypointsRef.current, value)
waypointsRef.current = next
setWaypoints(next)
setHasUnsavedChanges(true)
@@ -632,7 +636,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
draft.points.map(({ lat, lng, name }) => ({
id: randomId(), lat, lng, role: 'via' as const, ...(name ? { name } : {}),
})),
type === 'kayak',
type,
)
setDraftSource(draft.source ?? null)
tourTypeRef.current = type
@@ -725,7 +729,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
interruptRouting()
const opened = openedTourFields(result.tour)
const nextWaypoints = normalizePlannerWaypoints(plannerWaypointsOf(result.waypoints, randomId), opened.tourType === 'kayak')
const nextWaypoints = normalizePlannerWaypoints(plannerWaypointsOf(result.waypoints, randomId), opened.tourType)
forgetDraft()
contextGenerationRef.current += 1
draftRevisionRef.current += 1
@@ -916,7 +920,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
return {
tripId, canEdit, canAssign,
name, setName, maxHikingDifficulty, setMaxHikingDifficulty, hikingPreferences, setHikingPreferences,
bikeProfile, setBikeProfile, isBike: tourType === 'bike', winterActivity, setWinterActivity, winterActivityChoice,
bikeProfile, setBikeProfile, isBike: tourType === 'bike', isHorse: tourType === 'horse', winterActivity, setWinterActivity, winterActivityChoice,
runProfile, setRunProfile, isRun: tourType === 'run', runPace, runPaceMissing, runPaceInvalid,
tourType, setTourType, isPaddle, paddle, paddleSpeedInvalid, paddleIncomplete,
description, setDescription, website, setWebsite, websiteInvalid,
@@ -28,6 +28,7 @@ import {
import { isTourRoute, type TourRouteUnreachable } from './tourRouteOutcome';
import {
enrichTourElevations,
rideTour,
routeBikeTour,
routeCityWalk,
routeRun,
@@ -111,7 +112,7 @@ async function routePaddle({ inputs, plannedKey, runKey, signal, stale, setPlann
const profile = { boat: paddleBoat ?? 'kayak', lakeStyle: paddleLakeStyle } as const;
const step = await planPaddleStep(waypoints, tripId, profile, signal, randomId);
if (stale()) return;
if ('points' in step) return inputs.patchWaypoints(normalizePlannerWaypoints(step.points, true));
if ('points' in step) return inputs.patchWaypoints(normalizePlannerWaypoints(step.points, 'kayak'));
const planned = step.route;
setPlanned({
route: planned.coordinates,
@@ -130,17 +131,19 @@ async function routePaddle({ inputs, plannedKey, runKey, signal, stale, setPlann
/**
* A route on land for the type of the draft: a bike tour and a run for their
* profile, a city walk, a hike within its limit.
* profile, a city walk, a hike within its limit, and a ride (HOR-01) put
* together from its sections, never routed.
*/
function routeLand(inputs: RouteRun['inputs'], signal: AbortSignal) {
async function routeLand(inputs: RouteRun['inputs'], signal: AbortSignal) {
const { waypoints, tourType, bikeProfile, runProfile, maxHikingDifficulty, hikingPreferences } = inputs;
if (tourType === 'bike') return routeBikeTour(waypoints, bikeProfile, signal);
if (tourType === 'run') return routeRun(waypoints, runProfile, signal);
if (tourType === 'walk') return routeCityWalk(waypoints, signal);
return routeWalkingTour(waypoints, signal, maxHikingDifficulty, hikingPreferences);
if (tourType === 'horse') return rideTour(waypoints);
if (tourType === 'bike') return await routeBikeTour(waypoints, bikeProfile, signal);
if (tourType === 'run') return await routeRun(waypoints, runProfile, signal);
if (tourType === 'walk') return await routeCityWalk(waypoints, signal);
return await routeWalkingTour(waypoints, signal, maxHikingDifficulty, hikingPreferences);
}
// A hike, a run or a bike tour on land, then the heights along it.
// A hike, a run, a city walk, a bike tour or a ride on land, then the heights along it.
async function routeOnLand({ inputs, plannedKey, runKey, signal, stale, setPlanned, setUnreachable }: RouteRun) {
const { setStatus, setError } = inputs;
setStatus('routing');
@@ -181,8 +184,10 @@ async function routeOnLand({ inputs, plannedKey, runKey, signal, stale, setPlann
* The route of the planner's draft, planned again a moment after its points
* or its choices change: a hike on foot within its limit, a bike tour and a
* run for their profile (BIK-01, RUN-01), a paddle tour on the water by the
* server's router. A tour on land then gets its heights; a later change makes an answer
* still on its way stale, and a failure keeps the last route that was planned.
* server's router, a ride put together from its horse route sections and
* manual legs (HOR-01). Every tour on land then gets its heights; a later
* change makes an answer still on its way stale, and a failure keeps the last
* route that was planned.
*/
export function useTourRoute(inputs: TourRouteInputs) {
const {
@@ -1,4 +1,5 @@
import { Activity, Bike, Footprints, Mountain, MountainSnow, Route, Waves, type LucideIcon } from 'lucide-react';
import { Horseshoe } from './horse/HorseshoeIcon';
export function tourDayRowIcon(type: string): LucideIcon {
switch (type) {
@@ -14,6 +15,8 @@ export function tourDayRowIcon(type: string): LucideIcon {
return Waves;
case 'ski':
return MountainSnow;
case 'horse':
return Horseshoe;
default:
return Route;
}
+5 -4
View File
@@ -46,10 +46,10 @@ export function withoutHeights<
/**
* What the time on the move is called: walking for a hike (in the wording the
* place asks for) and for a city walk (CW-02), riding for a bike tour,
* paddling for a paddle tour, and for a run (RUN-01) the running time it is
* planned with, said as planned wherever it shows, so it never reads as a
* time someone recorded.
* place asks for) and for a city walk (CW-02), riding for a bike tour and for
* a ride on horseback (HOR-01), paddling for a paddle tour, and for a run
* (RUN-01) the running time it is planned with, said as planned wherever it
* shows, so it never reads as a time someone recorded.
*/
export function movingTimeLabel(
tour: { tour_type?: string } | undefined,
@@ -59,5 +59,6 @@ export function movingTimeLabel(
if (tour?.tour_type === 'kayak') return t('tours.paddle.timeEstimatedShort');
if (tour?.tour_type === 'walk') return t('tours.walk.timeEstimatedShort');
if (tour?.tour_type === 'run') return t('tours.run.timeEstimated');
if (tour?.tour_type === 'horse') return t('tours.horse.time');
return tour?.tour_type === 'bike' ? t('tours.bike.timeEstimatedShort') : hikeLabel;
}
@@ -22,6 +22,8 @@ import type { TourBaseLayer } from '../Map/MapLayerSwitcher';
* pistes as an overlay (SKI-01).
* - Paddle: the standard map, whose water reads clearly, and satellite. No topo and
* no ferratas, those are hiking maps. Seamarks as an overlay.
* - Horse (HOR-01): the standard map, topo for the lie of the land and satellite for
* the tracks on the ground. No ferratas, a horse climbs none.
* - Any other type (switched off, or one this build does not plan): the standard map
* and satellite, no overlays.
*
@@ -53,6 +55,7 @@ const SKI: TourMapLayerSet = {
overlays: ['pistes'],
};
const PADDLE: TourMapLayerSet = { bases: ['default', 'satellite'], defaultBase: 'default', overlays: ['seamarks'] };
const HORSE: TourMapLayerSet = { bases: ['default', 'topo', 'satellite'], defaultBase: 'default', overlays: [] };
const OTHER: TourMapLayerSet = { bases: ['default', 'satellite'], defaultBase: 'default', overlays: [] };
/** `hikingMap`, `cycleMap`: the admin allows OpenHikingMap (MAP-01) and CyclOSM (BIK-06) on this install. */
@@ -65,6 +68,7 @@ export function tourMapLayersFor(
if (type === 'run') return RUN;
if (type === 'ski') return SKI;
if (type === 'kayak') return PADDLE;
if (type === 'horse') return HORSE;
return OTHER;
}
+2 -2
View File
@@ -30,8 +30,8 @@ export function TourGradeTile({ tour }: { tour: Pick<TourListItem, 'max_hiking_d
const { t } = useTranslation()
const level = tour.max_hiking_difficulty
const source = tourSource(tour)
// A tour without a grade (a paddle or a bike tour, a run, a ski track, an import) names its type instead; a hike keeps the mountain.
const type = tour.tour_type && ['kayak', 'bike', 'run', 'ski'].includes(tour.tour_type) ? tour.tour_type : 'hike'
// A tour without a grade (a paddle or a bike tour, a run, a ride, a ski track, an import) names its type instead; a hike keeps the mountain.
const type = tour.tour_type && ['kayak', 'bike', 'run', 'horse', 'ski'].includes(tour.tour_type) ? tour.tour_type : 'hike'
const label = level != null ? t(`tours.planner.difficulty.t${level}`) : type !== 'hike' ? t(`tourTypes.${type}`) : null
return (
<span className="relative flex-none">