feat(tours): hiking-rule walking time, source marks in the map search, tour rows and status badges reworked, ferrata card in the map glass

This commit is contained in:
Maurice
2026-10-14 17:45:31 +02:00
parent a805b4b882
commit 6d43f3a4e6
42 changed files with 385 additions and 120 deletions
@@ -5,10 +5,20 @@ import type { Ferrata } from '@trek/shared';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { formatDistance } from '../../utils/units';
import { MAP_CONTROL_SHADOW } from '../Map/mapControlShadow';
import { SoftPill } from '../Planner/planParts';
import { fs } from '../shared/DialogShell';
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
import { ferrataOsmUrl } from './ferrataModel';
/** The map controls' frosted glass, so the card reads as one of them. */
const MAP_GLASS = {
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
};
/**
* One via ferrata, picked on the map: its name, the route it belongs to, its
* grade range and total length as badges, and the way back to the source.
@@ -21,10 +31,13 @@ export default function FerrataDetailCard({
group,
segment,
onClose,
width,
}: {
group: FerrataGroup;
segment: Ferrata;
onClose?: () => void;
/** Set when the card stands on another control and takes its width. */
width?: number;
}) {
const { t } = useTranslation();
const unit = useSettingsStore((s) => s.settings.distance_unit);
@@ -37,13 +50,14 @@ export default function FerrataDetailCard({
return (
<article
className="w-max min-w-[min(18rem,calc(100vw_-_2rem))] max-w-[22.5rem] rounded-xl border border-edge bg-surface-card p-3 text-content shadow-lg"
className={`rounded-2xl p-3 text-content ${width ? '' : 'w-max min-w-[min(18rem,calc(100vw_-_2rem))] max-w-[22.5rem]'}`}
style={{ ...MAP_GLASS, width }}
data-testid="ferrata-detail-card"
>
<div className="flex items-start gap-2">
<Route size={16} className="mt-0.5 flex-none text-content-muted" aria-hidden="true" />
<div className="min-w-0 flex-1">
<h3 className="line-clamp-2 break-words text-subtitle font-semibold" title={name}>
<h3 className="line-clamp-2 break-words font-semibold" style={fs(14, 'body')} title={name}>
{name}
</h3>
{route && <p className="truncate text-caption text-content-muted">{route}</p>}
@@ -71,12 +85,15 @@ export default function FerrataDetailCard({
href={ferrataOsmUrl(segment)}
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1 text-body font-medium text-accent-on hover:underline"
className="mt-3 inline-flex items-center gap-1 font-medium text-content hover:underline"
style={fs(12, 'body')}
>
{t('ferratas.viewInOsm')}
<ExternalLink size={13} aria-hidden="true" />
</a>
<p className="mt-2 text-caption text-content-faint">{t('ferratas.attribution')}</p>
<p className="mt-2 border-t border-edge-faint pt-2 text-content-faint" style={fs(9)}>
{t('ferratas.attribution')}
</p>
</article>
);
}
@@ -22,12 +22,15 @@ export default function FerrataDetailOverlay({
onOutsidePress,
insetEnd,
bottom,
width,
}: {
selection: FerrataSelection | null;
onClose: () => void;
onOutsidePress?: () => void;
insetEnd: number;
bottom: number;
/** A fixed width, to match what the card stands on (the grade legend in 3D). */
width?: number;
}) {
const ref = useRef<HTMLDivElement>(null);
const open = !!selection;
@@ -55,11 +58,11 @@ export default function FerrataDetailOverlay({
return (
<div
ref={ref}
className="absolute z-[20] flex justify-end"
className="absolute z-[20] flex justify-end transition-[bottom] duration-200 ease-out"
style={{ insetInlineEnd: insetEnd, bottom, maxWidth: `calc(100% - ${insetEnd + 32}px)` }}
data-testid="ferrata-detail-overlay"
>
<FerrataDetailCard group={selection.group} segment={selection.segment} onClose={onClose} />
<FerrataDetailCard group={selection.group} segment={selection.segment} onClose={onClose} width={width} />
</div>
);
}
@@ -3,9 +3,11 @@ import { MapPin, Search } from 'lucide-react'
import { mapsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useLocationBias } from '../../hooks/useLocationBias'
import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions'
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
import { useSuggestionDropdown } from './useSuggestionDropdown'
import { PlaceSuggestion, SuggestionInputBox, SuggestionList, SuggestionRow } from './SuggestionDropdown'
import { SoftPill } from './planParts'
export interface LocationPoint {
name: string
@@ -30,9 +32,11 @@ interface Props {
* name; from the third character on the map search answers instead.
*/
places?: LocationPoint[]
/** Marks each search result with where it came from (OSM, Google, TREK, a plugin), as the place form does. */
showSource?: boolean
}
export default function LocationSelect({ value, onChange, placeholder, ariaLabel, showSearchStatus = false, floating = false, style, places }: Props) {
export default function LocationSelect({ value, onChange, placeholder, ariaLabel, showSearchStatus = false, floating = false, style, places, showSource = false }: Props) {
const { t, locale } = useTranslation()
const placeLang = usePlaceLanguage()
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
@@ -40,6 +44,9 @@ export default function LocationSelect({ value, onChange, placeholder, ariaLabel
const [query, setQuery] = useState(value?.name || '')
const { open, setOpen, highlight, setHighlight, wrapRef, handleKey } = useSuggestionDropdown()
const [results, setResults] = useState<any[]>([])
// What answered the last search, for the rows that do not name their own source.
const [listSource, setListSource] = useState('')
const { sourceLabel } = usePlaceSuggestions()
const [loading, setLoading] = useState(false)
const [searchError, setSearchError] = useState(false)
// The text the last answer was for, so a status never describes older text.
@@ -72,6 +79,7 @@ export default function LocationSelect({ value, onChange, placeholder, ariaLabel
const data = await mapsApi.search(trimmed, placeLang, locationBias)
if (stale) return
setResults(data.places || [])
setListSource(data.source || '')
setHighlight(-1)
setSearchedQuery(trimmed)
} catch {
@@ -170,6 +178,7 @@ export default function LocationSelect({ value, onChange, placeholder, ariaLabel
optionId={showSearchStatus ? `${listId}-option-${i}` : undefined}
>
<PlaceSuggestion name={r.name} address={r.address} />
{showSource && !showPicks && <SourceMark label={sourceLabel(r, listSource)} />}
</SuggestionRow>
))}
</SuggestionList>
@@ -178,6 +187,12 @@ export default function LocationSelect({ value, onChange, placeholder, ariaLabel
)
}
/** Where a result came from, quiet on purpose, beside its name. */
function SourceMark({ label }: { label: string | null }) {
if (!label) return null
return <span className="flex-none self-center"><SoftPill>{label}</SoftPill></span>
}
function pickRows<T>(value: LocationPoint | null, typed: string, places: LocationPoint[] | undefined, results: T[]) {
const showPicks = !value && typed.length < 3 && !!places?.length
const rows: (T | LocationPoint)[] = showPicks
+22 -18
View File
@@ -1,7 +1,8 @@
import { useId, type ReactNode } from 'react'
import type { TourListItem } from '@trek/shared'
import { Link2 } from 'lucide-react'
import { fs } from '../shared/DialogShell'
import { useTranslation } from '../../i18n'
import { SoftPill } from '../Planner/planParts'
import { TourFactPills, TourGradeTile } from './tourParts'
import { tourWebsitePresentation } from './tourPresentation'
@@ -16,7 +17,6 @@ interface TourListRowProps {
/** A tour in a list, shaped like a place row: tile, name, its facts as pills and the day action on the right. */
export default function TourListRow({ tour, selected = false, disabled = false, onSelect, action, children }: TourListRowProps) {
const { t } = useTranslation()
const nameId = useId()
const tone = selected ? 'bg-surface-selected' : disabled ? '' : 'hover:bg-surface-hover'
const website = tourWebsitePresentation(tour.website)
@@ -37,28 +37,32 @@ export default function TourListRow({ tour, selected = false, disabled = false,
}
}}
>
<div className="flex items-start gap-2.5">
{/* The tile, the name and the day actions share the head line; the rest runs the
full width under them, below the tile too. */}
<div className="flex items-center gap-2.5">
<TourGradeTile tour={tour} />
<div className="min-w-0 flex-1 pt-px">
<span id={nameId} className="block min-w-0 truncate font-semibold leading-tight text-content" style={fs(13, 'body')}>{tour.name}</span>
<TourFactPills tour={tour} className="mt-1.5" />
{tour.description && (
<div data-testid="tour-description-preview" className="mt-1 overflow-hidden text-ellipsis text-content-faint" style={{ ...fs(11), lineHeight: 1.25, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
{tour.description}
</div>
)}
{website && (
<div className="mt-1 truncate text-content-faint" style={fs(10.5)}>
{website.provider ? `${website.provider} · ${website.domain}` : `${t('places.formWebsite')} · ${website.domain}`}
</div>
)}
</div>
<span id={nameId} className="block min-w-0 flex-1 truncate font-semibold leading-tight text-content" style={fs(13, 'body')}>{tour.name}</span>
{action && (
<div data-testid="tour-action-row" role="presentation" className="flex flex-none items-center gap-1.5 pt-1" onClick={event => event.stopPropagation()}>
<div data-testid="tour-action-row" role="presentation" className="flex flex-none items-center gap-1.5" onClick={event => event.stopPropagation()}>
{action}
</div>
)}
</div>
{tour.description && (
<div data-testid="tour-description-preview" className="mt-2.5 rounded-[10px] bg-surface-card px-2 py-1.5 text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint" style={fs(11)}>
<span className="overflow-hidden text-ellipsis" style={{ lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
{tour.description}
</span>
</div>
)}
<TourFactPills tour={tour} className={tour.description ? 'mt-1.5' : 'mt-2.5'} />
{website && (
<div className="mt-1 flex min-w-0">
<SoftPill icon={<Link2 size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
<span className="min-w-0 truncate">{website.provider ?? website.domain}</span>
</SoftPill>
</div>
)}
{children}
</li>
)
@@ -0,0 +1,19 @@
// FE-TOURS-HIKINGTIME-001 to -003: walking time after DIN 33466.
import { describe, expect, it } from 'vitest';
import { hikingTimeMinutes } from './hikingTime';
describe('hikingTimeMinutes', () => {
it('FE-TOURS-HIKINGTIME-001: on the flat it is 4 km an hour', () => {
expect(hikingTimeMinutes(8, 0, 0)).toBe(120);
});
it('FE-TOURS-HIKINGTIME-002: a short steep climb is ruled by the height, the distance counts by half', () => {
// 2 km and 450 m up: 1.5 h for the height plus half of 0.5 h for the distance.
expect(hikingTimeMinutes(2, 450, 0)).toBe(105);
});
it('FE-TOURS-HIKINGTIME-003: a long walk is ruled by the distance, the height counts by half', () => {
// 12 km, 300 m up and 500 m down: 3 h for the distance plus half of 2 h for the height.
expect(hikingTimeMinutes(12, 300, 500)).toBe(240);
});
});
+19
View File
@@ -0,0 +1,19 @@
/**
* Walking time the way hiking signposts in the Alps state it (DIN 33466, as the
* DAV and the Swiss hiking trails use it): 4 km an hour on the flat, 300 m of
* climb and 500 m of descent an hour, and of the time for the distance and the
* time for the height only the larger counts in full, the smaller by half.
*
* The router's own estimate is a walking pace along the line and barely sees a
* slope, so a short steep climb came out far too quick.
*/
const FLAT_KM_PER_HOUR = 4;
const CLIMB_M_PER_HOUR = 300;
const DESCENT_M_PER_HOUR = 500;
export function hikingTimeMinutes(distanceKm: number, climbM: number, descentM: number): number {
const flat = distanceKm / FLAT_KM_PER_HOUR;
const height = climbM / CLIMB_M_PER_HOUR + descentM / DESCENT_M_PER_HOUR;
const hours = Math.max(flat, height) + Math.min(flat, height) / 2;
return Math.round(hours * 60);
}
@@ -1,12 +1,11 @@
import { AlertTriangle, Info } from 'lucide-react';
import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { AlertTriangle } from 'lucide-react';
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { SoftPill } from '../../Planner/planParts';
import CustomSelect from '../../shared/CustomSelect';
import { INPUT, LABEL } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { FoldButton, TourNotice, TourSection } from '../tourParts';
import { FoldButton, InfoDot, TourNotice, TourSection } from '../tourParts';
import { TOUR_DURATION_INPUT_MAX_LENGTH, formatTourDuration, parseTourDuration } from './tourDurationInput';
import type { TourPlannerController } from './useTourPlanner';
@@ -39,26 +38,6 @@ function writeDetailsOpen(open: boolean) {
}
}
/** The round info dot beside a label; hover, focus and a tap show the explanation as a tooltip. */
export function InfoDot({ description, label }: { description: string; label: string }) {
const id = useId();
return (
<Tooltip label={description}>
<button
type="button"
aria-label={label}
aria-describedby={id}
className="grid h-5 w-5 flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-card hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<Info size={13} strokeWidth={2} aria-hidden="true" />
<span id={id} className="sr-only">
{description}
</span>
</button>
</Tooltip>
);
}
/** A field label with what belongs beside it (an info dot, a badge) on the same line. */
function FieldLabel({ htmlFor, children, aside }: { htmlFor: string; children: ReactNode; aside?: ReactNode }) {
return (
@@ -195,7 +195,6 @@ describe('TourPlannerToursRail', () => {
fireEvent.click(screen.getByRole('button', { name: /Summit day/ }))
await waitFor(() => expect(onAssignToDay).toHaveBeenCalledWith(42, 7))
expect(screen.getByRole('status')).toHaveTextContent('Tour saved')
expect(screen.queryByRole('button', { name: 'Assign to a day' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add to day: Saved ridge walk' })).toBeEnabled()
})
@@ -563,7 +562,7 @@ describe('TourPlannerRail', () => {
expect(screen.getAllByLabelText('Move waypoint up').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true)
expect(screen.getAllByLabelText('Move waypoint down').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true)
expect(screen.getAllByLabelText('Remove waypoint').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true)
expect(screen.getByRole('status')).toHaveTextContent('Saving…')
expect(within(screen.getByRole('status')).getByLabelText('Saving…')).toBeInTheDocument()
})
it('TOUR-PLANNER-RAIL-006: places the safety note beside Save and numbers rows like map pins', () => {
@@ -813,12 +812,10 @@ describe('TourPlannerRail', () => {
it('TOUR-PLANNER-RAIL-019: restored new and saved drafts show a non-blocking notice', () => {
const { rerender } = render(<TourPlannerRail planner={planner({ draftRestored: true, hasUnsavedChanges: true, mode: { type: 'new-draft' } })} />)
expect(screen.getByRole('status')).toHaveTextContent('Draft')
expect(screen.getByLabelText('Unsaved draft restored')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'New tour' })).toBeInTheDocument()
rerender(<TourPlannerRail planner={planner({ draftRestored: true, hasUnsavedChanges: true, editingPlaceId: tour.place_id, mode: { type: 'edit-saved', placeId: tour.place_id } })} />)
expect(screen.getByRole('status')).toHaveTextContent('Draft')
expect(screen.getByLabelText('Unsaved draft restored')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument()
})
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { TourListItem } from '@trek/shared'
import { AlertTriangle, ArrowLeft, Info, Plus, Redo2, RotateCcw, Save, Trash2, Undo2 } from 'lucide-react'
import { AlertTriangle, ArrowLeft, Circle, Info, Loader2, Plus, Redo2, RotateCcw, Save, Trash2, Undo2 } from 'lucide-react'
import type { Day } from '../../../types'
import { useTranslation } from '../../../i18n'
import { useSettingsStore } from '../../../store/settingsStore'
@@ -58,9 +58,21 @@ function RailTitle({ title, sub, aside }: { title: string; sub?: ReactNode; asid
)
}
/** A status as a small round icon beside the title, its words in the tooltip and for a screen reader. */
function StatusDot({ label, tone, children }: { label: string; tone: 'neutral' | 'info' | 'warning'; children: ReactNode }) {
const look = tone === 'warning' ? 'text-warning' : tone === 'info' ? 'text-info' : 'text-content-muted'
return (
<Tooltip label={label}>
<span aria-label={label} className={`grid h-[20px] w-[20px] flex-none place-items-center rounded-full bg-surface-card shadow-sm ring-1 ring-inset ring-edge-faint ${look}`}>
{children}
</span>
</Tooltip>
)
}
/**
* What the editor is doing right now, as badges under the head's title: a
* restored draft, routing or saving, and a failure with its retry. Nothing
* What the editor is doing right now, as icon badges beside the head's title:
* a restored draft, routing or saving, and a failure with its retry. Nothing
* when there is nothing to say.
*/
function RailStatusBadges({ planner }: { planner: TourPlannerController }) {
@@ -71,19 +83,21 @@ function RailStatusBadges({ planner }: { planner: TourPlannerController }) {
const canRetry = planner.status === 'routing-failed' || planner.status === 'elevation-failed'
if (!restored && !statusKey && !failed) return null
const warning = planner.error != null || canRetry
const statusLabel = failed ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null
const busy = planner.isSaving || planner.status === 'routing' || planner.status === 'enriching-elevation'
return (
<div role="status" data-testid="tour-planner-status" className="mt-1 flex min-w-0 flex-wrap items-center gap-1">
<span role="status" data-testid="tour-planner-status" className="ms-1.5 flex flex-none items-center gap-1">
{restored && (
<Tooltip label={t('tours.planner.restored')}>
<span className="inline-flex" aria-label={t('tours.planner.restored')}>
<SoftPill tone="info" icon={<RotateCcw size={10} strokeWidth={2.2} className="flex-none" />}>{t('tours.planner.restoredShort')}</SoftPill>
</span>
</Tooltip>
<StatusDot label={t('tours.planner.restored')} tone="info">
<RotateCcw size={11} strokeWidth={2.2} />
</StatusDot>
)}
{(failed || statusKey) && (
<SoftPill tone={warning ? 'warning' : 'neutral'} icon={warning ? <AlertTriangle size={10} strokeWidth={2.2} className="flex-none" /> : undefined}>
{failed ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null}
</SoftPill>
{statusLabel && (
<StatusDot label={statusLabel} tone={warning ? 'warning' : 'neutral'}>
{warning
? <AlertTriangle size={11} strokeWidth={2.2} />
: busy ? <Loader2 size={11} strokeWidth={2.2} className="animate-spin" /> : <Circle size={7} strokeWidth={3} />}
</StatusDot>
)}
{canRetry && (
<button type="button" onClick={planner.retry}
@@ -92,7 +106,7 @@ function RailStatusBadges({ planner }: { planner: TourPlannerController }) {
{t('tours.planner.retry')}
</button>
)}
</div>
</span>
)
}
@@ -187,7 +201,7 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
<BarButton label={t('tours.planner.backToTours')} onClick={() => requestClose()} disabled={isSaving} className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
<ArrowLeft size={16} strokeWidth={2} />
</BarButton>
<RailTitle title={t(isSavedEdit ? 'tours.planner.editTitle' : 'tours.planner.newTitle')} sub={<RailStatusBadges planner={planner} />} />
<RailTitle title={t(isSavedEdit ? 'tours.planner.editTitle' : 'tours.planner.newTitle')} aside={<RailStatusBadges planner={planner} />} />
<BarButton label={t('tours.planner.undo')} onClick={() => { if (canEdit && !isSaving) planner.undo() }} disabled={!canEdit || isSaving || !planner.canUndo}>
<Undo2 size={15} strokeWidth={2} />
</BarButton>
@@ -315,9 +329,14 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
const routeAnalysis = planner.routeAnalysis
// A finished save is said once, as the app says it everywhere: a toast.
// Once per save: the toast helper and t are fresh objects on every render, so the
// effect reruns often; only a save it has not announced yet gets the toast.
const savedTour = planner.saveOutcome
const announced = useRef<typeof savedTour>(null)
useEffect(() => {
if (savedTour) toast.success(t('tours.planner.saved'))
if (!savedTour || announced.current === savedTour) return
announced.current = savedTour
toast.success(t('tours.planner.saved'))
}, [savedTour, t, toast])
const openTour = async (tour: TourListItem) => {
@@ -341,7 +360,9 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
const tourTimeFields = [
{
label: t('tours.planner.inspector.duration'),
label: t('tours.planner.walkingEstimated'),
shortLabel: t('tours.planner.walkingEstimatedShort'),
info: { label: t('tours.planner.walkingEstimatedInfoLabel'), description: t('tours.planner.walkingEstimatedInfo') },
value: planner.walkingDurationMinutes == null
? t('tours.planner.walkingTimeUnknown')
: formatPlannedTourDuration(planner.walkingDurationMinutes),
@@ -20,6 +20,8 @@ vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detai
vi.mock('../useTourPermissions', () => ({ useTourPermissions: ({ canEdit = true, canAssign = true }: { canEdit?: boolean; canAssign?: boolean }) => ({ canEdit, canAssign }) }))
import { useTourPlanner } from './useTourPlanner'
import { hikingTimeMinutes } from '../hikingTime'
import { analyzeRouteGeometry } from '../../../utils/routeGeometry'
import { TourPlannerRail } from './TourPlannerPanels'
const routed = {
@@ -28,6 +30,9 @@ const routed = {
durationSeconds: 1800,
}
const enriched = [[48, 11, 500], [48.01, 11.02, 550]] as [number, number, number][]
// With the heights in, the walking time is the hiking rule over the enriched line, not the router's.
const enrichedAnalysis = analyzeRouteGeometry(JSON.stringify(enriched))!
const enrichedWalkingSeconds = hikingTimeMinutes(enrichedAnalysis.distanceKm, enrichedAnalysis.gain!, enrichedAnalysis.loss!) * 60
const savedTour = {
place_id: 42,
name: 'Saved ridge',
@@ -299,7 +304,7 @@ describe('useTourPlanner', () => {
expect(result.current.canSave).toBe(true)
await act(async () => { await result.current.save() })
expect(createTour).toHaveBeenCalledWith(73, expect.objectContaining({
duration_seconds: routed.durationSeconds,
duration_seconds: enrichedWalkingSeconds,
planned_duration_minutes: 95,
break_additional_minutes: 40,
}))
@@ -311,13 +316,15 @@ describe('useTourPlanner', () => {
act(() => result.current.setPlannedDurationMinutes(null))
await act(async () => { await result.current.save() })
expect(updateTour).toHaveBeenCalledWith(73, savedTour.place_id, expect.objectContaining({
duration_seconds: routed.durationSeconds,
duration_seconds: enrichedWalkingSeconds,
planned_duration_minutes: null,
break_additional_minutes: 40,
}))
})
it('RS-02: recalculates automatic total when route changes but preserves a manual override', async () => {
// No heights for this line, so the router's time stands and each new route brings its own.
enrichTourElevations.mockResolvedValue(routed.coordinates)
routeWalkingTour
.mockResolvedValueOnce({ ...routed, durationSeconds: 3600 })
.mockResolvedValueOnce({ ...routed, durationSeconds: 5400 })
@@ -5,6 +5,7 @@ import { randomId } from '../../../utils/randomId'
import type { TourBaseLayer } from '../../Map/MapLayerSwitcher'
import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/routeGeometry'
import { enrichTourElevations, routeWalkingTour, type TourElevationPoint } from './tourRouting'
import { hikingTimeMinutes } from '../hikingTime'
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
export interface TourPlannerWaypoint {
@@ -417,7 +418,15 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const websiteInvalid = website.trim().length > 0 && !tourWebsiteSchema.safeParse(website).success
const plannedDurationInvalid = plannedDurationMinutes !== null
&& (!Number.isInteger(plannedDurationMinutes) || plannedDurationMinutes < 0 || plannedDurationMinutes > MAX_PLANNED_TOUR_DURATION_MINUTES)
const walkingDurationMinutes = durationSeconds === null ? null : Math.round(durationSeconds / 60)
const routeAnalysis = useMemo(
() => analyzeRouteGeometry(JSON.stringify(enrichedGeometry ?? route)),
[enrichedGeometry, route],
)
// The router's time only stands in until the route has its heights; from then on the
// walking time is the signposts' rule over distance, climb and descent.
const walkingDurationMinutes = routeAnalysis?.gain != null && routeAnalysis.loss != null
? hikingTimeMinutes(routeAnalysis.distanceKm, routeAnalysis.gain, routeAnalysis.loss)
: durationSeconds === null ? null : Math.round(durationSeconds / 60)
const automaticPlannedTotalMinutes = walkingDurationMinutes === null
? null
: walkingDurationMinutes + (breakAdditionalMinutes ?? 0)
@@ -603,10 +612,6 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
}, [interruptRouting])
const waypointKey = useMemo(() => tourWaypointKey(waypoints), [waypoints])
const routeAnalysis = useMemo(
() => analyzeRouteGeometry(JSON.stringify(enrichedGeometry ?? route)),
[enrichedGeometry, route],
)
const readOnlyGpxAnalysis = useMemo(
() => analyzeRouteGeometry(readOnlyGpxTour?.routeGeometry),
[readOnlyGpxTour?.routeGeometry],
@@ -713,7 +718,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
sequence,
})),
max_hiking_difficulty: maxHikingDifficulty,
duration_seconds: durationSeconds,
duration_seconds: walkingDurationMinutes === null ? durationSeconds : walkingDurationMinutes * 60,
planned_duration_minutes: plannedDurationMinutes,
break_additional_minutes: breakAdditionalMinutes,
} as const
@@ -755,7 +760,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setIsSaving(false)
}
}
}, [breakAdditionalMinutes, canSave, description, durationSeconds, editingPlaceId, enrichedGeometry, invalidateDetailRequest, maxHikingDifficulty, name, onSaved, plannedDurationMinutes, storageKey, tripId, waypoints, website])
}, [breakAdditionalMinutes, canSave, description, durationSeconds, walkingDurationMinutes, editingPlaceId, enrichedGeometry, invalidateDetailRequest, maxHikingDifficulty, name, onSaved, plannedDurationMinutes, storageKey, tripId, waypoints, website])
return {
canEdit, canAssign,
@@ -1,6 +1,6 @@
import type { FerrataBbox } from '@trek/shared';
import { ChevronDown, ChevronUp, Gauge, Palette, Pause, Play, RotateCcw, Scan, TrendingUp } from 'lucide-react';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import type { DistanceUnit } from '../../../types';
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry';
@@ -44,6 +44,14 @@ export interface TourTerrainMapProps {
onControlsHeightChange?: (px: number) => void;
/** The "set points" switch in the layer pill, while a tour is being edited. */
placeTool?: MapPlaceTool;
/** The grade legend's width and how far its open bands reach above it, for a card that sits on it; null while there is none. */
onLegendLayout?: (layout: TerrainLegendLayout | null) => void;
}
export interface TerrainLegendLayout {
width: number;
/** How far the unfolded bands reach above the legend pill, 0 while they are folded. */
raisedBy: number;
}
const SPEEDS = [1, 2, 4] as const;
@@ -67,7 +75,7 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
const { setContainer, ...terrain } = useTourTerrain({ ...props, panels: { leftWidth, rightWidth } });
const stats = useMemo(() => terrainStats(samples), [samples]);
const { fit, height: controlsHeight, attachRow, attachStack } = useControlRowFit();
const legend = terrain.canReplay ? <GradeLegend stats={stats} unit={unit} /> : null;
const legend = terrain.canReplay ? <GradeLegend stats={stats} unit={unit} onLayout={props.onLegendLayout} /> : null;
const legendAbove = fit === 'stacked';
useEffect(() => {
@@ -270,15 +278,46 @@ function PreloadRing({ share, label }: { share: number; label: string }) {
}
/** The grade mix as a pill like the dock; it unfolds upwards into the bands. */
function GradeLegend({ stats, unit }: { stats: TerrainStats; unit: DistanceUnit }) {
function GradeLegend({
stats,
unit,
onLayout,
}: {
stats: TerrainStats;
unit: DistanceUnit;
onLayout?: (layout: TerrainLegendLayout | null) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
// A card over the legend takes its width and stands on its bands when they are open.
useLayoutEffect(() => {
const wrap = wrapRef.current;
if (!wrap || !onLayout) return;
const report = () => {
const panel = panelRef.current;
onLayout({ width: wrap.offsetWidth, raisedBy: open && panel ? panel.offsetHeight + 8 : 0 });
};
report();
if (typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(report);
observer.observe(wrap);
if (panelRef.current) observer.observe(panelRef.current);
return () => observer.disconnect();
}, [open, onLayout]);
useEffect(() => () => onLayout?.(null), [onLayout]);
const total = stats.totalMeters || 1;
const width = (key: GradeBandKey) => `${(stats.bandMeters[key] / total) * 100}%`;
return (
<div className="pointer-events-auto relative">
<div ref={wrapRef} className="pointer-events-auto relative">
{open && (
<div id="tour-terrain-grades" className="absolute inset-x-0 bottom-full mb-2 rounded-2xl p-3" style={GLASS}>
<div
ref={panelRef}
id="tour-terrain-grades"
className="absolute inset-x-0 bottom-full mb-2 rounded-2xl p-3"
style={GLASS}
>
<ul className="flex flex-col gap-1">
{GRADE_BANDS.map((band) => (
<li key={band.key} className="flex items-center gap-2 text-content-secondary" style={fs(11)}>
+58 -5
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import type { TourListItem } from '@trek/shared'
import { AlertTriangle, ArrowDown, ArrowUp, ChevronDown, Clock, Mountain, Plus, Ruler, type LucideIcon } from 'lucide-react'
import { AlertTriangle, ArrowDown, ArrowUp, ChevronDown, Clock, Info, Mountain, Plus, Ruler, type LucideIcon } from 'lucide-react'
import type { Day, DistanceUnit } from '../../types'
import type { RouteGeometryAnalysis } from '../../utils/routeGeometry'
import { useTranslation } from '../../i18n'
@@ -159,6 +159,20 @@ export function TourDayMenu({ days, placeId, onPick, disabled = false, label, va
)
}
/** The round info dot beside a label; hover, focus and a tap show the explanation as a tooltip. */
export function InfoDot({ description, label }: { description: string; label: string }) {
const id = useId()
return (
<Tooltip label={description}>
<button type="button" aria-label={label} aria-describedby={id}
className="grid h-5 w-5 flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-card hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
<Info size={13} strokeWidth={2} aria-hidden="true" />
<span id={id} className="sr-only">{description}</span>
</button>
</Tooltip>
)
}
/** A labelled block of a tour panel: the eyebrow, an optional control on its right, then the content. */
export function TourSection({ label, action, children, className = '' }: { label: ReactNode; action?: ReactNode; children: ReactNode; className?: string }) {
return (
@@ -197,6 +211,42 @@ export function FoldButton({ expanded, onToggle, controls, label }: { expanded:
)
}
interface TourMetricField {
label: string
/** Said instead of `label` where the field is too narrow for it, rather than cutting it off. */
shortLabel?: string
value: string
info?: { label: string; description: string }
}
/** A label that falls back to its short form when the full one does not fit its box. */
function FitLabel({ full, short }: { full: string; short?: string }) {
const ref = useRef<HTMLSpanElement>(null)
const [narrow, setNarrow] = useState(false)
useLayoutEffect(() => {
const element = ref.current?.parentElement
if (!element || !short) return
// Measured with the full text in place: it fits when it does not overflow its box.
const check = () => {
const probe = ref.current
if (!probe) return
setNarrow(probe.scrollWidth > element.clientWidth + 0.5)
}
check()
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(check)
observer.observe(element)
return () => observer.disconnect()
}, [full, short])
if (!short) return <>{full}</>
return (
<>
<span ref={ref} aria-hidden={narrow || undefined} className={`inline-block whitespace-nowrap ${narrow ? 'invisible absolute' : ''}`}>{full}</span>
{narrow && <span title={full}>{short}</span>}
</>
)
}
/**
* A tour's numbers as labelled fields, the way a booking card shows its facts:
* distance, any extra the caller has (the walking time), then the altitudes.
@@ -204,12 +254,12 @@ export function FoldButton({ expanded, onToggle, controls, label }: { expanded:
export function TourMetricFields({ analysis, unit, extra = [], columns }: {
analysis: Pick<RouteGeometryAnalysis, 'distanceKm' | 'minEle' | 'maxEle' | 'gain' | 'loss'> | null
unit?: DistanceUnit
extra?: Array<{ label: string; value: string }>
extra?: Array<TourMetricField>
/** A fixed column count for a narrow rail; the inspector lets the fields flow. */
columns?: 2
}) {
const { t } = useTranslation()
const fields: Array<{ label: string; value: string }> = [
const fields: TourMetricField[] = [
{ label: t('tours.detail.distance'), value: analysis ? formatDistance(analysis.distanceKm, unit) : '-' },
...extra,
]
@@ -222,7 +272,10 @@ export function TourMetricFields({ analysis, unit, extra = [], columns }: {
<dl className={`m-0 grid gap-2 ${grid}`}>
{fields.map(field => (
<div key={field.label} className="min-w-0">
<dt><Eyebrow className="mb-[3px] truncate">{field.label}</Eyebrow></dt>
<dt className="mb-[3px] flex min-w-0 items-center gap-0.5">
<Eyebrow className="relative min-w-0 truncate"><FitLabel full={field.label} short={field.shortLabel} /></Eyebrow>
{field.info && <span className="-my-1 flex-none"><InfoDot label={field.info.label} description={field.info.description} /></span>}
</dt>
<dd className={`${BOX} m-0 truncate px-[10px] py-[7px] text-center font-semibold tabular-nums text-content`} style={fs(12.5, 'body')}>{field.value}</dd>
</div>
))}
@@ -93,9 +93,8 @@ describe('Tours English and German copy', () => {
})} />)
expect(screen.getByRole('heading', { name: newTitle })).toBeInTheDocument()
const strings = language === 'de' ? de : en
expect(screen.getByText(toursText(strings, 'tours.planner.restoredShort'))).toBeInTheDocument()
expect(screen.getByLabelText(toursText(strings, 'tours.planner.restored'))).toBeInTheDocument()
expect(screen.getByText(toursText(strings, 'tours.planner.status.routing'))).toBeInTheDocument()
expect(screen.getByLabelText(toursText(strings, 'tours.planner.status.routing'))).toBeInTheDocument()
expect(screen.getByText(toursText(strings, 'tours.planner.difficulty.t3Warning'))).toBeInTheDocument()
expect(screen.getByText((_, element) => element?.tagName === 'SPAN' && element.textContent?.includes(toursText(strings, 'tours.planner.safetyNote')) === true)).toBeInTheDocument()
expect(screen.getByLabelText(language === 'de' ? 'Wegpunkt 1' : 'Waypoint 1')).toBeInTheDocument()
@@ -149,7 +148,7 @@ describe('Tours English and German copy', () => {
assertNoRawToursKeys()
}
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, status: 'ready', error: 'save' })} />)
expect(screen.getByText(toursText(de, 'tours.planner.status.saveFailed'))).toBeInTheDocument()
expect(screen.getByLabelText(toursText(de, 'tours.planner.status.saveFailed'))).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: toursText(de, 'tours.planner.maxDifficulty') }))
fireEvent.click(screen.getByRole('button', { name: toursText(de, 'tours.planner.difficulty.t4') }))
expect(screen.getByRole('heading', { name: toursText(de, 'tours.planner.difficulty.alpineTitle') })).toBeInTheDocument()
@@ -6,6 +6,7 @@ import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../componen
import { MapUndoHint } from '../../components/Map/MapUndoHint';
import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto';
import LocationSelect from '../../components/Planner/LocationSelect';
import type { TerrainLegendLayout } from '../../components/Tours/terrain/TourTerrainMap';
import { isRtlLanguage, useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { useTourMapPoints } from './useTourMapPoints';
@@ -58,6 +59,7 @@ export function TourPlannerMapStage({
const [terrain3d, setTerrain3d] = useState(false);
// How high the 3D map's controls reach: one row, or two once the grade legend moved above the dock.
const [terrainControlsPx, setTerrainControlsPx] = useState(MAP_ROUND_CONTROL_SIZE);
const [terrainLegend, setTerrainLegend] = useState<TerrainLegendLayout | null>(null);
// One via ferrata layer for both modes: it stays on, and keeps its card, across 2D and 3D.
// The trip's area for the layer to prefetch: the framed points and the tour's line.
const ferrataArea = useMemo(
@@ -111,6 +113,7 @@ export function TourPlannerMapStage({
ferrataLayer={ferrata.layer}
onViewportChange={ferrata.onViewportChange}
onControlsHeightChange={setTerrainControlsPx}
onLegendLayout={setTerrainLegend}
placeTool={placeTool}
/>
</Suspense>
@@ -151,7 +154,10 @@ export function TourPlannerMapStage({
onClose={ferrata.close}
onOutsidePress={closingPress.arm}
insetEnd={(isRtlLanguage(language) ? leftInset : rightInset) + MAP_LAYER_SWITCHER_INSET}
bottom={bottomRowTop}
// On the flat map nothing sits under the card's corner, so it rests at the edge gap; in 3D it
// stands on the dock row, and on the grade bands while they are open.
bottom={terrain3d ? bottomRowTop + (terrainLegend?.raisedBy ?? 0) : CONTROL_GAP}
width={terrain3d ? terrainLegend?.width : undefined}
/>
{/* "Point set", with the way back, above the bottom row and between the panels. */}
{placedHint.shown && (
@@ -202,6 +208,7 @@ export function TourPlannerMapStage({
ariaLabel={t('tours.planner.mapSearch')}
floating
showSearchStatus
showSource
style={{ width: '100%' }}
/>
</div>
+4 -1
View File
@@ -69,10 +69,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': 'إظهار التفاصيل',
'tours.planner.collapseDetails': 'إخفاء التفاصيل',
'tours.planner.safetyInfoLabel': 'حول السلامة',
'tours.planner.restoredShort': 'مسودة',
'tours.planner.addPoint': 'إضافة نقطة',
'tours.planner.addPointActive': 'انقر على الخريطة',
'tours.planner.emptyHint': 'فعّل «إضافة نقطة» وانقر على الخريطة. من نقطتين فصاعدًا يحسب TREK المسار.',
'tours.planner.walkingEstimated': 'وقت المشي التقديري',
'tours.planner.walkingEstimatedShort': 'وقت المشي التقريبي',
'tours.planner.walkingEstimatedInfoLabel': 'كيف يُقدَّر وقت المشي',
'tours.planner.walkingEstimatedInfo': 'محسوب من المسافة والصعود والنزول وفق قاعدة زمن المشي المعتادة (DIN 33466)، دون الاستراحات.',
'tours.planner.waypoints': 'نقاط المرور',
'tours.planner.waypointLabel': 'نقطة المرور {n}',
'tours.durationMinutes': '{count} د',
+4 -1
View File
@@ -67,10 +67,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': 'Təfərrüatları göstər',
'tours.planner.collapseDetails': 'Təfərrüatları gizlət',
'tours.planner.safetyInfoLabel': 'Təhlükəsizlik haqqında',
'tours.planner.restoredShort': 'Qaralama',
'tours.planner.addPoint': 'Nöqtə əlavə et',
'tours.planner.addPointActive': 'Xəritəyə klikləyin',
'tours.planner.emptyHint': '“Nöqtə əlavə et”i aktiv edin və xəritəyə klikləyin. İki nöqtədən sonra TREK marşrutu hesablayır.',
'tours.planner.walkingEstimated': 'Təxmini piyada vaxtı',
'tours.planner.walkingEstimatedShort': 'Təxm. piyada vaxtı',
'tours.planner.walkingEstimatedInfoLabel': 'Piyada vaxtı necə təxmin edilir',
'tours.planner.walkingEstimatedInfo': 'Məsafə, qalxma və enmədən adi yürüş vaxtı qaydası (DIN 33466) ilə hesablanır, fasilələr daxil deyil.',
'tours.planner.waypoints': 'Yol nöqtələri',
'tours.planner.waypointLabel': 'Yol nöqtəsi {n}',
'tours.durationMinutes': '{count} dəq.',
+4 -1
View File
@@ -68,10 +68,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': 'Mostrar detalhes',
'tours.planner.collapseDetails': 'Ocultar detalhes',
'tours.planner.safetyInfoLabel': 'Sobre segurança',
'tours.planner.restoredShort': 'Rascunho',
'tours.planner.addPoint': 'Adicionar ponto',
'tours.planner.addPointActive': 'Clique no mapa',
'tours.planner.emptyHint': 'Ative “Adicionar ponto” e clique no mapa. A partir de dois pontos, o TREK calcula a rota.',
'tours.planner.walkingEstimated': 'Tempo de caminhada estimado',
'tours.planner.walkingEstimatedShort': 'Tempo est.',
'tours.planner.walkingEstimatedInfoLabel': 'Como o tempo de caminhada é estimado',
'tours.planner.walkingEstimatedInfo': 'Calculado a partir da distância, subida e descida pela regra usual de tempo de caminhada (DIN 33466), sem pausas.',
'tours.planner.waypoints': 'Pontos de passagem',
'tours.planner.waypointLabel': 'Ponto de passagem {n}',
'tours.durationMinutes': '{count} min',
+4 -1
View File
@@ -67,10 +67,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': 'Mostra els detalls',
'tours.planner.collapseDetails': 'Amaga els detalls',
'tours.planner.safetyInfoLabel': 'Sobre la seguretat',
'tours.planner.restoredShort': 'Esborrany',
'tours.planner.addPoint': 'Afegeix un punt',
'tours.planner.addPointActive': 'Fes clic al mapa',
'tours.planner.emptyHint': 'Activa «Afegeix un punt» i fes clic al mapa. A partir de dos punts, TREK calcula la ruta.',
'tours.planner.walkingEstimated': 'Temps a peu estimat',
'tours.planner.walkingEstimatedShort': 'Temps est.',
'tours.planner.walkingEstimatedInfoLabel': 'Com s’estima el temps a peu',
'tours.planner.walkingEstimatedInfo': 'Calculat a partir de la distància, la pujada i la baixada amb la regla habitual de temps de marxa (DIN 33466), sense pauses.',
'tours.planner.waypoints': 'Punts de pas',
'tours.planner.waypointLabel': 'Punt de pas {n}',
'tours.durationMinutes': '{count} min',
+4 -1
View File
@@ -66,10 +66,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': 'Zobrazit podrobnosti',
'tours.planner.collapseDetails': 'Skrýt podrobnosti',
'tours.planner.safetyInfoLabel': 'O bezpečnosti',
'tours.planner.restoredShort': 'Koncept',
'tours.planner.addPoint': 'Přidat bod',
'tours.planner.addPointActive': 'Klikněte do mapy',
'tours.planner.emptyHint': 'Zapněte „Přidat bod“ a klikněte do mapy. Od dvou bodů TREK spočítá trasu.',
'tours.planner.walkingEstimated': 'Odhadovaný čas chůze',
'tours.planner.walkingEstimatedShort': 'Odhad. čas chůze',
'tours.planner.walkingEstimatedInfoLabel': 'Jak se čas chůze odhaduje',
'tours.planner.walkingEstimatedInfo': 'Vypočteno ze vzdálenosti, stoupání a klesání podle obvyklého pravidla pro čas chůze (DIN 33466), bez přestávek.',
'tours.planner.waypoints': 'Průjezdní body',
'tours.planner.waypointLabel': 'Bod {n}',
'tours.durationMinutes': '{count} min',
+4 -1
View File
@@ -67,10 +67,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': 'Details einblenden',
'tours.planner.collapseDetails': 'Details ausblenden',
'tours.planner.safetyInfoLabel': 'Hinweis zur Sicherheit',
'tours.planner.restoredShort': 'Entwurf',
'tours.planner.addPoint': 'Punkt hinzufügen',
'tours.planner.addPointActive': 'Auf Karte klicken',
'tours.planner.emptyHint': 'Schalte „Punkt hinzufügen“ ein und klicke auf die Karte. Ab zwei Punkten berechnet TREK die Route.',
'tours.planner.walkingEstimated': 'Geschätzte Gehzeit',
'tours.planner.walkingEstimatedShort': 'Gesch. Gehzeit',
'tours.planner.walkingEstimatedInfoLabel': 'Wie die Gehzeit geschätzt wird',
'tours.planner.walkingEstimatedInfo': 'Nach der üblichen Wanderzeit-Regel (DIN 33466) aus Strecke, Auf- und Abstieg berechnet, ohne Pausen.',
'tours.planner.waypoints': 'Wegpunkte',
'tours.planner.waypointLabel': 'Wegpunkt {n}',
'tours.durationMinutes': '{count} Min.',
+4 -1
View File
@@ -66,10 +66,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': 'Show details',
'tours.planner.collapseDetails': 'Hide details',
'tours.planner.safetyInfoLabel': 'About safety',
'tours.planner.restoredShort': 'Draft',
'tours.planner.addPoint': 'Add point',
'tours.planner.addPointActive': 'Click the map',
'tours.planner.emptyHint': 'Turn on Add point and click the map. From two points on, TREK works out the route.',
'tours.planner.walkingEstimated': 'Estimated walking time',
'tours.planner.walkingEstimatedShort': 'Est. walking time',
'tours.planner.walkingEstimatedInfoLabel': 'How the walking time is estimated',
'tours.planner.walkingEstimatedInfo': 'Worked out from distance, climb and descent with the usual hiking time rule (DIN 33466), without breaks.',
'tours.planner.waypoints': 'Waypoints',
'tours.planner.waypointLabel': 'Waypoint {n}',
'tours.durationMinutes': '{count} min',
+4 -1
View File
@@ -67,10 +67,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': 'Mostrar detalles',
'tours.planner.collapseDetails': 'Ocultar detalles',
'tours.planner.safetyInfoLabel': 'Sobre la seguridad',
'tours.planner.restoredShort': 'Borrador',
'tours.planner.addPoint': 'Añadir punto',
'tours.planner.addPointActive': 'Haz clic en el mapa',
'tours.planner.emptyHint': 'Activa «Añadir punto» y haz clic en el mapa. A partir de dos puntos, TREK calcula la ruta.',
'tours.planner.walkingEstimated': 'Tiempo a pie estimado',
'tours.planner.walkingEstimatedShort': 'Tiempo est.',
'tours.planner.walkingEstimatedInfoLabel': 'Cómo se estima el tiempo a pie',
'tours.planner.walkingEstimatedInfo': 'Calculado a partir de la distancia, la subida y la bajada con la regla habitual de tiempo de marcha (DIN 33466), sin pausas.',
'tours.planner.waypoints': 'Puntos de paso',
'tours.planner.waypointLabel': 'Punto de paso {n}',
'tours.durationMinutes': '{count} min',
+4 -1
View File
@@ -66,10 +66,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': 'Näita üksikasju',
'tours.planner.collapseDetails': 'Peida üksikasjad',
'tours.planner.safetyInfoLabel': 'Ohutusest',
'tours.planner.restoredShort': 'Mustand',
'tours.planner.addPoint': 'Lisa punkt',
'tours.planner.addPointActive': 'Klõpsa kaardil',
'tours.planner.emptyHint': 'Lülita sisse „Lisa punkt“ ja klõpsa kaardil. Alates kahest punktist arvutab TREK marsruudi.',
'tours.planner.walkingEstimated': 'Hinnanguline kõndimisaeg',
'tours.planner.walkingEstimatedShort': 'Hinn. kõndimisaeg',
'tours.planner.walkingEstimatedInfoLabel': 'Kuidas kõndimisaega hinnatakse',
'tours.planner.walkingEstimatedInfo': 'Arvutatud vahemaa, tõusu ja laskumise põhjal tavapärase matkaaja reegli (DIN 33466) järgi, ilma pausideta.',
'tours.planner.waypoints': 'Teekonnapunktid',
'tours.planner.waypointLabel': 'Teekonnapunkt {n}',
'tours.durationMinutes': '{count} min',
+4 -1
View File
@@ -69,10 +69,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': 'Afficher les détails',
'tours.planner.collapseDetails': 'Masquer les détails',
'tours.planner.safetyInfoLabel': 'À propos de la sécurité',
'tours.planner.restoredShort': 'Brouillon',
'tours.planner.addPoint': 'Ajouter un point',
'tours.planner.addPointActive': 'Cliquez sur la carte',
'tours.planner.emptyHint': 'Activez « Ajouter un point » et cliquez sur la carte. Dès deux points, TREK calcule l’itinéraire.',
'tours.planner.walkingEstimated': 'Temps de marche estimé',
'tours.planner.walkingEstimatedShort': 'Temps estimé',
'tours.planner.walkingEstimatedInfoLabel': 'Comment le temps de marche est estimé',
'tours.planner.walkingEstimatedInfo': 'Calculé à partir de la distance, du dénivelé positif et négatif selon la règle habituelle de temps de marche (DIN 33466), sans les pauses.',
'tours.planner.waypoints': 'Points de passage',
'tours.planner.waypointLabel': 'Point de passage {n}',
'tours.durationMinutes': '{count} min',
+4 -1
View File
@@ -69,10 +69,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': 'Εμφάνιση λεπτομερειών',
'tours.planner.collapseDetails': 'Απόκρυψη λεπτομερειών',
'tours.planner.safetyInfoLabel': 'Σχετικά με την ασφάλεια',
'tours.planner.restoredShort': 'Πρόχειρο',
'tours.planner.addPoint': 'Προσθήκη σημείου',
'tours.planner.addPointActive': 'Κλικ στον χάρτη',
'tours.planner.emptyHint': 'Ενεργοποιήστε την «Προσθήκη σημείου» και κάντε κλικ στον χάρτη. Από δύο σημεία και πάνω, το TREK υπολογίζει τη διαδρομή.',
'tours.planner.walkingEstimated': 'Εκτιμώμενος χρόνος πεζοπορίας',
'tours.planner.walkingEstimatedShort': 'Εκτ. χρόνος',
'tours.planner.walkingEstimatedInfoLabel': 'Πώς εκτιμάται ο χρόνος πεζοπορίας',
'tours.planner.walkingEstimatedInfo': 'Υπολογίζεται από την απόσταση, την ανάβαση και την κατάβαση με τον συνήθη κανόνα χρόνου πεζοπορίας (DIN 33466), χωρίς διαλείμματα.',
'tours.planner.waypoints': 'Σημεία διέλευσης',
'tours.planner.waypointLabel': 'Σημείο διέλευσης {n}',
'tours.durationMinutes': '{count} λεπτά',
+4 -1
View File
@@ -66,10 +66,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': 'Részletek megjelenítése',
'tours.planner.collapseDetails': 'Részletek elrejtése',
'tours.planner.safetyInfoLabel': 'A biztonságról',
'tours.planner.restoredShort': 'Piszkozat',
'tours.planner.addPoint': 'Pont hozzáadása',
'tours.planner.addPointActive': 'Kattints a térképre',
'tours.planner.emptyHint': 'Kapcsold be a „Pont hozzáadása” gombot, és kattints a térképre. Két ponttól a TREK kiszámolja az útvonalat.',
'tours.planner.walkingEstimated': 'Becsült gyaloglási idő',
'tours.planner.walkingEstimatedShort': 'Becs. idő',
'tours.planner.walkingEstimatedInfoLabel': 'Hogyan becsüljük a gyaloglási időt',
'tours.planner.walkingEstimatedInfo': 'A távolságból, az emelkedésből és a lejtésből számolva a szokásos túraidő-szabály (DIN 33466) szerint, szünetek nélkül.',
'tours.planner.waypoints': 'Útvonalpontok',
'tours.planner.waypointLabel': '{n}. útvonalpont',
'tours.durationMinutes': '{count} perc',
+4 -1
View File
@@ -66,10 +66,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': 'Tampilkan detail',
'tours.planner.collapseDetails': 'Sembunyikan detail',
'tours.planner.safetyInfoLabel': 'Tentang keselamatan',
'tours.planner.restoredShort': 'Draf',
'tours.planner.addPoint': 'Tambah titik',
'tours.planner.addPointActive': 'Klik peta',
'tours.planner.emptyHint': 'Aktifkan Tambah titik lalu klik peta. Mulai dari dua titik, TREK menghitung rutenya.',
'tours.planner.walkingEstimated': 'Perkiraan waktu jalan',
'tours.planner.walkingEstimatedShort': 'Perk. waktu jalan',
'tours.planner.walkingEstimatedInfoLabel': 'Cara waktu jalan diperkirakan',
'tours.planner.walkingEstimatedInfo': 'Dihitung dari jarak, tanjakan, dan turunan dengan aturan waktu mendaki yang umum (DIN 33466), tanpa istirahat.',
'tours.planner.waypoints': 'Titik lintasan',
'tours.planner.waypointLabel': 'Titik lintasan {n}',
'tours.durationMinutes': '{count} menit',
+4 -1
View File
@@ -68,10 +68,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': 'Mostra dettagli',
'tours.planner.collapseDetails': 'Nascondi dettagli',
'tours.planner.safetyInfoLabel': 'Sulla sicurezza',
'tours.planner.restoredShort': 'Bozza',
'tours.planner.addPoint': 'Aggiungi punto',
'tours.planner.addPointActive': 'Clicca sulla mappa',
'tours.planner.emptyHint': 'Attiva «Aggiungi punto» e fai clic sulla mappa. Da due punti in poi TREK calcola il percorso.',
'tours.planner.walkingEstimated': 'Tempo di cammino stimato',
'tours.planner.walkingEstimatedShort': 'Tempo stim.',
'tours.planner.walkingEstimatedInfoLabel': 'Come viene stimato il tempo di cammino',
'tours.planner.walkingEstimatedInfo': 'Calcolato da distanza, salita e discesa con la consueta regola dei tempi di cammino (DIN 33466), senza pause.',
'tours.planner.waypoints': 'Punti di passaggio',
'tours.planner.waypointLabel': 'Punto di passaggio {n}',
'tours.durationMinutes': '{count} min',
+4 -1
View File
@@ -64,10 +64,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': '詳細を表示',
'tours.planner.collapseDetails': '詳細を隠す',
'tours.planner.safetyInfoLabel': '安全について',
'tours.planner.restoredShort': '下書き',
'tours.planner.addPoint': '地点を追加',
'tours.planner.addPointActive': '地図をクリック',
'tours.planner.emptyHint': '「地点を追加」をオンにして地図をクリックします。2 地点からTREKがルートを計算します。',
'tours.planner.walkingEstimated': '推定歩行時間',
'tours.planner.walkingEstimatedShort': '推定時間',
'tours.planner.walkingEstimatedInfoLabel': '歩行時間の推定方法',
'tours.planner.walkingEstimatedInfo': '一般的な歩行時間の規則(DIN 33466)で、距離と登り・下りから計算しています。休憩は含みません。',
'tours.planner.waypoints': 'ルート上の地点',
'tours.planner.waypointLabel': '地点{n}',
'tours.durationMinutes': '{count}分',
+4 -1
View File
@@ -65,10 +65,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': '세부 정보 표시',
'tours.planner.collapseDetails': '세부 정보 숨기기',
'tours.planner.safetyInfoLabel': '안전 안내',
'tours.planner.restoredShort': '초안',
'tours.planner.addPoint': '지점 추가',
'tours.planner.addPointActive': '지도를 클릭',
'tours.planner.emptyHint': '“지점 추가”를 켜고 지도를 클릭하세요. 지점이 두 개가 되면 TREK이 경로를 계산합니다.',
'tours.planner.walkingEstimated': '예상 보행 시간',
'tours.planner.walkingEstimatedShort': '예상 시간',
'tours.planner.walkingEstimatedInfoLabel': '보행 시간을 추정하는 방법',
'tours.planner.walkingEstimatedInfo': '일반적인 산행 시간 규칙(DIN 33466)에 따라 거리, 오르막, 내리막으로 계산하며 휴식은 포함하지 않습니다.',
'tours.planner.waypoints': '경로 지점',
'tours.planner.waypointLabel': '지점 {n}',
'tours.durationMinutes': '{count}분',
+4 -1
View File
@@ -67,10 +67,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': 'Details tonen',
'tours.planner.collapseDetails': 'Details verbergen',
'tours.planner.safetyInfoLabel': 'Over veiligheid',
'tours.planner.restoredShort': 'Concept',
'tours.planner.addPoint': 'Punt toevoegen',
'tours.planner.addPointActive': 'Klik op de kaart',
'tours.planner.emptyHint': 'Zet „Punt toevoegen” aan en klik op de kaart. Vanaf twee punten berekent TREK de route.',
'tours.planner.walkingEstimated': 'Geschatte looptijd',
'tours.planner.walkingEstimatedShort': 'Geschatte tijd',
'tours.planner.walkingEstimatedInfoLabel': 'Hoe de looptijd wordt geschat',
'tours.planner.walkingEstimatedInfo': 'Berekend uit afstand, stijging en daling met de gangbare wandeltijdregel (DIN 33466), zonder pauzes.',
'tours.planner.waypoints': 'Routepunten',
'tours.planner.waypointLabel': 'Routepunt {n}',
'tours.durationMinutes': '{count} min',
+4 -1
View File
@@ -69,10 +69,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': 'Pokaż szczegóły',
'tours.planner.collapseDetails': 'Ukryj szczegóły',
'tours.planner.safetyInfoLabel': 'O bezpieczeństwie',
'tours.planner.restoredShort': 'Szkic',
'tours.planner.addPoint': 'Dodaj punkt',
'tours.planner.addPointActive': 'Kliknij mapę',
'tours.planner.emptyHint': 'Włącz „Dodaj punkt” i kliknij mapę. Od dwóch punktów TREK wyznacza trasę.',
'tours.planner.walkingEstimated': 'Szacowany czas marszu',
'tours.planner.walkingEstimatedShort': 'Szac. czas marszu',
'tours.planner.walkingEstimatedInfoLabel': 'Jak szacowany jest czas marszu',
'tours.planner.walkingEstimatedInfo': 'Obliczony z odległości, podejścia i zejścia według zwykłej reguły czasu marszu (DIN 33466), bez przerw.',
'tours.planner.waypoints': 'Punkty trasy',
'tours.planner.waypointLabel': 'Punkt trasy {n}',
'tours.durationMinutes': '{count} min',
+4 -1
View File
@@ -69,10 +69,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': 'Показать подробности',
'tours.planner.collapseDetails': 'Скрыть подробности',
'tours.planner.safetyInfoLabel': 'О безопасности',
'tours.planner.restoredShort': 'Черновик',
'tours.planner.addPoint': 'Добавить точку',
'tours.planner.addPointActive': 'Нажмите на карту',
'tours.planner.emptyHint': 'Включите «Добавить точку» и нажмите на карту. Начиная с двух точек TREK рассчитает маршрут.',
'tours.planner.walkingEstimated': 'Примерное время в пути',
'tours.planner.walkingEstimatedShort': 'Прим. время',
'tours.planner.walkingEstimatedInfoLabel': 'Как оценивается время в пути',
'tours.planner.walkingEstimatedInfo': 'Рассчитано по расстоянию, подъёму и спуску по обычному правилу времени в пути (DIN 33466), без перерывов.',
'tours.planner.waypoints': 'Путевые точки',
'tours.planner.waypointLabel': 'Путевая точка {n}',
'tours.durationMinutes': '{count} мин',
+4 -1
View File
@@ -66,10 +66,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': 'Zobraziť podrobnosti',
'tours.planner.collapseDetails': 'Skryť podrobnosti',
'tours.planner.safetyInfoLabel': 'O bezpečnosti',
'tours.planner.restoredShort': 'Koncept',
'tours.planner.addPoint': 'Pridať bod',
'tours.planner.addPointActive': 'Kliknite do mapy',
'tours.planner.emptyHint': 'Zapnite „Pridať bod“ a kliknite do mapy. Od dvoch bodov TREK vypočíta trasu.',
'tours.planner.walkingEstimated': 'Odhadovaný čas chôdze',
'tours.planner.walkingEstimatedShort': 'Odhad. čas chôdze',
'tours.planner.walkingEstimatedInfoLabel': 'Ako sa čas chôdze odhaduje',
'tours.planner.walkingEstimatedInfo': 'Vypočítané zo vzdialenosti, stúpania a klesania podľa bežného pravidla pre čas chôdze (DIN 33466), bez prestávok.',
'tours.planner.waypoints': 'Body trasy',
'tours.planner.waypointLabel': 'Bod trasy {n}',
'tours.durationMinutes': '{count} min',
+4 -1
View File
@@ -66,10 +66,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': 'Visa detaljer',
'tours.planner.collapseDetails': 'Dölj detaljer',
'tours.planner.safetyInfoLabel': 'Om säkerhet',
'tours.planner.restoredShort': 'Utkast',
'tours.planner.addPoint': 'Lägg till punkt',
'tours.planner.addPointActive': 'Klicka på kartan',
'tours.planner.emptyHint': 'Slå på ”Lägg till punkt” och klicka på kartan. Från två punkter räknar TREK ut rutten.',
'tours.planner.walkingEstimated': 'Uppskattad gångtid',
'tours.planner.walkingEstimatedShort': 'Uppsk. gångtid',
'tours.planner.walkingEstimatedInfoLabel': 'Hur gångtiden uppskattas',
'tours.planner.walkingEstimatedInfo': 'Beräknad utifrån sträcka, stigning och nedstigning enligt den vanliga vandringstidsregeln (DIN 33466), utan pauser.',
'tours.planner.waypoints': 'Ruttpunkter',
'tours.planner.waypointLabel': 'Ruttpunkt {n}',
'tours.durationMinutes': '{count} min',
+4 -1
View File
@@ -65,10 +65,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': 'แสดงรายละเอียด',
'tours.planner.collapseDetails': 'ซ่อนรายละเอียด',
'tours.planner.safetyInfoLabel': 'เกี่ยวกับความปลอดภัย',
'tours.planner.restoredShort': 'ฉบับร่าง',
'tours.planner.addPoint': 'เพิ่มจุด',
'tours.planner.addPointActive': 'คลิกบนแผนที่',
'tours.planner.emptyHint': 'เปิด “เพิ่มจุด” แล้วคลิกบนแผนที่ เมื่อมีสองจุดขึ้นไป TREK จะคำนวณเส้นทาง',
'tours.planner.walkingEstimated': 'เวลาเดินโดยประมาณ',
'tours.planner.walkingEstimatedShort': 'เวลาโดยประมาณ',
'tours.planner.walkingEstimatedInfoLabel': 'วิธีประมาณเวลาเดิน',
'tours.planner.walkingEstimatedInfo': 'คำนวณจากระยะทาง ทางขึ้น และทางลง ตามกฎเวลาเดินป่าทั่วไป (DIN 33466) ไม่รวมเวลาพัก',
'tours.planner.waypoints': 'จุดแวะ',
'tours.planner.waypointLabel': 'จุดแวะ {n}',
'tours.durationMinutes': '{count} นาที',
+4 -1
View File
@@ -66,10 +66,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': 'Ayrıntıları göster',
'tours.planner.collapseDetails': 'Ayrıntıları gizle',
'tours.planner.safetyInfoLabel': 'Güvenlik hakkında',
'tours.planner.restoredShort': 'Taslak',
'tours.planner.addPoint': 'Nokta ekle',
'tours.planner.addPointActive': 'Haritaya tıklayın',
'tours.planner.emptyHint': '“Nokta ekle”yi açın ve haritaya tıklayın. İki noktadan itibaren TREK rotayı hesaplar.',
'tours.planner.walkingEstimated': 'Tahmini yürüyüş süresi',
'tours.planner.walkingEstimatedShort': 'Tahm. süre',
'tours.planner.walkingEstimatedInfoLabel': 'Yürüyüş süresi nasıl tahmin edilir',
'tours.planner.walkingEstimatedInfo': 'Mesafe, çıkış ve inişten olağan yürüyüş süresi kuralıyla (DIN 33466) hesaplanır, molalar dahil değildir.',
'tours.planner.waypoints': 'Rota noktaları',
'tours.planner.waypointLabel': 'Rota noktası {n}',
'tours.durationMinutes': '{count} dk',
+4 -1
View File
@@ -68,10 +68,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': 'Показати подробиці',
'tours.planner.collapseDetails': 'Сховати подробиці',
'tours.planner.safetyInfoLabel': 'Про безпеку',
'tours.planner.restoredShort': 'Чернетка',
'tours.planner.addPoint': 'Додати точку',
'tours.planner.addPointActive': 'Натисніть на карту',
'tours.planner.emptyHint': 'Увімкніть «Додати точку» і натисніть на карту. Від двох точок TREK розрахує маршрут.',
'tours.planner.walkingEstimated': 'Орієнтовний час у дорозі',
'tours.planner.walkingEstimatedShort': 'Орієнт. час',
'tours.planner.walkingEstimatedInfoLabel': 'Як оцінюється час у дорозі',
'tours.planner.walkingEstimatedInfo': 'Розраховано за відстанню, підйомом і спуском за звичайним правилом часу ходьби (DIN 33466), без перерв.',
'tours.planner.waypoints': 'Маршрутні точки',
'tours.planner.waypointLabel': 'Маршрутна точка {n}',
'tours.durationMinutes': '{count} хв',
+4 -1
View File
@@ -66,10 +66,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': 'Hiện chi tiết',
'tours.planner.collapseDetails': 'Ẩn chi tiết',
'tours.planner.safetyInfoLabel': 'Về an toàn',
'tours.planner.restoredShort': 'Bản nháp',
'tours.planner.addPoint': 'Thêm điểm',
'tours.planner.addPointActive': 'Nhấp vào bản đồ',
'tours.planner.emptyHint': 'Bật “Thêm điểm” rồi nhấp vào bản đồ. Từ hai điểm trở lên, TREK sẽ tính tuyến đường.',
'tours.planner.walkingEstimated': 'Thời gian đi bộ ước tính',
'tours.planner.walkingEstimatedShort': 'TG ước tính',
'tours.planner.walkingEstimatedInfoLabel': 'Cách ước tính thời gian đi bộ',
'tours.planner.walkingEstimatedInfo': 'Tính từ quãng đường, đoạn lên và đoạn xuống theo quy tắc thời gian đi bộ thông thường (DIN 33466), không tính nghỉ.',
'tours.planner.waypoints': 'Các điểm trên tuyến',
'tours.planner.waypointLabel': 'Điểm {n}',
'tours.durationMinutes': '{count} phút',
+4 -1
View File
@@ -63,10 +63,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': '顯示詳細資訊',
'tours.planner.collapseDetails': '隱藏詳細資訊',
'tours.planner.safetyInfoLabel': '關於安全',
'tours.planner.restoredShort': '草稿',
'tours.planner.addPoint': '新增地點',
'tours.planner.addPointActive': '點擊地圖',
'tours.planner.emptyHint': '開啟「新增地點」,然後點擊地圖。有兩個地點後,TREK 會計算路線。',
'tours.planner.walkingEstimated': '預估步行時間',
'tours.planner.walkingEstimatedShort': '預估時間',
'tours.planner.walkingEstimatedInfoLabel': '步行時間如何估算',
'tours.planner.walkingEstimatedInfo': '依常用的健行時間規則(DIN 33466),根據距離、上坡與下坡計算,不含休息。',
'tours.planner.waypoints': '路線點',
'tours.planner.waypointLabel': '路線點 {n}',
'tours.durationMinutes': '{count} 分鐘',
+4 -1
View File
@@ -63,10 +63,13 @@ const tours: TranslationStrings = {
'tours.planner.expandDetails': '显示详情',
'tours.planner.collapseDetails': '隐藏详情',
'tours.planner.safetyInfoLabel': '关于安全',
'tours.planner.restoredShort': '草稿',
'tours.planner.addPoint': '添加地点',
'tours.planner.addPointActive': '点击地图',
'tours.planner.emptyHint': '打开“添加地点”,然后点击地图。有两个地点后,TREK 会计算路线。',
'tours.planner.walkingEstimated': '预计步行时间',
'tours.planner.walkingEstimatedShort': '预计时间',
'tours.planner.walkingEstimatedInfoLabel': '步行时间如何估算',
'tours.planner.walkingEstimatedInfo': '按常用的徒步时间规则(DIN 33466),根据距离、上坡和下坡计算,不含休息。',
'tours.planner.waypoints': '路线点',
'tours.planner.waypointLabel': '路线点 {n}',
'tours.durationMinutes': '{count} 分钟',