mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
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:
@@ -0,0 +1,34 @@
|
||||
// FE-API-HORSE-001 to FE-API-HORSE-002
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { horseRoutesApi } from './horseRoutes';
|
||||
|
||||
const reply = { routes: [], truncated: false, source: 'openstreetmap', attribution: '© OpenStreetMap contributors' };
|
||||
const BBOX = '47.10000,11.20000,47.30000,11.50000';
|
||||
|
||||
describe('horseRoutesApi.list', () => {
|
||||
it('FE-API-HORSE-001: asks GET /api/trips/:tripId/tours/horse-routes with the view and returns the body', async () => {
|
||||
let url = '';
|
||||
server.use(
|
||||
http.get('/api/trips/7/tours/horse-routes', ({ request }) => {
|
||||
url = request.url;
|
||||
return HttpResponse.json(reply);
|
||||
})
|
||||
);
|
||||
|
||||
await expect(horseRoutesApi.list(7, BBOX)).resolves.toEqual(reply);
|
||||
expect(new URL(url).searchParams.get('bbox')).toBe(BBOX);
|
||||
});
|
||||
|
||||
it('FE-API-HORSE-002: no Overpass mirror answering (502) rejects', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/7/tours/horse-routes', () =>
|
||||
HttpResponse.json({ error: 'Horse route data is unavailable right now' }, { status: 502 })
|
||||
)
|
||||
);
|
||||
|
||||
await expect(horseRoutesApi.list(7, BBOX)).rejects.toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* How long the browser waits for GET /api/trips/:tripId/tours/horse-routes.
|
||||
* The server races the Overpass mirrors under its own deadline (25 s by
|
||||
* default) and answers 502 when none made it, so this sits just above it, as
|
||||
* the via ferrata layer's wait does.
|
||||
*/
|
||||
const HORSE_ROUTES_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* HOR-01 GET /api/trips/:tripId/tours/horse-routes: the signposted horse
|
||||
* routes OpenStreetMap knows inside one map view. The answer comes back
|
||||
* unparsed; repo/horseRouteRepo.ts checks it against the shared contract.
|
||||
*/
|
||||
export const horseRoutesApi = {
|
||||
list: (tripId: number | string, bbox: string, signal?: AbortSignal): Promise<unknown> =>
|
||||
apiClient
|
||||
.get(`/trips/${tripId}/tours/horse-routes`, { params: { bbox }, signal, timeout: HORSE_ROUTES_TIMEOUT_MS })
|
||||
.then((response) => response.data as unknown),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -28,6 +28,7 @@ import './index.css'
|
||||
import './styles/ferratas.css'
|
||||
import './styles/paddle.css'
|
||||
import './styles/bike.css'
|
||||
import './styles/horse.css'
|
||||
// Native HTML5 drag-and-drop never fires on touch input, so the planner's place /
|
||||
// day reordering was dead on Android and iOS. The `drag-drop-touch` polyfill synthesises
|
||||
// the standard drag events from touch gestures over draggable elements (#1265). It is
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { MapViewProps } from '../../components/Map/mapViewContract';
|
||||
import { useTourMapView } from '../../components/Tours/planner/useTourMapView';
|
||||
import { clearPaddleObstacles, publishPaddleObstacles, selectPaddleObstacle } from '../../store/paddleObstacleStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTourMapViewportStore } from '../../store/tourMapViewportStore';
|
||||
import { initialTourPlaceToolState, useTourPlaceToolStore } from '../../store/tourPlaceToolStore';
|
||||
import { TourPlannerMapStage, type TourPlannerMapStageProps } from './TourPlannerMapStage';
|
||||
|
||||
@@ -338,6 +339,8 @@ describe('TourPlannerMapStage', () => {
|
||||
renderStage(planner('view-saved'));
|
||||
const area = { south: 46.5, west: 11.7, north: 46.6, east: 11.9 };
|
||||
act(() => stage.flatMap.props?.onViewportChange?.(area));
|
||||
// HOR-01: the rail's horse route search looks in the same view.
|
||||
expect(useTourMapViewportStore.getState().bounds).toEqual(area);
|
||||
act(() => stage.flatMap.props?.ferrataLayer?.onToggle());
|
||||
|
||||
expect(vi.mocked(useTripFerratas).mock.lastCall?.slice(0, 3)).toEqual([7, area, true]);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { isRtlLanguage, useTranslation } from '../../i18n';
|
||||
import { selectPaddleObstacle, usePaddleObstacleStore } from '../../store/paddleObstacleStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { publishTourFerrataCrossings } from '../../store/tourFerrataCrossingStore';
|
||||
import { publishTourMapViewport } from '../../store/tourMapViewportStore';
|
||||
import { useFerrataAdopt } from './useFerrataAdopt';
|
||||
import { useTourMapPoints } from './useTourMapPoints';
|
||||
import { useTourStageLayers } from './useTourStageLayers';
|
||||
@@ -116,10 +117,13 @@ export function TourPlannerMapStage({
|
||||
(bounds: FerrataBbox, zoom: number) => {
|
||||
ferrataViewportChange(bounds);
|
||||
setMapZoom(zoom);
|
||||
// HOR-01: the rail looks for horse routes "in this view".
|
||||
publishTourMapViewport(bounds);
|
||||
onViewportChange?.(bounds);
|
||||
},
|
||||
[ferrataViewportChange, onViewportChange]
|
||||
);
|
||||
useEffect(() => () => publishTourMapViewport(null), []);
|
||||
// The rails beside the map warn about the stretches that climb a ferrata, and band them on the profile.
|
||||
const { t: translate, language } = useTranslation();
|
||||
const tourWaypoints = tourPlanner.mode.type === 'view-gpx' ? NO_POINTS : tourPlanner.waypoints;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// FE-REPO-HORSE-001 to FE-REPO-HORSE-003: the online-only read behind a ride's horse routes.
|
||||
import { HORSE_ROUTES_ATTRIBUTION } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { horseRoutesApi } from '../api/horseRoutes';
|
||||
import { isEffectivelyOffline } from '../sync/networkMode';
|
||||
import { horseRouteRepo } from './horseRouteRepo';
|
||||
import { OnlineOnlyError } from './onlineOnly';
|
||||
|
||||
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) }));
|
||||
vi.mock('../api/horseRoutes', () => ({ horseRoutesApi: { list: vi.fn() } }));
|
||||
|
||||
const BBOX = '47.10000,11.20000,47.30000,11.50000';
|
||||
const reply = {
|
||||
routes: [
|
||||
{
|
||||
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: [{ value: 'designated', length_m: 1112 }],
|
||||
signposted: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
source: 'openstreetmap',
|
||||
attribution: HORSE_ROUTES_ATTRIBUTION,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(false);
|
||||
vi.mocked(horseRoutesApi.list).mockResolvedValue(reply);
|
||||
});
|
||||
|
||||
describe('horseRouteRepo.list', () => {
|
||||
it('FE-REPO-HORSE-001: asks the api for the trip and view with the signal and returns the parsed answer', async () => {
|
||||
const signal = new AbortController().signal;
|
||||
await expect(horseRouteRepo.list(7, BBOX, signal)).resolves.toEqual(reply);
|
||||
expect(horseRoutesApi.list).toHaveBeenCalledWith(7, BBOX, signal);
|
||||
});
|
||||
|
||||
it('FE-REPO-HORSE-002: offline it refuses without making the request', async () => {
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(true);
|
||||
await expect(horseRouteRepo.list(7, BBOX, new AbortController().signal)).rejects.toBeInstanceOf(OnlineOnlyError);
|
||||
expect(horseRoutesApi.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-REPO-HORSE-003: an answer off the contract is refused, never shown or taken onto a ride', async () => {
|
||||
vi.mocked(horseRoutesApi.list).mockResolvedValue({ ...reply, routes: [{ ...reply.routes[0], id: 'way/42' }] });
|
||||
await expect(horseRouteRepo.list(7, BBOX, new AbortController().signal)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { horseRoutesResponseSchema, type HorseRoutesResponse } from '@trek/shared';
|
||||
import { horseRoutesApi } from '../api/horseRoutes';
|
||||
import { requireOnline } from './onlineOnly';
|
||||
|
||||
/**
|
||||
* HOR-01: the signposted horse routes in a map view, from OpenStreetMap
|
||||
* through the server. Online only on purpose: they are live map data asked
|
||||
* for by hand, not trip data, and a section the rider takes is kept with the
|
||||
* ride itself, so nothing here needs a copy on the device. Offline no request
|
||||
* is made at all; every answer is parsed against the shared contract rather
|
||||
* than cast, because its names and lines go on to be shown and saved.
|
||||
*/
|
||||
export const horseRouteRepo = {
|
||||
async list(tripId: number | string, bbox: string, signal: AbortSignal): Promise<HorseRoutesResponse> {
|
||||
requireOnline();
|
||||
return horseRoutesResponseSchema.parse(await horseRoutesApi.list(tripId, bbox, signal));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { FerrataBbox } from '@trek/shared';
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* The area the tour planner's map shows, as it last reported it, for the rails
|
||||
* beside it that look for something "in this view" (HOR-01's horse routes).
|
||||
* The map stage writes it; null while no planner map is on screen.
|
||||
*/
|
||||
export const useTourMapViewportStore = create<{ bounds: FerrataBbox | null }>(() => ({ bounds: null }));
|
||||
|
||||
export function publishTourMapViewport(bounds: FerrataBbox | null): void {
|
||||
useTourMapViewportStore.setState({ bounds });
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* ============================================================================
|
||||
Rides on horseback (HOR-01): the colour the sections of signposted horse
|
||||
routes are drawn in, on the maps and on the waypoint list's badges. Global,
|
||||
since the 2D, GL and 3D maps and the planner's cards all read the tokens.
|
||||
========================================================================== */
|
||||
|
||||
/* A saddle brown nothing else on the tour map uses, on a rim of the map's own white. */
|
||||
:root {
|
||||
--horse-route: #a16207;
|
||||
--horse-route-casing: #ffffff;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--horse-route: #f59e0b;
|
||||
--horse-route-casing: #0f172a;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useInAppNotificationStore } from '../../src/store/inAppNotificationStor
|
||||
import { usePaddleObstacleStore } from '../../src/store/paddleObstacleStore';
|
||||
import { usePermissionsStore } from '../../src/store/permissionsStore';
|
||||
import { useSettingsStore } from '../../src/store/settingsStore';
|
||||
import { useTourMapViewportStore } from '../../src/store/tourMapViewportStore';
|
||||
import { useTourMapVisibilityStore } from '../../src/store/tourMapVisibilityStore';
|
||||
import { useTrackMarkToolStore } from '../../src/store/trackMarkToolStore';
|
||||
import { useTripStore } from '../../src/store/tripStore';
|
||||
@@ -38,6 +39,7 @@ export const RESET_STORES: Record<string, ResettableStore> = {
|
||||
useInAppNotificationStore,
|
||||
usePermissionsStore,
|
||||
useTourMapVisibilityStore,
|
||||
useTourMapViewportStore,
|
||||
usePaddleObstacleStore,
|
||||
useTrackMarkToolStore,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ export function hikePlannerDefaults() {
|
||||
setTourType: vi.fn(),
|
||||
isPaddle: false,
|
||||
isBike: false,
|
||||
isHorse: false,
|
||||
bikeProfile: 'touring' as const,
|
||||
setBikeProfile: vi.fn(),
|
||||
// HIK-01: a hike without a winter label, as every hike starts.
|
||||
|
||||
@@ -146,8 +146,8 @@ export const FERRATA_TILE_PAYLOAD: JsonColumn<FerrataTilePayload | null> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* A tour leg taken as a fixed line (a via ferrata's own line from OSM) rather
|
||||
* than routed. A row that does not decode reads as a routed leg, which the
|
||||
* A tour leg taken as a fixed line (a via ferrata's own line from OSM, a
|
||||
* section of a horse route, a stretch drawn by hand) rather than routed. A row that does not decode reads as a routed leg, which the
|
||||
* editor then plans again.
|
||||
*/
|
||||
export const TOUR_WAYPOINT_FIXED_LEG: JsonColumn<TourFixedLeg | null> = {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
/**
|
||||
* HOR-01: a tour can be a ride on horseback. `horse` is a key of its own in
|
||||
* `tour_types`, enabled, with no routing profile: a ride is never routed, it
|
||||
* is made of sections of signposted horse routes and stretches the rider
|
||||
* draws by hand. Those live in `tour_waypoints.fixed_leg` like every other
|
||||
* fixed leg, so no column is added and no existing tour changes its meaning.
|
||||
* An instance that already defined the key keeps its own row.
|
||||
*/
|
||||
export class Migration20200101045600_a_tour_can_be_ridden_on_horseback extends Migration {
|
||||
override name = 'Migration20200101045600_a_tour_can_be_ridden_on_horseback';
|
||||
|
||||
override up(): void {
|
||||
this.addSql(`
|
||||
INSERT INTO tour_types (key, label_key, icon, color, routing_profile, is_sport, enabled, sort_order)
|
||||
VALUES ('horse', 'tourTypes.horse', 'Horseshoe', '#a16207', NULL, 1, 1, 7)
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
HORSE_ROUTE_SECTION_MAX_POINTS,
|
||||
haversineMetres,
|
||||
type HorseAccess,
|
||||
type HorseAccessShare,
|
||||
type HorseSignposted,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* HOR-01: a horse route's ways cut into the sections a rider can take whole.
|
||||
*
|
||||
* The member ways are read as a graph of their nodes: a section runs from a
|
||||
* node where the route ends, forks or meets itself to the next such node, so
|
||||
* a gap in the route ends a section and a fork starts new ones. Nothing is
|
||||
* joined across a gap, nothing is guessed about which branch is the "main"
|
||||
* one, and a section keeps the line of its ways point for point. Pure, so the
|
||||
* service and its tests share it.
|
||||
*/
|
||||
|
||||
/** One step of a member way between two of its nodes. */
|
||||
export interface RouteEdge {
|
||||
from: number;
|
||||
to: number;
|
||||
/** The `horse=*` value of the way the step is on. */
|
||||
access: HorseAccess;
|
||||
/** The way the route is signposted along the step: 1 from `from` to `to`, -1 the other way, 0 both or unsaid. */
|
||||
signed: 1 | -1 | 0;
|
||||
/** The place of its way among the route's members, which orders the sections. */
|
||||
order: number;
|
||||
}
|
||||
|
||||
/** A section as the route answer carries it, before it is numbered. */
|
||||
interface SectionLine {
|
||||
geometry: [number, number][];
|
||||
lengthM: number;
|
||||
access: HorseAccessShare[];
|
||||
signposted: HorseSignposted | null;
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface Step {
|
||||
edge: RouteEdge;
|
||||
/** Walked from `from` to `to`. */
|
||||
forward: boolean;
|
||||
}
|
||||
|
||||
interface Chain {
|
||||
nodes: number[];
|
||||
steps: Step[];
|
||||
}
|
||||
|
||||
/** The edges at every node, by index. */
|
||||
function incidence(edges: readonly RouteEdge[]): Map<number, number[]> {
|
||||
const at = new Map<number, number[]>();
|
||||
const add = (node: number, index: number) => {
|
||||
const list = at.get(node);
|
||||
if (list) list.push(index);
|
||||
else at.set(node, [index]);
|
||||
};
|
||||
edges.forEach((edge, index) => {
|
||||
add(edge.from, index);
|
||||
add(edge.to, index);
|
||||
});
|
||||
return at;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every edge in exactly one chain. A chain starts at a node that is no plain
|
||||
* through point (an end, a fork, a crossing) and goes on through nodes with
|
||||
* two edges until it reaches the next such node; what is left after that are
|
||||
* closed rings without any, each walked once round from where it is entered.
|
||||
*/
|
||||
function chainsOf(edges: readonly RouteEdge[]): Chain[] {
|
||||
const at = incidence(edges);
|
||||
const used = new Set<number>();
|
||||
const chains: Chain[] = [];
|
||||
const through = (node: number) => at.get(node)?.length === 2;
|
||||
const walk = (start: number, first: number) => {
|
||||
const chain: Chain = { nodes: [start], steps: [] };
|
||||
let node = start;
|
||||
let index: number | undefined = first;
|
||||
while (index !== undefined) {
|
||||
used.add(index);
|
||||
const edge = edges[index] as RouteEdge;
|
||||
const forward = edge.from === node;
|
||||
node = forward ? edge.to : edge.from;
|
||||
chain.steps.push({ edge, forward });
|
||||
chain.nodes.push(node);
|
||||
index = through(node) && node !== start ? at.get(node)?.find((next) => !used.has(next)) : undefined;
|
||||
}
|
||||
chains.push(chain);
|
||||
};
|
||||
for (const [node, list] of at) {
|
||||
if (through(node)) continue;
|
||||
for (const index of list) if (!used.has(index)) walk(node, index);
|
||||
}
|
||||
edges.forEach((edge, index) => {
|
||||
if (!used.has(index)) walk(edge.from, index);
|
||||
});
|
||||
return chains;
|
||||
}
|
||||
|
||||
/** The direction a run of steps is signposted in: one way along all of it, or null. */
|
||||
function signpostedOf(steps: readonly Step[]): HorseSignposted | null {
|
||||
const signs = new Set(steps.map(({ edge, forward }) => edge.signed * (forward ? 1 : -1)));
|
||||
if (signs.size !== 1 || signs.has(0)) return null;
|
||||
return signs.has(1) ? 'with' : 'against';
|
||||
}
|
||||
|
||||
/** A run of steps as a section: its line, its length, the access along it and its direction. */
|
||||
function toSection(nodes: readonly number[], steps: readonly Step[], coords: ReadonlyMap<number, [number, number]>) {
|
||||
const geometry = nodes.map((node) => coords.get(node) as [number, number]);
|
||||
const byValue = new Map<HorseAccess, number>();
|
||||
let lengthM = 0;
|
||||
steps.forEach(({ edge }, index) => {
|
||||
const [aLat, aLng] = geometry[index] as [number, number];
|
||||
const [bLat, bLng] = geometry[index + 1] as [number, number];
|
||||
const metres = haversineMetres(aLat, aLng, bLat, bLng);
|
||||
lengthM += metres;
|
||||
byValue.set(edge.access, (byValue.get(edge.access) ?? 0) + metres);
|
||||
});
|
||||
const access = [...byValue].map(([value, metres]) => ({ value, length_m: Math.round(metres) }));
|
||||
return {
|
||||
geometry,
|
||||
lengthM: Math.round(lengthM),
|
||||
access: access.sort((a, b) => b.length_m - a.length_m),
|
||||
signposted: signpostedOf(steps),
|
||||
order: Math.min(...steps.map(({ edge }) => edge.order)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The sections of a route out of its edges, in the order its ways are listed,
|
||||
* each at most HORSE_ROUTE_SECTION_MAX_POINTS points long: a longer chain is
|
||||
* cut into pieces that meet end to end. A section of no length is left out.
|
||||
* Every node an edge names must have its place in `coords`.
|
||||
*/
|
||||
export function routeSections(
|
||||
edges: readonly RouteEdge[],
|
||||
coords: ReadonlyMap<number, [number, number]>,
|
||||
): SectionLine[] {
|
||||
const perPiece = HORSE_ROUTE_SECTION_MAX_POINTS - 1;
|
||||
const sections: SectionLine[] = [];
|
||||
for (const { nodes, steps } of chainsOf(edges)) {
|
||||
for (let start = 0; start < steps.length; start += perPiece) {
|
||||
const piece = steps.slice(start, start + perPiece);
|
||||
sections.push(toSection(nodes.slice(start, start + piece.length + 1), piece, coords));
|
||||
}
|
||||
}
|
||||
return sections.filter((section) => section.lengthM > 0).sort((a, b) => a.order - b.order);
|
||||
}
|
||||
@@ -30,11 +30,14 @@ const SKI_ACTIVITY: Record<SkiDiscipline, string> = {
|
||||
touring: 'backcountry_skiing',
|
||||
};
|
||||
|
||||
/** The activity a ride names in GPX (HOR-01), in the words Garmin reads. */
|
||||
const HORSE_ACTIVITY = 'horseback_riding';
|
||||
|
||||
/**
|
||||
* The activity a tour's track names in GPX: a paddle tour its boat, a bike
|
||||
* tour and a run their profile, a ski track its discipline (SKI-02), a hike on
|
||||
* snowshoes snowshoeing (HIK-01) and any other hike hiking; whatever is
|
||||
* missing falls back to the plainest.
|
||||
* tour and a run their profile, a ski track its discipline (SKI-02), a ride
|
||||
* horseback riding (HOR-01), a hike on snowshoes snowshoeing (HIK-01) and any
|
||||
* other hike hiking; whatever is missing falls back to the plainest.
|
||||
*/
|
||||
export function gpxActivityOf(tour: {
|
||||
tour_type: string;
|
||||
@@ -49,6 +52,7 @@ export function gpxActivityOf(tour: {
|
||||
if (type === 'bike') return BIKE_ACTIVITY[(bike ?? 'touring') as BikeProfile] ?? BIKE_ACTIVITY.touring;
|
||||
if (type === 'run') return RUN_ACTIVITY[(tour.run_profile ?? 'running') as RunProfile] ?? RUN_ACTIVITY.running;
|
||||
if (type === 'ski') return SKI_ACTIVITY[tour.ski_discipline as SkiDiscipline] ?? 'skiing';
|
||||
if (type === 'horse') return HORSE_ACTIVITY;
|
||||
if (type !== 'hike') return type;
|
||||
// A winter hike is walked as any hike is; only snowshoes make it an activity of its own.
|
||||
return tour.winter_activity === 'snowshoe' ? 'snowshoeing' : 'hiking';
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { AddonGuard } from '../addons/addon.guard';
|
||||
import { RequireAddon } from '../addons/require-addon.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { badRequest } from '../common/domain-error';
|
||||
import { ResponseContract } from '../common/response-contract';
|
||||
import { TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { TourHorseRoutesService } from './tour-horse-routes.service';
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
FERRATA_BBOX_INVALID_MESSAGE,
|
||||
FERRATA_BBOX_TOO_LARGE_MESSAGE,
|
||||
horseRoutesQuerySchema,
|
||||
horseRoutesResponseSchema,
|
||||
type HorseRoutesQuery,
|
||||
type HorseRoutesResponse,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The query through the shared contract, answered with one of two messages:
|
||||
* a box that is too large is a different fix for the client (zoom in) than
|
||||
* one that is malformed.
|
||||
*/
|
||||
export function parseHorseRoutesQuery(raw: Record<string, unknown>): HorseRoutesQuery {
|
||||
const parsed = horseRoutesQuerySchema.safeParse(raw);
|
||||
if (parsed.success) return parsed.data;
|
||||
const tooLarge = parsed.error.issues.some((issue) => issue.message === FERRATA_BBOX_TOO_LARGE_MESSAGE);
|
||||
throw badRequest(tooLarge ? FERRATA_BBOX_TOO_LARGE_MESSAGE : FERRATA_BBOX_INVALID_MESSAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* HOR-01 GET /api/trips/:tripId/tours/horse-routes?bbox=south,west,north,east:
|
||||
* the signposted horse routes OpenStreetMap knows inside a map view, cut into
|
||||
* the sections a horse tour can take. A read, so anyone who sees the trip may
|
||||
* ask; the box is the caller's view and nothing about the trip goes out.
|
||||
* Declared before the tours' `:placeId` routes, which would read the path as
|
||||
* a tour.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/tours')
|
||||
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
|
||||
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
|
||||
export class TourHorseRoutesController {
|
||||
constructor(private readonly horseRoutes: TourHorseRoutesService) {}
|
||||
|
||||
@Get('horse-routes')
|
||||
@ResponseContract(horseRoutesResponseSchema)
|
||||
async list(@Query() raw: Record<string, unknown>): Promise<HorseRoutesResponse> {
|
||||
return await this.horseRoutes.list(parseHorseRoutesQuery(raw).bbox);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { routeSections, type RouteEdge } from './horse-route-sections';
|
||||
import {
|
||||
HORSE_ROUTE_SECTIONS_CAP,
|
||||
HORSE_ROUTES_RESULT_CAP,
|
||||
type FerrataBbox,
|
||||
type HorseAccess,
|
||||
type HorseRoute,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* HOR-01, the pure half of the horse route lookup: the Overpass query, the
|
||||
* box it asks for, and the routes read out of the elements it answers with.
|
||||
* Kept free of I/O so the service and its tests share it.
|
||||
*/
|
||||
|
||||
/** How long Overpass itself may work on one query, in seconds (`[timeout:N]`). */
|
||||
const HORSE_ROUTES_QUERY_TIMEOUT_S = 25;
|
||||
|
||||
/** The most member ways one query asks for; one more is asked, which is how a cut answer is told. */
|
||||
export const HORSE_ROUTE_WAYS_CAP = 4000;
|
||||
|
||||
/** The grid a box is grown outwards to, so nearby views share one answer. */
|
||||
const GRID_DEG = 0.05;
|
||||
const NAME_MAX = 255;
|
||||
const REF_MAX = 64;
|
||||
|
||||
const snapDown = (v: number) => Math.floor(v / GRID_DEG + 1e-9) * GRID_DEG;
|
||||
const snapUp = (v: number) => Math.ceil(v / GRID_DEG - 1e-9) * GRID_DEG;
|
||||
const fixed = (v: number) => Number(v.toFixed(2));
|
||||
|
||||
/** The box grown outwards to the 0.05 degree grid and clamped to the globe. */
|
||||
export function snapHorseRouteBbox(bbox: FerrataBbox): FerrataBbox {
|
||||
return {
|
||||
south: fixed(Math.max(-90, snapDown(bbox.south))),
|
||||
west: fixed(Math.max(-180, snapDown(bbox.west))),
|
||||
north: fixed(Math.min(90, snapUp(bbox.north))),
|
||||
east: fixed(Math.min(180, snapUp(bbox.east))),
|
||||
};
|
||||
}
|
||||
|
||||
/** The key a box is remembered under: its snapped edges. */
|
||||
export function horseRouteBboxKey(bbox: FerrataBbox): string {
|
||||
return [bbox.south, bbox.west, bbox.north, bbox.east].join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* The `route=horse` relations that reach into the box, with their members,
|
||||
* and those of their ways that lie in it, with their tags, nodes and line. A
|
||||
* way crossing the edge comes whole; a member way outside the box is left out
|
||||
* and the route is said to go on.
|
||||
*/
|
||||
export function buildHorseRoutesQuery(bbox: FerrataBbox): string {
|
||||
const box = `(${bbox.south},${bbox.west},${bbox.north},${bbox.east})`;
|
||||
return [
|
||||
`[out:json][timeout:${HORSE_ROUTES_QUERY_TIMEOUT_S}];`,
|
||||
`rel["route"="horse"]${box}->.routes;`,
|
||||
`way(r.routes)${box}->.ways;`,
|
||||
`.routes out body qt ${HORSE_ROUTES_RESULT_CAP + 1};`,
|
||||
`.ways out body geom qt ${HORSE_ROUTE_WAYS_CAP + 1};`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const osmId = z.number().int().positive();
|
||||
const tags = z.record(z.string(), z.string()).optional();
|
||||
const point = z.object({ lat: z.number().min(-90).max(90), lon: z.number().min(-180).max(180) });
|
||||
|
||||
const wayElementSchema = z.object({
|
||||
type: z.literal('way'),
|
||||
id: osmId,
|
||||
tags,
|
||||
nodes: z.array(osmId).max(20000),
|
||||
// A point Overpass could not place comes back as null; its steps are left out.
|
||||
geometry: z.array(point.nullable()).max(20000),
|
||||
});
|
||||
const relationElementSchema = z.object({
|
||||
type: z.literal('relation'),
|
||||
id: osmId,
|
||||
tags,
|
||||
members: z.array(z.object({ type: z.string(), ref: z.number().int(), role: z.string().optional() })).max(20000),
|
||||
});
|
||||
const elementSchema = z.discriminatedUnion('type', [wayElementSchema, relationElementSchema]);
|
||||
type WayElement = z.infer<typeof wayElementSchema>;
|
||||
type RelationElement = z.infer<typeof relationElementSchema>;
|
||||
|
||||
const KNOWN_ACCESS = new Set<HorseAccess>([
|
||||
'designated',
|
||||
'yes',
|
||||
'permissive',
|
||||
'destination',
|
||||
'discouraged',
|
||||
'private',
|
||||
'no',
|
||||
]);
|
||||
|
||||
/** `horse=*` as tagged: a common value as it is, any other `other`, none `unknown`. */
|
||||
export function horseAccessOf(tag: string | undefined): HorseAccess {
|
||||
const value = tag?.trim().toLowerCase() ?? '';
|
||||
if (value === '') return 'unknown';
|
||||
return KNOWN_ACCESS.has(value as HorseAccess) ? (value as HorseAccess) : 'other';
|
||||
}
|
||||
|
||||
/** A member's role as the direction the route is signposted along its way: 1 with it, -1 against, 0 both. */
|
||||
function signOfRole(role: string | undefined): 1 | -1 | 0 {
|
||||
if (role === 'forward') return 1;
|
||||
if (role === 'backward') return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function cleanText(value: string | undefined, max: number): string | null {
|
||||
const text = value?.trim() ?? '';
|
||||
return text === '' ? null : text.slice(0, max);
|
||||
}
|
||||
|
||||
/** The steps of one member way, each between two placed nodes, and the places of its nodes. */
|
||||
function wayEdges(way: WayElement, role: string | undefined, order: number, coords: Map<number, [number, number]>) {
|
||||
const access = horseAccessOf(way.tags?.horse);
|
||||
const signed = signOfRole(role);
|
||||
const edges: RouteEdge[] = [];
|
||||
way.nodes.forEach((node, index) => {
|
||||
const place = way.geometry[index];
|
||||
if (place) coords.set(node, [place.lat, place.lon]);
|
||||
const next = way.nodes[index + 1];
|
||||
const nextPlace = way.geometry[index + 1];
|
||||
if (next === undefined || !place || !nextPlace || next === node) return;
|
||||
edges.push({ from: node, to: next, access, signed, order });
|
||||
});
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* One relation as a route: its sections out of the member ways the answer
|
||||
* holds, each way once (the first time the route lists it), and whether it
|
||||
* has member ways the answer does not hold. Null when none of its ways is in
|
||||
* the box, so there is nothing of it to take; `cut` when it had more sections
|
||||
* than one answer carries.
|
||||
*/
|
||||
function toRoute(
|
||||
relation: RelationElement,
|
||||
ways: ReadonlyMap<number, WayElement>,
|
||||
): { route: HorseRoute; cut: boolean } | null {
|
||||
const coords = new Map<number, [number, number]>();
|
||||
const edges: RouteEdge[] = [];
|
||||
const seen = new Set<number>();
|
||||
let continues = false;
|
||||
relation.members.forEach((member, order) => {
|
||||
if (member.type !== 'way' || seen.has(member.ref)) return;
|
||||
seen.add(member.ref);
|
||||
const way = ways.get(member.ref);
|
||||
if (!way) {
|
||||
continues = true;
|
||||
return;
|
||||
}
|
||||
edges.push(...wayEdges(way, member.role, order, coords));
|
||||
});
|
||||
const sections = routeSections(edges, coords);
|
||||
if (sections.length === 0) return null;
|
||||
const route: HorseRoute = {
|
||||
id: `relation/${relation.id}`,
|
||||
name: cleanText(relation.tags?.name, NAME_MAX),
|
||||
ref: cleanText(relation.tags?.ref, REF_MAX),
|
||||
osmUrl: `https://www.openstreetmap.org/relation/${relation.id}`,
|
||||
continues,
|
||||
sections: sections.slice(0, HORSE_ROUTE_SECTIONS_CAP).map((section, index) => ({
|
||||
number: index + 1,
|
||||
geometry: section.geometry,
|
||||
length_m: section.lengthM,
|
||||
access: section.access,
|
||||
signposted: section.signposted,
|
||||
})),
|
||||
};
|
||||
return { route, cut: sections.length > HORSE_ROUTE_SECTIONS_CAP };
|
||||
}
|
||||
|
||||
/**
|
||||
* The routes out of the raw elements. An element of another shape is left
|
||||
* out rather than failing the whole answer. `truncated` says the answer was
|
||||
* cut: more relations or ways than were asked for, or a route with more
|
||||
* sections than one answer carries.
|
||||
*/
|
||||
export function toHorseRoutes(elements: unknown[]): { routes: HorseRoute[]; truncated: boolean } {
|
||||
const ways = new Map<number, WayElement>();
|
||||
const relations: RelationElement[] = [];
|
||||
for (const raw of elements) {
|
||||
const parsed = elementSchema.safeParse(raw);
|
||||
if (!parsed.success) continue;
|
||||
if (parsed.data.type === 'way') ways.set(parsed.data.id, parsed.data);
|
||||
else relations.push(parsed.data);
|
||||
}
|
||||
const cut = relations.length > HORSE_ROUTES_RESULT_CAP || ways.size > HORSE_ROUTE_WAYS_CAP;
|
||||
const routes: HorseRoute[] = [];
|
||||
let sectionsCut = false;
|
||||
for (const relation of relations.slice(0, HORSE_ROUTES_RESULT_CAP)) {
|
||||
const read = toRoute(relation, ways);
|
||||
if (!read) continue;
|
||||
sectionsCut ||= read.cut;
|
||||
routes.push(read.route);
|
||||
}
|
||||
return { routes, truncated: cut || sectionsCut };
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { OverpassElementsClient } from '../overpass/overpass-elements.service';
|
||||
import {
|
||||
buildHorseRoutesQuery,
|
||||
horseRouteBboxKey,
|
||||
snapHorseRouteBbox,
|
||||
toHorseRoutes,
|
||||
} from './tour-horse-routes.helpers';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
HORSE_ROUTES_ATTRIBUTION,
|
||||
HORSE_ROUTES_UNAVAILABLE_CODE,
|
||||
type FerrataBbox,
|
||||
type HorseRoutesResponse,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** How long an answer is kept: signposted routes change on the scale of seasons, the mirrors are shared. */
|
||||
export const HORSE_ROUTES_KEPT_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
/** The most answers kept at once; the one kept longest goes first. */
|
||||
const HORSE_ROUTES_KEPT_MAX = 200;
|
||||
|
||||
export const HORSE_ROUTES_UNAVAILABLE_MESSAGE = 'Horse route data is unavailable right now';
|
||||
|
||||
type Answer = Omit<HorseRoutesResponse, 'source' | 'attribution'>;
|
||||
|
||||
/**
|
||||
* HOR-01: the signposted horse routes around a map view, from OpenStreetMap
|
||||
* through the operator's Overpass mirrors. The view is grown to the 0.05
|
||||
* degree grid, so nearby views share one answer, and an answer is kept for
|
||||
* HORSE_ROUTES_KEPT_MS in memory; two requests for the same box at once share
|
||||
* one query. Read only: nothing is stored, and nothing about the trip is sent.
|
||||
*
|
||||
* When no mirror answers, the client gets a 502 `{ error, code }` it can show,
|
||||
* and whatever it had loaded before stays as it was.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TourHorseRoutesService {
|
||||
private readonly kept = new Map<string, { at: number; answer: Answer }>();
|
||||
private readonly pending = new Map<string, Promise<Answer>>();
|
||||
|
||||
constructor(private readonly overpass: OverpassElementsClient) {}
|
||||
|
||||
async list(bbox: FerrataBbox, now: number = Date.now()): Promise<HorseRoutesResponse> {
|
||||
const box = snapHorseRouteBbox(bbox);
|
||||
const key = horseRouteBboxKey(box);
|
||||
const known = this.kept.get(key);
|
||||
const answer = known && now - known.at < HORSE_ROUTES_KEPT_MS ? known.answer : await this.fetch(key, box, now);
|
||||
return { ...answer, source: 'openstreetmap', attribution: HORSE_ROUTES_ATTRIBUTION };
|
||||
}
|
||||
|
||||
/** One query for the box, shared with every request for it until it settles. */
|
||||
private fetch(key: string, box: FerrataBbox, now: number): Promise<Answer> {
|
||||
const running = this.pending.get(key);
|
||||
if (running) return running;
|
||||
const run = this.query(box).then((answer) => {
|
||||
this.keep(key, answer, now);
|
||||
return answer;
|
||||
});
|
||||
this.pending.set(key, run);
|
||||
const settle = () => this.pending.delete(key);
|
||||
void run.then(settle, settle);
|
||||
return run;
|
||||
}
|
||||
|
||||
private async query(box: FerrataBbox): Promise<Answer> {
|
||||
let elements: unknown[];
|
||||
try {
|
||||
elements = await this.overpass.query(buildHorseRoutesQuery(box), 'HorseRoutes');
|
||||
} catch {
|
||||
// The client already logged why each mirror failed.
|
||||
throw new DomainError(502, HORSE_ROUTES_UNAVAILABLE_MESSAGE, {
|
||||
code: HORSE_ROUTES_UNAVAILABLE_CODE,
|
||||
details: { code: HORSE_ROUTES_UNAVAILABLE_CODE },
|
||||
});
|
||||
}
|
||||
return toHorseRoutes(elements);
|
||||
}
|
||||
|
||||
private keep(key: string, answer: Answer, at: number): void {
|
||||
this.kept.delete(key);
|
||||
this.kept.set(key, { at, answer });
|
||||
// A map keeps its insertion order: the first key is the one kept longest.
|
||||
if (this.kept.size > HORSE_ROUTES_KEPT_MAX) this.kept.delete(this.kept.keys().next().value as string);
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export class ToursMcp {
|
||||
@Tool({
|
||||
name: 'list_tours',
|
||||
description:
|
||||
'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours and ski tracks planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, or ski for a ski track), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the winter label of a hike (winter_activity: winter_hike or snowshoe, null for none; a label the travellers chose, while the route and its time are planned as for any hike, never a statement that a way can be walked in winter or anything about snow or avalanches), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the classification of a ski track (ski_discipline: alpine, snowboard, cross_country or touring for ski touring; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a ski track is imported from a GPX file and kept as recorded, never routed, its distance and heights come from the file, it has no ski time worked out, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), the revision (raised by every saved change of the tour), and the days of the trip each tour is on.',
|
||||
'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours, rides on horseback and ski tracks planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, horse for a ride, or ski for a ski track; a ride has no riding time worked out, so its duration is null), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the winter label of a hike (winter_activity: winter_hike or snowshoe, null for none; a label the travellers chose, while the route and its time are planned as for any hike, never a statement that a way can be walked in winter or anything about snow or avalanches), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the classification of a ski track (ski_discipline: alpine, snowboard, cross_country or touring for ski touring; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a ski track is imported from a GPX file and kept as recorded, never routed, its distance and heights come from the file, it has no ski time worked out, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), the revision (raised by every saved change of the tour), and the days of the trip each tour is on.',
|
||||
inputSchema: { tripId: idSchema },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
@@ -100,7 +100,7 @@ export class ToursMcp {
|
||||
@Tool({
|
||||
name: 'get_tour_plan',
|
||||
description:
|
||||
'Read one saved tour of a trip: its facts, its ordered control points (start, via and end; an import without its own shows the two ends of its track), where it came from (provenance) and the days it is on. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. A control point whose fixed_leg is set leads to the next one along a line the router did not plan: kind ferrata follows a via ferrata, kind free is drawn by hand and was checked by nothing. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. A city walk (tour_type walk) has stations for control points, shaping points and the way back of a loop aside, each with every field spelled out: key (its own id, the same across saves; the visits of a day are kept by it), name, stay_minutes (the stay in minutes; 0 for a point only passed through, null when not known), importance (optional or must; null for a normal station), protected (true for a station any change you propose has to keep where it is: neither removed, replaced nor moved; the travellers can still edit it themselves in the app) and opening_hours (as OpenStreetMap writes them; a hint to check, never a promise; null when none is known). How far a walk got on a day is get_city_walk_progress. The full track geometry is not included.',
|
||||
'Read one saved tour of a trip: its facts, its ordered control points (start, via and end; an import without its own shows the two ends of its track), where it came from (provenance) and the days it is on. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. A control point whose fixed_leg is set leads to the next one along a line the router did not plan: kind ferrata follows a via ferrata, kind free is drawn by hand and was checked by nothing, kind horse_route (a ride only) follows a section of a signposted horse route from OpenStreetMap in the direction the rider chose (signposted: with or against the direction the route is signposted in, null where it is not signposted one way; access: the metres of each horse=* value its ways carried when it was taken, unknown where untagged). Being on a horse route is no permission to ride it. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. A city walk (tour_type walk) has stations for control points, shaping points and the way back of a loop aside, each with every field spelled out: key (its own id, the same across saves; the visits of a day are kept by it), name, stay_minutes (the stay in minutes; 0 for a point only passed through, null when not known), importance (optional or must; null for a normal station), protected (true for a station any change you propose has to keep where it is: neither removed, replaced nor moved; the travellers can still edit it themselves in the app) and opening_hours (as OpenStreetMap writes them; a hint to check, never a promise; null when none is known). How far a walk got on a day is get_city_walk_progress. The full track geometry is not included.',
|
||||
inputSchema: { tripId: idSchema, placeId: idSchema.describe('The place id of the tour, as list_tours gives it') },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
|
||||
@@ -19,6 +19,8 @@ import { WandererModule } from '../wanderer/wanderer.module';
|
||||
import { CityWalkBriefingService } from './city-walk-briefing.service';
|
||||
import { CityWalkMcp } from './city-walk.mcp';
|
||||
import { TourGpxExportService } from './tour-gpx-export.service';
|
||||
import { TourHorseRoutesController } from './tour-horse-routes.controller';
|
||||
import { TourHorseRoutesService } from './tour-horse-routes.service';
|
||||
import { TourLibraryController, TourTakeOverController } from './tour-library.controller';
|
||||
import { TourLibraryMcp } from './tour-library.mcp';
|
||||
import { TourLibraryService } from './tour-library.service';
|
||||
@@ -68,7 +70,8 @@ import { Module } from '@nestjs/common';
|
||||
McpSharedModule,
|
||||
// PLN-02 searches places along a tour through the map search.
|
||||
MapsModule,
|
||||
// BIK-05 reads the trail grades along a mountain bike tour from OpenStreetMap.
|
||||
// BIK-05 reads the trail grades along a mountain bike tour from OpenStreetMap,
|
||||
// HOR-01 the signposted horse routes around a ride.
|
||||
OverpassModule,
|
||||
AddonsModule,
|
||||
// The wanderer import reads trails through the caller's own connection.
|
||||
@@ -88,6 +91,7 @@ import { Module } from '@nestjs/common';
|
||||
]),
|
||||
],
|
||||
controllers: [
|
||||
TourHorseRoutesController,
|
||||
TourTakeOverController,
|
||||
TourPoiSearchController,
|
||||
TourSkiController,
|
||||
@@ -106,6 +110,7 @@ import { Module } from '@nestjs/common';
|
||||
TourPaddleCheckService,
|
||||
TourObstacleReviewsService,
|
||||
TourGpxExportService,
|
||||
TourHorseRoutesService,
|
||||
ToursWandererService,
|
||||
TourLibraryService,
|
||||
TourPoiSearchService,
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Tours e2e for rides on horseback (HOR-01): a horse tour saved, read, copied
|
||||
* and exported with the origin of each of its legs, and the horse routes
|
||||
* around a map view, through the real JwtAuthGuard, AddonGuard and
|
||||
* TripAccessGuard against a migrated and seeded temp SQLite db. Overpass is a
|
||||
* container stub, the permission check a container spy and the broadcast goes
|
||||
* to a FakeRealtimeService, as in tours-bike.e2e.test.ts.
|
||||
*
|
||||
* Fixtures: user 1 owns trip 5, user 2 is a member of it whom the permission
|
||||
* spy denies 'place_edit', user 3 has no access.
|
||||
*/
|
||||
import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
|
||||
import { Places } from '../../src/db/entities/Places.entity';
|
||||
import { Tours } from '../../src/db/entities/Tours.entity';
|
||||
import { Trips } from '../../src/db/entities/Trips.entity';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
|
||||
import { MapsService } from '../../src/nest/maps/maps.service';
|
||||
import { OverpassElementsClient } from '../../src/nest/overpass/overpass-elements.service';
|
||||
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
||||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||||
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
|
||||
import { HORSE_ROUTES_UNAVAILABLE_MESSAGE } from '../../src/nest/tours/tour-horse-routes.service';
|
||||
import { ToursModule } from '../../src/nest/tours/tours.module';
|
||||
import { createSnapshotTestDb } from '../helpers/db-mock';
|
||||
import { countRows, deleteRows, findRow, insertRows } from '../helpers/factories/rows';
|
||||
import { setAddonEnabled } from '../helpers/factories/settings';
|
||||
import { addTripMember } from '../helpers/factories/trips';
|
||||
import { makeUser } from '../helpers/factories/users';
|
||||
import { FakeRealtimeService } from '../helpers/fake-realtime';
|
||||
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
||||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||||
import { sessionCookie } from './harness';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { FERRATA_BBOX_TOO_LARGE_MESSAGE, HORSE_ROUTES_ATTRIBUTION } from '@trek/shared';
|
||||
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
|
||||
|
||||
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
|
||||
LOG_LEVEL: 'error',
|
||||
logInfo: vi.fn(),
|
||||
logDebug: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
logWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
const { journeyHooks } = vi.hoisted(() => ({
|
||||
journeyHooks: {
|
||||
reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined),
|
||||
onPlaceCreated: vi.fn().mockResolvedValue(undefined),
|
||||
onPlaceUpdated: vi.fn().mockResolvedValue(undefined),
|
||||
onPlaceDeleted: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
// The horse routes come from OpenStreetMap through Overpass: no network in a test.
|
||||
const overpassQuery = vi.fn();
|
||||
const realtime = new FakeRealtimeService();
|
||||
|
||||
let orm: TestOrm;
|
||||
let checkPermission: MockInstance;
|
||||
|
||||
const OWNER = 1;
|
||||
const VIEWER = 2;
|
||||
const OUTSIDER = 3;
|
||||
|
||||
const GEOMETRY = [
|
||||
[47, 11, 600],
|
||||
[47.005, 11.005, 640],
|
||||
[47.01, 11.01, 700],
|
||||
[47.02, 11.02, 650],
|
||||
];
|
||||
const SECTION = {
|
||||
kind: 'horse_route',
|
||||
ref: 'relation/42',
|
||||
name: 'Reitweg Isartal',
|
||||
geometry: [
|
||||
[47, 11],
|
||||
[47.005, 11.005],
|
||||
[47.01, 11.01],
|
||||
],
|
||||
signposted: 'with',
|
||||
access: [
|
||||
{ value: 'designated', length_m: 900 },
|
||||
{ value: 'unknown', length_m: 450 },
|
||||
],
|
||||
};
|
||||
const DRAWN = {
|
||||
kind: 'free',
|
||||
geometry: [
|
||||
[47.01, 11.01],
|
||||
[47.02, 11.02],
|
||||
],
|
||||
};
|
||||
const rideBody = (overrides: Record<string, unknown> = {}) => ({
|
||||
name: 'Ausritt am Fluss',
|
||||
tour_type: 'horse',
|
||||
route_geometry: GEOMETRY,
|
||||
waypoints: [
|
||||
{ lat: 47, lng: 11, role: 'start', sequence: 0, fixed_leg: SECTION },
|
||||
{ lat: 47.01, lng: 11.01, role: 'via', sequence: 1, fixed_leg: DRAWN },
|
||||
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 2 },
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
const GPX = `<?xml version="1.0"?>
|
||||
<gpx version="1.1" creator="e2e"><trk><name>Recorded ride</name><trkseg>
|
||||
<trkpt lat="47" lon="11"><ele>600</ele></trkpt>
|
||||
<trkpt lat="47.01" lon="11.01"><ele>640</ele></trkpt>
|
||||
</trkseg></trk></gpx>`;
|
||||
const overpassAnswer = [
|
||||
{
|
||||
type: 'relation',
|
||||
id: 42,
|
||||
tags: { route: 'horse', name: 'Reitweg Isartal', ref: 'R1' },
|
||||
members: [
|
||||
{ type: 'way', ref: 1, role: '' },
|
||||
{ type: 'way', ref: 2, role: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'way',
|
||||
id: 1,
|
||||
tags: { horse: 'designated' },
|
||||
nodes: [1, 2],
|
||||
geometry: [
|
||||
{ lat: 47.15, lon: 11.25 },
|
||||
{ lat: 47.16, lon: 11.25 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe('Horse tours e2e (real guards + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [
|
||||
await TestUnitOfWorkModule.forRoot(db),
|
||||
await createTestMikroOrmModule(db),
|
||||
RealtimeModule,
|
||||
ToursModule,
|
||||
],
|
||||
})
|
||||
.overrideProvider(RealtimeService)
|
||||
.useValue(realtime)
|
||||
.overrideProvider(JourneyDomainService)
|
||||
.useValue(journeyHooks)
|
||||
.overrideProvider(MapsService)
|
||||
.useValue({ pois: vi.fn() })
|
||||
.overrideProvider(OverpassElementsClient)
|
||||
.useValue({ query: overpassQuery })
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalPipes(new ZodValidationPipe());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
orm = await createTestOrm(db);
|
||||
// Pinned ids: the session cookies sign for exactly these users.
|
||||
await makeUser(orm, { id: 1, username: 'owner', email: 'owner@example.test', role: 'user', password_version: 0 });
|
||||
await makeUser(orm, { id: 2, username: 'viewer', email: 'viewer@example.test', role: 'user', password_version: 0 });
|
||||
await makeUser(orm, {
|
||||
id: 3,
|
||||
username: 'outsider',
|
||||
email: 'outsider@example.test',
|
||||
role: 'user',
|
||||
password_version: 0,
|
||||
});
|
||||
await insertRows(orm, Trips, [{ id: 5, user: 1, title: 'Isar' }]);
|
||||
await addTripMember(orm, 5, 2);
|
||||
app = await build();
|
||||
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
checkPermission.mockImplementation(
|
||||
(action: string, _role: string, _owner: number, userId: number) =>
|
||||
!(action === 'place_edit' && userId === VIEWER),
|
||||
);
|
||||
await setAddonEnabled(orm, 'tours', true);
|
||||
realtime.broadcastMock.mockClear();
|
||||
overpassQuery.mockReset();
|
||||
await deleteRows(orm, DayAssignments);
|
||||
await deleteRows(orm, Places);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await orm.close();
|
||||
});
|
||||
|
||||
const send = (method: 'post' | 'put', path: string, body: Record<string, unknown>, userId = OWNER) =>
|
||||
request(server)[method](`/api/trips/5/tours${path}`).set('Cookie', sessionCookie(userId)).send(body);
|
||||
const create = async (body: Record<string, unknown> = rideBody()) => {
|
||||
const res = await send('post', '', body);
|
||||
expect(res.status).toBe(201);
|
||||
return res.body.tour.place_id as number;
|
||||
};
|
||||
|
||||
describe('a ride and the origin of its legs', () => {
|
||||
it('saves a ride, lists it without a riding time and broadcasts the change', async () => {
|
||||
const placeId = await create();
|
||||
expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({
|
||||
tour_type: 'horse',
|
||||
duration: null,
|
||||
max_hiking_difficulty: null,
|
||||
bike_profile: null,
|
||||
});
|
||||
const list = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(VIEWER));
|
||||
expect(list.status).toBe(200);
|
||||
const tour = list.body.tours.find((item: { place_id: number }) => item.place_id === placeId);
|
||||
expect(tour).toMatchObject({ tour_type: 'horse', duration: null });
|
||||
// The heights of its line are read like any other tour's; only a time is never worked out.
|
||||
expect(tour.elevation_gain).toBeGreaterThan(0);
|
||||
expect(realtime.broadcastMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens again with each leg as taken, a section with its direction and access', async () => {
|
||||
const placeId = await create();
|
||||
const res = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tour.tour_type).toBe('horse');
|
||||
expect(res.body.waypoints.map((point: { fixed_leg?: unknown }) => point.fixed_leg ?? null)).toEqual([
|
||||
SECTION,
|
||||
DRAWN,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses a routed leg, a via ferrata, a riding time worked out and a section on a hike', async () => {
|
||||
const ferrata = { kind: 'ferrata', ref: 'way/1', name: null, geometry: SECTION.geometry };
|
||||
const routed = rideBody({
|
||||
waypoints: [
|
||||
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 47.01, lng: 11.01, role: 'end', sequence: 1 },
|
||||
],
|
||||
});
|
||||
const bodies = [
|
||||
routed,
|
||||
rideBody({
|
||||
waypoints: [
|
||||
{ lat: 47, lng: 11, role: 'start', sequence: 0, fixed_leg: ferrata },
|
||||
{ lat: 47.01, lng: 11.01, role: 'end', sequence: 1 },
|
||||
],
|
||||
}),
|
||||
rideBody({ duration_seconds: 5400 }),
|
||||
rideBody({ tour_type: 'hike', max_hiking_difficulty: 2 }),
|
||||
];
|
||||
const statuses = await bodies.reduce<Promise<number[]>>(
|
||||
async (done, body) => [...(await done), (await send('post', '', body)).status],
|
||||
Promise.resolve([]),
|
||||
);
|
||||
expect(statuses).toEqual([400, 400, 400, 400]);
|
||||
expect(await countRows(orm, Tours)).toBe(0);
|
||||
});
|
||||
|
||||
it('a member who may only read cannot save one', async () => {
|
||||
expect((await send('post', '', rideBody(), VIEWER)).status).toBe(403);
|
||||
});
|
||||
|
||||
it('a copy keeps the type and the legs', async () => {
|
||||
const placeId = await create();
|
||||
const copy = await send('post', `/${placeId}/copy`, { name: 'Noch einmal' });
|
||||
expect(copy.status).toBe(201);
|
||||
expect(copy.body.tour).toMatchObject({ tour_type: 'horse' });
|
||||
expect(copy.body.waypoints[0].fixed_leg).toEqual(SECTION);
|
||||
});
|
||||
|
||||
it('exports a ride as horseback riding', async () => {
|
||||
const placeId = await create();
|
||||
const res = await request(server)
|
||||
.get(`/api/trips/5/tours/${placeId}/export.gpx`)
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.buffer(true)
|
||||
.parse((response, done) => {
|
||||
let text = '';
|
||||
response.setEncoding('utf8');
|
||||
response.on('data', (chunk: string) => (text += chunk));
|
||||
response.on('end', () => done(null, text));
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(String(res.body)).toContain('<type>horseback_riding</type>');
|
||||
});
|
||||
|
||||
it('imports a recorded ride as a ride, with nothing of another type', async () => {
|
||||
const res = await request(server)
|
||||
.post('/api/trips/5/tours/import/gpx')
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.field('tour_type', 'horse')
|
||||
.attach('file', Buffer.from(GPX), { filename: 'ride.gpx', contentType: 'application/gpx+xml' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.tours[0]).toMatchObject({ tour_type: 'horse', origin: 'gpx', duration: null, bike_profile: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET horse-routes', () => {
|
||||
const ask = (bbox: string | undefined, userId?: number) => {
|
||||
const req = request(server).get('/api/trips/5/tours/horse-routes');
|
||||
const withQuery = bbox === undefined ? req : req.query({ bbox });
|
||||
return userId ? withQuery.set('Cookie', sessionCookie(userId)) : withQuery;
|
||||
};
|
||||
|
||||
it('answers the routes in the view, cut into sections, to anyone who sees the trip', async () => {
|
||||
overpassQuery.mockResolvedValueOnce(overpassAnswer);
|
||||
const res = await ask('47.12,11.21,47.18,11.29', VIEWER);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
truncated: false,
|
||||
source: 'openstreetmap',
|
||||
attribution: HORSE_ROUTES_ATTRIBUTION,
|
||||
});
|
||||
expect(res.body.routes).toEqual([
|
||||
expect.objectContaining({ id: 'relation/42', name: 'Reitweg Isartal', ref: 'R1', continues: true }),
|
||||
]);
|
||||
expect(res.body.routes[0].sections[0]).toMatchObject({
|
||||
number: 1,
|
||||
signposted: null,
|
||||
access: [{ value: 'designated', length_m: 1112 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('says when no mirror answered, and refuses a box too large, an outsider and no session', async () => {
|
||||
overpassQuery.mockRejectedValueOnce(new Error('all mirrors down'));
|
||||
const failed = await ask('48.12,11.21,48.18,11.29', OWNER);
|
||||
expect(failed.status).toBe(502);
|
||||
expect(failed.body).toEqual({ error: HORSE_ROUTES_UNAVAILABLE_MESSAGE, code: 'horse_routes_unavailable' });
|
||||
const wide = await ask('46,11,47,11.4', OWNER);
|
||||
expect(wide.status).toBe(400);
|
||||
expect(wide.body).toEqual({ error: FERRATA_BBOX_TOO_LARGE_MESSAGE });
|
||||
expect((await ask('47.12,11.21,47.18,11.29', OUTSIDER)).status).toBe(404);
|
||||
expect((await ask('47.12,11.21,47.18,11.29')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('is not there while the Tours addon is off', async () => {
|
||||
await setAddonEnabled(orm, 'tours', false);
|
||||
expect((await ask('47.12,11.21,47.18,11.29', OWNER)).status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,7 @@ const UNNUMBERED = [
|
||||
'Migration20200101045500_a_hike_can_be_marked_as_a_winter_activity',
|
||||
'Migration20200101045510_a_tour_can_be_a_run',
|
||||
'Migration20200101045520_a_tour_can_be_a_ski_track',
|
||||
'Migration20200101045600_a_tour_can_be_ridden_on_horseback',
|
||||
];
|
||||
// The last step the positional runner ever had; planLegacyBaseline refuses a
|
||||
// schema_version past it.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* `Migration20200101045600_a_tour_can_be_ridden_on_horseback` (HOR-01):
|
||||
* `tour_types` gains the enabled `horse` key with no routing profile, the
|
||||
* other types stay as they were, and an instance that already defined the key
|
||||
* keeps its own row.
|
||||
*/
|
||||
import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step';
|
||||
import type { MikroORM } from '@mikro-orm/sqlite';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const TARGET = 'Migration20200101045600_a_tour_can_be_ridden_on_horseback';
|
||||
|
||||
async function ormBeforeTarget(): Promise<MikroORM> {
|
||||
const orm = await createMigrationOrm();
|
||||
const names = await pendingNames(orm);
|
||||
const index = names.indexOf(TARGET);
|
||||
expect(index).toBeGreaterThan(0);
|
||||
await migrateTo(orm, names[index - 1]);
|
||||
return orm;
|
||||
}
|
||||
|
||||
describe('Tour horse type migration', () => {
|
||||
it('TOURHORSEMIG-001: adds the enabled horse type without a routing profile and leaves the others alone', async () => {
|
||||
const orm = await ormBeforeTarget();
|
||||
try {
|
||||
const before = await rawQuery(orm, "SELECT key, enabled FROM tour_types WHERE key <> 'horse' ORDER BY key");
|
||||
expect(await rawQuery(orm, "SELECT key FROM tour_types WHERE key = 'horse'")).toEqual([]);
|
||||
|
||||
await migrateTo(orm, TARGET);
|
||||
|
||||
expect(
|
||||
await rawQuery(
|
||||
orm,
|
||||
"SELECT key, label_key, routing_profile, is_sport, enabled, sort_order FROM tour_types WHERE key = 'horse'",
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
key: 'horse',
|
||||
label_key: 'tourTypes.horse',
|
||||
routing_profile: null,
|
||||
is_sport: 1,
|
||||
enabled: 1,
|
||||
sort_order: 7,
|
||||
},
|
||||
]);
|
||||
expect(await rawQuery(orm, "SELECT key, enabled FROM tour_types WHERE key <> 'horse' ORDER BY key")).toEqual(
|
||||
before,
|
||||
);
|
||||
} finally {
|
||||
await orm.close(true);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it('TOURHORSEMIG-002: a horse type an instance already defined keeps its own values', async () => {
|
||||
const orm = await ormBeforeTarget();
|
||||
try {
|
||||
await rawExec(
|
||||
orm,
|
||||
"INSERT INTO tour_types (key, label_key, icon, color, routing_profile, enabled, sort_order) VALUES ('horse', 'tourTypes.horse', 'Own', '#000000', NULL, 0, 9)",
|
||||
);
|
||||
|
||||
await migrateTo(orm, TARGET);
|
||||
|
||||
expect(await rawQuery(orm, "SELECT icon, enabled, sort_order FROM tour_types WHERE key = 'horse'")).toEqual([
|
||||
{ icon: 'Own', enabled: 0, sort_order: 9 },
|
||||
]);
|
||||
} finally {
|
||||
await orm.close(true);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
@@ -41,7 +41,8 @@ describe('Tour types migration', () => {
|
||||
orm,
|
||||
'SELECT key, enabled, label_key FROM tour_types ORDER BY sort_order',
|
||||
);
|
||||
expect(types.map((t) => t.key)).toEqual([...tourTypeKeySchema.options]);
|
||||
// The keys the contract named then; `horse` came later with a migration of its own (HOR-01).
|
||||
expect(types.map((t) => t.key)).toEqual(tourTypeKeySchema.options.filter((key) => key !== 'horse'));
|
||||
expect(types.filter((t) => t.enabled === 1).map((t) => t.key)).toEqual(['hike']);
|
||||
for (const t of types) expect(t.label_key).toBe(`tourTypes.${t.key}`);
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { routeSections, type RouteEdge } from '../../../src/nest/tours/horse-route-sections';
|
||||
import { HORSE_ROUTE_SECTION_MAX_POINTS, type HorseAccess } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// HORSE-SECT-001 to HORSE-SECT-007
|
||||
|
||||
/** Node n lies n thousandths of a degree north of 47° N, about 111 m apart. */
|
||||
const coords = (count: number) =>
|
||||
new Map<number, [number, number]>(Array.from({ length: count }, (_, n) => [n + 1, [47 + n * 0.001, 11]]));
|
||||
|
||||
/** The steps of a way along the nodes given, in that order. */
|
||||
function way(nodes: number[], order: number, extra: { access?: HorseAccess; signed?: 1 | -1 | 0 } = {}): RouteEdge[] {
|
||||
return nodes.slice(1).map((to, index) => ({
|
||||
from: nodes[index] as number,
|
||||
to,
|
||||
access: extra.access ?? 'unknown',
|
||||
signed: extra.signed ?? 0,
|
||||
order,
|
||||
}));
|
||||
}
|
||||
|
||||
const firstPoints = (sections: ReturnType<typeof routeSections>) =>
|
||||
sections.map((section) => [section.geometry[0], section.geometry[section.geometry.length - 1]]);
|
||||
|
||||
describe('routeSections (HOR-01)', () => {
|
||||
it('HORSE-SECT-001: ways that meet end to end are one section, with the access of each along it', () => {
|
||||
const sections = routeSections(
|
||||
[...way([1, 2, 3], 0, { access: 'designated' }), ...way([3, 4, 5], 1, { access: 'private' })],
|
||||
coords(5),
|
||||
);
|
||||
expect(sections).toHaveLength(1);
|
||||
expect(sections[0]?.geometry).toEqual([
|
||||
[47, 11],
|
||||
[47.001, 11],
|
||||
[47.002, 11],
|
||||
[47.003, 11],
|
||||
[47.004, 11],
|
||||
]);
|
||||
expect(sections[0]?.lengthM).toBeCloseTo(445, -1);
|
||||
expect(sections[0]?.access.map((share) => share.value).sort()).toEqual(['designated', 'private']);
|
||||
expect(sections[0]?.access.reduce((sum, share) => sum + share.length_m, 0)).toBeCloseTo(445, -1);
|
||||
expect(sections[0]?.signposted).toBeNull();
|
||||
});
|
||||
|
||||
it('HORSE-SECT-002: a fork ends a section, and each branch is one of its own', () => {
|
||||
const sections = routeSections([...way([1, 2, 3], 0), ...way([3, 4], 1), ...way([3, 5], 2)], coords(5));
|
||||
expect(sections).toHaveLength(3);
|
||||
for (const section of sections) {
|
||||
const ends = [section.geometry[0], section.geometry[section.geometry.length - 1]];
|
||||
expect(ends).toContainEqual([47.002, 11]);
|
||||
}
|
||||
});
|
||||
|
||||
it('HORSE-SECT-003: a gap stays a gap, never joined by a line of its own', () => {
|
||||
const sections = routeSections([...way([1, 2], 0), ...way([4, 5], 1)], coords(5));
|
||||
expect(firstPoints(sections)).toEqual([
|
||||
[
|
||||
[47, 11],
|
||||
[47.001, 11],
|
||||
],
|
||||
[
|
||||
[47.003, 11],
|
||||
[47.004, 11],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('HORSE-SECT-004: a ring without a fork is walked once round', () => {
|
||||
const ring = new Map<number, [number, number]>([
|
||||
[1, [47, 11]],
|
||||
[2, [47.001, 11]],
|
||||
[3, [47.001, 11.001]],
|
||||
]);
|
||||
const sections = routeSections(way([1, 2, 3, 1], 0), ring);
|
||||
expect(sections).toHaveLength(1);
|
||||
expect(sections[0]?.geometry).toHaveLength(4);
|
||||
expect(sections[0]?.geometry[0]).toEqual(sections[0]?.geometry[3]);
|
||||
});
|
||||
|
||||
it('HORSE-SECT-005: says the signposted direction only where all of the section is signposted one way', () => {
|
||||
expect(routeSections(way([1, 2, 3], 0, { signed: 1 }), coords(3))[0]?.signposted).toBe('with');
|
||||
expect(routeSections(way([1, 2, 3], 0, { signed: -1 }), coords(3))[0]?.signposted).toBe('against');
|
||||
const mixed = [...way([1, 2], 0, { signed: 1 }), ...way([2, 3], 1)];
|
||||
expect(routeSections(mixed, coords(3))[0]?.signposted).toBeNull();
|
||||
// A way listed against the walk still reads the right way round.
|
||||
const turned = [...way([1, 2], 0, { signed: 1 }), ...way([3, 2], 1, { signed: -1 })];
|
||||
expect(routeSections(turned, coords(3))[0]?.signposted).toBe('with');
|
||||
});
|
||||
|
||||
it('HORSE-SECT-006: a stretch longer than a fixed leg takes is cut into pieces that meet end to end', () => {
|
||||
const count = HORSE_ROUTE_SECTION_MAX_POINTS + 10;
|
||||
const nodes = Array.from({ length: count }, (_, n) => n + 1);
|
||||
const places = new Map<number, [number, number]>(nodes.map((n) => [n, [47 + n * 0.00001, 11]]));
|
||||
const sections = routeSections(way(nodes, 0), places);
|
||||
expect(sections.map((section) => section.geometry.length)).toEqual([HORSE_ROUTE_SECTION_MAX_POINTS, 11]);
|
||||
expect(sections[1]?.geometry[0]).toEqual(sections[0]?.geometry[HORSE_ROUTE_SECTION_MAX_POINTS - 1]);
|
||||
});
|
||||
|
||||
it('HORSE-SECT-007: sections follow the order the route lists its ways, and one of no length is left out', () => {
|
||||
const places = new Map<number, [number, number]>([
|
||||
[1, [47, 11]],
|
||||
[2, [47.001, 11]],
|
||||
[3, [47.1, 11]],
|
||||
[4, [47.101, 11]],
|
||||
[5, [47.2, 11]],
|
||||
[6, [47.2, 11]],
|
||||
]);
|
||||
const sections = routeSections([...way([3, 4], 0), ...way([1, 2], 1), ...way([5, 6], 2)], places);
|
||||
expect(sections.map((section) => section.order)).toEqual([0, 1]);
|
||||
expect(sections[0]?.geometry[0]).toEqual([47.1, 11]);
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,9 @@ import { gpxActivityOf } from '../../../src/nest/tours/tour-gpx-export.helpers';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// TOUR-GPX-ACTIVITY-001 to TOUR-GPX-ACTIVITY-005
|
||||
// TOUR-GPX-ACTIVITY-001 to TOUR-GPX-ACTIVITY-007
|
||||
|
||||
describe('gpxActivityOf (PR1-06A, BIK-01, HIK-01, RUN-01, SKI-02)', () => {
|
||||
describe('gpxActivityOf (PR1-06A, BIK-01, HIK-01, RUN-01, SKI-02, HOR-01)', () => {
|
||||
it('TOUR-GPX-ACTIVITY-001: a bike tour names its profile, and the plainest riding without a known one', () => {
|
||||
expect(gpxActivityOf({ tour_type: 'bike', bike_profile: 'mtb' })).toBe('mountain_biking');
|
||||
expect(gpxActivityOf({ tour_type: 'bike', bike_profile: null })).toBe('cycling');
|
||||
@@ -44,4 +44,8 @@ describe('gpxActivityOf (PR1-06A, BIK-01, HIK-01, RUN-01, SKI-02)', () => {
|
||||
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: null })).toBe('skiing');
|
||||
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: 'freeride' })).toBe('skiing');
|
||||
});
|
||||
|
||||
it('TOUR-GPX-ACTIVITY-007: a horse tour is horseback riding (HOR-01)', () => {
|
||||
expect(gpxActivityOf({ tour_type: 'horse' })).toBe('horseback_riding');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ADDON_IDS } from '../../../src/addons';
|
||||
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
|
||||
import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
|
||||
import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard';
|
||||
import { RESPONSE_CONTRACT_KEY } from '../../../src/nest/common/response-contract';
|
||||
import { TripAccessGuard } from '../../../src/nest/permissions/trip-access.guard';
|
||||
import {
|
||||
parseHorseRoutesQuery,
|
||||
TourHorseRoutesController,
|
||||
} from '../../../src/nest/tours/tour-horse-routes.controller';
|
||||
import type { TourHorseRoutesService } from '../../../src/nest/tours/tour-horse-routes.service';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants';
|
||||
import {
|
||||
FERRATA_BBOX_INVALID_MESSAGE,
|
||||
FERRATA_BBOX_TOO_LARGE_MESSAGE,
|
||||
HORSE_ROUTES_ATTRIBUTION,
|
||||
horseRoutesResponseSchema,
|
||||
type HorseRoutesResponse,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// HORSE-CTRL-001 to HORSE-CTRL-004
|
||||
|
||||
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
try {
|
||||
fn();
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected a throw');
|
||||
}
|
||||
|
||||
const answer: HorseRoutesResponse = {
|
||||
routes: [],
|
||||
truncated: false,
|
||||
source: 'openstreetmap',
|
||||
attribution: HORSE_ROUTES_ATTRIBUTION,
|
||||
};
|
||||
|
||||
describe('TourHorseRoutesController (HOR-01)', () => {
|
||||
it('HORSE-CTRL-001: reads the box of the map view', () => {
|
||||
expect(parseHorseRoutesQuery({ bbox: '47.1,11.2,47.4,11.6' }).bbox).toEqual({
|
||||
south: 47.1,
|
||||
west: 11.2,
|
||||
north: 47.4,
|
||||
east: 11.6,
|
||||
});
|
||||
});
|
||||
|
||||
it('HORSE-CTRL-002: answers a malformed box and one too large with their own messages', () => {
|
||||
for (const raw of [{}, { bbox: '1,2,3' }, { bbox: '47.4,11.2,47.1,11.6' }]) {
|
||||
expect(thrown(() => parseHorseRoutesQuery(raw))).toEqual({
|
||||
status: 400,
|
||||
body: { error: FERRATA_BBOX_INVALID_MESSAGE },
|
||||
});
|
||||
}
|
||||
expect(thrown(() => parseHorseRoutesQuery({ bbox: '46,11,47,11.4' }))).toEqual({
|
||||
status: 400,
|
||||
body: { error: FERRATA_BBOX_TOO_LARGE_MESSAGE },
|
||||
});
|
||||
});
|
||||
|
||||
it('HORSE-CTRL-003: hands the box to the service and returns its answer', async () => {
|
||||
const list = vi.fn(async () => answer);
|
||||
const controller = new TourHorseRoutesController({ list } as unknown as TourHorseRoutesService);
|
||||
await expect(controller.list({ bbox: '47.1,11.2,47.4,11.6' })).resolves.toBe(answer);
|
||||
expect(list).toHaveBeenCalledWith({ south: 47.1, west: 11.2, north: 47.4, east: 11.6 });
|
||||
});
|
||||
|
||||
it('HORSE-CTRL-004: sits behind the Tours addon, the session and the trip access, and declares its contract', () => {
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, TourHorseRoutesController)).toEqual([
|
||||
AddonGuard,
|
||||
JwtAuthGuard,
|
||||
TripAccessGuard,
|
||||
]);
|
||||
expect(Reflect.getMetadata(REQUIRE_ADDON, TourHorseRoutesController)).toEqual({
|
||||
addonId: ADDON_IDS.TOURS,
|
||||
label: 'Tours',
|
||||
});
|
||||
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, TourHorseRoutesController.prototype.list)).toBe(
|
||||
horseRoutesResponseSchema,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
buildHorseRoutesQuery,
|
||||
HORSE_ROUTE_WAYS_CAP,
|
||||
horseAccessOf,
|
||||
horseRouteBboxKey,
|
||||
snapHorseRouteBbox,
|
||||
toHorseRoutes,
|
||||
} from '../../../src/nest/tours/tour-horse-routes.helpers';
|
||||
import { HORSE_ROUTE_SECTIONS_CAP, HORSE_ROUTES_RESULT_CAP, horseRouteSchema } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// HORSE-HELP-001 to HORSE-HELP-008
|
||||
|
||||
const at = (lat: number, lon: number) => ({ lat, lon });
|
||||
const way = (
|
||||
id: number,
|
||||
nodes: number[],
|
||||
tags: Record<string, string> = {},
|
||||
geometry: Array<{ lat: number; lon: number } | null> = nodes.map((n) => at(47 + n * 0.001, 11)),
|
||||
) => ({ type: 'way', id, tags, nodes, geometry });
|
||||
const relation = (
|
||||
id: number,
|
||||
members: Array<{ type?: string; ref: number; role?: string }>,
|
||||
tags: Record<string, string> = { route: 'horse', name: 'Reitweg', ref: 'R1' },
|
||||
) => ({ type: 'relation', id, tags, members: members.map((member) => ({ type: 'way', role: '', ...member })) });
|
||||
|
||||
describe('horse route helpers (HOR-01)', () => {
|
||||
it('HORSE-HELP-001: asks for the routes in the box, their members and their ways in it with tags and nodes', () => {
|
||||
expect(buildHorseRoutesQuery({ south: 47.1, west: 11.2, north: 47.4, east: 11.6 })).toBe(
|
||||
[
|
||||
'[out:json][timeout:25];',
|
||||
'rel["route"="horse"](47.1,11.2,47.4,11.6)->.routes;',
|
||||
'way(r.routes)(47.1,11.2,47.4,11.6)->.ways;',
|
||||
`.routes out body qt ${HORSE_ROUTES_RESULT_CAP + 1};`,
|
||||
`.ways out body geom qt ${HORSE_ROUTE_WAYS_CAP + 1};`,
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('HORSE-HELP-002: grows a box to the 0.05 degree grid, clamped to the globe, and keys it by its edges', () => {
|
||||
const box = snapHorseRouteBbox({ south: 47.12, west: 11.21, north: 47.18, east: 11.29 });
|
||||
expect(box).toEqual({ south: 47.1, west: 11.2, north: 47.2, east: 11.3 });
|
||||
expect(horseRouteBboxKey(box)).toBe('47.1,11.2,47.2,11.3');
|
||||
expect(snapHorseRouteBbox({ south: -89.99, west: -179.99, north: 89.99, east: 179.99 })).toEqual({
|
||||
south: -90,
|
||||
west: -180,
|
||||
north: 90,
|
||||
east: 180,
|
||||
});
|
||||
});
|
||||
|
||||
it('HORSE-HELP-003: reads horse=* as tagged, any other value as other and none as unknown', () => {
|
||||
const tagged = ['designated', ' Yes ', 'permissive', 'destination', 'discouraged', 'private', 'no'];
|
||||
expect(tagged.map((value) => horseAccessOf(value))).toEqual([
|
||||
'designated',
|
||||
'yes',
|
||||
'permissive',
|
||||
'destination',
|
||||
'discouraged',
|
||||
'private',
|
||||
'no',
|
||||
]);
|
||||
expect(horseAccessOf('customers')).toBe('other');
|
||||
expect(horseAccessOf('')).toBe('unknown');
|
||||
expect(horseAccessOf(undefined)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('HORSE-HELP-004: a route with its sections, the access of its ways and its own name and ref', () => {
|
||||
const { routes, truncated } = toHorseRoutes([
|
||||
relation(7, [{ ref: 1 }, { ref: 2, role: 'forward' }]),
|
||||
way(1, [1, 2, 3], { horse: 'designated' }),
|
||||
way(2, [3, 4], { horse: 'no' }),
|
||||
]);
|
||||
expect(truncated).toBe(false);
|
||||
expect(routes).toHaveLength(1);
|
||||
const route = routes[0];
|
||||
expect(horseRouteSchema.parse(route)).toEqual(route);
|
||||
expect(route).toMatchObject({
|
||||
id: 'relation/7',
|
||||
name: 'Reitweg',
|
||||
ref: 'R1',
|
||||
osmUrl: 'https://www.openstreetmap.org/relation/7',
|
||||
continues: false,
|
||||
});
|
||||
expect(route?.sections).toHaveLength(1);
|
||||
expect(route?.sections[0]).toMatchObject({ number: 1, signposted: null });
|
||||
expect(route?.sections[0]?.access.map((share) => share.value)).toEqual(['designated', 'no']);
|
||||
});
|
||||
|
||||
it('HORSE-HELP-005: says a route goes on past the box, and leaves out one with no way in it', () => {
|
||||
const { routes } = toHorseRoutes([
|
||||
relation(7, [{ ref: 1 }, { ref: 99 }]),
|
||||
relation(8, [{ ref: 98 }]),
|
||||
way(1, [1, 2]),
|
||||
]);
|
||||
expect(routes.map((route) => [route.id, route.continues])).toEqual([['relation/7', true]]);
|
||||
});
|
||||
|
||||
it('HORSE-HELP-006: reads each way once, skips other members, unplaced points and elements of another shape', () => {
|
||||
const { routes } = toHorseRoutes([
|
||||
relation(7, [{ ref: 1 }, { ref: 1, role: 'backward' }, { type: 'node', ref: 5 }, { ref: 2 }], {
|
||||
route: 'horse',
|
||||
name: ' ',
|
||||
}),
|
||||
way(1, [1, 2, 3], {}, [at(47, 11), null, at(47.002, 11)]),
|
||||
way(2, [10, 11], { horse: 'yes' }),
|
||||
{ type: 'way', id: 3 },
|
||||
{ type: 'node', id: 4, lat: 47, lon: 11 },
|
||||
null,
|
||||
]);
|
||||
expect(routes).toHaveLength(1);
|
||||
expect(routes[0]).toMatchObject({ name: null, ref: null, continues: false });
|
||||
// The way with an unplaced middle point has no step left; only the second way gives a section.
|
||||
expect(routes[0]?.sections.map((section) => section.access[0]?.value)).toEqual(['yes']);
|
||||
});
|
||||
|
||||
it('HORSE-HELP-007: an answer past the caps says it was cut', () => {
|
||||
const many = Array.from({ length: HORSE_ROUTES_RESULT_CAP + 1 }, (_, index) => relation(index + 1, [{ ref: 1 }]));
|
||||
const cut = toHorseRoutes([...many, way(1, [1, 2])]);
|
||||
expect(cut.truncated).toBe(true);
|
||||
expect(cut.routes).toHaveLength(HORSE_ROUTES_RESULT_CAP);
|
||||
});
|
||||
|
||||
it('HORSE-HELP-008: a route with more sections than an answer carries is cut to the cap and said to be', () => {
|
||||
const count = HORSE_ROUTE_SECTIONS_CAP + 1;
|
||||
const members = Array.from({ length: count }, (_, index) => ({ ref: index + 1 }));
|
||||
// Each way two nodes of its own, apart from the others: as many sections as ways.
|
||||
const ways = members.map(({ ref }) => way(ref, [ref * 10, ref * 10 + 1]));
|
||||
const { routes, truncated } = toHorseRoutes([relation(7, members), ...ways]);
|
||||
expect(truncated).toBe(true);
|
||||
expect(routes[0]?.sections).toHaveLength(HORSE_ROUTE_SECTIONS_CAP);
|
||||
expect(routes[0]?.sections.map((section) => section.number).slice(0, 3)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { DomainError } from '../../../src/nest/common/domain-error';
|
||||
import { OverpassElementsClient } from '../../../src/nest/overpass/overpass-elements.service';
|
||||
import {
|
||||
HORSE_ROUTES_KEPT_MS,
|
||||
HORSE_ROUTES_UNAVAILABLE_MESSAGE,
|
||||
TourHorseRoutesService,
|
||||
} from '../../../src/nest/tours/tour-horse-routes.service';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { HORSE_ROUTES_ATTRIBUTION, HORSE_ROUTES_UNAVAILABLE_CODE, horseRoutesResponseSchema } from '@trek/shared';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// HORSE-SVC-001 to HORSE-SVC-005
|
||||
|
||||
const query = vi.fn<(q: string, caller: string) => Promise<unknown[]>>();
|
||||
let service: TourHorseRoutesService;
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
const box = { south: 47.12, west: 11.21, north: 47.18, east: 11.29 };
|
||||
const elements = [
|
||||
{ type: 'relation', id: 7, tags: { route: 'horse', name: 'Reitweg' }, members: [{ type: 'way', ref: 1, role: '' }] },
|
||||
{
|
||||
type: 'way',
|
||||
id: 1,
|
||||
tags: { horse: 'designated' },
|
||||
nodes: [1, 2],
|
||||
geometry: [
|
||||
{ lat: 47.15, lon: 11.25 },
|
||||
{ lat: 47.16, lon: 11.25 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
query.mockReset();
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [TourHorseRoutesService, { provide: OverpassElementsClient, useValue: { query } }],
|
||||
}).compile();
|
||||
service = moduleRef.get(TourHorseRoutesService);
|
||||
});
|
||||
|
||||
describe('TourHorseRoutesService (HOR-01)', () => {
|
||||
it('HORSE-SVC-001: asks Overpass for the box grown to the grid and answers the contract', async () => {
|
||||
query.mockResolvedValue(elements);
|
||||
const answer = await service.list(box, NOW);
|
||||
expect(horseRoutesResponseSchema.parse(answer)).toEqual(answer);
|
||||
expect(answer).toMatchObject({ truncated: false, source: 'openstreetmap', attribution: HORSE_ROUTES_ATTRIBUTION });
|
||||
expect(answer.routes.map((route) => route.id)).toEqual(['relation/7']);
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
expect(query.mock.calls[0]?.[0]).toContain('(47.1,11.2,47.2,11.3)');
|
||||
expect(query.mock.calls[0]?.[1]).toBe('HorseRoutes');
|
||||
});
|
||||
|
||||
it('HORSE-SVC-002: keeps an answer for nearby views, and asks again once it is old', async () => {
|
||||
query.mockResolvedValue(elements);
|
||||
await service.list(box, NOW);
|
||||
await service.list({ south: 47.11, west: 11.22, north: 47.19, east: 11.28 }, NOW + 1000);
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
await service.list(box, NOW + HORSE_ROUTES_KEPT_MS);
|
||||
expect(query).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('HORSE-SVC-003: two requests for the same box at once share one query', async () => {
|
||||
let answer: (value: unknown[]) => void = () => undefined;
|
||||
query.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
}),
|
||||
);
|
||||
const both = Promise.all([service.list(box, NOW), service.list(box, NOW)]);
|
||||
answer(elements);
|
||||
const [first, second] = await both;
|
||||
expect(first).toEqual(second);
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('HORSE-SVC-004: no mirror answering is a 502 the client can show, and the next request asks again', async () => {
|
||||
query.mockRejectedValueOnce(new Error('all mirrors down'));
|
||||
const failure = await service.list(box, NOW).catch((err: unknown) => err);
|
||||
expect(failure).toBeInstanceOf(DomainError);
|
||||
expect((failure as DomainError).getStatus()).toBe(502);
|
||||
expect((failure as DomainError).getResponse()).toEqual({
|
||||
error: HORSE_ROUTES_UNAVAILABLE_MESSAGE,
|
||||
code: HORSE_ROUTES_UNAVAILABLE_CODE,
|
||||
});
|
||||
query.mockResolvedValueOnce(elements);
|
||||
expect((await service.list(box, NOW)).routes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('HORSE-SVC-005: keeps at most two hundred answers, letting the one kept longest go', async () => {
|
||||
query.mockResolvedValue([]);
|
||||
const nth = (n: number) => ({ south: 40 + n * 0.1, west: 5, north: 40.05 + n * 0.1, east: 5.05 });
|
||||
for (let n = 0; n <= 200; n += 1) await service.list(nth(n), NOW);
|
||||
expect(query).toHaveBeenCalledTimes(201);
|
||||
await service.list(nth(200), NOW);
|
||||
expect(query).toHaveBeenCalledTimes(201);
|
||||
await service.list(nth(0), NOW);
|
||||
expect(query).toHaveBeenCalledTimes(202);
|
||||
});
|
||||
});
|
||||
@@ -1038,5 +1038,62 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.status.unrouted.two': 'مقاطع غير مخطّطة: {count}',
|
||||
'tours.paddle.status.unrouted.few': 'مقاطع غير مخطّطة: {count}',
|
||||
'tours.paddle.status.unrouted.many': 'مقاطع غير مخطّطة: {count}',
|
||||
'tourTypes.horse': 'جولة على ظهر الخيل',
|
||||
'tours.planner.tourTypeHint.horse': 'على ظهر الخيل، عبر مسارات فروسية معلّمة',
|
||||
'tours.planner.namePlaceholder.horse': 'جولة على الخيل بلا عنوان',
|
||||
'tours.horse.time': 'وقت الركوب',
|
||||
'tours.horse.timeInfoLabel': 'لماذا لا يوجد وقت ركوب',
|
||||
'tours.horse.timeInfo':
|
||||
'لا يحسب TREK وقت الركوب: الإيقاع يحدده الحصان والفارس. أدخل بنفسك إجمالي الوقت المخطط إن أردت.',
|
||||
'tours.horse.routes.title': 'مسارات الفروسية',
|
||||
'tours.horse.routes.infoLabel': 'عن مسارات الفروسية',
|
||||
'tours.horse.routes.info':
|
||||
'مسارات فروسية معلّمة من OpenStreetMap، في مقاطع تُؤخذ كاملة. كون الطريق جزءًا من مسار لا يعني الإذن بركوب الخيل فيه.',
|
||||
'tours.horse.routes.search': 'ابحث في عرض الخريطة',
|
||||
'tours.horse.routes.searchAgain': 'ابحث في هذا العرض',
|
||||
'tours.horse.routes.loading': 'جارٍ البحث عن مسارات الفروسية…',
|
||||
'tours.horse.routes.zoomIn': 'كبّر الخريطة أكثر للبحث عن مسارات الفروسية.',
|
||||
'tours.horse.routes.offline': 'تحتاج مسارات الفروسية إلى اتصال.',
|
||||
'tours.horse.routes.failed': 'تعذرت قراءة مسارات الفروسية. جولتك لم تتغير.',
|
||||
'tours.horse.routes.none': 'لا توجد مسارات فروسية معلّمة في هذا العرض.',
|
||||
'tours.horse.routes.truncated': 'توجد هنا مسارات أكثر مما يعرضه بحث واحد. كبّر الخريطة لرؤيتها كلها.',
|
||||
'tours.horse.routes.unnamed': 'مسار فروسية بلا اسم',
|
||||
'tours.horse.routes.sections': 'المقاطع',
|
||||
'tours.horse.routes.continues': 'يستمر خارج العرض',
|
||||
'tours.horse.dialog.eyebrow': 'مسار فروسية',
|
||||
'tours.horse.dialog.osm': 'افتح في OpenStreetMap',
|
||||
'tours.horse.dialog.section': 'المقطع {number}',
|
||||
'tours.horse.dialog.direction': 'الاتجاه',
|
||||
'tours.horse.dialog.asMapped': 'كما رُسم على الخريطة',
|
||||
'tours.horse.dialog.reversed': 'معكوس',
|
||||
'tours.horse.dialog.signedWith': 'معلّم في هذا الاتجاه',
|
||||
'tours.horse.dialog.signedAgainst': 'هذا المقطع معلّم في الاتجاه المعاكس.',
|
||||
'tours.horse.dialog.connection': 'الاتصال بجولتك',
|
||||
'tours.horse.dialog.firstSection': 'يبدأ جولتك',
|
||||
'tours.horse.dialog.joins': 'يتصل بنهاية جولتك',
|
||||
'tours.horse.dialog.gap': 'بين نهاية جولتك وهذا المقطع {distance} خارج أي مسار فروسية.',
|
||||
'tours.horse.dialog.joinGap': 'صِل بمقطع يدوي',
|
||||
'tours.horse.dialog.joinGapHint': 'مرسوم مستقيمًا وغير مُتحقق منه',
|
||||
'tours.horse.dialog.adopt': 'خذ المقطع',
|
||||
'tours.horse.access.title': 'وصول الخيل',
|
||||
'tours.horse.access.note':
|
||||
'ما يسجله OpenStreetMap على أنه horse=* على طول الطرق. قد تنقص البيانات أو تكون قديمة؛ العبرة باللافتات في المكان.',
|
||||
'tours.horse.access.closedNotice': 'جزء من هذا المقطع مسجل على أنه مغلق أو خاص أمام الخيل.',
|
||||
'tours.horse.access.value.designated': 'مخصص للفرسان',
|
||||
'tours.horse.access.value.yes': 'مسموح',
|
||||
'tours.horse.access.value.permissive': 'يتسامح به المالك',
|
||||
'tours.horse.access.value.destination': 'للوصول فقط',
|
||||
'tours.horse.access.value.discouraged': 'غير مستحسن',
|
||||
'tours.horse.access.value.private': 'خاص',
|
||||
'tours.horse.access.value.no': 'ممنوع',
|
||||
'tours.horse.access.value.other': 'قاعدة أخرى',
|
||||
'tours.horse.access.value.unknown': 'غير موجود في البيانات',
|
||||
'tours.horse.access.summary.open': 'مفتوح للفرسان حسب البيانات',
|
||||
'tours.horse.access.summary.limited': 'مقيّد جزئيًا',
|
||||
'tours.horse.access.summary.closed': 'مغلق جزئيًا',
|
||||
'tours.horse.access.summary.unknown': 'الوصول غير موجود في البيانات',
|
||||
'tours.horse.leg.info': 'مقطع من مسار فروسية معلّم من OpenStreetMap. وصول الخيل: {access}',
|
||||
'tours.horse.leg.manual': 'يدوي، غير مُتحقق منه',
|
||||
'tours.horse.leg.manualInfo': 'مرسوم باليد، مستقيم. لم يتحقق منه أي مسار فروسية ولا أي مخطط مسارات.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1003,5 +1003,63 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.wind.shoreTaken': 'Göllər sahilə yaxın planlaşdırılıb.',
|
||||
'tours.paddle.status.unrouted': '{count} hissə planlaşdırılmayıb',
|
||||
'tours.paddle.status.unrouted.one': '{count} hissə planlaşdırılmayıb',
|
||||
'tourTypes.horse': 'At gəzintisi',
|
||||
'tours.planner.tourTypeHint.horse': 'At belində, nişanlanmış atçılıq marşrutları ilə',
|
||||
'tours.planner.namePlaceholder.horse': 'Adsız at gəzintisi',
|
||||
'tours.horse.time': 'Sürmə vaxtı',
|
||||
'tours.horse.timeInfoLabel': 'Niyə sürmə vaxtı yoxdur',
|
||||
'tours.horse.timeInfo':
|
||||
'TREK sürmə vaxtını hesablamır: tempi at və süvari müəyyən edir. İstəsən, planlaşdırılan ümumi vaxtı özün daxil et.',
|
||||
'tours.horse.routes.title': 'Atçılıq marşrutları',
|
||||
'tours.horse.routes.infoLabel': 'Atçılıq marşrutları haqqında',
|
||||
'tours.horse.routes.info':
|
||||
'OpenStreetMap-dən nişanlanmış atçılıq marşrutları, tam götürülən hissələrlə. Yolun marşruta aid olması orada at sürməyə icazə vermir.',
|
||||
'tours.horse.routes.search': 'Xəritə görünüşündə axtar',
|
||||
'tours.horse.routes.searchAgain': 'Bu görünüşdə axtar',
|
||||
'tours.horse.routes.loading': 'Atçılıq marşrutları axtarılır…',
|
||||
'tours.horse.routes.zoomIn': 'Atçılıq marşrutları axtarmaq üçün daha çox yaxınlaşdır.',
|
||||
'tours.horse.routes.offline': 'Atçılıq marşrutları üçün bağlantı lazımdır.',
|
||||
'tours.horse.routes.failed': 'Atçılıq marşrutları oxunmadı. Gəzintin dəyişmədi.',
|
||||
'tours.horse.routes.none': 'Bu görünüşdə nişanlanmış atçılıq marşrutu yoxdur.',
|
||||
'tours.horse.routes.truncated':
|
||||
'Burada bir axtarışın göstərdiyindən çox marşrut var. Hamısını görmək üçün yaxınlaşdır.',
|
||||
'tours.horse.routes.unnamed': 'Adsız atçılıq marşrutu',
|
||||
'tours.horse.routes.sections': 'Hissələr',
|
||||
'tours.horse.routes.continues': 'Görünüşdən kənarda davam edir',
|
||||
'tours.horse.dialog.eyebrow': 'Atçılıq marşrutu',
|
||||
'tours.horse.dialog.osm': 'OpenStreetMap-də aç',
|
||||
'tours.horse.dialog.section': 'Hissə {number}',
|
||||
'tours.horse.dialog.direction': 'İstiqamət',
|
||||
'tours.horse.dialog.asMapped': 'Xəritədəki kimi',
|
||||
'tours.horse.dialog.reversed': 'Əks',
|
||||
'tours.horse.dialog.signedWith': 'Bu istiqamətdə nişanlanıb',
|
||||
'tours.horse.dialog.signedAgainst': 'Bu hissə əks istiqamətdə nişanlanıb.',
|
||||
'tours.horse.dialog.connection': 'Gəzintiyə qoşulma',
|
||||
'tours.horse.dialog.firstSection': 'Gəzintini başladır',
|
||||
'tours.horse.dialog.joins': 'Gəzintinin sonuna qoşulur',
|
||||
'tours.horse.dialog.gap': 'Gəzintinin sonu ilə bu hissə arasında atçılıq marşrutu olmayan {distance} var.',
|
||||
'tours.horse.dialog.joinGap': 'Əl ilə çəkilmiş hissə ilə birləşdir',
|
||||
'tours.horse.dialog.joinGapHint': 'Düz çəkilib və yoxlanılmayıb',
|
||||
'tours.horse.dialog.adopt': 'Hissəni götür',
|
||||
'tours.horse.access.title': 'At üçün giriş',
|
||||
'tours.horse.access.note':
|
||||
'OpenStreetMap-in yollar boyunca horse=* kimi qeyd etdikləri. Məlumatlar çatışmaya və ya köhnə ola bilər; yerdəki lövhələr əsasdır.',
|
||||
'tours.horse.access.closedNotice': 'Bu hissənin bir qismi atlar üçün bağlı və ya şəxsi kimi qeyd olunub.',
|
||||
'tours.horse.access.value.designated': 'Süvarilər üçün ayrılıb',
|
||||
'tours.horse.access.value.yes': 'İcazəlidir',
|
||||
'tours.horse.access.value.permissive': 'Sahibi tərəfindən dözülür',
|
||||
'tours.horse.access.value.destination': 'Yalnız təyinat üçün',
|
||||
'tours.horse.access.value.discouraged': 'Tövsiyə olunmur',
|
||||
'tours.horse.access.value.private': 'Şəxsi',
|
||||
'tours.horse.access.value.no': 'Qadağandır',
|
||||
'tours.horse.access.value.other': 'Başqa qayda',
|
||||
'tours.horse.access.value.unknown': 'Məlumatlarda yoxdur',
|
||||
'tours.horse.access.summary.open': 'Məlumatlara görə süvarilərə açıqdır',
|
||||
'tours.horse.access.summary.limited': 'Qismən məhdud',
|
||||
'tours.horse.access.summary.closed': 'Qismən bağlı',
|
||||
'tours.horse.access.summary.unknown': 'Giriş məlumatlarda yoxdur',
|
||||
'tours.horse.leg.info': 'OpenStreetMap-dən nişanlanmış atçılıq marşrutunun hissəsi. At üçün giriş: {access}',
|
||||
'tours.horse.leg.manual': 'Əl ilə, yoxlanılmayıb',
|
||||
'tours.horse.leg.manualInfo': 'Əl ilə, düz çəkilib. Nə atçılıq marşrutu, nə də marşrut planlayıcısı yoxlayıb.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1014,5 +1014,62 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.wind.shoreTaken': 'Os lagos estão planejados perto da margem.',
|
||||
'tours.paddle.status.unrouted': '{count} trechos não planejados',
|
||||
'tours.paddle.status.unrouted.one': '{count} trecho não planejado',
|
||||
'tourTypes.horse': 'Passeio a cavalo',
|
||||
'tours.planner.tourTypeHint.horse': 'A cavalo, por rotas equestres sinalizadas',
|
||||
'tours.planner.namePlaceholder.horse': 'Passeio a cavalo sem título',
|
||||
'tours.horse.time': 'Tempo a cavalo',
|
||||
'tours.horse.timeInfoLabel': 'Por que não há tempo a cavalo',
|
||||
'tours.horse.timeInfo':
|
||||
'O TREK não calcula tempo a cavalo: o ritmo é do cavalo e de quem monta. Se quiser, informe você mesmo um total planejado.',
|
||||
'tours.horse.routes.title': 'Rotas equestres',
|
||||
'tours.horse.routes.infoLabel': 'Sobre as rotas equestres',
|
||||
'tours.horse.routes.info':
|
||||
'Rotas equestres sinalizadas do OpenStreetMap, em trechos que você adota inteiros. Fazer parte de uma rota não dá permissão para percorrê-la a cavalo.',
|
||||
'tours.horse.routes.search': 'Buscar na área do mapa',
|
||||
'tours.horse.routes.searchAgain': 'Buscar nesta área',
|
||||
'tours.horse.routes.loading': 'Procurando rotas equestres…',
|
||||
'tours.horse.routes.zoomIn': 'Aproxime mais o mapa para procurar rotas equestres.',
|
||||
'tours.horse.routes.offline': 'As rotas equestres precisam de conexão.',
|
||||
'tours.horse.routes.failed': 'Não foi possível ler as rotas equestres. Seu passeio não mudou.',
|
||||
'tours.horse.routes.none': 'Nenhuma rota equestre sinalizada nesta área.',
|
||||
'tours.horse.routes.truncated': 'Há mais rotas aqui do que uma busca mostra. Aproxime o mapa para ver todas.',
|
||||
'tours.horse.routes.unnamed': 'Rota equestre sem nome',
|
||||
'tours.horse.routes.sections': 'Trechos',
|
||||
'tours.horse.routes.continues': 'Continua além da área',
|
||||
'tours.horse.dialog.eyebrow': 'Rota equestre',
|
||||
'tours.horse.dialog.osm': 'Abrir no OpenStreetMap',
|
||||
'tours.horse.dialog.section': 'Trecho {number}',
|
||||
'tours.horse.dialog.direction': 'Sentido',
|
||||
'tours.horse.dialog.asMapped': 'Como mapeado',
|
||||
'tours.horse.dialog.reversed': 'Invertido',
|
||||
'tours.horse.dialog.signedWith': 'Sinalizado neste sentido',
|
||||
'tours.horse.dialog.signedAgainst': 'Este trecho é sinalizado no sentido contrário.',
|
||||
'tours.horse.dialog.connection': 'Ligação com seu passeio',
|
||||
'tours.horse.dialog.firstSection': 'Começa seu passeio',
|
||||
'tours.horse.dialog.joins': 'Liga-se ao fim do seu passeio',
|
||||
'tours.horse.dialog.gap': 'Entre o fim do seu passeio e este trecho há {distance} sem rota equestre.',
|
||||
'tours.horse.dialog.joinGap': 'Ligar com um trecho manual',
|
||||
'tours.horse.dialog.joinGapHint': 'Traçado reto e não verificado',
|
||||
'tours.horse.dialog.adopt': 'Adotar trecho',
|
||||
'tours.horse.access.title': 'Acesso a cavalo',
|
||||
'tours.horse.access.note':
|
||||
'O que o OpenStreetMap registra como horse=* ao longo das vias. Os dados podem faltar ou estar desatualizados; valem as placas no local.',
|
||||
'tours.horse.access.closedNotice': 'Parte deste trecho está registrada como proibida ou particular para cavalos.',
|
||||
'tours.horse.access.value.designated': 'Destinado a cavaleiros',
|
||||
'tours.horse.access.value.yes': 'Permitido',
|
||||
'tours.horse.access.value.permissive': 'Tolerado pelo proprietário',
|
||||
'tours.horse.access.value.destination': 'Somente acesso local',
|
||||
'tours.horse.access.value.discouraged': 'Desaconselhado',
|
||||
'tours.horse.access.value.private': 'Particular',
|
||||
'tours.horse.access.value.no': 'Proibido',
|
||||
'tours.horse.access.value.other': 'Outra regra',
|
||||
'tours.horse.access.value.unknown': 'Fora dos dados',
|
||||
'tours.horse.access.summary.open': 'Aberto a cavaleiros segundo os dados',
|
||||
'tours.horse.access.summary.limited': 'Restrito em parte',
|
||||
'tours.horse.access.summary.closed': 'Proibido em parte',
|
||||
'tours.horse.access.summary.unknown': 'Acesso fora dos dados',
|
||||
'tours.horse.leg.info': 'Trecho de uma rota equestre sinalizada do OpenStreetMap. Acesso a cavalo: {access}',
|
||||
'tours.horse.leg.manual': 'Manual, não verificado',
|
||||
'tours.horse.leg.manualInfo': 'Traçado à mão, em linha reta. Nenhuma rota equestre e nenhum roteador o verificou.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1015,5 +1015,64 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.wind.shoreTaken': 'Els llacs estan planificats a prop de la riba.',
|
||||
'tours.paddle.status.unrouted': '{count} trams sense planificar',
|
||||
'tours.paddle.status.unrouted.one': '{count} tram sense planificar',
|
||||
'tourTypes.horse': 'Ruta a cavall',
|
||||
'tours.planner.tourTypeHint.horse': 'A cavall, per rutes eqüestres senyalitzades',
|
||||
'tours.planner.namePlaceholder.horse': 'Ruta a cavall sense títol',
|
||||
'tours.horse.time': 'Temps a cavall',
|
||||
'tours.horse.timeInfoLabel': 'Per què no hi ha temps a cavall',
|
||||
'tours.horse.timeInfo':
|
||||
'TREK no calcula cap temps a cavall: el ritme el marquen el cavall i el genet. Si vols, introdueix tu mateix un total previst.',
|
||||
'tours.horse.routes.title': 'Rutes eqüestres',
|
||||
'tours.horse.routes.infoLabel': 'Sobre les rutes eqüestres',
|
||||
'tours.horse.routes.info':
|
||||
"Rutes eqüestres senyalitzades d'OpenStreetMap, en trams que s'agafen sencers. Formar part d'una ruta no dona permís per fer-la a cavall.",
|
||||
'tours.horse.routes.search': 'Cerca a la vista',
|
||||
'tours.horse.routes.searchAgain': 'Cerca en aquesta vista',
|
||||
'tours.horse.routes.loading': "S'estan cercant rutes eqüestres…",
|
||||
'tours.horse.routes.zoomIn': 'Apropa més el mapa per cercar rutes eqüestres.',
|
||||
'tours.horse.routes.offline': 'Les rutes eqüestres necessiten connexió.',
|
||||
'tours.horse.routes.failed': "No s'han pogut llegir les rutes eqüestres. La teva ruta no ha canviat.",
|
||||
'tours.horse.routes.none': 'No hi ha rutes eqüestres senyalitzades en aquesta vista.',
|
||||
'tours.horse.routes.truncated':
|
||||
'Aquí hi ha més rutes de les que mostra una cerca. Apropa el mapa per veure-les totes.',
|
||||
'tours.horse.routes.unnamed': 'Ruta eqüestre sense nom',
|
||||
'tours.horse.routes.sections': 'Trams',
|
||||
'tours.horse.routes.continues': 'Continua fora de la vista',
|
||||
'tours.horse.dialog.eyebrow': 'Ruta eqüestre',
|
||||
'tours.horse.dialog.osm': 'Obre a OpenStreetMap',
|
||||
'tours.horse.dialog.section': 'Tram {number}',
|
||||
'tours.horse.dialog.direction': 'Sentit',
|
||||
'tours.horse.dialog.asMapped': 'Tal com està cartografiat',
|
||||
'tours.horse.dialog.reversed': 'Invertit',
|
||||
'tours.horse.dialog.signedWith': 'Senyalitzat en aquest sentit',
|
||||
'tours.horse.dialog.signedAgainst': 'Aquest tram està senyalitzat en sentit contrari.',
|
||||
'tours.horse.dialog.connection': 'Enllaç amb la teva ruta',
|
||||
'tours.horse.dialog.firstSection': 'Comença la teva ruta',
|
||||
'tours.horse.dialog.joins': 'Enllaça amb el final de la teva ruta',
|
||||
'tours.horse.dialog.gap': 'Entre el final de la teva ruta i aquest tram hi ha {distance} sense ruta eqüestre.',
|
||||
'tours.horse.dialog.joinGap': 'Uneix amb un tram manual',
|
||||
'tours.horse.dialog.joinGapHint': 'Traçat recte i sense comprovar',
|
||||
'tours.horse.dialog.adopt': 'Agafa el tram',
|
||||
'tours.horse.access.title': 'Accés a cavall',
|
||||
'tours.horse.access.note':
|
||||
'El que OpenStreetMap indica com a horse=* al llarg dels camins. Les dades poden faltar o estar desfasades; valen els senyals sobre el terreny.',
|
||||
'tours.horse.access.closedNotice': 'Part d’aquest tram consta com a prohibida o privada per als cavalls.',
|
||||
'tours.horse.access.value.designated': 'Reservat als genets',
|
||||
'tours.horse.access.value.yes': 'Permès',
|
||||
'tours.horse.access.value.permissive': 'Tolerat pel propietari',
|
||||
'tours.horse.access.value.destination': 'Només accés a destinació',
|
||||
'tours.horse.access.value.discouraged': 'Desaconsellat',
|
||||
'tours.horse.access.value.private': 'Privat',
|
||||
'tours.horse.access.value.no': 'Prohibit',
|
||||
'tours.horse.access.value.other': 'Una altra regla',
|
||||
'tours.horse.access.value.unknown': 'Sense dades',
|
||||
'tours.horse.access.summary.open': 'Obert als genets segons les dades',
|
||||
'tours.horse.access.summary.limited': 'Restringit en part',
|
||||
'tours.horse.access.summary.closed': 'Prohibit en part',
|
||||
'tours.horse.access.summary.unknown': 'Accés sense dades',
|
||||
'tours.horse.leg.info': "Tram d'una ruta eqüestre senyalitzada d'OpenStreetMap. Accés a cavall: {access}",
|
||||
'tours.horse.leg.manual': 'Manual, sense comprovar',
|
||||
'tours.horse.leg.manualInfo':
|
||||
"Traçat a mà, en línia recta. Ni una ruta eqüestre ni un planificador no l'ha comprovat.",
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1003,5 +1003,62 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.status.unrouted': '{count} úseků nenaplánováno',
|
||||
'tours.paddle.status.unrouted.one': '{count} úsek nenaplánován',
|
||||
'tours.paddle.status.unrouted.few': '{count} úseky nenaplánovány',
|
||||
'tourTypes.horse': 'Vyjížďka na koni',
|
||||
'tours.planner.tourTypeHint.horse': 'Na koni, po značených jezdeckých trasách',
|
||||
'tours.planner.namePlaceholder.horse': 'Vyjížďka bez názvu',
|
||||
'tours.horse.time': 'Doba jízdy',
|
||||
'tours.horse.timeInfoLabel': 'Proč tu není doba jízdy',
|
||||
'tours.horse.timeInfo':
|
||||
'TREK dobu jízdy nepočítá: tempo určuje kůň a jezdec. Pokud chceš, zadej plánovaný celkový čas sám.',
|
||||
'tours.horse.routes.title': 'Jezdecké trasy',
|
||||
'tours.horse.routes.infoLabel': 'O jezdeckých trasách',
|
||||
'tours.horse.routes.info':
|
||||
'Značené jezdecké trasy z OpenStreetMap, po úsecích, které převezmeš celé. To, že cesta patří k trase, neznamená, že po ní smíš jet na koni.',
|
||||
'tours.horse.routes.search': 'Hledat ve výřezu mapy',
|
||||
'tours.horse.routes.searchAgain': 'Prohledat tento výřez',
|
||||
'tours.horse.routes.loading': 'Hledají se jezdecké trasy…',
|
||||
'tours.horse.routes.zoomIn': 'Přibliž mapu ještě víc a hledej jezdecké trasy.',
|
||||
'tours.horse.routes.offline': 'Jezdecké trasy potřebují připojení.',
|
||||
'tours.horse.routes.failed': 'Jezdecké trasy se nepodařilo načíst. Tvoje vyjížďka zůstává beze změny.',
|
||||
'tours.horse.routes.none': 'V tomto výřezu nejsou žádné značené jezdecké trasy.',
|
||||
'tours.horse.routes.truncated': 'Je tu víc tras, než ukáže jedno hledání. Přibliž mapu, abys viděl všechny.',
|
||||
'tours.horse.routes.unnamed': 'Jezdecká trasa bez názvu',
|
||||
'tours.horse.routes.sections': 'Úseky',
|
||||
'tours.horse.routes.continues': 'Pokračuje mimo výřez',
|
||||
'tours.horse.dialog.eyebrow': 'Jezdecká trasa',
|
||||
'tours.horse.dialog.osm': 'Otevřít v OpenStreetMap',
|
||||
'tours.horse.dialog.section': 'Úsek {number}',
|
||||
'tours.horse.dialog.direction': 'Směr',
|
||||
'tours.horse.dialog.asMapped': 'Jak je zmapováno',
|
||||
'tours.horse.dialog.reversed': 'Obráceně',
|
||||
'tours.horse.dialog.signedWith': 'Značeno tímto směrem',
|
||||
'tours.horse.dialog.signedAgainst': 'Tento úsek je značen opačným směrem.',
|
||||
'tours.horse.dialog.connection': 'Napojení na vyjížďku',
|
||||
'tours.horse.dialog.firstSection': 'Začíná tvou vyjížďku',
|
||||
'tours.horse.dialog.joins': 'Navazuje na konec tvé vyjížďky',
|
||||
'tours.horse.dialog.gap': 'Mezi koncem tvé vyjížďky a tímto úsekem je {distance} bez jezdecké trasy.',
|
||||
'tours.horse.dialog.joinGap': 'Propojit ručním úsekem',
|
||||
'tours.horse.dialog.joinGapHint': 'Nakresleno rovně a neověřeno',
|
||||
'tours.horse.dialog.adopt': 'Převzít úsek',
|
||||
'tours.horse.access.title': 'Přístup pro koně',
|
||||
'tours.horse.access.note':
|
||||
'Co OpenStreetMap podél cest uvádí jako horse=*. Údaje mohou chybět nebo být zastaralé; platí značení na místě.',
|
||||
'tours.horse.access.closedNotice': 'Část tohoto úseku je pro koně uvedena jako zakázaná nebo soukromá.',
|
||||
'tours.horse.access.value.designated': 'Vyhrazeno pro jezdce',
|
||||
'tours.horse.access.value.yes': 'Povoleno',
|
||||
'tours.horse.access.value.permissive': 'Trpěno vlastníkem',
|
||||
'tours.horse.access.value.destination': 'Jen pro dopravní obsluhu',
|
||||
'tours.horse.access.value.discouraged': 'Nedoporučeno',
|
||||
'tours.horse.access.value.private': 'Soukromé',
|
||||
'tours.horse.access.value.no': 'Zakázáno',
|
||||
'tours.horse.access.value.other': 'Jiné pravidlo',
|
||||
'tours.horse.access.value.unknown': 'V datech chybí',
|
||||
'tours.horse.access.summary.open': 'Podle dat otevřeno pro jezdce',
|
||||
'tours.horse.access.summary.limited': 'Částečně omezeno',
|
||||
'tours.horse.access.summary.closed': 'Částečně zakázáno',
|
||||
'tours.horse.access.summary.unknown': 'Přístup v datech chybí',
|
||||
'tours.horse.leg.info': 'Úsek značené jezdecké trasy z OpenStreetMap. Přístup pro koně: {access}',
|
||||
'tours.horse.leg.manual': 'Ručně, neověřeno',
|
||||
'tours.horse.leg.manualInfo': 'Nakresleno ručně, rovně. Neověřila ho žádná jezdecká trasa ani plánovač.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1020,6 +1020,63 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.wind.shoreTaken': 'Die Seen sind ufernah geplant.',
|
||||
'tours.paddle.status.unrouted': '{count} Abschnitte nicht geplant',
|
||||
'tours.paddle.status.unrouted.one': '{count} Abschnitt nicht geplant',
|
||||
'tourTypes.horse': 'Ausritt',
|
||||
'tours.planner.tourTypeHint.horse': 'Zu Pferd, auf ausgeschilderten Reitrouten',
|
||||
'tours.planner.namePlaceholder.horse': 'Unbenannter Ausritt',
|
||||
'tours.horse.time': 'Reitzeit',
|
||||
'tours.horse.timeInfoLabel': 'Warum es keine Reitzeit gibt',
|
||||
'tours.horse.timeInfo':
|
||||
'TREK berechnet keine Reitzeit: Das Tempo bestimmen Pferd und Reiter. Trag bei Bedarf selbst eine geplante Gesamtzeit ein.',
|
||||
'tours.horse.routes.title': 'Reitrouten',
|
||||
'tours.horse.routes.infoLabel': 'Über Reitrouten',
|
||||
'tours.horse.routes.info':
|
||||
'Ausgeschilderte Reitrouten aus OpenStreetMap, in Abschnitten, die du ganz übernimmst. Dass ein Weg zu einer Route gehört, erlaubt nicht, ihn zu reiten.',
|
||||
'tours.horse.routes.search': 'Im Kartenausschnitt suchen',
|
||||
'tours.horse.routes.searchAgain': 'Diesen Ausschnitt durchsuchen',
|
||||
'tours.horse.routes.loading': 'Suche Reitrouten…',
|
||||
'tours.horse.routes.zoomIn': 'Zoome weiter hinein, um nach Reitrouten zu suchen.',
|
||||
'tours.horse.routes.offline': 'Reitrouten brauchen eine Verbindung.',
|
||||
'tours.horse.routes.failed': 'Reitrouten konnten nicht gelesen werden. Dein Ausritt bleibt unverändert.',
|
||||
'tours.horse.routes.none': 'Keine ausgeschilderten Reitrouten in diesem Ausschnitt.',
|
||||
'tours.horse.routes.truncated': 'Hier gibt es mehr Routen, als eine Suche zeigt. Zoome hinein, um alle zu sehen.',
|
||||
'tours.horse.routes.unnamed': 'Reitroute ohne Namen',
|
||||
'tours.horse.routes.sections': 'Abschnitte',
|
||||
'tours.horse.routes.continues': 'Geht über den Ausschnitt hinaus',
|
||||
'tours.horse.dialog.eyebrow': 'Reitroute',
|
||||
'tours.horse.dialog.osm': 'In OpenStreetMap öffnen',
|
||||
'tours.horse.dialog.section': 'Abschnitt {number}',
|
||||
'tours.horse.dialog.direction': 'Richtung',
|
||||
'tours.horse.dialog.asMapped': 'Wie erfasst',
|
||||
'tours.horse.dialog.reversed': 'Umgekehrt',
|
||||
'tours.horse.dialog.signedWith': 'In dieser Richtung ausgeschildert',
|
||||
'tours.horse.dialog.signedAgainst': 'Dieser Abschnitt ist in der Gegenrichtung ausgeschildert.',
|
||||
'tours.horse.dialog.connection': 'Anschluss an deinen Ausritt',
|
||||
'tours.horse.dialog.firstSection': 'Beginnt deinen Ausritt',
|
||||
'tours.horse.dialog.joins': 'Schließt ans Ende deines Ausritts an',
|
||||
'tours.horse.dialog.gap': 'Zwischen dem Ende deines Ausritts und diesem Abschnitt liegen {distance} ohne Reitroute.',
|
||||
'tours.horse.dialog.joinGap': 'Mit manuellem Abschnitt verbinden',
|
||||
'tours.horse.dialog.joinGapHint': 'Gerade gezogen und ungeprüft',
|
||||
'tours.horse.dialog.adopt': 'Abschnitt übernehmen',
|
||||
'tours.horse.access.title': 'Reitzugang',
|
||||
'tours.horse.access.note':
|
||||
'Was OpenStreetMap entlang der Wege als horse=* erfasst. Angaben können fehlen oder veraltet sein; vor Ort gelten die Schilder.',
|
||||
'tours.horse.access.closedNotice': 'Ein Teil dieses Abschnitts ist für Pferde als gesperrt oder privat erfasst.',
|
||||
'tours.horse.access.value.designated': 'Als Reitweg ausgewiesen',
|
||||
'tours.horse.access.value.yes': 'Erlaubt',
|
||||
'tours.horse.access.value.permissive': 'Vom Eigentümer geduldet',
|
||||
'tours.horse.access.value.destination': 'Nur Anlieger',
|
||||
'tours.horse.access.value.discouraged': 'Nicht empfohlen',
|
||||
'tours.horse.access.value.private': 'Privat',
|
||||
'tours.horse.access.value.no': 'Verboten',
|
||||
'tours.horse.access.value.other': 'Andere Regel',
|
||||
'tours.horse.access.value.unknown': 'Nicht in den Daten',
|
||||
'tours.horse.access.summary.open': 'Laut Daten offen für Reiter',
|
||||
'tours.horse.access.summary.limited': 'Teils eingeschränkt',
|
||||
'tours.horse.access.summary.closed': 'Teils gesperrt',
|
||||
'tours.horse.access.summary.unknown': 'Zugang nicht in den Daten',
|
||||
'tours.horse.leg.info': 'Abschnitt einer ausgeschilderten Reitroute aus OpenStreetMap. Reitzugang: {access}',
|
||||
'tours.horse.leg.manual': 'Manuell, ungeprüft',
|
||||
'tours.horse.leg.manualInfo': 'Von Hand gezogen, gerade. Weder eine Reitroute noch ein Router hat ihn geprüft.',
|
||||
};
|
||||
|
||||
export default tours;
|
||||
|
||||
@@ -991,5 +991,62 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.wind.shoreTaken': 'The lakes are planned near the shore.',
|
||||
'tours.paddle.status.unrouted': '{count} legs not planned',
|
||||
'tours.paddle.status.unrouted.one': '{count} leg not planned',
|
||||
'tourTypes.horse': 'Horse ride',
|
||||
'tours.planner.tourTypeHint.horse': 'On horseback, along signposted horse routes',
|
||||
'tours.planner.namePlaceholder.horse': 'Untitled ride',
|
||||
'tours.horse.time': 'Riding time',
|
||||
'tours.horse.timeInfoLabel': 'Why there is no riding time',
|
||||
'tours.horse.timeInfo':
|
||||
"TREK works out no riding time: the pace is the horse's and the rider's. Enter a planned total yourself if you want one.",
|
||||
'tours.horse.routes.title': 'Horse routes',
|
||||
'tours.horse.routes.infoLabel': 'About horse routes',
|
||||
'tours.horse.routes.info':
|
||||
'Signposted horse routes from OpenStreetMap, in sections you take whole. Being on a route is no permission to ride it.',
|
||||
'tours.horse.routes.search': 'Find in map view',
|
||||
'tours.horse.routes.searchAgain': 'Search this view',
|
||||
'tours.horse.routes.loading': 'Looking for horse routes…',
|
||||
'tours.horse.routes.zoomIn': 'Zoom in further to look for horse routes.',
|
||||
'tours.horse.routes.offline': 'Horse routes need a connection.',
|
||||
'tours.horse.routes.failed': 'Horse routes could not be read. Your ride is unchanged.',
|
||||
'tours.horse.routes.none': 'No signposted horse routes in this view.',
|
||||
'tours.horse.routes.truncated': 'There are more routes here than one search shows. Zoom in to see them all.',
|
||||
'tours.horse.routes.unnamed': 'Unnamed horse route',
|
||||
'tours.horse.routes.sections': 'Sections',
|
||||
'tours.horse.routes.continues': 'Goes on beyond the view',
|
||||
'tours.horse.dialog.eyebrow': 'Horse route',
|
||||
'tours.horse.dialog.osm': 'Open in OpenStreetMap',
|
||||
'tours.horse.dialog.section': 'Section {number}',
|
||||
'tours.horse.dialog.direction': 'Direction',
|
||||
'tours.horse.dialog.asMapped': 'As mapped',
|
||||
'tours.horse.dialog.reversed': 'Reversed',
|
||||
'tours.horse.dialog.signedWith': 'Signposted this way',
|
||||
'tours.horse.dialog.signedAgainst': 'This section is signposted the other way.',
|
||||
'tours.horse.dialog.connection': 'Joining your ride',
|
||||
'tours.horse.dialog.firstSection': 'Starts your ride',
|
||||
'tours.horse.dialog.joins': 'Joins the end of your ride',
|
||||
'tours.horse.dialog.gap': '{distance} lie between the end of your ride and this section, on no horse route.',
|
||||
'tours.horse.dialog.joinGap': 'Join with a manual section',
|
||||
'tours.horse.dialog.joinGapHint': 'Drawn straight and unchecked',
|
||||
'tours.horse.dialog.adopt': 'Take section',
|
||||
'tours.horse.access.title': 'Riding access',
|
||||
'tours.horse.access.note':
|
||||
'What OpenStreetMap tags as horse=* along the ways. Tags can be missing or out of date; the signs on site count.',
|
||||
'tours.horse.access.closedNotice': 'Part of this section is tagged as closed or private for horses.',
|
||||
'tours.horse.access.value.designated': 'Designated for riders',
|
||||
'tours.horse.access.value.yes': 'Allowed',
|
||||
'tours.horse.access.value.permissive': 'Permitted by the owner',
|
||||
'tours.horse.access.value.destination': 'Access only',
|
||||
'tours.horse.access.value.discouraged': 'Discouraged',
|
||||
'tours.horse.access.value.private': 'Private',
|
||||
'tours.horse.access.value.no': 'Not allowed',
|
||||
'tours.horse.access.value.other': 'Other rule',
|
||||
'tours.horse.access.value.unknown': 'Not in the data',
|
||||
'tours.horse.access.summary.open': 'Open to riders as tagged',
|
||||
'tours.horse.access.summary.limited': 'Restricted in part',
|
||||
'tours.horse.access.summary.closed': 'Closed in part',
|
||||
'tours.horse.access.summary.unknown': 'Access not in the data',
|
||||
'tours.horse.leg.info': 'A section of a signposted horse route from OpenStreetMap. Riding access: {access}',
|
||||
'tours.horse.leg.manual': 'Manual, unchecked',
|
||||
'tours.horse.leg.manualInfo': 'Drawn by hand, straight. No horse route and no router checked it.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1011,5 +1011,63 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.wind.shoreTaken': 'Los lagos están planificados cerca de la orilla.',
|
||||
'tours.paddle.status.unrouted': '{count} tramos sin planificar',
|
||||
'tours.paddle.status.unrouted.one': '{count} tramo sin planificar',
|
||||
'tourTypes.horse': 'Ruta a caballo',
|
||||
'tours.planner.tourTypeHint.horse': 'A caballo, por rutas ecuestres señalizadas',
|
||||
'tours.planner.namePlaceholder.horse': 'Ruta a caballo sin título',
|
||||
'tours.horse.time': 'Tiempo a caballo',
|
||||
'tours.horse.timeInfoLabel': 'Por qué no hay tiempo a caballo',
|
||||
'tours.horse.timeInfo':
|
||||
'TREK no calcula ningún tiempo a caballo: el ritmo lo marcan el caballo y el jinete. Si quieres, introduce tú mismo un total previsto.',
|
||||
'tours.horse.routes.title': 'Rutas ecuestres',
|
||||
'tours.horse.routes.infoLabel': 'Sobre las rutas ecuestres',
|
||||
'tours.horse.routes.info':
|
||||
'Rutas ecuestres señalizadas de OpenStreetMap, en tramos que se toman enteros. Formar parte de una ruta no da permiso para recorrerla a caballo.',
|
||||
'tours.horse.routes.search': 'Buscar en la vista',
|
||||
'tours.horse.routes.searchAgain': 'Buscar en esta vista',
|
||||
'tours.horse.routes.loading': 'Buscando rutas ecuestres…',
|
||||
'tours.horse.routes.zoomIn': 'Acerca más el mapa para buscar rutas ecuestres.',
|
||||
'tours.horse.routes.offline': 'Las rutas ecuestres necesitan conexión.',
|
||||
'tours.horse.routes.failed': 'No se pudieron leer las rutas ecuestres. Tu ruta no ha cambiado.',
|
||||
'tours.horse.routes.none': 'No hay rutas ecuestres señalizadas en esta vista.',
|
||||
'tours.horse.routes.truncated':
|
||||
'Aquí hay más rutas de las que muestra una búsqueda. Acerca el mapa para verlas todas.',
|
||||
'tours.horse.routes.unnamed': 'Ruta ecuestre sin nombre',
|
||||
'tours.horse.routes.sections': 'Tramos',
|
||||
'tours.horse.routes.continues': 'Continúa fuera de la vista',
|
||||
'tours.horse.dialog.eyebrow': 'Ruta ecuestre',
|
||||
'tours.horse.dialog.osm': 'Abrir en OpenStreetMap',
|
||||
'tours.horse.dialog.section': 'Tramo {number}',
|
||||
'tours.horse.dialog.direction': 'Sentido',
|
||||
'tours.horse.dialog.asMapped': 'Como está cartografiado',
|
||||
'tours.horse.dialog.reversed': 'Invertido',
|
||||
'tours.horse.dialog.signedWith': 'Señalizado en este sentido',
|
||||
'tours.horse.dialog.signedAgainst': 'Este tramo está señalizado en el sentido contrario.',
|
||||
'tours.horse.dialog.connection': 'Conexión con tu ruta',
|
||||
'tours.horse.dialog.firstSection': 'Comienza tu ruta',
|
||||
'tours.horse.dialog.joins': 'Enlaza con el final de tu ruta',
|
||||
'tours.horse.dialog.gap': 'Entre el final de tu ruta y este tramo hay {distance} sin ruta ecuestre.',
|
||||
'tours.horse.dialog.joinGap': 'Unir con un tramo manual',
|
||||
'tours.horse.dialog.joinGapHint': 'Trazado recto y sin comprobar',
|
||||
'tours.horse.dialog.adopt': 'Tomar tramo',
|
||||
'tours.horse.access.title': 'Acceso a caballo',
|
||||
'tours.horse.access.note':
|
||||
'Lo que OpenStreetMap indica como horse=* a lo largo de las vías. Los datos pueden faltar o estar desfasados; valen las señales sobre el terreno.',
|
||||
'tours.horse.access.closedNotice': 'Parte de este tramo figura como prohibida o privada para caballos.',
|
||||
'tours.horse.access.value.designated': 'Reservado a jinetes',
|
||||
'tours.horse.access.value.yes': 'Permitido',
|
||||
'tours.horse.access.value.permissive': 'Tolerado por el propietario',
|
||||
'tours.horse.access.value.destination': 'Solo acceso a destino',
|
||||
'tours.horse.access.value.discouraged': 'Desaconsejado',
|
||||
'tours.horse.access.value.private': 'Privado',
|
||||
'tours.horse.access.value.no': 'Prohibido',
|
||||
'tours.horse.access.value.other': 'Otra regla',
|
||||
'tours.horse.access.value.unknown': 'Sin datos',
|
||||
'tours.horse.access.summary.open': 'Abierto a jinetes según los datos',
|
||||
'tours.horse.access.summary.limited': 'Restringido en parte',
|
||||
'tours.horse.access.summary.closed': 'Prohibido en parte',
|
||||
'tours.horse.access.summary.unknown': 'Acceso sin datos',
|
||||
'tours.horse.leg.info': 'Tramo de una ruta ecuestre señalizada de OpenStreetMap. Acceso a caballo: {access}',
|
||||
'tours.horse.leg.manual': 'Manual, sin comprobar',
|
||||
'tours.horse.leg.manualInfo': 'Trazado a mano, en línea recta. Ni una ruta ecuestre ni un planificador lo comprobó.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -998,5 +998,63 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.wind.shoreTaken': 'Järved on planeeritud kalda lähedale.',
|
||||
'tours.paddle.status.unrouted': '{count} lõiku planeerimata',
|
||||
'tours.paddle.status.unrouted.one': '{count} lõik planeerimata',
|
||||
'tourTypes.horse': 'Ratsamatk',
|
||||
'tours.planner.tourTypeHint.horse': 'Hobusel, märgistatud ratsaradadel',
|
||||
'tours.planner.namePlaceholder.horse': 'Nimetu ratsamatk',
|
||||
'tours.horse.time': 'Ratsutamisaeg',
|
||||
'tours.horse.timeInfoLabel': 'Miks ratsutamisaega pole',
|
||||
'tours.horse.timeInfo':
|
||||
'TREK ratsutamisaega ei arvuta: tempo sõltub hobusest ja ratsanikust. Soovi korral sisesta kavandatud koguaeg ise.',
|
||||
'tours.horse.routes.title': 'Ratsarajad',
|
||||
'tours.horse.routes.infoLabel': 'Ratsaradadest',
|
||||
'tours.horse.routes.info':
|
||||
'Märgistatud ratsarajad OpenStreetMapist, lõikudena, mille võtad tervikuna üle. Rajale kuulumine ei anna luba seal ratsutada.',
|
||||
'tours.horse.routes.search': 'Otsi kaardivaatest',
|
||||
'tours.horse.routes.searchAgain': 'Otsi sellest vaatest',
|
||||
'tours.horse.routes.loading': 'Otsin ratsaradu…',
|
||||
'tours.horse.routes.zoomIn': 'Suumi rohkem sisse, et ratsaradu otsida.',
|
||||
'tours.horse.routes.offline': 'Ratsarajad vajavad ühendust.',
|
||||
'tours.horse.routes.failed': 'Ratsaradu ei õnnestunud lugeda. Sinu matk ei muutunud.',
|
||||
'tours.horse.routes.none': 'Selles vaates pole märgistatud ratsaradu.',
|
||||
'tours.horse.routes.truncated': 'Siin on rohkem radu, kui üks otsing näitab. Suumi sisse, et kõiki näha.',
|
||||
'tours.horse.routes.unnamed': 'Nimetu ratsarada',
|
||||
'tours.horse.routes.sections': 'Lõigud',
|
||||
'tours.horse.routes.continues': 'Jätkub väljaspool vaadet',
|
||||
'tours.horse.dialog.eyebrow': 'Ratsarada',
|
||||
'tours.horse.dialog.osm': 'Ava OpenStreetMapis',
|
||||
'tours.horse.dialog.section': 'Lõik {number}',
|
||||
'tours.horse.dialog.direction': 'Suund',
|
||||
'tours.horse.dialog.asMapped': 'Nagu kaardistatud',
|
||||
'tours.horse.dialog.reversed': 'Vastupidi',
|
||||
'tours.horse.dialog.signedWith': 'Selles suunas märgistatud',
|
||||
'tours.horse.dialog.signedAgainst': 'See lõik on märgistatud vastassuunas.',
|
||||
'tours.horse.dialog.connection': 'Ühendus matkaga',
|
||||
'tours.horse.dialog.firstSection': 'Alustab matka',
|
||||
'tours.horse.dialog.joins': 'Liitub matka lõpuga',
|
||||
'tours.horse.dialog.gap': 'Matka lõpu ja selle lõigu vahel on {distance} ilma ratsarajata.',
|
||||
'tours.horse.dialog.joinGap': 'Ühenda käsitsi lõiguga',
|
||||
'tours.horse.dialog.joinGapHint': 'Sirgelt joonistatud, kontrollimata',
|
||||
'tours.horse.dialog.adopt': 'Võta lõik üle',
|
||||
'tours.horse.access.title': 'Ligipääs hobusega',
|
||||
'tours.horse.access.note':
|
||||
'Mida OpenStreetMap teede ääres märgib kui horse=*. Andmed võivad puududa või olla aegunud; kohapeal kehtivad sildid.',
|
||||
'tours.horse.access.closedNotice': 'Osa sellest lõigust on hobustele märgitud suletuks või eravaldusse kuuluvaks.',
|
||||
'tours.horse.access.value.designated': 'Määratud ratsanikele',
|
||||
'tours.horse.access.value.yes': 'Lubatud',
|
||||
'tours.horse.access.value.permissive': 'Omaniku loal',
|
||||
'tours.horse.access.value.destination': 'Ainult sihtkohta',
|
||||
'tours.horse.access.value.discouraged': 'Ei soovitata',
|
||||
'tours.horse.access.value.private': 'Eravaldus',
|
||||
'tours.horse.access.value.no': 'Keelatud',
|
||||
'tours.horse.access.value.other': 'Muu reegel',
|
||||
'tours.horse.access.value.unknown': 'Andmetes puudub',
|
||||
'tours.horse.access.summary.open': 'Andmete järgi ratsanikele avatud',
|
||||
'tours.horse.access.summary.limited': 'Osaliselt piiratud',
|
||||
'tours.horse.access.summary.closed': 'Osaliselt suletud',
|
||||
'tours.horse.access.summary.unknown': 'Ligipääs andmetes puudub',
|
||||
'tours.horse.leg.info': 'Märgistatud ratsaraja lõik OpenStreetMapist. Ligipääs hobusega: {access}',
|
||||
'tours.horse.leg.manual': 'Käsitsi, kontrollimata',
|
||||
'tours.horse.leg.manualInfo':
|
||||
'Käsitsi joonistatud, sirge. Seda ei kontrollinud ükski ratsarada ega marsruudiplaneerija.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1029,5 +1029,66 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.wind.shoreTaken': 'Les lacs sont planifiés près de la rive.',
|
||||
'tours.paddle.status.unrouted': '{count} tronçons non planifiés',
|
||||
'tours.paddle.status.unrouted.one': '{count} tronçon non planifié',
|
||||
'tourTypes.horse': 'Randonnée équestre',
|
||||
'tours.planner.tourTypeHint.horse': 'À cheval, sur des itinéraires équestres balisés',
|
||||
'tours.planner.namePlaceholder.horse': 'Randonnée équestre sans titre',
|
||||
'tours.horse.time': 'Temps à cheval',
|
||||
'tours.horse.timeInfoLabel': "Pourquoi il n'y a pas de temps à cheval",
|
||||
'tours.horse.timeInfo':
|
||||
"TREK ne calcule aucun temps à cheval : l'allure dépend du cheval et du cavalier. Saisissez vous-même une durée totale prévue si besoin.",
|
||||
'tours.horse.routes.title': 'Itinéraires équestres',
|
||||
'tours.horse.routes.infoLabel': 'À propos des itinéraires équestres',
|
||||
'tours.horse.routes.info':
|
||||
"Itinéraires équestres balisés d'OpenStreetMap, en tronçons repris en entier. Faire partie d'un itinéraire n'autorise pas à y monter à cheval.",
|
||||
'tours.horse.routes.search': 'Chercher dans la vue',
|
||||
'tours.horse.routes.searchAgain': 'Chercher dans cette vue',
|
||||
'tours.horse.routes.loading': 'Recherche des itinéraires équestres…',
|
||||
'tours.horse.routes.zoomIn': 'Zoomez davantage pour chercher des itinéraires équestres.',
|
||||
'tours.horse.routes.offline': 'Les itinéraires équestres demandent une connexion.',
|
||||
'tours.horse.routes.failed': "Impossible de lire les itinéraires équestres. Votre randonnée n'a pas changé.",
|
||||
'tours.horse.routes.none': 'Aucun itinéraire équestre balisé dans cette vue.',
|
||||
'tours.horse.routes.truncated':
|
||||
"Il y a plus d'itinéraires ici qu'une recherche n'en montre. Zoomez pour tous les voir.",
|
||||
'tours.horse.routes.unnamed': 'Itinéraire équestre sans nom',
|
||||
'tours.horse.routes.sections': 'Tronçons',
|
||||
'tours.horse.routes.continues': 'Continue au-delà de la vue',
|
||||
'tours.horse.dialog.eyebrow': 'Itinéraire équestre',
|
||||
'tours.horse.dialog.osm': 'Ouvrir dans OpenStreetMap',
|
||||
'tours.horse.dialog.section': 'Tronçon {number}',
|
||||
'tours.horse.dialog.direction': 'Sens',
|
||||
'tours.horse.dialog.asMapped': 'Comme cartographié',
|
||||
'tours.horse.dialog.reversed': 'Inversé',
|
||||
'tours.horse.dialog.signedWith': 'Balisé dans ce sens',
|
||||
'tours.horse.dialog.signedAgainst': "Ce tronçon est balisé dans l'autre sens.",
|
||||
'tours.horse.dialog.connection': 'Raccord à votre randonnée',
|
||||
'tours.horse.dialog.firstSection': 'Commence votre randonnée',
|
||||
'tours.horse.dialog.joins': 'Se raccorde à la fin de votre randonnée',
|
||||
'tours.horse.dialog.gap':
|
||||
'{distance} séparent la fin de votre randonnée de ce tronçon, hors de tout itinéraire équestre.',
|
||||
'tours.horse.dialog.joinGap': 'Relier par un tronçon manuel',
|
||||
'tours.horse.dialog.joinGapHint': 'Tracé en ligne droite, non vérifié',
|
||||
'tours.horse.dialog.adopt': 'Reprendre le tronçon',
|
||||
'tours.horse.access.title': 'Accès à cheval',
|
||||
'tours.horse.access.note':
|
||||
"Ce qu'OpenStreetMap indique en horse=* le long des voies. Les données peuvent manquer ou dater ; les panneaux sur place font foi.",
|
||||
'tours.horse.access.closedNotice':
|
||||
'Une partie de ce tronçon est indiquée comme interdite ou privée pour les chevaux.',
|
||||
'tours.horse.access.value.designated': 'Réservé aux cavaliers',
|
||||
'tours.horse.access.value.yes': 'Autorisé',
|
||||
'tours.horse.access.value.permissive': 'Toléré par le propriétaire',
|
||||
'tours.horse.access.value.destination': 'Riverains uniquement',
|
||||
'tours.horse.access.value.discouraged': 'Déconseillé',
|
||||
'tours.horse.access.value.private': 'Privé',
|
||||
'tours.horse.access.value.no': 'Interdit',
|
||||
'tours.horse.access.value.other': 'Autre règle',
|
||||
'tours.horse.access.value.unknown': 'Absent des données',
|
||||
'tours.horse.access.summary.open': 'Ouvert aux cavaliers selon les données',
|
||||
'tours.horse.access.summary.limited': 'En partie restreint',
|
||||
'tours.horse.access.summary.closed': 'En partie interdit',
|
||||
'tours.horse.access.summary.unknown': 'Accès absent des données',
|
||||
'tours.horse.leg.info': "Tronçon d'un itinéraire équestre balisé d'OpenStreetMap. Accès à cheval : {access}",
|
||||
'tours.horse.leg.manual': 'Manuel, non vérifié',
|
||||
'tours.horse.leg.manualInfo':
|
||||
"Tracé à la main, en ligne droite. Ni un itinéraire équestre ni un calculateur d'itinéraire ne l'a vérifié.",
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1030,5 +1030,64 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.wind.shoreTaken': 'Οι λίμνες σχεδιάζονται κοντά στην όχθη.',
|
||||
'tours.paddle.status.unrouted': '{count} τμήματα χωρίς σχεδιασμό',
|
||||
'tours.paddle.status.unrouted.one': '{count} τμήμα χωρίς σχεδιασμό',
|
||||
'tourTypes.horse': 'Ιππική βόλτα',
|
||||
'tours.planner.tourTypeHint.horse': 'Με άλογο, σε σηματοδοτημένες ιππικές διαδρομές',
|
||||
'tours.planner.namePlaceholder.horse': 'Ιππική βόλτα χωρίς τίτλο',
|
||||
'tours.horse.time': 'Χρόνος ιππασίας',
|
||||
'tours.horse.timeInfoLabel': 'Γιατί δεν υπάρχει χρόνος ιππασίας',
|
||||
'tours.horse.timeInfo':
|
||||
'Το TREK δεν υπολογίζει χρόνο ιππασίας: τον ρυθμό τον ορίζουν το άλογο και ο αναβάτης. Αν θέλεις, δώσε μόνος σου έναν προγραμματισμένο συνολικό χρόνο.',
|
||||
'tours.horse.routes.title': 'Ιππικές διαδρομές',
|
||||
'tours.horse.routes.infoLabel': 'Σχετικά με τις ιππικές διαδρομές',
|
||||
'tours.horse.routes.info':
|
||||
'Σηματοδοτημένες ιππικές διαδρομές από το OpenStreetMap, σε τμήματα που παίρνεις ολόκληρα. Το ότι ένας δρόμος ανήκει σε διαδρομή δεν δίνει άδεια ιππασίας.',
|
||||
'tours.horse.routes.search': 'Αναζήτηση στην προβολή χάρτη',
|
||||
'tours.horse.routes.searchAgain': 'Αναζήτηση σε αυτή την προβολή',
|
||||
'tours.horse.routes.loading': 'Αναζήτηση ιππικών διαδρομών…',
|
||||
'tours.horse.routes.zoomIn': 'Μεγέθυνε κι άλλο για να αναζητήσεις ιππικές διαδρομές.',
|
||||
'tours.horse.routes.offline': 'Οι ιππικές διαδρομές χρειάζονται σύνδεση.',
|
||||
'tours.horse.routes.failed': 'Δεν διαβάστηκαν οι ιππικές διαδρομές. Η βόλτα σου δεν άλλαξε.',
|
||||
'tours.horse.routes.none': 'Καμία σηματοδοτημένη ιππική διαδρομή σε αυτή την προβολή.',
|
||||
'tours.horse.routes.truncated':
|
||||
'Εδώ υπάρχουν περισσότερες διαδρομές από όσες δείχνει μία αναζήτηση. Μεγέθυνε για να τις δεις όλες.',
|
||||
'tours.horse.routes.unnamed': 'Ιππική διαδρομή χωρίς όνομα',
|
||||
'tours.horse.routes.sections': 'Τμήματα',
|
||||
'tours.horse.routes.continues': 'Συνεχίζει έξω από την προβολή',
|
||||
'tours.horse.dialog.eyebrow': 'Ιππική διαδρομή',
|
||||
'tours.horse.dialog.osm': 'Άνοιγμα στο OpenStreetMap',
|
||||
'tours.horse.dialog.section': 'Τμήμα {number}',
|
||||
'tours.horse.dialog.direction': 'Κατεύθυνση',
|
||||
'tours.horse.dialog.asMapped': 'Όπως χαρτογραφήθηκε',
|
||||
'tours.horse.dialog.reversed': 'Αντίστροφα',
|
||||
'tours.horse.dialog.signedWith': 'Σηματοδοτημένο προς αυτή την κατεύθυνση',
|
||||
'tours.horse.dialog.signedAgainst': 'Αυτό το τμήμα είναι σηματοδοτημένο προς την αντίθετη κατεύθυνση.',
|
||||
'tours.horse.dialog.connection': 'Σύνδεση με τη βόλτα σου',
|
||||
'tours.horse.dialog.firstSection': 'Ξεκινά τη βόλτα σου',
|
||||
'tours.horse.dialog.joins': 'Συνδέεται με το τέλος της βόλτας σου',
|
||||
'tours.horse.dialog.gap':
|
||||
'Ανάμεσα στο τέλος της βόλτας σου και σε αυτό το τμήμα υπάρχουν {distance} χωρίς ιππική διαδρομή.',
|
||||
'tours.horse.dialog.joinGap': 'Σύνδεση με χειροκίνητο τμήμα',
|
||||
'tours.horse.dialog.joinGapHint': 'Ευθεία γραμμή, χωρίς έλεγχο',
|
||||
'tours.horse.dialog.adopt': 'Λήψη τμήματος',
|
||||
'tours.horse.access.title': 'Πρόσβαση με άλογο',
|
||||
'tours.horse.access.note':
|
||||
'Ό,τι καταγράφει το OpenStreetMap ως horse=* κατά μήκος των δρόμων. Τα στοιχεία μπορεί να λείπουν ή να είναι παλιά· ισχύουν οι πινακίδες επί τόπου.',
|
||||
'tours.horse.access.closedNotice': 'Μέρος αυτού του τμήματος καταγράφεται ως κλειστό ή ιδιωτικό για άλογα.',
|
||||
'tours.horse.access.value.designated': 'Προορίζεται για αναβάτες',
|
||||
'tours.horse.access.value.yes': 'Επιτρέπεται',
|
||||
'tours.horse.access.value.permissive': 'Ανεκτό από τον ιδιοκτήτη',
|
||||
'tours.horse.access.value.destination': 'Μόνο για πρόσβαση',
|
||||
'tours.horse.access.value.discouraged': 'Δεν συνιστάται',
|
||||
'tours.horse.access.value.private': 'Ιδιωτικό',
|
||||
'tours.horse.access.value.no': 'Απαγορεύεται',
|
||||
'tours.horse.access.value.other': 'Άλλος κανόνας',
|
||||
'tours.horse.access.value.unknown': 'Δεν υπάρχει στα δεδομένα',
|
||||
'tours.horse.access.summary.open': 'Ανοιχτό σε αναβάτες κατά τα δεδομένα',
|
||||
'tours.horse.access.summary.limited': 'Εν μέρει περιορισμένο',
|
||||
'tours.horse.access.summary.closed': 'Εν μέρει κλειστό',
|
||||
'tours.horse.access.summary.unknown': 'Η πρόσβαση δεν υπάρχει στα δεδομένα',
|
||||
'tours.horse.leg.info': 'Τμήμα σηματοδοτημένης ιππικής διαδρομής από το OpenStreetMap. Πρόσβαση με άλογο: {access}',
|
||||
'tours.horse.leg.manual': 'Χειροκίνητο, χωρίς έλεγχο',
|
||||
'tours.horse.leg.manualInfo': 'Σχεδιασμένο με το χέρι, ευθύ. Ούτε ιππική διαδρομή ούτε δρομολογητής το έλεγξε.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1014,5 +1014,62 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.wind.shoreTaken': 'A tavak a part mentén vannak megtervezve.',
|
||||
'tours.paddle.status.unrouted': '{count} szakasz nincs megtervezve',
|
||||
'tours.paddle.status.unrouted.one': '{count} szakasz nincs megtervezve',
|
||||
'tourTypes.horse': 'Lovastúra',
|
||||
'tours.planner.tourTypeHint.horse': 'Lóháton, jelzett lovas útvonalakon',
|
||||
'tours.planner.namePlaceholder.horse': 'Névtelen lovastúra',
|
||||
'tours.horse.time': 'Lovaglási idő',
|
||||
'tours.horse.timeInfoLabel': 'Miért nincs lovaglási idő',
|
||||
'tours.horse.timeInfo':
|
||||
'A TREK nem számol lovaglási időt: a tempót a ló és a lovas adja. Ha szeretnéd, add meg magad a tervezett teljes időt.',
|
||||
'tours.horse.routes.title': 'Lovas útvonalak',
|
||||
'tours.horse.routes.infoLabel': 'A lovas útvonalakról',
|
||||
'tours.horse.routes.info':
|
||||
'Jelzett lovas útvonalak az OpenStreetMapből, egészben átvehető szakaszokban. Az, hogy egy út egy útvonalhoz tartozik, nem jelent engedélyt a lovaglásra.',
|
||||
'tours.horse.routes.search': 'Keresés a térképnézetben',
|
||||
'tours.horse.routes.searchAgain': 'Keresés ebben a nézetben',
|
||||
'tours.horse.routes.loading': 'Lovas útvonalak keresése…',
|
||||
'tours.horse.routes.zoomIn': 'Nagyíts tovább, hogy lovas útvonalakat keress.',
|
||||
'tours.horse.routes.offline': 'A lovas útvonalakhoz kapcsolat kell.',
|
||||
'tours.horse.routes.failed': 'A lovas útvonalakat nem sikerült beolvasni. A lovastúrád nem változott.',
|
||||
'tours.horse.routes.none': 'Ebben a nézetben nincs jelzett lovas útvonal.',
|
||||
'tours.horse.routes.truncated': 'Itt több útvonal van, mint amennyit egy keresés mutat. Nagyíts, hogy mindet lásd.',
|
||||
'tours.horse.routes.unnamed': 'Névtelen lovas útvonal',
|
||||
'tours.horse.routes.sections': 'Szakaszok',
|
||||
'tours.horse.routes.continues': 'A nézeten túl folytatódik',
|
||||
'tours.horse.dialog.eyebrow': 'Lovas útvonal',
|
||||
'tours.horse.dialog.osm': 'Megnyitás az OpenStreetMapben',
|
||||
'tours.horse.dialog.section': '{number}. szakasz',
|
||||
'tours.horse.dialog.direction': 'Irány',
|
||||
'tours.horse.dialog.asMapped': 'Ahogy fel van térképezve',
|
||||
'tours.horse.dialog.reversed': 'Fordítva',
|
||||
'tours.horse.dialog.signedWith': 'Ebben az irányban jelzett',
|
||||
'tours.horse.dialog.signedAgainst': 'Ez a szakasz az ellenkező irányban van jelezve.',
|
||||
'tours.horse.dialog.connection': 'Csatlakozás a túrához',
|
||||
'tours.horse.dialog.firstSection': 'Elindítja a túrát',
|
||||
'tours.horse.dialog.joins': 'A túra végéhez csatlakozik',
|
||||
'tours.horse.dialog.gap': 'A túra vége és e szakasz között {distance} van lovas útvonal nélkül.',
|
||||
'tours.horse.dialog.joinGap': 'Összekötés kézi szakasszal',
|
||||
'tours.horse.dialog.joinGapHint': 'Egyenesen húzva, ellenőrizetlen',
|
||||
'tours.horse.dialog.adopt': 'Szakasz átvétele',
|
||||
'tours.horse.access.title': 'Lovas hozzáférés',
|
||||
'tours.horse.access.note':
|
||||
'Amit az OpenStreetMap az utak mentén horse=* címkével rögzít. Az adatok hiányozhatnak vagy elavultak lehetnek; a helyszíni táblák a mérvadók.',
|
||||
'tours.horse.access.closedNotice': 'A szakasz egy része lovak számára lezártként vagy magánútként szerepel.',
|
||||
'tours.horse.access.value.designated': 'Lovasoknak kijelölt',
|
||||
'tours.horse.access.value.yes': 'Engedélyezett',
|
||||
'tours.horse.access.value.permissive': 'A tulajdonos eltűri',
|
||||
'tours.horse.access.value.destination': 'Csak célforgalom',
|
||||
'tours.horse.access.value.discouraged': 'Nem ajánlott',
|
||||
'tours.horse.access.value.private': 'Magán',
|
||||
'tours.horse.access.value.no': 'Tilos',
|
||||
'tours.horse.access.value.other': 'Más szabály',
|
||||
'tours.horse.access.value.unknown': 'Nincs az adatokban',
|
||||
'tours.horse.access.summary.open': 'Az adatok szerint nyitott a lovasoknak',
|
||||
'tours.horse.access.summary.limited': 'Részben korlátozott',
|
||||
'tours.horse.access.summary.closed': 'Részben lezárt',
|
||||
'tours.horse.access.summary.unknown': 'A hozzáférés nincs az adatokban',
|
||||
'tours.horse.leg.info': 'Jelzett lovas útvonal szakasza az OpenStreetMapből. Lovas hozzáférés: {access}',
|
||||
'tours.horse.leg.manual': 'Kézi, ellenőrizetlen',
|
||||
'tours.horse.leg.manualInfo': 'Kézzel, egyenesen húzva. Sem lovas útvonal, sem útvonaltervező nem ellenőrizte.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -994,5 +994,64 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.wind.shoreAdopt': 'Rencanakan dekat tepi',
|
||||
'tours.paddle.wind.shoreTaken': 'Danau direncanakan dekat tepi.',
|
||||
'tours.paddle.status.unrouted': '{count} bagian belum direncanakan',
|
||||
'tourTypes.horse': 'Tur berkuda',
|
||||
'tours.planner.tourTypeHint.horse': 'Menunggang kuda, menyusuri rute berkuda bertanda',
|
||||
'tours.planner.namePlaceholder.horse': 'Tur berkuda tanpa judul',
|
||||
'tours.horse.time': 'Waktu berkuda',
|
||||
'tours.horse.timeInfoLabel': 'Mengapa tidak ada waktu berkuda',
|
||||
'tours.horse.timeInfo':
|
||||
'TREK tidak menghitung waktu berkuda: lajunya ditentukan kuda dan penunggangnya. Jika mau, isi sendiri total waktu yang direncanakan.',
|
||||
'tours.horse.routes.title': 'Rute berkuda',
|
||||
'tours.horse.routes.infoLabel': 'Tentang rute berkuda',
|
||||
'tours.horse.routes.info':
|
||||
'Rute berkuda bertanda dari OpenStreetMap, dalam bagian yang diambil utuh. Menjadi bagian sebuah rute bukan berarti boleh dilalui dengan kuda.',
|
||||
'tours.horse.routes.search': 'Cari di tampilan peta',
|
||||
'tours.horse.routes.searchAgain': 'Cari di tampilan ini',
|
||||
'tours.horse.routes.loading': 'Mencari rute berkuda…',
|
||||
'tours.horse.routes.zoomIn': 'Perbesar peta lagi untuk mencari rute berkuda.',
|
||||
'tours.horse.routes.offline': 'Rute berkuda memerlukan koneksi.',
|
||||
'tours.horse.routes.failed': 'Rute berkuda tidak dapat dibaca. Tur Anda tidak berubah.',
|
||||
'tours.horse.routes.none': 'Tidak ada rute berkuda bertanda di tampilan ini.',
|
||||
'tours.horse.routes.truncated':
|
||||
'Ada lebih banyak rute di sini daripada yang ditampilkan satu pencarian. Perbesar untuk melihat semuanya.',
|
||||
'tours.horse.routes.unnamed': 'Rute berkuda tanpa nama',
|
||||
'tours.horse.routes.sections': 'Bagian',
|
||||
'tours.horse.routes.continues': 'Berlanjut di luar tampilan',
|
||||
'tours.horse.dialog.eyebrow': 'Rute berkuda',
|
||||
'tours.horse.dialog.osm': 'Buka di OpenStreetMap',
|
||||
'tours.horse.dialog.section': 'Bagian {number}',
|
||||
'tours.horse.dialog.direction': 'Arah',
|
||||
'tours.horse.dialog.asMapped': 'Sesuai peta',
|
||||
'tours.horse.dialog.reversed': 'Terbalik',
|
||||
'tours.horse.dialog.signedWith': 'Bertanda ke arah ini',
|
||||
'tours.horse.dialog.signedAgainst': 'Bagian ini bertanda ke arah sebaliknya.',
|
||||
'tours.horse.dialog.connection': 'Sambungan ke tur Anda',
|
||||
'tours.horse.dialog.firstSection': 'Memulai tur Anda',
|
||||
'tours.horse.dialog.joins': 'Tersambung ke akhir tur Anda',
|
||||
'tours.horse.dialog.gap': 'Antara akhir tur Anda dan bagian ini ada {distance} tanpa rute berkuda.',
|
||||
'tours.horse.dialog.joinGap': 'Sambungkan dengan bagian manual',
|
||||
'tours.horse.dialog.joinGapHint': 'Ditarik lurus dan belum diperiksa',
|
||||
'tours.horse.dialog.adopt': 'Ambil bagian',
|
||||
'tours.horse.access.title': 'Akses berkuda',
|
||||
'tours.horse.access.note':
|
||||
'Yang dicatat OpenStreetMap sebagai horse=* di sepanjang jalan. Data bisa kurang atau usang; rambu di lokasi yang berlaku.',
|
||||
'tours.horse.access.closedNotice': 'Sebagian bagian ini tercatat tertutup atau pribadi untuk kuda.',
|
||||
'tours.horse.access.value.designated': 'Khusus penunggang kuda',
|
||||
'tours.horse.access.value.yes': 'Diizinkan',
|
||||
'tours.horse.access.value.permissive': 'Diizinkan oleh pemilik',
|
||||
'tours.horse.access.value.destination': 'Hanya untuk tujuan',
|
||||
'tours.horse.access.value.discouraged': 'Tidak dianjurkan',
|
||||
'tours.horse.access.value.private': 'Pribadi',
|
||||
'tours.horse.access.value.no': 'Dilarang',
|
||||
'tours.horse.access.value.other': 'Aturan lain',
|
||||
'tours.horse.access.value.unknown': 'Tidak ada di data',
|
||||
'tours.horse.access.summary.open': 'Terbuka bagi penunggang kuda menurut data',
|
||||
'tours.horse.access.summary.limited': 'Sebagian dibatasi',
|
||||
'tours.horse.access.summary.closed': 'Sebagian ditutup',
|
||||
'tours.horse.access.summary.unknown': 'Akses tidak ada di data',
|
||||
'tours.horse.leg.info': 'Bagian dari rute berkuda bertanda dari OpenStreetMap. Akses berkuda: {access}',
|
||||
'tours.horse.leg.manual': 'Manual, belum diperiksa',
|
||||
'tours.horse.leg.manualInfo':
|
||||
'Digambar tangan, lurus. Tidak ada rute berkuda maupun perencana rute yang memeriksanya.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1019,5 +1019,65 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.wind.shoreTaken': 'I laghi sono pianificati vicino alla riva.',
|
||||
'tours.paddle.status.unrouted': '{count} tratti non pianificati',
|
||||
'tours.paddle.status.unrouted.one': '{count} tratto non pianificato',
|
||||
'tourTypes.horse': 'Escursione a cavallo',
|
||||
'tours.planner.tourTypeHint.horse': 'A cavallo, lungo itinerari equestri segnalati',
|
||||
'tours.planner.namePlaceholder.horse': 'Escursione a cavallo senza titolo',
|
||||
'tours.horse.time': 'Tempo a cavallo',
|
||||
'tours.horse.timeInfoLabel': 'Perché non c’è un tempo a cavallo',
|
||||
'tours.horse.timeInfo':
|
||||
"TREK non calcola alcun tempo a cavallo: l'andatura dipende dal cavallo e da chi lo monta. Se vuoi, inserisci tu un totale previsto.",
|
||||
'tours.horse.routes.title': 'Itinerari equestri',
|
||||
'tours.horse.routes.infoLabel': 'Sugli itinerari equestri',
|
||||
'tours.horse.routes.info':
|
||||
'Itinerari equestri segnalati da OpenStreetMap, in tratti da prendere interi. Far parte di un itinerario non dà il permesso di percorrerlo a cavallo.',
|
||||
'tours.horse.routes.search': 'Cerca nella vista',
|
||||
'tours.horse.routes.searchAgain': 'Cerca in questa vista',
|
||||
'tours.horse.routes.loading': 'Ricerca degli itinerari equestri…',
|
||||
'tours.horse.routes.zoomIn': 'Ingrandisci ancora per cercare itinerari equestri.',
|
||||
'tours.horse.routes.offline': 'Gli itinerari equestri richiedono una connessione.',
|
||||
'tours.horse.routes.failed': 'Impossibile leggere gli itinerari equestri. La tua escursione non è cambiata.',
|
||||
'tours.horse.routes.none': 'Nessun itinerario equestre segnalato in questa vista.',
|
||||
'tours.horse.routes.truncated':
|
||||
'Qui ci sono più itinerari di quanti ne mostri una ricerca. Ingrandisci per vederli tutti.',
|
||||
'tours.horse.routes.unnamed': 'Itinerario equestre senza nome',
|
||||
'tours.horse.routes.sections': 'Tratti',
|
||||
'tours.horse.routes.continues': 'Prosegue oltre la vista',
|
||||
'tours.horse.dialog.eyebrow': 'Itinerario equestre',
|
||||
'tours.horse.dialog.osm': 'Apri in OpenStreetMap',
|
||||
'tours.horse.dialog.section': 'Tratto {number}',
|
||||
'tours.horse.dialog.direction': 'Senso',
|
||||
'tours.horse.dialog.asMapped': 'Come mappato',
|
||||
'tours.horse.dialog.reversed': 'Invertito',
|
||||
'tours.horse.dialog.signedWith': 'Segnalato in questo senso',
|
||||
'tours.horse.dialog.signedAgainst': 'Questo tratto è segnalato nel senso opposto.',
|
||||
'tours.horse.dialog.connection': 'Collegamento alla tua escursione',
|
||||
'tours.horse.dialog.firstSection': 'Inizia la tua escursione',
|
||||
'tours.horse.dialog.joins': 'Si collega alla fine della tua escursione',
|
||||
'tours.horse.dialog.gap':
|
||||
'Tra la fine della tua escursione e questo tratto ci sono {distance} senza itinerario equestre.',
|
||||
'tours.horse.dialog.joinGap': 'Collega con un tratto manuale',
|
||||
'tours.horse.dialog.joinGapHint': 'Tracciato dritto e non verificato',
|
||||
'tours.horse.dialog.adopt': 'Prendi il tratto',
|
||||
'tours.horse.access.title': 'Accesso a cavallo',
|
||||
'tours.horse.access.note':
|
||||
'Ciò che OpenStreetMap indica come horse=* lungo le strade. I dati possono mancare o essere superati; valgono i cartelli sul posto.',
|
||||
'tours.horse.access.closedNotice': 'Parte di questo tratto risulta vietata o privata per i cavalli.',
|
||||
'tours.horse.access.value.designated': 'Riservato ai cavalieri',
|
||||
'tours.horse.access.value.yes': 'Consentito',
|
||||
'tours.horse.access.value.permissive': 'Tollerato dal proprietario',
|
||||
'tours.horse.access.value.destination': 'Solo per i frontisti',
|
||||
'tours.horse.access.value.discouraged': 'Sconsigliato',
|
||||
'tours.horse.access.value.private': 'Privato',
|
||||
'tours.horse.access.value.no': 'Vietato',
|
||||
'tours.horse.access.value.other': 'Altra regola',
|
||||
'tours.horse.access.value.unknown': 'Non nei dati',
|
||||
'tours.horse.access.summary.open': 'Aperto ai cavalieri secondo i dati',
|
||||
'tours.horse.access.summary.limited': 'In parte limitato',
|
||||
'tours.horse.access.summary.closed': 'In parte vietato',
|
||||
'tours.horse.access.summary.unknown': 'Accesso non nei dati',
|
||||
'tours.horse.leg.info': 'Tratto di un itinerario equestre segnalato da OpenStreetMap. Accesso a cavallo: {access}',
|
||||
'tours.horse.leg.manual': 'Manuale, non verificato',
|
||||
'tours.horse.leg.manualInfo':
|
||||
'Tracciato a mano, dritto. Né un itinerario equestre né un pianificatore lo ha verificato.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -970,5 +970,59 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.wind.shoreAdopt': '岸沿いで計画',
|
||||
'tours.paddle.wind.shoreTaken': '湖は岸沿いで計画されています。',
|
||||
'tours.paddle.status.unrouted': '未計画の区間 {count} 件',
|
||||
'tourTypes.horse': '乗馬ツアー',
|
||||
'tours.planner.tourTypeHint.horse': '馬に乗って、標識のある乗馬ルートを進む',
|
||||
'tours.planner.namePlaceholder.horse': '名前のない乗馬ツアー',
|
||||
'tours.horse.time': '乗馬時間',
|
||||
'tours.horse.timeInfoLabel': '乗馬時間がない理由',
|
||||
'tours.horse.timeInfo': 'TREK は乗馬時間を計算しません。ペースは馬と乗り手しだいです。必要なら予定の合計時間をご自身で入力してください。',
|
||||
'tours.horse.routes.title': '乗馬ルート',
|
||||
'tours.horse.routes.infoLabel': '乗馬ルートについて',
|
||||
'tours.horse.routes.info': 'OpenStreetMap の標識付き乗馬ルートを、まるごと取り込む区間ごとに表示します。ルートに含まれていても、乗馬が許可されているとは限りません。',
|
||||
'tours.horse.routes.search': '地図の表示範囲で探す',
|
||||
'tours.horse.routes.searchAgain': 'この範囲を検索',
|
||||
'tours.horse.routes.loading': '乗馬ルートを検索中…',
|
||||
'tours.horse.routes.zoomIn': '乗馬ルートを探すにはさらに拡大してください。',
|
||||
'tours.horse.routes.offline': '乗馬ルートには接続が必要です。',
|
||||
'tours.horse.routes.failed': '乗馬ルートを読み込めませんでした。ツアーは変わっていません。',
|
||||
'tours.horse.routes.none': 'この範囲に標識付きの乗馬ルートはありません。',
|
||||
'tours.horse.routes.truncated': 'ここには1回の検索で表示できる以上のルートがあります。拡大するとすべて表示されます。',
|
||||
'tours.horse.routes.unnamed': '名前のない乗馬ルート',
|
||||
'tours.horse.routes.sections': '区間',
|
||||
'tours.horse.routes.continues': '表示範囲の外へ続く',
|
||||
'tours.horse.dialog.eyebrow': '乗馬ルート',
|
||||
'tours.horse.dialog.osm': 'OpenStreetMap で開く',
|
||||
'tours.horse.dialog.section': '区間 {number}',
|
||||
'tours.horse.dialog.direction': '方向',
|
||||
'tours.horse.dialog.asMapped': '地図どおり',
|
||||
'tours.horse.dialog.reversed': '逆方向',
|
||||
'tours.horse.dialog.signedWith': 'この方向に標識あり',
|
||||
'tours.horse.dialog.signedAgainst': 'この区間は逆方向に標識があります。',
|
||||
'tours.horse.dialog.connection': 'ツアーとのつながり',
|
||||
'tours.horse.dialog.firstSection': 'ツアーの始まり',
|
||||
'tours.horse.dialog.joins': 'ツアーの終点につながる',
|
||||
'tours.horse.dialog.gap': 'ツアーの終点とこの区間の間に、乗馬ルートのない {distance} があります。',
|
||||
'tours.horse.dialog.joinGap': '手動の区間でつなぐ',
|
||||
'tours.horse.dialog.joinGapHint': '直線で引かれ、未確認',
|
||||
'tours.horse.dialog.adopt': '区間を取り込む',
|
||||
'tours.horse.access.title': '乗馬でのアクセス',
|
||||
'tours.horse.access.note': 'OpenStreetMap が道沿いに horse=* として記録している情報です。データが欠けていたり古かったりすることがあり、現地の標識が優先されます。',
|
||||
'tours.horse.access.closedNotice': 'この区間の一部は、馬について通行禁止または私有地として記録されています。',
|
||||
'tours.horse.access.value.designated': '乗馬専用',
|
||||
'tours.horse.access.value.yes': '許可',
|
||||
'tours.horse.access.value.permissive': '所有者が黙認',
|
||||
'tours.horse.access.value.destination': '目的地への通行のみ',
|
||||
'tours.horse.access.value.discouraged': '非推奨',
|
||||
'tours.horse.access.value.private': '私有',
|
||||
'tours.horse.access.value.no': '禁止',
|
||||
'tours.horse.access.value.other': 'その他の規則',
|
||||
'tours.horse.access.value.unknown': 'データになし',
|
||||
'tours.horse.access.summary.open': 'データ上は乗り手に開放',
|
||||
'tours.horse.access.summary.limited': '一部制限あり',
|
||||
'tours.horse.access.summary.closed': '一部通行禁止',
|
||||
'tours.horse.access.summary.unknown': 'アクセスはデータになし',
|
||||
'tours.horse.leg.info': 'OpenStreetMap の標識付き乗馬ルートの区間です。乗馬でのアクセス: {access}',
|
||||
'tours.horse.leg.manual': '手動、未確認',
|
||||
'tours.horse.leg.manualInfo': '手で直線に引いた区間です。乗馬ルートもルーターも確認していません。',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -967,5 +967,59 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.wind.shoreAdopt': '물가 가까이 계획',
|
||||
'tours.paddle.wind.shoreTaken': '호수는 물가 가까이 계획되어 있습니다.',
|
||||
'tours.paddle.status.unrouted': '계획되지 않은 구간 {count}개',
|
||||
'tourTypes.horse': '승마 투어',
|
||||
'tours.planner.tourTypeHint.horse': '말을 타고, 표지가 있는 승마 루트를 따라',
|
||||
'tours.planner.namePlaceholder.horse': '이름 없는 승마 투어',
|
||||
'tours.horse.time': '승마 시간',
|
||||
'tours.horse.timeInfoLabel': '승마 시간이 없는 이유',
|
||||
'tours.horse.timeInfo': 'TREK은 승마 시간을 계산하지 않습니다. 속도는 말과 기수에 달려 있습니다. 원하면 예정 총 시간을 직접 입력하세요.',
|
||||
'tours.horse.routes.title': '승마 루트',
|
||||
'tours.horse.routes.infoLabel': '승마 루트 안내',
|
||||
'tours.horse.routes.info': 'OpenStreetMap의 표지가 있는 승마 루트를 통째로 가져오는 구간 단위로 보여 줍니다. 루트에 속한다고 해서 말을 타도 된다는 뜻은 아닙니다.',
|
||||
'tours.horse.routes.search': '지도 화면에서 찾기',
|
||||
'tours.horse.routes.searchAgain': '이 화면 검색',
|
||||
'tours.horse.routes.loading': '승마 루트를 찾는 중…',
|
||||
'tours.horse.routes.zoomIn': '승마 루트를 찾으려면 더 확대하세요.',
|
||||
'tours.horse.routes.offline': '승마 루트에는 연결이 필요합니다.',
|
||||
'tours.horse.routes.failed': '승마 루트를 읽지 못했습니다. 투어는 그대로입니다.',
|
||||
'tours.horse.routes.none': '이 화면에는 표지가 있는 승마 루트가 없습니다.',
|
||||
'tours.horse.routes.truncated': '이곳에는 한 번의 검색으로 보이는 것보다 많은 루트가 있습니다. 모두 보려면 확대하세요.',
|
||||
'tours.horse.routes.unnamed': '이름 없는 승마 루트',
|
||||
'tours.horse.routes.sections': '구간',
|
||||
'tours.horse.routes.continues': '화면 밖으로 이어짐',
|
||||
'tours.horse.dialog.eyebrow': '승마 루트',
|
||||
'tours.horse.dialog.osm': 'OpenStreetMap에서 열기',
|
||||
'tours.horse.dialog.section': '구간 {number}',
|
||||
'tours.horse.dialog.direction': '방향',
|
||||
'tours.horse.dialog.asMapped': '지도에 그려진 대로',
|
||||
'tours.horse.dialog.reversed': '반대로',
|
||||
'tours.horse.dialog.signedWith': '이 방향으로 표지 있음',
|
||||
'tours.horse.dialog.signedAgainst': '이 구간은 반대 방향으로 표지가 있습니다.',
|
||||
'tours.horse.dialog.connection': '투어와의 연결',
|
||||
'tours.horse.dialog.firstSection': '투어의 시작',
|
||||
'tours.horse.dialog.joins': '투어의 끝에 이어짐',
|
||||
'tours.horse.dialog.gap': '투어의 끝과 이 구간 사이에 승마 루트가 없는 {distance}가 있습니다.',
|
||||
'tours.horse.dialog.joinGap': '수동 구간으로 연결',
|
||||
'tours.horse.dialog.joinGapHint': '직선으로 그었고 확인되지 않음',
|
||||
'tours.horse.dialog.adopt': '구간 가져오기',
|
||||
'tours.horse.access.title': '말 통행',
|
||||
'tours.horse.access.note': 'OpenStreetMap이 길을 따라 horse=*로 기록한 내용입니다. 데이터가 없거나 오래되었을 수 있으니 현장 표지판을 따르세요.',
|
||||
'tours.horse.access.closedNotice': '이 구간의 일부는 말에게 통행 금지 또는 사유지로 기록되어 있습니다.',
|
||||
'tours.horse.access.value.designated': '승마 전용',
|
||||
'tours.horse.access.value.yes': '허용',
|
||||
'tours.horse.access.value.permissive': '소유자가 묵인',
|
||||
'tours.horse.access.value.destination': '목적지 통행만',
|
||||
'tours.horse.access.value.discouraged': '권장하지 않음',
|
||||
'tours.horse.access.value.private': '사유지',
|
||||
'tours.horse.access.value.no': '금지',
|
||||
'tours.horse.access.value.other': '기타 규칙',
|
||||
'tours.horse.access.value.unknown': '데이터에 없음',
|
||||
'tours.horse.access.summary.open': '데이터상 기수에게 개방',
|
||||
'tours.horse.access.summary.limited': '일부 제한',
|
||||
'tours.horse.access.summary.closed': '일부 금지',
|
||||
'tours.horse.access.summary.unknown': '통행 정보가 데이터에 없음',
|
||||
'tours.horse.leg.info': 'OpenStreetMap의 표지가 있는 승마 루트의 구간입니다. 말 통행: {access}',
|
||||
'tours.horse.leg.manual': '수동, 확인되지 않음',
|
||||
'tours.horse.leg.manualInfo': '손으로 그은 직선 구간입니다. 승마 루트도 경로 계산기도 확인하지 않았습니다.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1015,5 +1015,64 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.wind.shoreTaken': 'De meren zijn dicht bij de oever gepland.',
|
||||
'tours.paddle.status.unrouted': '{count} delen niet gepland',
|
||||
'tours.paddle.status.unrouted.one': '{count} deel niet gepland',
|
||||
'tourTypes.horse': 'Rit te paard',
|
||||
'tours.planner.tourTypeHint.horse': 'Te paard, langs bewegwijzerde ruiterroutes',
|
||||
'tours.planner.namePlaceholder.horse': 'Naamloze rit',
|
||||
'tours.horse.time': 'Rijtijd',
|
||||
'tours.horse.timeInfoLabel': 'Waarom er geen rijtijd is',
|
||||
'tours.horse.timeInfo':
|
||||
'TREK berekent geen rijtijd: het tempo bepalen paard en ruiter. Vul zelf een geplande totale tijd in als je die wilt.',
|
||||
'tours.horse.routes.title': 'Ruiterroutes',
|
||||
'tours.horse.routes.infoLabel': 'Over ruiterroutes',
|
||||
'tours.horse.routes.info':
|
||||
'Bewegwijzerde ruiterroutes uit OpenStreetMap, in trajecten die je in hun geheel overneemt. Dat een weg bij een route hoort, betekent niet dat je er mag rijden.',
|
||||
'tours.horse.routes.search': 'Zoeken in kaartbeeld',
|
||||
'tours.horse.routes.searchAgain': 'Dit kaartbeeld doorzoeken',
|
||||
'tours.horse.routes.loading': 'Ruiterroutes zoeken…',
|
||||
'tours.horse.routes.zoomIn': 'Zoom verder in om naar ruiterroutes te zoeken.',
|
||||
'tours.horse.routes.offline': 'Ruiterroutes hebben een verbinding nodig.',
|
||||
'tours.horse.routes.failed': 'Ruiterroutes konden niet worden gelezen. Je rit is niet veranderd.',
|
||||
'tours.horse.routes.none': 'Geen bewegwijzerde ruiterroutes in dit kaartbeeld.',
|
||||
'tours.horse.routes.truncated':
|
||||
'Er zijn hier meer routes dan één zoekopdracht toont. Zoom in om ze allemaal te zien.',
|
||||
'tours.horse.routes.unnamed': 'Ruiterroute zonder naam',
|
||||
'tours.horse.routes.sections': 'Trajecten',
|
||||
'tours.horse.routes.continues': 'Loopt door buiten het kaartbeeld',
|
||||
'tours.horse.dialog.eyebrow': 'Ruiterroute',
|
||||
'tours.horse.dialog.osm': 'Openen in OpenStreetMap',
|
||||
'tours.horse.dialog.section': 'Traject {number}',
|
||||
'tours.horse.dialog.direction': 'Richting',
|
||||
'tours.horse.dialog.asMapped': 'Zoals gekarteerd',
|
||||
'tours.horse.dialog.reversed': 'Omgekeerd',
|
||||
'tours.horse.dialog.signedWith': 'In deze richting bewegwijzerd',
|
||||
'tours.horse.dialog.signedAgainst': 'Dit traject is in de andere richting bewegwijzerd.',
|
||||
'tours.horse.dialog.connection': 'Aansluiting op je rit',
|
||||
'tours.horse.dialog.firstSection': 'Begint je rit',
|
||||
'tours.horse.dialog.joins': 'Sluit aan op het einde van je rit',
|
||||
'tours.horse.dialog.gap': 'Tussen het einde van je rit en dit traject ligt {distance} zonder ruiterroute.',
|
||||
'tours.horse.dialog.joinGap': 'Verbinden met een handmatig traject',
|
||||
'tours.horse.dialog.joinGapHint': 'Recht getrokken en niet gecontroleerd',
|
||||
'tours.horse.dialog.adopt': 'Traject overnemen',
|
||||
'tours.horse.access.title': 'Toegang te paard',
|
||||
'tours.horse.access.note':
|
||||
'Wat OpenStreetMap langs de wegen als horse=* vastlegt. Gegevens kunnen ontbreken of verouderd zijn; ter plekke gelden de borden.',
|
||||
'tours.horse.access.closedNotice': 'Een deel van dit traject staat als verboden of privé voor paarden vermeld.',
|
||||
'tours.horse.access.value.designated': 'Aangewezen voor ruiters',
|
||||
'tours.horse.access.value.yes': 'Toegestaan',
|
||||
'tours.horse.access.value.permissive': 'Gedoogd door de eigenaar',
|
||||
'tours.horse.access.value.destination': 'Alleen bestemmingsverkeer',
|
||||
'tours.horse.access.value.discouraged': 'Afgeraden',
|
||||
'tours.horse.access.value.private': 'Privé',
|
||||
'tours.horse.access.value.no': 'Verboden',
|
||||
'tours.horse.access.value.other': 'Andere regel',
|
||||
'tours.horse.access.value.unknown': 'Niet in de gegevens',
|
||||
'tours.horse.access.summary.open': 'Volgens de gegevens open voor ruiters',
|
||||
'tours.horse.access.summary.limited': 'Deels beperkt',
|
||||
'tours.horse.access.summary.closed': 'Deels verboden',
|
||||
'tours.horse.access.summary.unknown': 'Toegang niet in de gegevens',
|
||||
'tours.horse.leg.info': 'Traject van een bewegwijzerde ruiterroute uit OpenStreetMap. Toegang te paard: {access}',
|
||||
'tours.horse.leg.manual': 'Handmatig, niet gecontroleerd',
|
||||
'tours.horse.leg.manualInfo':
|
||||
'Met de hand getrokken, recht. Geen ruiterroute en geen routeplanner heeft het gecontroleerd.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1042,5 +1042,63 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.status.unrouted.one': '{count} odcinek bez planu',
|
||||
'tours.paddle.status.unrouted.few': '{count} odcinki bez planu',
|
||||
'tours.paddle.status.unrouted.many': '{count} odcinków bez planu',
|
||||
'tourTypes.horse': 'Przejażdżka konna',
|
||||
'tours.planner.tourTypeHint.horse': 'Konno, oznakowanymi szlakami konnymi',
|
||||
'tours.planner.namePlaceholder.horse': 'Przejażdżka bez nazwy',
|
||||
'tours.horse.time': 'Czas jazdy',
|
||||
'tours.horse.timeInfoLabel': 'Dlaczego nie ma czasu jazdy',
|
||||
'tours.horse.timeInfo':
|
||||
'TREK nie wylicza czasu jazdy: tempo zależy od konia i jeźdźca. Jeśli chcesz, wpisz sam planowany czas łączny.',
|
||||
'tours.horse.routes.title': 'Szlaki konne',
|
||||
'tours.horse.routes.infoLabel': 'O szlakach konnych',
|
||||
'tours.horse.routes.info':
|
||||
'Oznakowane szlaki konne z OpenStreetMap, w odcinkach przejmowanych w całości. To, że droga należy do szlaku, nie oznacza pozwolenia na jazdę konną.',
|
||||
'tours.horse.routes.search': 'Szukaj w widoku mapy',
|
||||
'tours.horse.routes.searchAgain': 'Przeszukaj ten widok',
|
||||
'tours.horse.routes.loading': 'Szukanie szlaków konnych…',
|
||||
'tours.horse.routes.zoomIn': 'Przybliż mapę bardziej, aby szukać szlaków konnych.',
|
||||
'tours.horse.routes.offline': 'Szlaki konne wymagają połączenia.',
|
||||
'tours.horse.routes.failed': 'Nie udało się odczytać szlaków konnych. Twoja przejażdżka się nie zmieniła.',
|
||||
'tours.horse.routes.none': 'Brak oznakowanych szlaków konnych w tym widoku.',
|
||||
'tours.horse.routes.truncated':
|
||||
'Jest tu więcej szlaków, niż pokazuje jedno wyszukiwanie. Przybliż mapę, aby zobaczyć wszystkie.',
|
||||
'tours.horse.routes.unnamed': 'Szlak konny bez nazwy',
|
||||
'tours.horse.routes.sections': 'Odcinki',
|
||||
'tours.horse.routes.continues': 'Biegnie dalej poza widok',
|
||||
'tours.horse.dialog.eyebrow': 'Szlak konny',
|
||||
'tours.horse.dialog.osm': 'Otwórz w OpenStreetMap',
|
||||
'tours.horse.dialog.section': 'Odcinek {number}',
|
||||
'tours.horse.dialog.direction': 'Kierunek',
|
||||
'tours.horse.dialog.asMapped': 'Jak na mapie',
|
||||
'tours.horse.dialog.reversed': 'Odwrotnie',
|
||||
'tours.horse.dialog.signedWith': 'Oznakowany w tym kierunku',
|
||||
'tours.horse.dialog.signedAgainst': 'Ten odcinek jest oznakowany w przeciwnym kierunku.',
|
||||
'tours.horse.dialog.connection': 'Połączenie z przejażdżką',
|
||||
'tours.horse.dialog.firstSection': 'Rozpoczyna przejażdżkę',
|
||||
'tours.horse.dialog.joins': 'Łączy się z końcem przejażdżki',
|
||||
'tours.horse.dialog.gap': 'Między końcem przejażdżki a tym odcinkiem jest {distance} bez szlaku konnego.',
|
||||
'tours.horse.dialog.joinGap': 'Połącz odcinkiem ręcznym',
|
||||
'tours.horse.dialog.joinGapHint': 'Narysowany prosto, niesprawdzony',
|
||||
'tours.horse.dialog.adopt': 'Przejmij odcinek',
|
||||
'tours.horse.access.title': 'Dostęp dla koni',
|
||||
'tours.horse.access.note':
|
||||
'To, co OpenStreetMap zapisuje wzdłuż dróg jako horse=*. Danych może brakować lub mogą być nieaktualne; obowiązują znaki na miejscu.',
|
||||
'tours.horse.access.closedNotice': 'Część tego odcinka jest zapisana jako zamknięta lub prywatna dla koni.',
|
||||
'tours.horse.access.value.designated': 'Przeznaczony dla jeźdźców',
|
||||
'tours.horse.access.value.yes': 'Dozwolony',
|
||||
'tours.horse.access.value.permissive': 'Tolerowany przez właściciela',
|
||||
'tours.horse.access.value.destination': 'Tylko dojazd',
|
||||
'tours.horse.access.value.discouraged': 'Niezalecany',
|
||||
'tours.horse.access.value.private': 'Prywatny',
|
||||
'tours.horse.access.value.no': 'Zakazany',
|
||||
'tours.horse.access.value.other': 'Inna zasada',
|
||||
'tours.horse.access.value.unknown': 'Brak w danych',
|
||||
'tours.horse.access.summary.open': 'Według danych otwarty dla jeźdźców',
|
||||
'tours.horse.access.summary.limited': 'Częściowo ograniczony',
|
||||
'tours.horse.access.summary.closed': 'Częściowo zamknięty',
|
||||
'tours.horse.access.summary.unknown': 'Dostęp nieznany w danych',
|
||||
'tours.horse.leg.info': 'Odcinek oznakowanego szlaku konnego z OpenStreetMap. Dostęp dla koni: {access}',
|
||||
'tours.horse.leg.manual': 'Ręczny, niesprawdzony',
|
||||
'tours.horse.leg.manualInfo': 'Narysowany ręcznie, prosto. Nie sprawdził go żaden szlak konny ani planer tras.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1046,5 +1046,63 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.status.unrouted.one': '{count} участок не проложен',
|
||||
'tours.paddle.status.unrouted.few': '{count} участка не проложены',
|
||||
'tours.paddle.status.unrouted.many': '{count} участков не проложено',
|
||||
'tourTypes.horse': 'Конная прогулка',
|
||||
'tours.planner.tourTypeHint.horse': 'Верхом, по маркированным конным маршрутам',
|
||||
'tours.planner.namePlaceholder.horse': 'Конная прогулка без названия',
|
||||
'tours.horse.time': 'Время в седле',
|
||||
'tours.horse.timeInfoLabel': 'Почему нет времени в седле',
|
||||
'tours.horse.timeInfo':
|
||||
'TREK не рассчитывает время в седле: темп зависит от лошади и всадника. При желании укажите общее время сами.',
|
||||
'tours.horse.routes.title': 'Конные маршруты',
|
||||
'tours.horse.routes.infoLabel': 'О конных маршрутах',
|
||||
'tours.horse.routes.info':
|
||||
'Маркированные конные маршруты из OpenStreetMap, участками, которые берутся целиком. Принадлежность к маршруту не даёт права ездить по нему верхом.',
|
||||
'tours.horse.routes.search': 'Искать в видимой области',
|
||||
'tours.horse.routes.searchAgain': 'Искать в этой области',
|
||||
'tours.horse.routes.loading': 'Ищем конные маршруты…',
|
||||
'tours.horse.routes.zoomIn': 'Приблизьте карту, чтобы искать конные маршруты.',
|
||||
'tours.horse.routes.offline': 'Для конных маршрутов нужно подключение.',
|
||||
'tours.horse.routes.failed': 'Не удалось прочитать конные маршруты. Ваша прогулка не изменилась.',
|
||||
'tours.horse.routes.none': 'В этой области нет маркированных конных маршрутов.',
|
||||
'tours.horse.routes.truncated':
|
||||
'Здесь больше маршрутов, чем показывает один поиск. Приблизьте карту, чтобы увидеть все.',
|
||||
'tours.horse.routes.unnamed': 'Конный маршрут без названия',
|
||||
'tours.horse.routes.sections': 'Участки',
|
||||
'tours.horse.routes.continues': 'Продолжается за пределами области',
|
||||
'tours.horse.dialog.eyebrow': 'Конный маршрут',
|
||||
'tours.horse.dialog.osm': 'Открыть в OpenStreetMap',
|
||||
'tours.horse.dialog.section': 'Участок {number}',
|
||||
'tours.horse.dialog.direction': 'Направление',
|
||||
'tours.horse.dialog.asMapped': 'Как на карте',
|
||||
'tours.horse.dialog.reversed': 'В обратную сторону',
|
||||
'tours.horse.dialog.signedWith': 'Маркирован в этом направлении',
|
||||
'tours.horse.dialog.signedAgainst': 'Этот участок маркирован в обратном направлении.',
|
||||
'tours.horse.dialog.connection': 'Связь с прогулкой',
|
||||
'tours.horse.dialog.firstSection': 'Начинает прогулку',
|
||||
'tours.horse.dialog.joins': 'Продолжает конец прогулки',
|
||||
'tours.horse.dialog.gap': 'Между концом прогулки и этим участком {distance} без конного маршрута.',
|
||||
'tours.horse.dialog.joinGap': 'Соединить участком вручную',
|
||||
'tours.horse.dialog.joinGapHint': 'Прямая линия, не проверена',
|
||||
'tours.horse.dialog.adopt': 'Взять участок',
|
||||
'tours.horse.access.title': 'Доступ для лошадей',
|
||||
'tours.horse.access.note':
|
||||
'То, что OpenStreetMap отмечает вдоль дорог как horse=*. Данных может не быть или они устарели; на месте действуют знаки.',
|
||||
'tours.horse.access.closedNotice': 'Часть этого участка отмечена как закрытая или частная для лошадей.',
|
||||
'tours.horse.access.value.designated': 'Предназначен для всадников',
|
||||
'tours.horse.access.value.yes': 'Разрешено',
|
||||
'tours.horse.access.value.permissive': 'Допускается владельцем',
|
||||
'tours.horse.access.value.destination': 'Только для местного проезда',
|
||||
'tours.horse.access.value.discouraged': 'Не рекомендуется',
|
||||
'tours.horse.access.value.private': 'Частный',
|
||||
'tours.horse.access.value.no': 'Запрещено',
|
||||
'tours.horse.access.value.other': 'Другое правило',
|
||||
'tours.horse.access.value.unknown': 'Нет в данных',
|
||||
'tours.horse.access.summary.open': 'По данным открыто для всадников',
|
||||
'tours.horse.access.summary.limited': 'Частично ограничено',
|
||||
'tours.horse.access.summary.closed': 'Частично закрыто',
|
||||
'tours.horse.access.summary.unknown': 'Доступ не указан в данных',
|
||||
'tours.horse.leg.info': 'Участок маркированного конного маршрута из OpenStreetMap. Доступ для лошадей: {access}',
|
||||
'tours.horse.leg.manual': 'Вручную, не проверено',
|
||||
'tours.horse.leg.manualInfo': 'Нарисован вручную, по прямой. Ни конный маршрут, ни маршрутизатор его не проверяли.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1006,5 +1006,62 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.status.unrouted': '{count} úsekov nenaplánovaných',
|
||||
'tours.paddle.status.unrouted.one': '{count} úsek nenaplánovaný',
|
||||
'tours.paddle.status.unrouted.few': '{count} úseky nenaplánované',
|
||||
'tourTypes.horse': 'Vychádzka na koni',
|
||||
'tours.planner.tourTypeHint.horse': 'Na koni, po značených jazdeckých trasách',
|
||||
'tours.planner.namePlaceholder.horse': 'Vychádzka bez názvu',
|
||||
'tours.horse.time': 'Čas jazdy',
|
||||
'tours.horse.timeInfoLabel': 'Prečo tu nie je čas jazdy',
|
||||
'tours.horse.timeInfo':
|
||||
'TREK čas jazdy nepočíta: tempo určuje kôň a jazdec. Ak chceš, zadaj plánovaný celkový čas sám.',
|
||||
'tours.horse.routes.title': 'Jazdecké trasy',
|
||||
'tours.horse.routes.infoLabel': 'O jazdeckých trasách',
|
||||
'tours.horse.routes.info':
|
||||
'Značené jazdecké trasy z OpenStreetMap, po úsekoch, ktoré prevezmeš celé. To, že cesta patrí k trase, neznamená, že po nej smieš jazdiť na koni.',
|
||||
'tours.horse.routes.search': 'Hľadať vo výreze mapy',
|
||||
'tours.horse.routes.searchAgain': 'Prehľadať tento výrez',
|
||||
'tours.horse.routes.loading': 'Hľadajú sa jazdecké trasy…',
|
||||
'tours.horse.routes.zoomIn': 'Priblíž mapu ešte viac a hľadaj jazdecké trasy.',
|
||||
'tours.horse.routes.offline': 'Jazdecké trasy potrebujú pripojenie.',
|
||||
'tours.horse.routes.failed': 'Jazdecké trasy sa nepodarilo načítať. Tvoja vychádzka zostáva bez zmeny.',
|
||||
'tours.horse.routes.none': 'V tomto výreze nie sú žiadne značené jazdecké trasy.',
|
||||
'tours.horse.routes.truncated': 'Je tu viac trás, než ukáže jedno hľadanie. Priblíž mapu, aby si videl všetky.',
|
||||
'tours.horse.routes.unnamed': 'Jazdecká trasa bez názvu',
|
||||
'tours.horse.routes.sections': 'Úseky',
|
||||
'tours.horse.routes.continues': 'Pokračuje mimo výrez',
|
||||
'tours.horse.dialog.eyebrow': 'Jazdecká trasa',
|
||||
'tours.horse.dialog.osm': 'Otvoriť v OpenStreetMap',
|
||||
'tours.horse.dialog.section': 'Úsek {number}',
|
||||
'tours.horse.dialog.direction': 'Smer',
|
||||
'tours.horse.dialog.asMapped': 'Ako je zmapované',
|
||||
'tours.horse.dialog.reversed': 'Opačne',
|
||||
'tours.horse.dialog.signedWith': 'Značené týmto smerom',
|
||||
'tours.horse.dialog.signedAgainst': 'Tento úsek je značený opačným smerom.',
|
||||
'tours.horse.dialog.connection': 'Napojenie na vychádzku',
|
||||
'tours.horse.dialog.firstSection': 'Začína tvoju vychádzku',
|
||||
'tours.horse.dialog.joins': 'Nadväzuje na koniec tvojej vychádzky',
|
||||
'tours.horse.dialog.gap': 'Medzi koncom tvojej vychádzky a týmto úsekom je {distance} bez jazdeckej trasy.',
|
||||
'tours.horse.dialog.joinGap': 'Prepojiť ručným úsekom',
|
||||
'tours.horse.dialog.joinGapHint': 'Nakreslené rovno a neoverené',
|
||||
'tours.horse.dialog.adopt': 'Prevziať úsek',
|
||||
'tours.horse.access.title': 'Prístup pre kone',
|
||||
'tours.horse.access.note':
|
||||
'Čo OpenStreetMap pozdĺž ciest uvádza ako horse=*. Údaje môžu chýbať alebo byť zastarané; platí značenie na mieste.',
|
||||
'tours.horse.access.closedNotice': 'Časť tohto úseku je pre kone uvedená ako zakázaná alebo súkromná.',
|
||||
'tours.horse.access.value.designated': 'Vyhradené pre jazdcov',
|
||||
'tours.horse.access.value.yes': 'Povolené',
|
||||
'tours.horse.access.value.permissive': 'Tolerované vlastníkom',
|
||||
'tours.horse.access.value.destination': 'Len pre dopravnú obsluhu',
|
||||
'tours.horse.access.value.discouraged': 'Neodporúčané',
|
||||
'tours.horse.access.value.private': 'Súkromné',
|
||||
'tours.horse.access.value.no': 'Zakázané',
|
||||
'tours.horse.access.value.other': 'Iné pravidlo',
|
||||
'tours.horse.access.value.unknown': 'V dátach chýba',
|
||||
'tours.horse.access.summary.open': 'Podľa dát otvorené pre jazdcov',
|
||||
'tours.horse.access.summary.limited': 'Čiastočne obmedzené',
|
||||
'tours.horse.access.summary.closed': 'Čiastočne zakázané',
|
||||
'tours.horse.access.summary.unknown': 'Prístup v dátach chýba',
|
||||
'tours.horse.leg.info': 'Úsek značenej jazdeckej trasy z OpenStreetMap. Prístup pre kone: {access}',
|
||||
'tours.horse.leg.manual': 'Ručne, neoverené',
|
||||
'tours.horse.leg.manualInfo': 'Nakreslené ručne, rovno. Neoverila ho žiadna jazdecká trasa ani plánovač.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -995,5 +995,62 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.wind.shoreTaken': 'Sjöarna planeras nära stranden.',
|
||||
'tours.paddle.status.unrouted': '{count} etapper inte planerade',
|
||||
'tours.paddle.status.unrouted.one': '{count} etapp inte planerad',
|
||||
'tourTypes.horse': 'Ridtur',
|
||||
'tours.planner.tourTypeHint.horse': 'Till häst, längs skyltade ridleder',
|
||||
'tours.planner.namePlaceholder.horse': 'Namnlös ridtur',
|
||||
'tours.horse.time': 'Ridtid',
|
||||
'tours.horse.timeInfoLabel': 'Varför det inte finns någon ridtid',
|
||||
'tours.horse.timeInfo':
|
||||
'TREK räknar inte ut någon ridtid: tempot bestäms av häst och ryttare. Ange själv en planerad total tid om du vill.',
|
||||
'tours.horse.routes.title': 'Ridleder',
|
||||
'tours.horse.routes.infoLabel': 'Om ridleder',
|
||||
'tours.horse.routes.info':
|
||||
'Skyltade ridleder från OpenStreetMap, i delsträckor som du tar hela. Att en väg hör till en led ger inte lov att rida där.',
|
||||
'tours.horse.routes.search': 'Sök i kartvyn',
|
||||
'tours.horse.routes.searchAgain': 'Sök i den här vyn',
|
||||
'tours.horse.routes.loading': 'Söker ridleder…',
|
||||
'tours.horse.routes.zoomIn': 'Zooma in mer för att söka efter ridleder.',
|
||||
'tours.horse.routes.offline': 'Ridleder kräver anslutning.',
|
||||
'tours.horse.routes.failed': 'Ridlederna kunde inte läsas. Din ridtur är oförändrad.',
|
||||
'tours.horse.routes.none': 'Inga skyltade ridleder i den här vyn.',
|
||||
'tours.horse.routes.truncated': 'Här finns fler leder än en sökning visar. Zooma in för att se alla.',
|
||||
'tours.horse.routes.unnamed': 'Namnlös ridled',
|
||||
'tours.horse.routes.sections': 'Delsträckor',
|
||||
'tours.horse.routes.continues': 'Fortsätter utanför vyn',
|
||||
'tours.horse.dialog.eyebrow': 'Ridled',
|
||||
'tours.horse.dialog.osm': 'Öppna i OpenStreetMap',
|
||||
'tours.horse.dialog.section': 'Delsträcka {number}',
|
||||
'tours.horse.dialog.direction': 'Riktning',
|
||||
'tours.horse.dialog.asMapped': 'Som kartlagt',
|
||||
'tours.horse.dialog.reversed': 'Omvänt',
|
||||
'tours.horse.dialog.signedWith': 'Skyltad i den här riktningen',
|
||||
'tours.horse.dialog.signedAgainst': 'Den här delsträckan är skyltad i motsatt riktning.',
|
||||
'tours.horse.dialog.connection': 'Anslutning till din ridtur',
|
||||
'tours.horse.dialog.firstSection': 'Börjar din ridtur',
|
||||
'tours.horse.dialog.joins': 'Ansluter till slutet av din ridtur',
|
||||
'tours.horse.dialog.gap': 'Mellan slutet av din ridtur och den här delsträckan ligger {distance} utan ridled.',
|
||||
'tours.horse.dialog.joinGap': 'Förbind med en manuell delsträcka',
|
||||
'tours.horse.dialog.joinGapHint': 'Rakt dragen och okontrollerad',
|
||||
'tours.horse.dialog.adopt': 'Ta delsträcka',
|
||||
'tours.horse.access.title': 'Tillträde för häst',
|
||||
'tours.horse.access.note':
|
||||
'Det OpenStreetMap anger som horse=* längs vägarna. Uppgifter kan saknas eller vara inaktuella; skyltarna på plats gäller.',
|
||||
'tours.horse.access.closedNotice': 'En del av den här delsträckan är angiven som stängd eller privat för hästar.',
|
||||
'tours.horse.access.value.designated': 'Avsedd för ryttare',
|
||||
'tours.horse.access.value.yes': 'Tillåtet',
|
||||
'tours.horse.access.value.permissive': 'Tolereras av ägaren',
|
||||
'tours.horse.access.value.destination': 'Endast behörig trafik',
|
||||
'tours.horse.access.value.discouraged': 'Avrådes',
|
||||
'tours.horse.access.value.private': 'Privat',
|
||||
'tours.horse.access.value.no': 'Förbjudet',
|
||||
'tours.horse.access.value.other': 'Annan regel',
|
||||
'tours.horse.access.value.unknown': 'Saknas i datan',
|
||||
'tours.horse.access.summary.open': 'Öppet för ryttare enligt datan',
|
||||
'tours.horse.access.summary.limited': 'Delvis begränsat',
|
||||
'tours.horse.access.summary.closed': 'Delvis stängt',
|
||||
'tours.horse.access.summary.unknown': 'Tillträde saknas i datan',
|
||||
'tours.horse.leg.info': 'Delsträcka av en skyltad ridled från OpenStreetMap. Tillträde för häst: {access}',
|
||||
'tours.horse.leg.manual': 'Manuell, okontrollerad',
|
||||
'tours.horse.leg.manualInfo': 'Dragen för hand, rakt. Ingen ridled och ingen ruttplanerare har kontrollerat den.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -951,5 +951,62 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.wind.shoreAdopt': 'วางแผนใกล้ฝั่ง',
|
||||
'tours.paddle.wind.shoreTaken': 'ทะเลสาบถูกวางแผนให้อยู่ใกล้ฝั่ง',
|
||||
'tours.paddle.status.unrouted': 'ยังไม่ได้วางแผน {count} ช่วง',
|
||||
'tourTypes.horse': 'ทริปขี่ม้า',
|
||||
'tours.planner.tourTypeHint.horse': 'บนหลังม้า ตามเส้นทางขี่ม้าที่มีป้ายบอก',
|
||||
'tours.planner.namePlaceholder.horse': 'ทริปขี่ม้าที่ไม่มีชื่อ',
|
||||
'tours.horse.time': 'เวลาขี่ม้า',
|
||||
'tours.horse.timeInfoLabel': 'ทำไมจึงไม่มีเวลาขี่ม้า',
|
||||
'tours.horse.timeInfo':
|
||||
'TREK ไม่คำนวณเวลาขี่ม้า เพราะจังหวะขึ้นอยู่กับม้าและผู้ขี่ หากต้องการ ให้กรอกเวลารวมที่วางแผนไว้เอง',
|
||||
'tours.horse.routes.title': 'เส้นทางขี่ม้า',
|
||||
'tours.horse.routes.infoLabel': 'เกี่ยวกับเส้นทางขี่ม้า',
|
||||
'tours.horse.routes.info':
|
||||
'เส้นทางขี่ม้าที่มีป้ายบอกจาก OpenStreetMap แบ่งเป็นช่วงที่นำมาใช้ทั้งช่วง การอยู่บนเส้นทางไม่ได้หมายความว่าได้รับอนุญาตให้ขี่ม้า',
|
||||
'tours.horse.routes.search': 'ค้นหาในมุมมองแผนที่',
|
||||
'tours.horse.routes.searchAgain': 'ค้นหาในมุมมองนี้',
|
||||
'tours.horse.routes.loading': 'กำลังค้นหาเส้นทางขี่ม้า…',
|
||||
'tours.horse.routes.zoomIn': 'ซูมเข้าอีกเพื่อค้นหาเส้นทางขี่ม้า',
|
||||
'tours.horse.routes.offline': 'เส้นทางขี่ม้าต้องใช้การเชื่อมต่อ',
|
||||
'tours.horse.routes.failed': 'อ่านเส้นทางขี่ม้าไม่ได้ ทริปของคุณไม่เปลี่ยนแปลง',
|
||||
'tours.horse.routes.none': 'ไม่มีเส้นทางขี่ม้าที่มีป้ายบอกในมุมมองนี้',
|
||||
'tours.horse.routes.truncated': 'ที่นี่มีเส้นทางมากกว่าที่การค้นหาครั้งเดียวแสดงได้ ซูมเข้าเพื่อดูทั้งหมด',
|
||||
'tours.horse.routes.unnamed': 'เส้นทางขี่ม้าที่ไม่มีชื่อ',
|
||||
'tours.horse.routes.sections': 'ช่วงเส้นทาง',
|
||||
'tours.horse.routes.continues': 'ต่อเนื่องออกไปนอกมุมมอง',
|
||||
'tours.horse.dialog.eyebrow': 'เส้นทางขี่ม้า',
|
||||
'tours.horse.dialog.osm': 'เปิดใน OpenStreetMap',
|
||||
'tours.horse.dialog.section': 'ช่วงที่ {number}',
|
||||
'tours.horse.dialog.direction': 'ทิศทาง',
|
||||
'tours.horse.dialog.asMapped': 'ตามที่ทำแผนที่ไว้',
|
||||
'tours.horse.dialog.reversed': 'กลับทิศ',
|
||||
'tours.horse.dialog.signedWith': 'มีป้ายบอกในทิศทางนี้',
|
||||
'tours.horse.dialog.signedAgainst': 'ช่วงนี้มีป้ายบอกในทิศทางตรงข้าม',
|
||||
'tours.horse.dialog.connection': 'การต่อกับทริปของคุณ',
|
||||
'tours.horse.dialog.firstSection': 'เริ่มทริปของคุณ',
|
||||
'tours.horse.dialog.joins': 'ต่อจากจุดสิ้นสุดทริปของคุณ',
|
||||
'tours.horse.dialog.gap': 'ระหว่างจุดสิ้นสุดทริปของคุณกับช่วงนี้มีระยะ {distance} ที่ไม่อยู่บนเส้นทางขี่ม้า',
|
||||
'tours.horse.dialog.joinGap': 'เชื่อมด้วยช่วงที่วาดเอง',
|
||||
'tours.horse.dialog.joinGapHint': 'ลากเป็นเส้นตรงและยังไม่ตรวจสอบ',
|
||||
'tours.horse.dialog.adopt': 'นำช่วงนี้มาใช้',
|
||||
'tours.horse.access.title': 'การเข้าถึงสำหรับม้า',
|
||||
'tours.horse.access.note':
|
||||
'ข้อมูลที่ OpenStreetMap บันทึกเป็น horse=* ตามเส้นทาง ข้อมูลอาจขาดหรือเก่า ให้ถือป้ายในพื้นที่เป็นหลัก',
|
||||
'tours.horse.access.closedNotice': 'บางส่วนของช่วงนี้ถูกบันทึกว่าปิดหรือเป็นทางส่วนบุคคลสำหรับม้า',
|
||||
'tours.horse.access.value.designated': 'กำหนดให้ผู้ขี่ม้า',
|
||||
'tours.horse.access.value.yes': 'อนุญาต',
|
||||
'tours.horse.access.value.permissive': 'เจ้าของยินยอม',
|
||||
'tours.horse.access.value.destination': 'เฉพาะไปยังจุดหมาย',
|
||||
'tours.horse.access.value.discouraged': 'ไม่แนะนำ',
|
||||
'tours.horse.access.value.private': 'ส่วนบุคคล',
|
||||
'tours.horse.access.value.no': 'ห้าม',
|
||||
'tours.horse.access.value.other': 'กฎอื่น',
|
||||
'tours.horse.access.value.unknown': 'ไม่มีในข้อมูล',
|
||||
'tours.horse.access.summary.open': 'เปิดให้ผู้ขี่ม้าตามข้อมูล',
|
||||
'tours.horse.access.summary.limited': 'จำกัดบางส่วน',
|
||||
'tours.horse.access.summary.closed': 'ปิดบางส่วน',
|
||||
'tours.horse.access.summary.unknown': 'ไม่มีข้อมูลการเข้าถึง',
|
||||
'tours.horse.leg.info': 'ช่วงหนึ่งของเส้นทางขี่ม้าที่มีป้ายบอกจาก OpenStreetMap การเข้าถึงสำหรับม้า: {access}',
|
||||
'tours.horse.leg.manual': 'วาดเอง ยังไม่ตรวจสอบ',
|
||||
'tours.horse.leg.manualInfo': 'วาดด้วยมือเป็นเส้นตรง ไม่มีเส้นทางขี่ม้าหรือตัววางเส้นทางใดตรวจสอบ',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match)
|
||||
// placeholder spec; here only the general keys are.
|
||||
const groups = pluralGroups(Object.keys(english));
|
||||
const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups));
|
||||
const GENERAL_KEY_COUNT = 898;
|
||||
const GENERAL_KEY_COUNT = 952;
|
||||
|
||||
function literalKeys(source: string): string[] {
|
||||
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
|
||||
|
||||
@@ -1002,5 +1002,63 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.wind.shoreTaken': 'Göller kıyıya yakın planlandı.',
|
||||
'tours.paddle.status.unrouted': '{count} bölüm planlanmadı',
|
||||
'tours.paddle.status.unrouted.one': '{count} bölüm planlanmadı',
|
||||
'tourTypes.horse': 'At gezisi',
|
||||
'tours.planner.tourTypeHint.horse': 'At sırtında, işaretli binicilik rotalarında',
|
||||
'tours.planner.namePlaceholder.horse': 'Adsız at gezisi',
|
||||
'tours.horse.time': 'Sürüş süresi',
|
||||
'tours.horse.timeInfoLabel': 'Neden sürüş süresi yok',
|
||||
'tours.horse.timeInfo':
|
||||
'TREK sürüş süresi hesaplamaz: tempoyu at ve binici belirler. İstersen planlanan toplam süreyi kendin gir.',
|
||||
'tours.horse.routes.title': 'Binicilik rotaları',
|
||||
'tours.horse.routes.infoLabel': 'Binicilik rotaları hakkında',
|
||||
'tours.horse.routes.info':
|
||||
'OpenStreetMap kaynaklı işaretli binicilik rotaları, bütün olarak alınan bölümler hâlinde. Bir yolun rotaya ait olması orada ata binme izni vermez.',
|
||||
'tours.horse.routes.search': 'Harita görünümünde ara',
|
||||
'tours.horse.routes.searchAgain': 'Bu görünümde ara',
|
||||
'tours.horse.routes.loading': 'Binicilik rotaları aranıyor…',
|
||||
'tours.horse.routes.zoomIn': 'Binicilik rotası aramak için daha fazla yakınlaştır.',
|
||||
'tours.horse.routes.offline': 'Binicilik rotaları için bağlantı gerekir.',
|
||||
'tours.horse.routes.failed': 'Binicilik rotaları okunamadı. Gezin değişmedi.',
|
||||
'tours.horse.routes.none': 'Bu görünümde işaretli binicilik rotası yok.',
|
||||
'tours.horse.routes.truncated':
|
||||
'Burada bir aramanın gösterdiğinden daha fazla rota var. Hepsini görmek için yakınlaştır.',
|
||||
'tours.horse.routes.unnamed': 'Adsız binicilik rotası',
|
||||
'tours.horse.routes.sections': 'Bölümler',
|
||||
'tours.horse.routes.continues': 'Görünümün ötesinde devam ediyor',
|
||||
'tours.horse.dialog.eyebrow': 'Binicilik rotası',
|
||||
'tours.horse.dialog.osm': "OpenStreetMap'te aç",
|
||||
'tours.horse.dialog.section': 'Bölüm {number}',
|
||||
'tours.horse.dialog.direction': 'Yön',
|
||||
'tours.horse.dialog.asMapped': 'Haritadaki gibi',
|
||||
'tours.horse.dialog.reversed': 'Ters',
|
||||
'tours.horse.dialog.signedWith': 'Bu yönde işaretli',
|
||||
'tours.horse.dialog.signedAgainst': 'Bu bölüm ters yönde işaretlenmiş.',
|
||||
'tours.horse.dialog.connection': 'Geziye bağlantı',
|
||||
'tours.horse.dialog.firstSection': 'Gezini başlatır',
|
||||
'tours.horse.dialog.joins': 'Gezinin sonuna bağlanır',
|
||||
'tours.horse.dialog.gap': 'Gezinin sonu ile bu bölüm arasında binicilik rotası olmayan {distance} var.',
|
||||
'tours.horse.dialog.joinGap': 'Elle çizilen bir bölümle bağla',
|
||||
'tours.horse.dialog.joinGapHint': 'Düz çizilmiş ve denetlenmemiş',
|
||||
'tours.horse.dialog.adopt': 'Bölümü al',
|
||||
'tours.horse.access.title': 'At erişimi',
|
||||
'tours.horse.access.note':
|
||||
"OpenStreetMap'in yollar boyunca horse=* olarak kaydettiği bilgiler. Veriler eksik ya da güncel olmayabilir; yerindeki tabelalar geçerlidir.",
|
||||
'tours.horse.access.closedNotice': 'Bu bölümün bir kısmı atlar için kapalı ya da özel olarak kayıtlı.',
|
||||
'tours.horse.access.value.designated': 'Binicilere ayrılmış',
|
||||
'tours.horse.access.value.yes': 'İzinli',
|
||||
'tours.horse.access.value.permissive': 'Sahibi tarafından hoş görülür',
|
||||
'tours.horse.access.value.destination': 'Yalnızca varış için',
|
||||
'tours.horse.access.value.discouraged': 'Önerilmez',
|
||||
'tours.horse.access.value.private': 'Özel',
|
||||
'tours.horse.access.value.no': 'Yasak',
|
||||
'tours.horse.access.value.other': 'Başka kural',
|
||||
'tours.horse.access.value.unknown': 'Verilerde yok',
|
||||
'tours.horse.access.summary.open': 'Verilere göre binicilere açık',
|
||||
'tours.horse.access.summary.limited': 'Kısmen kısıtlı',
|
||||
'tours.horse.access.summary.closed': 'Kısmen kapalı',
|
||||
'tours.horse.access.summary.unknown': 'Erişim verilerde yok',
|
||||
'tours.horse.leg.info': "OpenStreetMap'teki işaretli bir binicilik rotasının bölümü. At erişimi: {access}",
|
||||
'tours.horse.leg.manual': 'Elle, denetlenmemiş',
|
||||
'tours.horse.leg.manualInfo': 'Elle, düz çizildi. Ne bir binicilik rotası ne de bir rota planlayıcı denetledi.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1036,5 +1036,62 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.status.unrouted.one': '{count} ділянку не прокладено',
|
||||
'tours.paddle.status.unrouted.few': '{count} ділянки не прокладено',
|
||||
'tours.paddle.status.unrouted.many': '{count} ділянок не прокладено',
|
||||
'tourTypes.horse': 'Кінна прогулянка',
|
||||
'tours.planner.tourTypeHint.horse': 'Верхи, маркованими кінними маршрутами',
|
||||
'tours.planner.namePlaceholder.horse': 'Кінна прогулянка без назви',
|
||||
'tours.horse.time': 'Час у сідлі',
|
||||
'tours.horse.timeInfoLabel': 'Чому немає часу в сідлі',
|
||||
'tours.horse.timeInfo':
|
||||
'TREK не розраховує час у сідлі: темп залежить від коня і вершника. За бажання вкажіть загальний час самі.',
|
||||
'tours.horse.routes.title': 'Кінні маршрути',
|
||||
'tours.horse.routes.infoLabel': 'Про кінні маршрути',
|
||||
'tours.horse.routes.info':
|
||||
'Марковані кінні маршрути з OpenStreetMap, ділянками, які беруться цілком. Належність до маршруту не дає права їздити ним верхи.',
|
||||
'tours.horse.routes.search': 'Шукати у видимій області',
|
||||
'tours.horse.routes.searchAgain': 'Шукати в цій області',
|
||||
'tours.horse.routes.loading': 'Шукаємо кінні маршрути…',
|
||||
'tours.horse.routes.zoomIn': 'Наблизьте карту, щоб шукати кінні маршрути.',
|
||||
'tours.horse.routes.offline': "Для кінних маршрутів потрібне з'єднання.",
|
||||
'tours.horse.routes.failed': 'Не вдалося прочитати кінні маршрути. Ваша прогулянка не змінилася.',
|
||||
'tours.horse.routes.none': 'У цій області немає маркованих кінних маршрутів.',
|
||||
'tours.horse.routes.truncated': 'Тут більше маршрутів, ніж показує один пошук. Наблизьте карту, щоб побачити всі.',
|
||||
'tours.horse.routes.unnamed': 'Кінний маршрут без назви',
|
||||
'tours.horse.routes.sections': 'Ділянки',
|
||||
'tours.horse.routes.continues': 'Продовжується за межами області',
|
||||
'tours.horse.dialog.eyebrow': 'Кінний маршрут',
|
||||
'tours.horse.dialog.osm': 'Відкрити в OpenStreetMap',
|
||||
'tours.horse.dialog.section': 'Ділянка {number}',
|
||||
'tours.horse.dialog.direction': 'Напрямок',
|
||||
'tours.horse.dialog.asMapped': 'Як на карті',
|
||||
'tours.horse.dialog.reversed': 'У зворотний бік',
|
||||
'tours.horse.dialog.signedWith': 'Маркований у цьому напрямку',
|
||||
'tours.horse.dialog.signedAgainst': 'Цю ділянку марковано у зворотному напрямку.',
|
||||
'tours.horse.dialog.connection': "Зв'язок із прогулянкою",
|
||||
'tours.horse.dialog.firstSection': 'Починає прогулянку',
|
||||
'tours.horse.dialog.joins': 'Продовжує кінець прогулянки',
|
||||
'tours.horse.dialog.gap': 'Між кінцем прогулянки та цією ділянкою {distance} без кінного маршруту.',
|
||||
'tours.horse.dialog.joinGap': 'Зʼєднати ділянкою вручну',
|
||||
'tours.horse.dialog.joinGapHint': 'Пряма лінія, не перевірено',
|
||||
'tours.horse.dialog.adopt': 'Взяти ділянку',
|
||||
'tours.horse.access.title': 'Доступ для коней',
|
||||
'tours.horse.access.note':
|
||||
'Те, що OpenStreetMap позначає вздовж доріг як horse=*. Даних може бракувати або вони застарілі; на місці діють знаки.',
|
||||
'tours.horse.access.closedNotice': 'Частину цієї ділянки позначено як закриту або приватну для коней.',
|
||||
'tours.horse.access.value.designated': 'Призначено для вершників',
|
||||
'tours.horse.access.value.yes': 'Дозволено',
|
||||
'tours.horse.access.value.permissive': 'Допускається власником',
|
||||
'tours.horse.access.value.destination': 'Лише для місцевого проїзду',
|
||||
'tours.horse.access.value.discouraged': 'Не рекомендовано',
|
||||
'tours.horse.access.value.private': 'Приватна',
|
||||
'tours.horse.access.value.no': 'Заборонено',
|
||||
'tours.horse.access.value.other': 'Інше правило',
|
||||
'tours.horse.access.value.unknown': 'Немає в даних',
|
||||
'tours.horse.access.summary.open': 'За даними відкрито для вершників',
|
||||
'tours.horse.access.summary.limited': 'Частково обмежено',
|
||||
'tours.horse.access.summary.closed': 'Частково закрито',
|
||||
'tours.horse.access.summary.unknown': 'Доступ не вказано в даних',
|
||||
'tours.horse.leg.info': 'Ділянка маркованого кінного маршруту з OpenStreetMap. Доступ для коней: {access}',
|
||||
'tours.horse.leg.manual': 'Вручну, не перевірено',
|
||||
'tours.horse.leg.manualInfo': 'Намальовано вручну, по прямій. Ні кінний маршрут, ні маршрутизатор її не перевіряли.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -987,5 +987,62 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.wind.shoreAdopt': 'Lập gần bờ',
|
||||
'tours.paddle.wind.shoreTaken': 'Các hồ được lập gần bờ.',
|
||||
'tours.paddle.status.unrouted': '{count} chặng chưa được lập',
|
||||
'tourTypes.horse': 'Chuyến cưỡi ngựa',
|
||||
'tours.planner.tourTypeHint.horse': 'Trên lưng ngựa, theo các tuyến cưỡi ngựa có biển chỉ dẫn',
|
||||
'tours.planner.namePlaceholder.horse': 'Chuyến cưỡi ngựa chưa đặt tên',
|
||||
'tours.horse.time': 'Thời gian cưỡi ngựa',
|
||||
'tours.horse.timeInfoLabel': 'Vì sao không có thời gian cưỡi ngựa',
|
||||
'tours.horse.timeInfo':
|
||||
'TREK không tính thời gian cưỡi ngựa: tốc độ do ngựa và người cưỡi quyết định. Nếu muốn, hãy tự nhập tổng thời gian dự kiến.',
|
||||
'tours.horse.routes.title': 'Tuyến cưỡi ngựa',
|
||||
'tours.horse.routes.infoLabel': 'Về tuyến cưỡi ngựa',
|
||||
'tours.horse.routes.info':
|
||||
'Các tuyến cưỡi ngựa có biển chỉ dẫn từ OpenStreetMap, theo từng đoạn được lấy nguyên. Thuộc một tuyến không có nghĩa là được phép cưỡi ngựa ở đó.',
|
||||
'tours.horse.routes.search': 'Tìm trong khung bản đồ',
|
||||
'tours.horse.routes.searchAgain': 'Tìm trong khung này',
|
||||
'tours.horse.routes.loading': 'Đang tìm tuyến cưỡi ngựa…',
|
||||
'tours.horse.routes.zoomIn': 'Phóng to thêm để tìm tuyến cưỡi ngựa.',
|
||||
'tours.horse.routes.offline': 'Tuyến cưỡi ngựa cần có kết nối.',
|
||||
'tours.horse.routes.failed': 'Không đọc được tuyến cưỡi ngựa. Chuyến đi của bạn không thay đổi.',
|
||||
'tours.horse.routes.none': 'Không có tuyến cưỡi ngựa có biển chỉ dẫn trong khung này.',
|
||||
'tours.horse.routes.truncated': 'Ở đây có nhiều tuyến hơn một lần tìm hiển thị. Phóng to để xem tất cả.',
|
||||
'tours.horse.routes.unnamed': 'Tuyến cưỡi ngựa không tên',
|
||||
'tours.horse.routes.sections': 'Các đoạn',
|
||||
'tours.horse.routes.continues': 'Tiếp tục ra ngoài khung',
|
||||
'tours.horse.dialog.eyebrow': 'Tuyến cưỡi ngựa',
|
||||
'tours.horse.dialog.osm': 'Mở trong OpenStreetMap',
|
||||
'tours.horse.dialog.section': 'Đoạn {number}',
|
||||
'tours.horse.dialog.direction': 'Hướng',
|
||||
'tours.horse.dialog.asMapped': 'Như trên bản đồ',
|
||||
'tours.horse.dialog.reversed': 'Đảo ngược',
|
||||
'tours.horse.dialog.signedWith': 'Có biển chỉ theo hướng này',
|
||||
'tours.horse.dialog.signedAgainst': 'Đoạn này có biển chỉ theo hướng ngược lại.',
|
||||
'tours.horse.dialog.connection': 'Nối vào chuyến đi',
|
||||
'tours.horse.dialog.firstSection': 'Bắt đầu chuyến đi',
|
||||
'tours.horse.dialog.joins': 'Nối vào cuối chuyến đi',
|
||||
'tours.horse.dialog.gap': 'Giữa cuối chuyến đi và đoạn này có {distance} không thuộc tuyến cưỡi ngựa nào.',
|
||||
'tours.horse.dialog.joinGap': 'Nối bằng một đoạn thủ công',
|
||||
'tours.horse.dialog.joinGapHint': 'Vẽ thẳng và chưa kiểm tra',
|
||||
'tours.horse.dialog.adopt': 'Lấy đoạn',
|
||||
'tours.horse.access.title': 'Quyền đi ngựa',
|
||||
'tours.horse.access.note':
|
||||
'Những gì OpenStreetMap ghi là horse=* dọc các con đường. Dữ liệu có thể thiếu hoặc cũ; biển báo tại chỗ mới là căn cứ.',
|
||||
'tours.horse.access.closedNotice': 'Một phần đoạn này được ghi là cấm hoặc riêng tư đối với ngựa.',
|
||||
'tours.horse.access.value.designated': 'Dành cho người cưỡi ngựa',
|
||||
'tours.horse.access.value.yes': 'Được phép',
|
||||
'tours.horse.access.value.permissive': 'Chủ sở hữu cho phép',
|
||||
'tours.horse.access.value.destination': 'Chỉ để đến nơi',
|
||||
'tours.horse.access.value.discouraged': 'Không khuyến khích',
|
||||
'tours.horse.access.value.private': 'Riêng tư',
|
||||
'tours.horse.access.value.no': 'Bị cấm',
|
||||
'tours.horse.access.value.other': 'Quy định khác',
|
||||
'tours.horse.access.value.unknown': 'Không có trong dữ liệu',
|
||||
'tours.horse.access.summary.open': 'Mở cho người cưỡi ngựa theo dữ liệu',
|
||||
'tours.horse.access.summary.limited': 'Hạn chế một phần',
|
||||
'tours.horse.access.summary.closed': 'Cấm một phần',
|
||||
'tours.horse.access.summary.unknown': 'Quyền đi không có trong dữ liệu',
|
||||
'tours.horse.leg.info': 'Một đoạn của tuyến cưỡi ngựa có biển chỉ dẫn từ OpenStreetMap. Quyền đi ngựa: {access}',
|
||||
'tours.horse.leg.manual': 'Thủ công, chưa kiểm tra',
|
||||
'tours.horse.leg.manualInfo': 'Vẽ tay, đường thẳng. Không tuyến cưỡi ngựa hay công cụ dẫn đường nào kiểm tra.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -916,5 +916,59 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.wind.shoreAdopt': '沿岸規劃',
|
||||
'tours.paddle.wind.shoreTaken': '湖泊已依沿岸規劃。',
|
||||
'tours.paddle.status.unrouted': '{count} 段未規劃',
|
||||
'tourTypes.horse': '騎馬遊',
|
||||
'tours.planner.tourTypeHint.horse': '騎馬,沿著有標示的騎馬路線',
|
||||
'tours.planner.namePlaceholder.horse': '未命名的騎馬遊',
|
||||
'tours.horse.time': '騎乘時間',
|
||||
'tours.horse.timeInfoLabel': '為什麼沒有騎乘時間',
|
||||
'tours.horse.timeInfo': 'TREK 不計算騎乘時間:節奏取決於馬和騎士。如有需要,請自行填寫計畫總時長。',
|
||||
'tours.horse.routes.title': '騎馬路線',
|
||||
'tours.horse.routes.infoLabel': '關於騎馬路線',
|
||||
'tours.horse.routes.info': '來自 OpenStreetMap 的有標示騎馬路線,按可整段採用的路段顯示。屬於某條路線並不代表允許在那裡騎馬。',
|
||||
'tours.horse.routes.search': '在地圖畫面中尋找',
|
||||
'tours.horse.routes.searchAgain': '搜尋此畫面',
|
||||
'tours.horse.routes.loading': '正在尋找騎馬路線…',
|
||||
'tours.horse.routes.zoomIn': '請再放大以尋找騎馬路線。',
|
||||
'tours.horse.routes.offline': '騎馬路線需要連線。',
|
||||
'tours.horse.routes.failed': '無法讀取騎馬路線。你的騎馬遊沒有改變。',
|
||||
'tours.horse.routes.none': '此畫面中沒有有標示的騎馬路線。',
|
||||
'tours.horse.routes.truncated': '這裡的路線比一次搜尋能顯示的更多。放大即可看到全部。',
|
||||
'tours.horse.routes.unnamed': '未命名的騎馬路線',
|
||||
'tours.horse.routes.sections': '路段',
|
||||
'tours.horse.routes.continues': '延伸到畫面之外',
|
||||
'tours.horse.dialog.eyebrow': '騎馬路線',
|
||||
'tours.horse.dialog.osm': '在 OpenStreetMap 中開啟',
|
||||
'tours.horse.dialog.section': '路段 {number}',
|
||||
'tours.horse.dialog.direction': '方向',
|
||||
'tours.horse.dialog.asMapped': '依地圖方向',
|
||||
'tours.horse.dialog.reversed': '反向',
|
||||
'tours.horse.dialog.signedWith': '此方向有標示',
|
||||
'tours.horse.dialog.signedAgainst': '此路段的標示為反方向。',
|
||||
'tours.horse.dialog.connection': '與騎馬遊的銜接',
|
||||
'tours.horse.dialog.firstSection': '作為騎馬遊的起點',
|
||||
'tours.horse.dialog.joins': '接在騎馬遊終點之後',
|
||||
'tours.horse.dialog.gap': '騎馬遊終點與此路段之間有 {distance} 不在任何騎馬路線上。',
|
||||
'tours.horse.dialog.joinGap': '用手動路段連接',
|
||||
'tours.horse.dialog.joinGapHint': '直線繪製,未經檢查',
|
||||
'tours.horse.dialog.adopt': '採用路段',
|
||||
'tours.horse.access.title': '馬匹通行',
|
||||
'tours.horse.access.note': 'OpenStreetMap 沿道路記錄的 horse=* 資訊。資料可能缺漏或過時;以現場標誌為準。',
|
||||
'tours.horse.access.closedNotice': '此路段的一部分被記錄為禁止馬匹通行或私人道路。',
|
||||
'tours.horse.access.value.designated': '騎馬專用',
|
||||
'tours.horse.access.value.yes': '允許',
|
||||
'tours.horse.access.value.permissive': '所有者默許',
|
||||
'tours.horse.access.value.destination': '僅限前往目的地',
|
||||
'tours.horse.access.value.discouraged': '不建議',
|
||||
'tours.horse.access.value.private': '私人',
|
||||
'tours.horse.access.value.no': '禁止',
|
||||
'tours.horse.access.value.other': '其他規定',
|
||||
'tours.horse.access.value.unknown': '資料中沒有',
|
||||
'tours.horse.access.summary.open': '依資料對騎士開放',
|
||||
'tours.horse.access.summary.limited': '部分受限',
|
||||
'tours.horse.access.summary.closed': '部分禁止',
|
||||
'tours.horse.access.summary.unknown': '通行資訊不在資料中',
|
||||
'tours.horse.leg.info': '來自 OpenStreetMap 有標示騎馬路線的一段。馬匹通行:{access}',
|
||||
'tours.horse.leg.manual': '手動,未經檢查',
|
||||
'tours.horse.leg.manualInfo': '手動繪製的直線。沒有任何騎馬路線或路線規劃器檢查過它。',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -917,5 +917,59 @@ const tours: TranslationStrings = {
|
||||
'tours.paddle.wind.shoreAdopt': '沿岸规划',
|
||||
'tours.paddle.wind.shoreTaken': '湖泊已按沿岸规划。',
|
||||
'tours.paddle.status.unrouted': '{count} 段未规划',
|
||||
'tourTypes.horse': '骑马游',
|
||||
'tours.planner.tourTypeHint.horse': '骑马,沿着有标识的骑马路线',
|
||||
'tours.planner.namePlaceholder.horse': '未命名的骑马游',
|
||||
'tours.horse.time': '骑行时间',
|
||||
'tours.horse.timeInfoLabel': '为什么没有骑行时间',
|
||||
'tours.horse.timeInfo': 'TREK 不计算骑行时间:节奏取决于马和骑手。如有需要,请自行填写计划总时长。',
|
||||
'tours.horse.routes.title': '骑马路线',
|
||||
'tours.horse.routes.infoLabel': '关于骑马路线',
|
||||
'tours.horse.routes.info': '来自 OpenStreetMap 的有标识骑马路线,按可整段采用的路段显示。属于某条路线并不等于允许在那里骑马。',
|
||||
'tours.horse.routes.search': '在地图视图中查找',
|
||||
'tours.horse.routes.searchAgain': '搜索此视图',
|
||||
'tours.horse.routes.loading': '正在查找骑马路线…',
|
||||
'tours.horse.routes.zoomIn': '请继续放大以查找骑马路线。',
|
||||
'tours.horse.routes.offline': '骑马路线需要联网。',
|
||||
'tours.horse.routes.failed': '无法读取骑马路线。你的骑马游没有改变。',
|
||||
'tours.horse.routes.none': '此视图中没有有标识的骑马路线。',
|
||||
'tours.horse.routes.truncated': '这里的路线比一次搜索能显示的更多。放大即可看到全部。',
|
||||
'tours.horse.routes.unnamed': '未命名的骑马路线',
|
||||
'tours.horse.routes.sections': '路段',
|
||||
'tours.horse.routes.continues': '延伸到视图之外',
|
||||
'tours.horse.dialog.eyebrow': '骑马路线',
|
||||
'tours.horse.dialog.osm': '在 OpenStreetMap 中打开',
|
||||
'tours.horse.dialog.section': '路段 {number}',
|
||||
'tours.horse.dialog.direction': '方向',
|
||||
'tours.horse.dialog.asMapped': '按地图方向',
|
||||
'tours.horse.dialog.reversed': '反向',
|
||||
'tours.horse.dialog.signedWith': '此方向有标识',
|
||||
'tours.horse.dialog.signedAgainst': '此路段的标识为反方向。',
|
||||
'tours.horse.dialog.connection': '与骑马游的衔接',
|
||||
'tours.horse.dialog.firstSection': '作为骑马游的起点',
|
||||
'tours.horse.dialog.joins': '接在骑马游终点之后',
|
||||
'tours.horse.dialog.gap': '骑马游终点与此路段之间有 {distance} 不在任何骑马路线上。',
|
||||
'tours.horse.dialog.joinGap': '用手动路段连接',
|
||||
'tours.horse.dialog.joinGapHint': '直线绘制,未经检查',
|
||||
'tours.horse.dialog.adopt': '采用路段',
|
||||
'tours.horse.access.title': '马匹通行',
|
||||
'tours.horse.access.note': 'OpenStreetMap 沿道路记录的 horse=* 信息。数据可能缺失或过时;以现场标志为准。',
|
||||
'tours.horse.access.closedNotice': '此路段的一部分被记录为禁止马匹通行或私人道路。',
|
||||
'tours.horse.access.value.designated': '骑马专用',
|
||||
'tours.horse.access.value.yes': '允许',
|
||||
'tours.horse.access.value.permissive': '所有者默许',
|
||||
'tours.horse.access.value.destination': '仅限前往目的地',
|
||||
'tours.horse.access.value.discouraged': '不建议',
|
||||
'tours.horse.access.value.private': '私人',
|
||||
'tours.horse.access.value.no': '禁止',
|
||||
'tours.horse.access.value.other': '其他规定',
|
||||
'tours.horse.access.value.unknown': '数据中没有',
|
||||
'tours.horse.access.summary.open': '按数据对骑手开放',
|
||||
'tours.horse.access.summary.limited': '部分受限',
|
||||
'tours.horse.access.summary.closed': '部分禁止',
|
||||
'tours.horse.access.summary.unknown': '通行信息不在数据中',
|
||||
'tours.horse.leg.info': '来自 OpenStreetMap 有标识骑马路线的一段。马匹通行:{access}',
|
||||
'tours.horse.leg.manual': '手动,未经检查',
|
||||
'tours.horse.leg.manualInfo': '手动绘制的直线。没有任何骑马路线或路线规划器检查过它。',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -73,6 +73,7 @@ export * from './tours/paddle-check.schema';
|
||||
export * from './tours/paddle-check-basis';
|
||||
export * from './tours/paddle-check';
|
||||
export * from './tours/route-corridor';
|
||||
export * from './tours/horse-route.schema';
|
||||
export * from './wanderer/wanderer.schema';
|
||||
export * from './geo/haversine';
|
||||
export * from './collection/collection.schema';
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
HORSE_ACCESS_VALUES,
|
||||
HORSE_ROUTES_ATTRIBUTION,
|
||||
horseAccessTone,
|
||||
horseRoutesQuerySchema,
|
||||
horseRoutesResponseSchema,
|
||||
turnedSignposted,
|
||||
} from './horse-route.schema';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const section = {
|
||||
number: 1,
|
||||
geometry: [
|
||||
[47.1, 11.2],
|
||||
[47.11, 11.21],
|
||||
],
|
||||
length_m: 1350,
|
||||
access: [
|
||||
{ value: 'designated', length_m: 900 },
|
||||
{ value: 'unknown', length_m: 450 },
|
||||
],
|
||||
signposted: null,
|
||||
};
|
||||
const route = {
|
||||
id: 'relation/42',
|
||||
name: 'Reitweg Isartal',
|
||||
ref: 'R1',
|
||||
osmUrl: 'https://www.openstreetmap.org/relation/42',
|
||||
continues: false,
|
||||
sections: [section],
|
||||
};
|
||||
const answer = { routes: [route], truncated: false, source: 'openstreetmap', attribution: HORSE_ROUTES_ATTRIBUTION };
|
||||
|
||||
describe('horse routes contract (HOR-01)', () => {
|
||||
it('HORSE-SCHEMA-001: reads a map view box as the via ferrata layer does', () => {
|
||||
expect(horseRoutesQuerySchema.parse({ bbox: '47.1,11.2,47.4,11.6' }).bbox).toEqual({
|
||||
south: 47.1,
|
||||
west: 11.2,
|
||||
north: 47.4,
|
||||
east: 11.6,
|
||||
});
|
||||
expect(horseRoutesQuerySchema.safeParse({ bbox: '46,11,47,11.4' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('HORSE-SCHEMA-002: takes a route with its sections, their access and signposting', () => {
|
||||
expect(horseRoutesResponseSchema.parse(answer)).toEqual(answer);
|
||||
const oneWay = { ...section, signposted: 'with' };
|
||||
expect(horseRoutesResponseSchema.safeParse({ ...answer, routes: [{ ...route, sections: [oneWay] }] }).success).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('HORSE-SCHEMA-003: refuses a route of another element, without sections, or a section of one point', () => {
|
||||
const refused = [
|
||||
{ ...route, id: 'way/42' },
|
||||
{ ...route, sections: [] },
|
||||
{ ...route, sections: [{ ...section, geometry: [[47.1, 11.2]] }] },
|
||||
{ ...route, sections: [{ ...section, number: 0 }] },
|
||||
{ ...route, sections: [{ ...section, access: [{ value: 'maybe', length_m: 1 }] }] },
|
||||
{ ...route, sections: [{ ...section, signposted: 'forward' }] },
|
||||
];
|
||||
for (const item of refused) {
|
||||
expect(horseRoutesResponseSchema.safeParse({ ...answer, routes: [item] }).success).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('HORSE-SCHEMA-004: sorts every horse=* value into one of four steps, the untagged as unknown', () => {
|
||||
expect(Object.fromEntries(HORSE_ACCESS_VALUES.map((value) => [value, horseAccessTone(value)]))).toEqual({
|
||||
designated: 'open',
|
||||
yes: 'open',
|
||||
permissive: 'open',
|
||||
destination: 'limited',
|
||||
discouraged: 'limited',
|
||||
other: 'limited',
|
||||
private: 'closed',
|
||||
no: 'closed',
|
||||
unknown: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('HORSE-SCHEMA-005: turns the signposted direction round, and leaves the unsaid unsaid', () => {
|
||||
expect(turnedSignposted('with')).toBe('against');
|
||||
expect(turnedSignposted('against')).toBe('with');
|
||||
expect(turnedSignposted(null)).toBeNull();
|
||||
expect(turnedSignposted(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { FERRATAS_ATTRIBUTION, ferratasQuerySchema } from '../ferratas/ferratas.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* HOR-01: the signposted horse routes around a trip, read from OpenStreetMap
|
||||
* (relations tagged `route=horse`) through Overpass
|
||||
* (GET /api/trips/:tripId/tours/horse-routes?bbox=south,west,north,east), and
|
||||
* what a horse tour keeps of a stretch it takes from one.
|
||||
*
|
||||
* A route comes in sections: the stretches of its ways that run on without a
|
||||
* fork, a gap or a crossing of the route with itself. A section is taken whole
|
||||
* and in the direction the rider picks; nothing here joins two sections or
|
||||
* fills a gap between them. Being part of a route says nothing about whether a
|
||||
* way may be ridden: the `horse=*` tags of its ways are given apart, and a way
|
||||
* without one stays unknown.
|
||||
*/
|
||||
|
||||
/** The same box the via ferrata layer asks with: one map view, at most half a degree a side. */
|
||||
export const horseRoutesQuerySchema = ferratasQuerySchema;
|
||||
export type HorseRoutesQuery = z.output<typeof horseRoutesQuerySchema>;
|
||||
|
||||
/** The most routes one answer carries; past it the answer says `truncated`. */
|
||||
export const HORSE_ROUTES_RESULT_CAP = 60;
|
||||
/** The most sections one route carries; a route cut there says `truncated` too. */
|
||||
export const HORSE_ROUTE_SECTIONS_CAP = 200;
|
||||
/**
|
||||
* The most points a section carries: the most a fixed leg of a tour takes, so
|
||||
* a section always goes onto the tour as it came. A longer stretch is cut
|
||||
* into sections of this length that meet end to end.
|
||||
*/
|
||||
export const HORSE_ROUTE_SECTION_MAX_POINTS = 5000;
|
||||
|
||||
/** ODbL asks for this next to anything drawn from the data. */
|
||||
export const HORSE_ROUTES_ATTRIBUTION = FERRATAS_ATTRIBUTION;
|
||||
|
||||
/** The `code` beside `error` when no Overpass mirror answered, so the client can say "try again later". */
|
||||
export const HORSE_ROUTES_UNAVAILABLE_CODE = 'horse_routes_unavailable';
|
||||
|
||||
/**
|
||||
* `horse=*` as OpenStreetMap tags a way. The common values are kept as they
|
||||
* are; any other tagged value is `other`, and a way without the tag `unknown`.
|
||||
*/
|
||||
export const HORSE_ACCESS_VALUES = [
|
||||
'designated',
|
||||
'yes',
|
||||
'permissive',
|
||||
'destination',
|
||||
'discouraged',
|
||||
'private',
|
||||
'no',
|
||||
'other',
|
||||
'unknown',
|
||||
] as const;
|
||||
export const horseAccessSchema = z.enum(HORSE_ACCESS_VALUES);
|
||||
export type HorseAccess = z.infer<typeof horseAccessSchema>;
|
||||
|
||||
/**
|
||||
* What a value says for a rider, in four steps: open (designated, yes,
|
||||
* permissive), limited (destination, discouraged, any other value), closed
|
||||
* (private, no) and unknown (not tagged). A step sorts what is tagged; it is
|
||||
* no permission, and the ground may say otherwise.
|
||||
*/
|
||||
export type HorseAccessTone = 'open' | 'limited' | 'closed' | 'unknown';
|
||||
const ACCESS_TONES: Readonly<Record<HorseAccess, HorseAccessTone>> = {
|
||||
designated: 'open',
|
||||
yes: 'open',
|
||||
permissive: 'open',
|
||||
destination: 'limited',
|
||||
discouraged: 'limited',
|
||||
other: 'limited',
|
||||
private: 'closed',
|
||||
no: 'closed',
|
||||
unknown: 'unknown',
|
||||
};
|
||||
export function horseAccessTone(value: HorseAccess): HorseAccessTone {
|
||||
return ACCESS_TONES[value];
|
||||
}
|
||||
|
||||
/** How much of a line carries one `horse=*` value, in metres. */
|
||||
export const horseAccessShareSchema = z.object({
|
||||
value: horseAccessSchema,
|
||||
length_m: z.number().finite().nonnegative(),
|
||||
});
|
||||
export type HorseAccessShare = z.infer<typeof horseAccessShareSchema>;
|
||||
|
||||
/** The values along a line, each once, with the metres it covers. */
|
||||
export const horseAccessSharesSchema = z.array(horseAccessShareSchema).max(HORSE_ACCESS_VALUES.length);
|
||||
|
||||
/**
|
||||
* Whether a line runs the way its route is signposted (`with`) or the other
|
||||
* way (`against`), where the route is signposted one way only along all of
|
||||
* it. Null where it is signposted both ways, or the data does not say.
|
||||
*/
|
||||
export const horseSignpostedSchema = z.enum(['with', 'against']);
|
||||
export type HorseSignposted = z.infer<typeof horseSignpostedSchema>;
|
||||
|
||||
/** The other direction: a section or a leg turned round. */
|
||||
export function turnedSignposted(value: HorseSignposted | null | undefined): HorseSignposted | null {
|
||||
if (value == null) return null;
|
||||
return value === 'with' ? 'against' : 'with';
|
||||
}
|
||||
|
||||
const latitude = z.number().min(-90).max(90);
|
||||
const longitude = z.number().min(-180).max(180);
|
||||
|
||||
export const horseRouteSectionSchema = z.object({
|
||||
/** Its place among the route's sections, from 1, in the order the route lists its ways. */
|
||||
number: z.number().int().positive(),
|
||||
/** The line as [lat, lng] pairs, in the order the route's ways run. */
|
||||
geometry: z
|
||||
.array(z.tuple([latitude, longitude]))
|
||||
.min(2)
|
||||
.max(HORSE_ROUTE_SECTION_MAX_POINTS),
|
||||
length_m: z.number().finite().nonnegative(),
|
||||
access: horseAccessSharesSchema,
|
||||
/** Along `geometry`, as the route's member roles say. */
|
||||
signposted: horseSignpostedSchema.nullable(),
|
||||
});
|
||||
export type HorseRouteSection = z.infer<typeof horseRouteSectionSchema>;
|
||||
|
||||
export const horseRouteSchema = z.object({
|
||||
/** The OSM relation, as `relation/<id>`. */
|
||||
id: z.string().regex(/^relation\/\d+$/),
|
||||
name: z.string().max(255).nullable(),
|
||||
ref: z.string().max(64).nullable(),
|
||||
osmUrl: z.string().url(),
|
||||
/** The route has ways beyond the box asked for: it goes on past what the sections show. */
|
||||
continues: z.boolean(),
|
||||
sections: z.array(horseRouteSectionSchema).min(1).max(HORSE_ROUTE_SECTIONS_CAP),
|
||||
});
|
||||
export type HorseRoute = z.infer<typeof horseRouteSchema>;
|
||||
|
||||
export const horseRoutesResponseSchema = z.object({
|
||||
routes: z.array(horseRouteSchema).max(HORSE_ROUTES_RESULT_CAP),
|
||||
/** True when the box held more than the caps allow and the rest was left out. */
|
||||
truncated: z.boolean(),
|
||||
source: z.literal('openstreetmap'),
|
||||
attribution: z.literal(HORSE_ROUTES_ATTRIBUTION),
|
||||
});
|
||||
export type HorseRoutesResponse = z.infer<typeof horseRoutesResponseSchema>;
|
||||
@@ -27,6 +27,8 @@ export const TOUR_POI_CATEGORIES_BY_TYPE: Record<PlannableTourType, readonly Tou
|
||||
kayak: ['slipway', 'boat_rental', 'drinking_water', 'food', 'toilets', 'picnic', 'campsite', 'shelter'],
|
||||
walk: ['sights', 'museum', 'park', 'viewpoint', 'cafe', 'restaurant', 'market', 'toilets', 'drinking_water'],
|
||||
run: ['drinking_water', 'toilets', 'park', 'viewpoint', 'cafe', 'food', 'shelter', 'sights'],
|
||||
// HOR-01: water for horse and rider first; a ride stops where a walk would.
|
||||
horse: ['drinking_water', 'shelter', 'viewpoint', 'food', 'picnic', 'toilets', 'campsite', 'sights'],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user