mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} د',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} λεπτά',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}分',
|
||||
|
||||
@@ -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}분',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} мин',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} นาที',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} хв',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} 分鐘',
|
||||
|
||||
@@ -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} 分钟',
|
||||
|
||||
Reference in New Issue
Block a user