feat(tours): tidy the tour editor and tour list, set points only on purpose, name waypoints on the map

This commit is contained in:
Maurice
2026-10-14 17:13:24 +02:00
parent 160bc89e78
commit e3983ef857
72 changed files with 2607 additions and 869 deletions
-5
View File
@@ -139,11 +139,6 @@ export const DYNAMIC_ALLOWED = [
'memories.* key in en',
}))
),
{
file: 'components/Tours/planner/TourPlannerPanels.tsx',
template: 'tours.planner.${role}',
because: "roleLabel takes role: 'start' | 'via' | 'end', and tours.planner.start/via/end exist",
},
];
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
+1 -1
View File
@@ -89,7 +89,7 @@
"src/components/Todo/todoListModel.ts": 1,
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
"src/components/Tours/planner/useTourPlanner.ts": 2,
"src/components/Tours/tourParts.tsx": 11,
"src/components/Tours/tourParts.tsx": 8,
"src/components/Trips/TripFormModal.tsx": 3,
"src/components/Trips/TripMembersModal.tsx": 1,
"src/components/Vacay/VacayCalendar.tsx": 1,
@@ -1,70 +0,0 @@
/**
* The name and grade of the via ferrata under the pointer on a GL map, the
* twin of the Leaflet layer's sticky tooltip: the same words, the look of the
* shared Tooltip (card surface, faint edge, caption tier), floating just above
* the pointer.
*
* Built from the DOM rather than React, because it follows every mouse move:
* a React state per move would render the whole map view each time. The words
* go in as text, never as markup. It lives in the map's own container, so it
* scrolls, hides and goes away with the map.
*/
/** How far above the pointer the label floats, in CSS pixels. */
const POINTER_GAP = 12;
const LABEL_CLASS =
'pointer-events-none absolute z-20 whitespace-nowrap rounded-lg border border-edge-faint bg-surface-card px-2.5 py-1 text-caption font-medium text-content shadow-lg';
/** Just the call the label needs, so the Mapbox and the MapLibre map both fit, and a stand-in without it gets none. */
interface LabelHost {
getContainer?(): HTMLElement;
}
interface FerrataHoverLabel {
show(text: { name: string; grade: string | null }, point: { x: number; y: number }): void;
hide(): void;
remove(): void;
}
const NO_LABEL: FerrataHoverLabel = { show: () => undefined, hide: () => undefined, remove: () => undefined };
export function ferrataHoverLabel(map: LabelHost): FerrataHoverLabel {
if (typeof map.getContainer !== 'function' || typeof document === 'undefined') return NO_LABEL;
const container = map.getContainer();
let element: HTMLDivElement | null = null;
const ensure = () => {
if (element) return element;
element = document.createElement('div');
element.className = LABEL_CLASS;
element.setAttribute('role', 'tooltip');
element.dataset.testid = 'ferrata-hover-label';
// The pointer's pixel on the map, not a side of the text: placed from the corner either way.
element.style.inset = '0px auto auto 0px';
container.appendChild(element);
return element;
};
return {
show({ name, grade }, { x, y }) {
const label = ensure();
label.replaceChildren(document.createTextNode(name));
if (grade) {
const badge = document.createElement('span');
badge.className = 'ms-1.5 font-semibold';
badge.textContent = grade;
label.appendChild(badge);
}
label.style.transform = `translate(${x}px, ${y - POINTER_GAP}px) translate(-50%, -100%)`;
label.hidden = false;
},
hide() {
if (element) element.hidden = true;
},
remove() {
element?.remove();
element = null;
},
};
}
@@ -4,8 +4,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import type { Ferrata } from '@trek/shared';
import { useTranslation } from '../../i18n';
import { mapHoverLabel } from '../Map/mapHoverLabel';
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
import { ferrataHoverLabel } from './ferrataHoverLabel';
import { FERRATA_ICON_IMAGE, FERRATA_ICON_MIN_ZOOM, FERRATA_ICON_PIXEL_RATIO, loadFerrataIcon } from './ferrataIcon';
import { ferrataFeatureCollection, ferrataLabelCollection } from './ferrataModel';
import type { FerrataPaint } from './ferrataPaint';
@@ -183,7 +183,7 @@ export function useFerrataLayerGL(
if (!map) return;
let waiting = false;
let disposed = false;
const hoverLabel = ferrataHoverLabel(map);
const hoverLabel = mapHoverLabel(map, 'ferrata-hover-label');
const draw = () => {
if (map.getSource(SOURCE)) return;
if (!map.isStyleLoaded()) {
@@ -222,8 +222,10 @@ export function useFerrataLayerGL(
const id: unknown = featureAt(event)?.group;
const group = typeof id === 'string' ? latest.current.groups.find((candidate) => candidate.id === id) : undefined;
setHovered(group ? group.id : null);
if (group) hoverLabel.show(ferrataLabel(group, latest.current.t), event.point);
else hoverLabel.hide();
if (group) {
const label = ferrataLabel(group, latest.current.t);
hoverLabel.show({ name: label.name, detail: label.grade }, event.point);
} else hoverLabel.hide();
};
const enter = () => {
map.getCanvas().style.cursor = 'pointer';
@@ -1,4 +1,4 @@
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-016
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-017
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
@@ -179,4 +179,27 @@ describe('TourMapLayerSwitcher via ferrata switch', () => {
const buttons = Array.from(group.querySelectorAll('button')).map((button) => button.getAttribute('aria-label') ?? button.textContent)
expect(buttons.slice(-2)).toEqual(['Via ferratas', '3D'])
})
it('FE-COMP-MAPLAYER-017: the "set points" switch leads the tools on its icon, pressed in the accent while on', async () => {
const onToggle = vi.fn()
const { rerender } = render(
<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}}
ferrataLayer={ferrataLayer()} placeTool={{ active: true, onToggle }} />
)
const group = screen.getByRole('group')
expect(group.querySelectorAll('span.bg-edge')).toHaveLength(1)
const buttons = Array.from(group.querySelectorAll('button')).map((button) => button.getAttribute('aria-label') ?? button.textContent)
expect(buttons.slice(-3)).toEqual(['Set points', 'Via ferratas', '3D'])
const tool = screen.getByRole('button', { name: 'Set points' })
expect(tool).toHaveAttribute('aria-pressed', 'true')
expect(tool).toHaveClass('bg-accent', 'text-accent-text')
expect(tool).not.toHaveTextContent('Set points')
fireEvent.click(tool)
expect(onToggle).toHaveBeenCalledTimes(1)
fireEvent.mouseEnter(tool)
expect(await screen.findByRole('tooltip')).toHaveTextContent('Set points')
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} placeTool={{ active: false, onToggle }} />)
expect(screen.getByRole('button', { name: 'Set points' })).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByRole('button', { name: 'Set points' })).toHaveClass('text-content-muted')
})
})
+33 -2
View File
@@ -1,4 +1,4 @@
import { Loader2, Map as MapIcon, Mountain, MountainSnow, Rotate3d, Satellite } from 'lucide-react'
import { Loader2, Map as MapIcon, MapPinPlus, Mountain, MountainSnow, Rotate3d, Satellite } from 'lucide-react'
import type { CSSProperties } from 'react'
import { useTranslation } from '../../i18n'
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
@@ -150,6 +150,33 @@ function FerrataToggle({ layer, compact = false }: { layer: FerrataMapLayer; com
)
}
/** The tour map's "set points" tool: while on, a click on the map places a point. */
export interface MapPlaceTool {
active: boolean
onToggle: () => void
}
/** The switch for the "set points" tool, on its icon, named by its tooltip. */
function PlaceToolToggle({ tool }: { tool: MapPlaceTool }) {
const { t } = useTranslation()
const label = t('tours.planner.placePoints')
return (
<Tooltip label={label} placement="top">
<button
type="button"
onClick={tool.onToggle}
aria-label={label}
aria-pressed={tool.active}
data-testid="map-place-tool"
className={tool.active ? 'bg-accent text-accent-text' : 'text-content-muted'}
style={segmentStyle(tool.active)}
>
<MapPinPlus size={15} strokeWidth={2} />
</button>
</Tooltip>
)
}
/**
* Per-view Tours control. It never reads or writes the global map setting.
* `compact` shows the base layers and the via ferrata switch by their icons
@@ -162,6 +189,7 @@ export function TourMapLayerSwitcher({
onToggleTerrain,
terrainActive = false,
ferrataLayer,
placeTool,
compact = false,
}: {
active: TourBaseLayer
@@ -171,6 +199,8 @@ export function TourMapLayerSwitcher({
terrainActive?: boolean
/** Offers the via ferrata switch, with its count badge, beside the base layers. */
ferrataLayer?: FerrataMapLayer
/** Offers the "set points" switch, while a tour is being edited. */
placeTool?: MapPlaceTool
compact?: boolean
}) {
const { t } = useTranslation()
@@ -196,7 +226,8 @@ export function TourMapLayerSwitcher({
</Tooltip>
)
})}
{(ferrataLayer || onToggleTerrain) && <span aria-hidden className="mx-0.5 h-5 w-px bg-edge" />}
{(ferrataLayer || onToggleTerrain || placeTool) && <span aria-hidden className="mx-0.5 h-5 w-px bg-edge" />}
{placeTool && <PlaceToolToggle tool={placeTool} />}
{ferrataLayer && <FerrataToggle layer={ferrataLayer} compact={compact} />}
{onToggleTerrain && (
<button
+51
View File
@@ -0,0 +1,51 @@
import { Check, Undo2 } from 'lucide-react';
import { fs } from '../shared/DialogShell';
import { MAP_CONTROL_SHADOW } from './mapControlShadow';
export interface MapUndoHintProps {
message: string;
undoLabel: string;
onUndo: () => void;
/** True while the pointer rests on the hint, so it does not vanish under it. */
onHold?: (held: boolean) => void;
}
/**
* A short note at the foot of the map that something just happened there, with
* the way back: a glass pill like the map's other controls. The caller places it
* and decides how long it stays.
*/
export function MapUndoHint({ message, undoLabel, onUndo, onHold }: Readonly<MapUndoHintProps>) {
return (
<div
role="status"
aria-live="polite"
data-testid="map-undo-hint"
className="trek-toast-enter pointer-events-auto flex max-w-full items-center gap-2 rounded-full p-1 ps-3 text-content"
style={{
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}}
onMouseEnter={() => onHold?.(true)}
onMouseLeave={() => onHold?.(false)}
onFocus={() => onHold?.(true)}
onBlur={() => onHold?.(false)}
>
<Check size={14} strokeWidth={2.4} className="flex-none text-accent" aria-hidden />
<span className="min-w-0 truncate font-semibold" style={fs(12, 'body')}>
{message}
</span>
<button
type="button"
onClick={onUndo}
className="bg-accent-soft flex h-[30px] flex-none items-center gap-1.5 rounded-full px-3 font-semibold text-accent hover:opacity-90"
style={fs(11)}
>
<Undo2 size={13} strokeWidth={2.2} aria-hidden />
{undoLabel}
</button>
</div>
);
}
+3 -3
View File
@@ -830,7 +830,7 @@ export const MapView = memo(function MapView({
onPlannerWaypointClick,
onPlannerWaypointMove,
routeProfileFocus = null,
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer,
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool,
}: MapViewProps) {
const globalBaseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
const updateSetting = useSettingsStore(s => s.updateSetting)
@@ -1298,7 +1298,7 @@ export const MapView = memo(function MapView({
<RoadtripViaMarkers viasByDay={roadtripVias} onMoveVia={onMoveVia} onRemoveVia={onRemoveVia} />
) : null}
{(plannerWaypoints as Array<{ id: string; lat: number; lng: number }>).map((point, index) => (
{plannerWaypoints.map((point, index) => (
<PlannerWaypointMarker
key={`planner-waypoint-${point.id}`}
point={point}
@@ -1381,7 +1381,7 @@ export const MapView = memo(function MapView({
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
{hasViewBaseLayer
? <TourMapLayerSwitcher active={baseLayer} onChange={onViewBaseLayerChange}
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} />
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} />
: <MapLayerSwitcher active={globalBaseLayer} onToggle={toggleBaseLayer} />}
</div>
</div>
+2 -2
View File
@@ -626,7 +626,7 @@ export function MapViewGL({
onPlannerWaypointClick,
onPlannerWaypointMove,
routeProfileFocus = null,
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer,
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool,
roadtripVias,
onMoveVia,
onRemoveVia,
@@ -2354,7 +2354,7 @@ export function MapViewGL({
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
{hasViewBaseLayer
? <TourMapLayerSwitcher active={baseLayer as TourBaseLayer} onChange={onViewBaseLayerChange}
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} />
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} />
: <MapLayerSwitcher active={globalBaseLayer as BaseLayer} onToggle={toggleBaseLayer} />}
</div>
{/* Hover tooltip — cursor-following name/category/address card, identical to
@@ -1,6 +1,7 @@
import L from 'leaflet';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Marker, Tooltip, useMap } from 'react-leaflet';
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
/** The pin's inline style for a size and selection; the renderer owns the colour. */
@@ -24,7 +25,7 @@ export default function PlannerWaypointMarker({
onMove,
pinCss,
}: {
point: { id: string; lat: number; lng: number };
point: { id: string; lat: number; lng: number; name?: string; detail?: string };
index: number;
selected: boolean;
onClick?: (id: string) => void;
@@ -76,8 +77,9 @@ export default function PlannerWaypointMarker({
zIndexOffset={selected ? 1200 : 1100}
eventHandlers={eventHandlers}
>
<Tooltip direction="top" offset={[0, -12]}>
{index + 1}
<Tooltip direction="top" offset={[0, -12]} opacity={1} className="map-tooltip">
<span className="font-medium">{point.name ?? index + 1}</span>
{point.detail && <span className={MAP_LABEL_BADGE_CLASS}>{point.detail}</span>}
</Tooltip>
</Marker>
);
+106
View File
@@ -0,0 +1,106 @@
/**
* A name and a short detail floating over a GL map (the via ferrata under the
* pointer and its grade, a tour waypoint and its role), the twin of the
* Leaflet `map-tooltip`: the look of the shared Tooltip (card surface, faint
* edge, caption tier), just above the point it is shown for.
*
* Built from the DOM rather than React, because it follows every mouse move:
* a React state per move would render the whole map view each time. The words
* go in as text, never as markup. It lives in the map's own container, so it
* scrolls, hides and goes away with the map.
*/
/** How far above the pointer the label floats, in CSS pixels. */
const POINTER_GAP = 12;
/** The detail beside the name (a role, a grade) as a badge, as the shared pills draw one. */
export const MAP_LABEL_BADGE_CLASS =
'ms-1.5 inline-flex h-[18px] items-center rounded-full bg-surface-secondary px-1.5 font-semibold text-content-secondary ring-1 ring-inset ring-edge-faint';
const LABEL_CLASS =
'pointer-events-none absolute z-20 flex items-center whitespace-nowrap rounded-lg border border-edge-faint bg-surface-card px-2.5 py-1 text-caption font-medium text-content shadow-lg';
/** Just the call the label needs, so the Mapbox and the MapLibre map both fit, and a stand-in without it gets none. */
interface LabelHost {
getContainer?(): HTMLElement;
}
export interface MapHoverLabel {
show(text: { name: string; detail: string | null }, point: { x: number; y: number }): void;
hide(): void;
remove(): void;
}
const NO_LABEL: MapHoverLabel = { show: () => undefined, hide: () => undefined, remove: () => undefined };
export function mapHoverLabel(map: LabelHost, testId = 'map-hover-label'): MapHoverLabel {
if (typeof map.getContainer !== 'function' || typeof document === 'undefined') return NO_LABEL;
const container = map.getContainer();
let element: HTMLDivElement | null = null;
const ensure = () => {
if (element) return element;
element = document.createElement('div');
element.className = LABEL_CLASS;
element.setAttribute('role', 'tooltip');
element.dataset.testid = testId;
// The pointer's pixel on the map, not a side of the text: placed from the corner either way.
element.style.inset = '0px auto auto 0px';
container.appendChild(element);
return element;
};
return {
show({ name, detail }, { x, y }) {
const label = ensure();
label.replaceChildren(document.createTextNode(name));
if (detail) {
const badge = document.createElement('span');
badge.className = MAP_LABEL_BADGE_CLASS;
badge.textContent = detail;
label.appendChild(badge);
}
label.style.transform = `translate(${x}px, ${y - POINTER_GAP}px) translate(-50%, -100%)`;
label.hidden = false;
},
hide() {
if (element) element.hidden = true;
},
remove() {
element?.remove();
element = null;
},
};
}
/** What a map pin names on hover: its place and, beside it, its role. */
export interface PinHoverText {
name: string;
detail: string | null;
}
/**
* Shows the label above a pin while the pointer rests on it, and hides it the
* moment the pin is pressed, so it never trails a drag. Returns the unwiring.
*/
export function bindPinHoverLabel(
pin: HTMLElement,
label: MapHoverLabel,
container: HTMLElement,
text: PinHoverText
): () => void {
const show = () => {
const box = pin.getBoundingClientRect();
const frame = container.getBoundingClientRect();
label.show(text, { x: box.left + box.width / 2 - frame.left, y: box.top - frame.top });
};
const hide = () => label.hide();
pin.addEventListener('pointerenter', show);
pin.addEventListener('pointerleave', hide);
pin.addEventListener('pointerdown', hide);
return () => {
pin.removeEventListener('pointerenter', show);
pin.removeEventListener('pointerleave', hide);
pin.removeEventListener('pointerdown', hide);
};
}
+116
View File
@@ -0,0 +1,116 @@
// FE-MAP-PRESS-001 to FE-MAP-PRESS-008: only a real click on the map places a point, never a drag or a long hold.
import { describe, expect, it } from 'vitest';
import { createPressTracker, isPlacementTap, TAP_MAX_DURATION_MS, TAP_MAX_TRAVEL_PX } from './mapPress';
function pointer(type: string, x: number, y: number, pointerId = 1): Event {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperties(event, {
clientX: { value: x },
clientY: { value: y },
pointerId: { value: pointerId },
});
return event;
}
/** A tracker on a fresh element, with a clock the test moves. */
function setup() {
const el = document.createElement('div');
let clock = 0;
const tracker = createPressTracker(el, () => clock);
return {
el,
tracker,
at: (ms: number) => {
clock = ms;
},
send: (type: string, x: number, y: number, id = 1) => el.dispatchEvent(pointer(type, x, y, id)),
};
}
describe('isPlacementTap', () => {
it('FE-MAP-PRESS-001: a short press that barely moved places', () => {
expect(isPlacementTap({ travelPx: 0, durationMs: 80, pointers: 1 })).toBe(true);
expect(isPlacementTap({ travelPx: TAP_MAX_TRAVEL_PX - 0.5, durationMs: TAP_MAX_DURATION_MS - 1, pointers: 1 })).toBe(
true
);
});
it('FE-MAP-PRESS-002: 4 px of travel, 300 ms of hold or a second finger never place', () => {
expect(isPlacementTap({ travelPx: TAP_MAX_TRAVEL_PX, durationMs: 50, pointers: 1 })).toBe(false);
expect(isPlacementTap({ travelPx: 0, durationMs: TAP_MAX_DURATION_MS, pointers: 1 })).toBe(false);
expect(isPlacementTap({ travelPx: 0, durationMs: 50, pointers: 2 })).toBe(false);
});
it('FE-MAP-PRESS-003: a click no press was seen for (keyboard, synthetic) is the map\'s own call', () => {
expect(isPlacementTap(null)).toBe(true);
});
});
describe('createPressTracker', () => {
it('FE-MAP-PRESS-004: hands out the finished press once, with its travel and length', () => {
const { tracker, at, send } = setup();
at(1000);
send('pointerdown', 10, 10);
at(1100);
send('pointerup', 12, 11);
const press = tracker.take();
expect(press?.durationMs).toBe(100);
expect(press?.travelPx).toBeCloseTo(Math.hypot(2, 1));
expect(press?.pointers).toBe(1);
expect(isPlacementTap(press)).toBe(true);
expect(tracker.take()).toBeNull();
});
it('FE-MAP-PRESS-005: a drag that came back to where it started still counts as a drag', () => {
const { tracker, at, send } = setup();
at(0);
send('pointerdown', 100, 100);
send('pointermove', 140, 100);
at(120);
send('pointerup', 100, 100);
const press = tracker.take();
expect(press?.travelPx).toBe(40);
expect(isPlacementTap(press)).toBe(false);
});
it('FE-MAP-PRESS-006: a pinch or a two-finger rotate is no tap, and ends with the last finger', () => {
const { tracker, at, send } = setup();
at(0);
send('pointerdown', 10, 10, 1);
send('pointerdown', 50, 50, 2);
at(80);
send('pointerup', 50, 50, 2);
expect(tracker.take()).toBeNull();
send('pointerup', 10, 10, 1);
const press = tracker.take();
expect(press?.pointers).toBe(2);
expect(isPlacementTap(press)).toBe(false);
});
it('FE-MAP-PRESS-007: a cancelled press (the browser took it for a gesture) never places', () => {
const { tracker, at, send } = setup();
at(0);
send('pointerdown', 10, 10);
at(50);
send('pointercancel', 10, 10);
expect(isPlacementTap(tracker.take())).toBe(false);
});
it('FE-MAP-PRESS-008: a new press drops the one nobody read, and dispose stops listening', () => {
const { tracker, at, send } = setup();
at(0);
send('pointerdown', 0, 0);
at(500);
send('pointerup', 0, 0);
send('pointerdown', 0, 0);
expect(tracker.take()).toBeNull();
at(550);
send('pointerup', 0, 0);
expect(tracker.take()?.durationMs).toBe(50);
tracker.dispose();
send('pointerdown', 0, 0);
send('pointerup', 0, 0);
expect(tracker.take()).toBeNull();
});
});
+118
View File
@@ -0,0 +1,118 @@
/**
* Telling a click that should put something on the map from the end of a pan,
* a rotation or a tilt. Both map engines fire their click after short drags
* and long holds too, which in 3D, where every look around is a drag, kept
* dropping waypoints nobody meant to set.
*/
/** How far the pointer may travel during a press that still counts as a click. */
export const TAP_MAX_TRAVEL_PX = 4;
/** How long a press that still counts as a click may last. */
export const TAP_MAX_DURATION_MS = 300;
/** One finished press on the map. */
export interface MapPress {
/** The farthest the first pointer got from where it went down. */
travelPx: number;
durationMs: number;
/** The most pointers down at once: two fingers pinch or rotate, they never place. */
pointers: number;
}
/**
* Whether a press places a point. A press that was never seen (a keyboard
* activation, a synthetic click) is left to the map, which only fires a click
* for it on purpose.
*/
export function isPlacementTap(press: MapPress | null): boolean {
if (!press) return true;
return press.pointers <= 1 && press.travelPx < TAP_MAX_TRAVEL_PX && press.durationMs < TAP_MAX_DURATION_MS;
}
export interface PressTracker {
/** The press that ended last, handed out once: the click it caused reads it, the next one does not. */
take: () => MapPress | null;
dispose: () => void;
}
interface Active {
id: number;
x: number;
y: number;
at: number;
travelPx: number;
pointers: Set<number>;
most: number;
cancelled: boolean;
}
/**
* Follows the presses on an element in the capture phase, so neither engine
* can swallow them first, and keeps the last finished one for the click that
* follows it.
*/
export function createPressTracker(target: EventTarget, now: () => number = () => performance.now()): PressTracker {
let active: Active | null = null;
let last: MapPress | null = null;
const down = (event: Event) => {
const e = event as PointerEvent;
if (active) {
active.pointers.add(e.pointerId);
active.most = Math.max(active.most, active.pointers.size);
return;
}
last = null;
active = {
id: e.pointerId,
x: e.clientX,
y: e.clientY,
at: now(),
travelPx: 0,
pointers: new Set([e.pointerId]),
most: 1,
cancelled: false,
};
};
const move = (event: Event) => {
const e = event as PointerEvent;
if (!active || e.pointerId !== active.id) return;
active.travelPx = Math.max(active.travelPx, Math.hypot(e.clientX - active.x, e.clientY - active.y));
};
const up = (event: Event) => {
const e = event as PointerEvent;
if (!active) return;
if (e.pointerId === active.id && !active.cancelled) {
move(e);
}
if (event.type === 'pointercancel') active.cancelled = true;
active.pointers.delete(e.pointerId);
if (active.pointers.size > 0) return;
last = {
travelPx: active.cancelled ? Number.POSITIVE_INFINITY : active.travelPx,
durationMs: now() - active.at,
pointers: active.most,
};
active = null;
};
const options = { capture: true, passive: true } as const;
target.addEventListener('pointerdown', down, options);
target.addEventListener('pointermove', move, options);
target.addEventListener('pointerup', up, options);
target.addEventListener('pointercancel', up, options);
return {
take: () => {
const press = last;
last = null;
return press;
},
dispose: () => {
target.removeEventListener('pointerdown', down, options);
target.removeEventListener('pointermove', move, options);
target.removeEventListener('pointerup', up, options);
target.removeEventListener('pointercancel', up, options);
},
};
}
+5 -2
View File
@@ -6,7 +6,7 @@ import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays';
import type { DayBoundaryControls } from './dayBoundaryDrag';
import type { CompassMap } from './MapCompassPill';
import type { TourBaseLayer } from './MapLayerSwitcher';
import type { MapPlaceTool, TourBaseLayer } from './MapLayerSwitcher';
import type { Poi } from './poiCategories';
/*
@@ -109,7 +109,8 @@ export interface MapViewProps {
/** Reports which option the pointer is over, so the list and the map agree. */
onHighlightAlternative?: (index: number | null) => void;
/** Generic numbered control points for list-first route editors. */
plannerWaypoints?: Array<{ id: string; lat: number; lng: number }>;
/** `name` and `detail` (its role) show on hover; without a name the pin is named by its number. */
plannerWaypoints?: Array<{ id: string; lat: number; lng: number; name?: string; detail?: string }>;
selectedPlannerWaypointId?: string | null;
onPlannerWaypointClick?: (id: string) => void;
/** Drops a dragged control point; returns false when the move was refused. */
@@ -119,6 +120,8 @@ export interface MapViewProps {
onViewBaseLayerChange?: (layer: TourBaseLayer) => void;
/** Offers the 3D terrain switch in the tour layer pill. */
onToggleTerrain3d?: () => void;
/** Offers the "set points" switch in the tour layer pill, while a tour is being edited. */
placeTool?: MapPlaceTool;
/**
* The via ferrata layer (RC-10): its switch, with the count as a badge, joins
* the tour layer pill, and while it is on its lines are drawn and a click on
@@ -1,5 +1,6 @@
import { useEffect, useLayoutEffect, useRef } from 'react';
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
import type { MapViewProps } from './mapViewContract';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
@@ -59,6 +60,7 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
pinsRef.current.forEach((pin) => pin.remove());
pinsRef.current = [];
const dragCleanups: (() => void)[] = [];
const hoverLabel = mapHoverLabel(map, 'planner-waypoint-label');
for (const [index, point] of plannerWaypoints.entries()) {
const selected = point.id === selectedPlannerWaypointId;
@@ -66,7 +68,8 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
const el = document.createElement('button');
el.type = 'button';
el.textContent = String(index + 1);
el.setAttribute('aria-label', `Waypoint ${index + 1}`);
const name = point.name ?? `Waypoint ${index + 1}`;
el.setAttribute('aria-label', name);
el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`;
const swallow = (event: Event) => event.stopPropagation();
const select = (event: Event) => {
@@ -78,6 +81,7 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
el.addEventListener('click', select);
const pin = attachPin(el, point.lng, point.lat);
pinsRef.current.push(pin);
dragCleanups.push(bindPinHoverLabel(el, hoverLabel, map.getContainer(), { name, detail: point.detail ?? null }));
if (plannerWaypointsDraggable) {
el.style.cursor = 'grab';
dragCleanups.push(
@@ -99,6 +103,7 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
return () => {
dragCleanups.forEach((cleanup) => cleanup());
hoverLabel.remove();
pinsRef.current.forEach((pin) => pin.remove());
pinsRef.current = [];
};
+2 -2
View File
@@ -2,7 +2,7 @@ import { useId, type ReactNode } from 'react'
import type { TourListItem } from '@trek/shared'
import { fs } from '../shared/DialogShell'
import { useTranslation } from '../../i18n'
import { TourFactPills, TourTile } from './tourParts'
import { TourFactPills, TourGradeTile } from './tourParts'
import { tourWebsitePresentation } from './tourPresentation'
interface TourListRowProps {
@@ -38,7 +38,7 @@ export default function TourListRow({ tour, selected = false, disabled = false,
}}
>
<div className="flex items-start gap-2.5">
<TourTile />
<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" />
@@ -102,7 +102,7 @@ describe('ToursSidebar', () => {
expect(labels[0]).toContain('1.9 km')
expect(labels[1]).toContain('13 m')
expect(labels[2]).toContain('11 m')
expect(screen.getByText('TREK')).toBeInTheDocument()
expect(within(row).queryByText('TREK')).not.toBeInTheDocument()
expect(screen.getByLabelText(/Maximum trail difficulty|Maximale Wanderschwierigkeit|T2/)).toBeInTheDocument()
const actionRow = row.querySelector('[data-testid="tour-action-row"]')!
expect(actionRow.querySelector('button')).toHaveAttribute('aria-haspopup', 'menu')
@@ -0,0 +1,379 @@
import { AlertTriangle, Info } from 'lucide-react';
import { useId, 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 { TOUR_DURATION_INPUT_MAX_LENGTH, formatTourDuration, parseTourDuration } from './tourDurationInput';
import type { TourPlannerController } from './useTourPlanner';
type Difficulty = 1 | 2 | 3 | 4 | 5 | 6;
interface TourFieldProps {
planner: TourPlannerController;
/** True while the viewer may not edit or a save is in flight. */
locked: boolean;
}
/** The tallest the description grows before it scrolls, about ten lines. */
const DESCRIPTION_MAX_HEIGHT = 240;
const DETAILS_OPEN_KEY = 'trek.tours.planner.detailsOpen';
function readDetailsOpen(): boolean {
try {
return localStorage.getItem(DETAILS_OPEN_KEY) !== 'false';
} catch {
// Storage blocked (private window, preview): the section starts open.
return true;
}
}
function writeDetailsOpen(open: boolean) {
try {
localStorage.setItem(DETAILS_OPEN_KEY, String(open));
} catch {
// Storage blocked: the choice lasts as long as the panel does.
}
}
/** 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 (
<div className="mb-[5px] flex min-h-5 min-w-0 items-center gap-1">
<label className={`${LABEL} !mb-0 min-w-0 truncate leading-5`} htmlFor={htmlFor}>
{children}
</label>
{aside}
</div>
);
}
function FieldError({ id, children }: { id: string; children: ReactNode }) {
return (
<span id={id} role="alert" className="mt-1 block text-danger" style={fs(11)}>
{children}
</span>
);
}
export function TourNameField({ planner, locked }: TourFieldProps) {
const { t } = useTranslation();
return (
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-name">
{t('tours.planner.name')}
</label>
<input
id="tour-planner-name"
disabled={locked}
value={planner.name}
onChange={(event) => {
if (!locked) planner.setName(event.target.value);
}}
maxLength={255}
placeholder={t('tours.planner.namePlaceholder')}
className={INPUT}
/>
</div>
);
}
/**
* A duration typed as "2:56", "2 h 56" or "176" minutes and shown as "2 h 56 min".
* A readable value reaches the planner while it is typed; text that is no
* duration reaches it as NaN on leaving the field, so the planner marks it
* invalid and holds Save back, and the text stays for the user to fix.
*/
function TourDurationInput({
id,
label,
value,
invalid,
errorId,
locked,
onCommit,
}: {
id: string;
label: string;
value: number | null;
invalid: boolean;
errorId: string;
locked: boolean;
onCommit: (minutes: number | null) => void;
}) {
const [draft, setDraft] = useState<string | null>(null);
const [focused, setFocused] = useState(false);
const showDraft = draft !== null && (focused || (value !== null && Number.isNaN(value)));
return (
<input
id={id}
aria-label={label}
type="text"
inputMode="text"
autoComplete="off"
maxLength={TOUR_DURATION_INPUT_MAX_LENGTH}
placeholder="0:00"
disabled={locked}
value={showDraft ? draft : formatTourDuration(value)}
onFocus={() => {
setFocused(true);
setDraft(formatTourDuration(value));
}}
onChange={(event) => {
if (locked) return;
const text = event.target.value;
setDraft(text);
const parsed = parseTourDuration(text);
if (parsed !== 'invalid') onCommit(parsed);
}}
onBlur={() => {
setFocused(false);
if (draft === null) return;
if (!locked && parseTourDuration(draft) === 'invalid') onCommit(Number.NaN);
else setDraft(null);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
}}
aria-invalid={invalid || undefined}
aria-describedby={invalid ? errorId : undefined}
className={`${INPUT} tabular-nums`}
/>
);
}
/** Breaks on top of the walking time, and the planned total that defaults to their sum, side by side. */
export function TourTimeFields({ planner, locked }: TourFieldProps) {
const { t } = useTranslation();
const manual = planner.plannedDurationMinutes != null;
return (
<div data-testid="tour-planner-time-grid" className="grid grid-cols-2 gap-3">
<div className="min-w-0">
<FieldLabel
htmlFor="tour-planner-breaks"
aside={
<InfoDot
label={t('tours.planner.breaksAdditionalInfoLabel')}
description={t('tours.planner.breaksAdditionalInfo')}
/>
}
>
{t('tours.planner.breaksShort')}
</FieldLabel>
<TourDurationInput
id="tour-planner-breaks"
label={t('tours.planner.breaksAdditional')}
value={planner.breakAdditionalMinutes}
invalid={planner.breakAdditionalInvalid}
errorId="tour-planner-breaks-error"
locked={locked}
onCommit={planner.setBreakAdditionalMinutes}
/>
{planner.breakAdditionalInvalid && (
<FieldError id="tour-planner-breaks-error">{t('tours.planner.timeValueInvalid')}</FieldError>
)}
</div>
<div className="min-w-0">
<FieldLabel
htmlFor="tour-planner-planned-duration"
aside={
<>
<InfoDot
label={t('tours.planner.plannedTotalInfoLabel')}
description={t('tours.planner.plannedTotalInfo')}
/>
{manual && (
<span data-testid="tour-planner-planned-manual" className="ms-auto inline-flex min-w-0">
<SoftPill tone="info">{t('tours.planner.plannedTotalManual')}</SoftPill>
</span>
)}
</>
}
>
{t('tours.planner.plannedShort')}
</FieldLabel>
<TourDurationInput
id="tour-planner-planned-duration"
label={t('tours.planner.plannedTotalDuration')}
value={planner.plannedDurationMinutes ?? planner.plannedTotalMinutes}
invalid={planner.plannedDurationInvalid}
errorId="tour-planner-planned-duration-error"
locked={locked}
onCommit={planner.setPlannedDurationMinutes}
/>
{planner.plannedDurationInvalid && (
<FieldError id="tour-planner-planned-duration-error">{t('tours.planner.timeValueInvalid')}</FieldError>
)}
</div>
</div>
);
}
/** The description, growing with its text up to about ten lines before it scrolls. */
function TourDescriptionField({ planner, locked }: TourFieldProps) {
const { t } = useTranslation();
const ref = useRef<HTMLTextAreaElement>(null);
const value = planner.description;
useLayoutEffect(() => {
const element = ref.current;
if (!element) return;
const borders = element.offsetHeight - element.clientHeight;
element.style.height = 'auto';
const wanted = element.scrollHeight + borders;
element.style.height = `${Math.min(wanted, DESCRIPTION_MAX_HEIGHT)}px`;
element.style.overflowY = wanted > DESCRIPTION_MAX_HEIGHT ? 'auto' : 'hidden';
}, [value]);
return (
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-description">
{t('places.formDescription')}
</label>
<textarea
ref={ref}
id="tour-planner-description"
data-testid="tour-planner-description"
disabled={locked}
value={value}
onChange={(event) => {
if (!locked) planner.setDescription(event.target.value);
}}
maxLength={2000}
rows={2}
className={`${INPUT} resize-none`}
/>
</div>
);
}
function TourWebsiteField({ planner, locked }: TourFieldProps) {
const { t } = useTranslation();
return (
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-website">
{t('places.formWebsite')}
</label>
<input
id="tour-planner-website"
type="url"
inputMode="url"
disabled={locked}
value={planner.website}
onChange={(event) => {
if (!locked) planner.setWebsite(event.target.value);
}}
maxLength={500}
placeholder={t('collab.notes.websitePlaceholder')}
aria-invalid={planner.websiteInvalid || undefined}
aria-describedby={planner.websiteInvalid ? 'tour-planner-website-error' : undefined}
className={INPUT}
/>
{planner.websiteInvalid && (
<FieldError id="tour-planner-website-error">{t('dawarich.error.invalid_url')}</FieldError>
)}
</div>
);
}
/** The difficulty cap, with the routing safety note behind the info dot beside its label. */
function TourDifficultyField({
planner,
locked,
onDifficultyChange,
}: TourFieldProps & { onDifficultyChange: (value: Difficulty) => void }) {
const { t } = useTranslation();
return (
<>
<div className="min-w-0">
<FieldLabel
htmlFor="tour-planner-difficulty"
aside={<InfoDot label={t('tours.planner.safetyInfoLabel')} description={t('tours.planner.safetyNote')} />}
>
{t('tours.planner.maxDifficulty')}
</FieldLabel>
<CustomSelect
id="tour-planner-difficulty"
disabled={locked}
ariaLabel={t('tours.planner.maxDifficulty')}
value={planner.maxHikingDifficulty}
onChange={(nextValue) => {
if (!locked) onDifficultyChange(Number(nextValue) as Difficulty);
}}
options={[1, 2, 3, 4, 5, 6].map((value) => ({ value, label: t(`tours.planner.difficulty.t${value}`) }))}
/>
</div>
{planner.maxHikingDifficulty === 3 && (
<TourNotice tone="warning" role="status" icon={<AlertTriangle size={13} strokeWidth={2} />}>
{t('tours.planner.difficulty.t3Warning')}
</TourNotice>
)}
</>
);
}
/**
* Description, website, the difficulty cap and the time, folded away with the
* chevron. The section opens by itself while one of its fields holds a value
* the planner refuses, so the reason Save is held back is never out of sight.
*/
export function TourDetailsSection({
planner,
locked,
onDifficultyChange,
}: TourFieldProps & { onDifficultyChange: (value: Difficulty) => void }) {
const { t } = useTranslation();
const [open, setOpen] = useState(readDetailsOpen);
const forcedOpen = planner.websiteInvalid || planner.plannedDurationInvalid || planner.breakAdditionalInvalid;
const expanded = open || forcedOpen;
const toggle = () => {
const next = !expanded;
setOpen(next);
writeDetailsOpen(next);
};
return (
<TourSection
label={<span className="text-content">{t('tours.planner.details')}</span>}
action={
<FoldButton
expanded={expanded}
onToggle={toggle}
controls="tour-planner-details"
label={t(expanded ? 'tours.planner.collapseDetails' : 'tours.planner.expandDetails')}
/>
}
>
<div id="tour-planner-details" hidden={!expanded} className="flex flex-col gap-3">
{expanded && (
<>
<TourDescriptionField planner={planner} locked={locked} />
<TourWebsiteField planner={planner} locked={locked} />
<TourDifficultyField planner={planner} locked={locked} onDifficultyChange={onDifficultyChange} />
<TourTimeFields planner={planner} locked={locked} />
</>
)}
</div>
</TourSection>
);
}
@@ -119,7 +119,7 @@ describe('TourPlannerToursRail', () => {
expect(screen.getByText('1 h 20 min')).toBeInTheDocument()
})
it('disables saved-tour switching and Plan another while a Save is pending', () => {
it('disables saved-tour switching while a Save is pending', () => {
const controller = planner({
isSaving: true,
saveOutcome: tour,
@@ -134,14 +134,12 @@ describe('TourPlannerToursRail', () => {
expect(row).toHaveAttribute('tabindex', '-1')
fireEvent.click(row)
expect(controller.openTour).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'Plan another' })).toBeDisabled()
})
it('TOUR-PLANNER-RAIL-001: shows saved Tours rows and route stats without hiding the safety note in navigation', () => {
render(<TourPlannerToursRail planner={planner({ mode: { type: 'new-draft' } })} tours={[tour]} {...railProps} />)
expect(screen.getByRole('complementary', { name: 'Tours of this trip' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).toHaveTextContent('TREK')
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).toHaveTextContent('4 km')
expect(screen.getByText('Ascent')).toBeInTheDocument()
expect(screen.getByText('50 m')).toBeInTheDocument()
@@ -176,11 +174,9 @@ describe('TourPlannerToursRail', () => {
const onAssignToDay = vi.fn().mockResolvedValue(undefined)
render(<TourPlannerToursRail planner={controller} tours={[tour]} {...railProps} onAssignToDay={onAssignToDay} />)
expect(screen.getByRole('status')).toHaveTextContent('Tour saved')
expect(screen.queryByRole('button', { name: 'Assign to a day' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Plan another' })).not.toBeInTheDocument()
expect(onAssignToDay).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Plan another' }))
expect(controller.startNewTour).toHaveBeenCalledOnce()
expect(controller.discard).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Add to day: Saved ridge walk' }))
const dayOption = screen.getByRole('button', { name: /Summit day/ })
@@ -673,8 +669,8 @@ describe('TourPlannerRail', () => {
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' } })} />)
expect(screen.queryByRole('button', { name: 'Plan new tour' })).not.toBeInTheDocument()
expect(screen.getByTestId('tour-planner-empty-state')).toHaveTextContent('Click the map to add your starting point.')
expect(screen.getByTestId('tour-planner-empty-state')).toHaveTextContent('Add another point to calculate a route.')
expect(screen.getByTestId('tour-planner-empty-state')).toHaveTextContent('Turn on Add point and click the map.')
expect(screen.getByTestId('tour-planner-empty-state')).toHaveTextContent('From two points on, TREK works out the route.')
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, waypoints: [{ id: 'first', lat: 48, lng: 11, role: 'start' }] })} />)
expect(screen.queryByTestId('tour-planner-empty-state')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Plan new tour' })).not.toBeInTheDocument()
@@ -817,11 +813,13 @@ 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('Unsaved draft restored')
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('Unsaved draft restored')
expect(screen.getByRole('status')).toHaveTextContent('Draft')
expect(screen.getByLabelText('Unsaved draft restored')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument()
})
})
@@ -1,14 +1,12 @@
import { useId, useRef, useState, type ReactNode } from 'react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { TourListItem } from '@trek/shared'
import { AlertTriangle, ArrowDown, ArrowLeft, ArrowUp, CheckCircle2, GripVertical, Info, MapPin, Plus, Redo2, RotateCcw, Save, ShieldAlert, Trash2, Undo2 } from 'lucide-react'
import { AlertTriangle, ArrowLeft, Info, Plus, Redo2, RotateCcw, Save, Trash2, Undo2 } from 'lucide-react'
import type { Day } from '../../../types'
import { useTranslation } from '../../../i18n'
import { useSettingsStore } from '../../../store/settingsStore'
import { formatDistance, formatElevation } from '../../../utils/units'
import ConfirmDialog from '../../shared/ConfirmDialog'
import CustomSelect from '../../shared/CustomSelect'
import { DialogButton, NEUTRAL_TINT, fs } from '../../shared/DialogShell'
import { INPUT, LABEL } from '../../shared/dialogParts'
import EmptyState from '../../shared/EmptyState'
import { useToast } from '../../shared/Toast'
import { Tooltip } from '../../shared/Tooltip'
@@ -18,6 +16,8 @@ import TourListRow from '../TourListRow'
import ElevationProfile from '../../shared/ElevationProfile'
import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourSource } from '../tourPresentation'
import { FoldButton, TourDayMenu, TourMetricFields, TourNotice, TourSection } from '../tourParts'
import { TourDetailsSection, TourNameField } from './TourEditorFields'
import TourWaypointList from './TourWaypointList'
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry'
import type { TourPlannerController, TourPlannerStatus } from './useTourPlanner'
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
@@ -28,9 +28,7 @@ const STATUS_KEY: Partial<Record<TourPlannerStatus, string>> = {
'routing-failed': 'tours.planner.status.routingFailed',
'enriching-elevation': 'tours.planner.status.elevation',
'elevation-failed': 'tours.planner.status.elevationFailed',
ready: 'tours.planner.status.ready',
saving: 'tours.planner.status.saving',
saved: 'tours.planner.saved',
}
/** The tinted head band both rails open with, like every panel head in the planner. */
@@ -38,10 +36,6 @@ const RAIL_HEAD = 'flex flex-none items-start gap-2 border-b border-edge-faint p
type Translate = (key: string, params?: Record<string, string | number | null>) => string
function roleLabel(role: 'start' | 'via' | 'end', t: (key: string) => string) {
return t(`tours.planner.${role}`)
}
function profileFocusLabel(focus: RouteProfileFocus, t: Translate, unit: 'metric' | 'imperial'): string {
return t('tours.planner.inspector.profileFocus', {
distance: formatDistance(focus.distanceMeters / 1000, unit),
@@ -57,26 +51,48 @@ function RailTitle({ title, sub, aside }: { title: string; sub?: ReactNode; asid
<h2 className="m-0 min-w-0 truncate font-bold tracking-[-0.01em] text-content" style={fs(14, 'body')}>{title}</h2>
{aside}
</div>
{sub && <p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11.5, 'body')}>{sub}</p>}
{typeof sub === 'string'
? <p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11.5, 'body')}>{sub}</p>
: sub}
</div>
)
}
function DurationInfo({ description, label }: { description: string; label: string }) {
const id = useId()
// Hover, focus and a tap show the explanation as a tooltip; nothing unfolds in the form.
/**
* 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
* when there is nothing to say.
*/
function RailStatusBadges({ planner }: { planner: TourPlannerController }) {
const { t } = useTranslation()
const statusKey = planner.isSaving ? STATUS_KEY.saving : STATUS_KEY[planner.status]
const restored = planner.draftRestored && planner.hasUnsavedChanges
const failed = planner.error === 'save'
const canRetry = planner.status === 'routing-failed' || planner.status === 'elevation-failed'
if (!restored && !statusKey && !failed) return null
const warning = planner.error != null || canRetry
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>
<div role="status" data-testid="tour-planner-status" className="mt-1 flex min-w-0 flex-wrap 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>
)}
{(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>
)}
{canRetry && (
<button type="button" onClick={planner.retry}
className="inline-flex h-[20px] items-center rounded-full bg-accent px-2 font-semibold text-accent-text shadow-sm transition-opacity hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
style={fs(10.5)}>
{t('tours.planner.retry')}
</button>
)}
</div>
)
}
@@ -88,8 +104,6 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
const [pendingDifficulty, setPendingDifficulty] = useState<1 | 2 | 3 | 4 | 5 | 6 | null>(null)
const alpineAcknowledged = useRef(false)
const isSaving = planner.isSaving
const statusKey = isSaving ? STATUS_KEY.saving : STATUS_KEY[planner.status]
const canRetry = planner.status === 'routing-failed' || planner.status === 'elevation-failed'
const requestClose = () => {
if (isSaving) return
if (planner.hasUnsavedChanges) setConfirmDiscard(true)
@@ -173,7 +187,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={t('tours.planner.mapHint')} />
<RailTitle title={t(isSavedEdit ? 'tours.planner.editTitle' : 'tours.planner.newTitle')} sub={<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>
@@ -183,37 +197,14 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
</div>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 py-3.5">
{planner.draftRestored && planner.hasUnsavedChanges && (
<TourNotice role="status" icon={<RotateCcw size={13} strokeWidth={2} className="text-content-faint" />}>{t('tours.planner.restored')}</TourNotice>
)}
<TourNameField planner={planner} locked={!canEdit || isSaving} />
<TourDetailsFields planner={planner} locked={!canEdit || isSaving} onDifficultyChange={changeDifficulty} />
<TourWaypointList planner={planner} canEdit={canEdit} isSaving={isSaving} />
<WaypointList planner={planner} canEdit={canEdit} isSaving={isSaving} />
{(statusKey || planner.error) && (
<TourNotice
role="status"
tone={planner.error ? 'warning' : 'neutral'}
icon={planner.error ? <AlertTriangle size={13} strokeWidth={2} /> : <Info size={13} strokeWidth={2} className="text-content-faint" />}
>
{planner.error === 'save' ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null}
{canRetry && (
<button type="button" onClick={planner.retry}
className="ms-2 inline-flex items-center rounded-full bg-surface-card px-2 py-[1px] font-semibold text-content shadow-sm hover:bg-surface-secondary"
style={fs(11)}>
{t('tours.planner.retry')}
</button>
)}
</TourNotice>
)}
<TourDetailsSection planner={planner} locked={!canEdit || isSaving} onDifficultyChange={changeDifficulty} />
</div>
<div className="flex flex-none flex-col gap-2.5 border-t border-edge-faint px-3 py-3">
<p className="m-0 flex items-start gap-1.5 leading-snug text-content-faint" style={fs(11)}>
<ShieldAlert size={13} strokeWidth={2} className="mt-px flex-none" />
<span>{t('tours.planner.safetyNote')} {t('tours.planner.safetyNoteDetails')}</span>
</p>
<div className="flex flex-none flex-col border-t border-edge-faint px-3 py-3">
<div className="grid grid-cols-[auto_1fr] gap-2 [&>button]:justify-center">
<DialogButton icon={<RotateCcw size={14} strokeWidth={2} />} onClick={requestClose} disabled={isSaving || (!planner.hasUnsavedChanges && !isSavedEdit)}>
{t('common.discard')}
@@ -252,323 +243,6 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
)
}
interface TourFieldProps {
planner: TourPlannerController
/** True while the viewer may not edit or a save is in flight. */
locked: boolean
}
/** Breaks on top of the moving time, and the planned total that defaults to their sum. */
function TourTimeGrid({ planner, locked }: TourFieldProps) {
const { t } = useTranslation()
return (
<div data-testid="tour-planner-time-grid" className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))] gap-3">
<div className="min-w-0">
<div className="mb-[5px] flex min-w-0 items-center gap-1">
<label className={`${LABEL} !mb-0 leading-5`} htmlFor="tour-planner-breaks">{t('tours.planner.breaksAdditional')}</label>
<DurationInfo
label={t('tours.planner.breaksAdditionalInfoLabel')}
description={t('tours.planner.breaksAdditionalInfo')}
/>
</div>
<input
id="tour-planner-breaks"
aria-label={t('tours.planner.breaksAdditional')}
type="number"
inputMode="numeric"
min={0}
max={1440}
step={1}
disabled={locked}
value={planner.breakAdditionalMinutes ?? ''}
onChange={event => { if (!locked) planner.setBreakAdditionalMinutes(event.target.value === '' ? null : Number(event.target.value)) }}
aria-invalid={planner.breakAdditionalInvalid || undefined}
aria-describedby={`tour-planner-breaks-hint${planner.breakAdditionalInvalid ? ' tour-planner-breaks-error' : ''}`}
className={INPUT}
/>
<span id="tour-planner-breaks-hint" className="mt-1 block text-content-faint" style={fs(10.5)}>
{t('tours.planner.breaksAdditionalHint')}
</span>
{planner.breakAdditionalInvalid && (
<span id="tour-planner-breaks-error" role="alert" className="mt-1 block text-danger" style={fs(11)}>
{t('tours.planner.timeValueInvalid')}
</span>
)}
</div>
<div className="min-w-0">
<div className="mb-[5px] flex min-w-0 items-center gap-1">
<label className={`${LABEL} !mb-0 leading-5`} htmlFor="tour-planner-planned-duration">
{t('tours.planner.plannedTotalDuration')}
{planner.plannedDurationMinutes != null && <span className="ms-1 normal-case text-info">· {t('tours.planner.plannedTotalManual')}</span>}
</label>
<DurationInfo
label={t('tours.planner.plannedTotalInfoLabel')}
description={t('tours.planner.plannedTotalInfo')}
/>
</div>
<div className="flex min-w-0 items-center gap-2">
<input
id="tour-planner-planned-duration"
aria-label={t('tours.planner.plannedTotalDuration')}
type="number"
inputMode="numeric"
min={0}
max={1440}
step={1}
disabled={locked}
value={planner.plannedDurationMinutes ?? planner.plannedTotalMinutes ?? ''}
onChange={event => { if (!locked) planner.setPlannedDurationMinutes(event.target.value === '' ? null : Number(event.target.value)) }}
aria-invalid={planner.plannedDurationInvalid || undefined}
aria-describedby={planner.plannedDurationInvalid ? 'tour-planner-planned-duration-error' : undefined}
className={`${INPUT} min-w-0 flex-none`}
style={{ width: '6rem' }}
/>
{planner.plannedDurationMinutes == null && <span className="text-content-faint" style={fs(10.5)}>{t('tours.planner.plannedTotalAutomatic')}</span>}
</div>
{planner.plannedDurationInvalid && (
<span id="tour-planner-planned-duration-error" role="alert" className="mt-1 block text-danger" style={fs(11)}>
{t('tours.planner.timeValueInvalid')}
</span>
)}
</div>
</div>
)
}
/** Name, description, timings, website and the difficulty cap of the tour being planned. */
function TourDetailsFields({ planner, locked, onDifficultyChange }: TourFieldProps & { onDifficultyChange: (value: 1 | 2 | 3 | 4 | 5 | 6) => void }) {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-3">
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-name">{t('tours.planner.name')}</label>
<input
id="tour-planner-name"
disabled={locked}
value={planner.name}
onChange={event => { if (!locked) planner.setName(event.target.value) }}
maxLength={255}
placeholder={t('tours.planner.namePlaceholder')}
className={INPUT}
/>
</div>
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-description">{t('places.formDescription')}</label>
<textarea
id="tour-planner-description"
disabled={locked}
value={planner.description}
onChange={event => { if (!locked) planner.setDescription(event.target.value) }}
maxLength={2000}
rows={3}
className={`${INPUT} min-h-[72px] resize-y`}
/>
</div>
<TourSection label={t('tours.planner.plannedTotalDuration')}>
<TourTimeGrid planner={planner} locked={locked} />
</TourSection>
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-website">{t('places.formWebsite')}</label>
<input
id="tour-planner-website"
type="url"
inputMode="url"
disabled={locked}
value={planner.website}
onChange={event => { if (!locked) planner.setWebsite(event.target.value) }}
maxLength={500}
placeholder={t('collab.notes.websitePlaceholder')}
aria-invalid={planner.websiteInvalid || undefined}
aria-describedby={planner.websiteInvalid ? 'tour-planner-website-error' : undefined}
className={INPUT}
/>
{planner.websiteInvalid && (
<span id="tour-planner-website-error" role="alert" className="mt-1 block text-danger" style={fs(11)}>
{t('dawarich.error.invalid_url')}
</span>
)}
</div>
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-difficulty">{t('tours.planner.maxDifficulty')}</label>
<CustomSelect
id="tour-planner-difficulty"
disabled={locked}
ariaLabel={t('tours.planner.maxDifficulty')}
value={planner.maxHikingDifficulty}
onChange={nextValue => {
if (!locked) onDifficultyChange(Number(nextValue) as 1 | 2 | 3 | 4 | 5 | 6)
}}
options={[1, 2, 3, 4, 5, 6].map(value => ({ value, label: t(`tours.planner.difficulty.t${value}`) }))}
/>
</div>
{planner.maxHikingDifficulty === 3 && (
<TourNotice tone="warning" role="status" icon={<AlertTriangle size={13} strokeWidth={2} />}>{t('tours.planner.difficulty.t3Warning')}</TourNotice>
)}
</div>
)
}
/** The ordered waypoints, reorderable by drag or by the arrow controls on each row. */
function WaypointList({ planner, canEdit, isSaving }: { planner: TourPlannerController; canEdit: boolean; isSaving: boolean }) {
const { t } = useTranslation()
const [draggedWaypointId, setDraggedWaypointId] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<{ id: string; placement: 'before' | 'after' } | null>(null);
const lastIndex = planner.waypoints.length - 1
return (
<TourSection label={t('tours.planner.waypoints')}>
{planner.waypoints.length === 0 ? (
<div data-testid="tour-planner-empty-state" className="flex items-start gap-2 rounded-[12px] border border-dashed border-edge px-3 py-3 leading-snug text-content-muted" style={fs(12, 'body')}>
<MapPin size={14} strokeWidth={2} className="mt-px flex-none text-content-faint" />
<p className="m-0">{t('tours.planner.startingPoint')} {t('tours.planner.firstUseDetails')}</p>
</div>
) : (
<ol data-touch-drag className="m-0 flex list-none flex-col gap-px p-0">
{planner.waypoints.map((point, index) => {
const selected = planner.selectedWaypointId === point.id;
const isDropTarget = dropTarget?.id === point.id && draggedWaypointId !== point.id;
return (
<li
key={point.id}
data-testid="tour-planner-waypoint-row"
onDragStart={(event) => {
if (!canEdit || isSaving) {
event.preventDefault();
return;
}
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', point.id);
}
setDraggedWaypointId(point.id);
setDropTarget(null);
}}
onDragOver={(event) => {
if (!canEdit || isSaving || !draggedWaypointId || draggedWaypointId === point.id) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
const bounds = event.currentTarget.getBoundingClientRect();
const placement = event.clientY >= bounds.top + bounds.height / 2 ? 'after' : 'before';
setDropTarget((current) =>
current?.id === point.id && current.placement === placement
? current
: { id: point.id, placement }
);
}}
onDrop={(event) => {
event.preventDefault();
const draggedId = draggedWaypointId || event.dataTransfer.getData('text/plain');
if (
canEdit &&
!isSaving &&
draggedId &&
draggedId !== point.id &&
dropTarget?.id === point.id
) {
planner.reorderWaypoint(draggedId, point.id, dropTarget.placement);
}
setDraggedWaypointId(null);
setDropTarget(null);
}}
onDragEnd={() => {
setDraggedWaypointId(null);
setDropTarget(null);
}}
className={`relative rounded-[12px] ${draggedWaypointId === point.id ? 'opacity-45' : ''} ${isDropTarget ? 'bg-surface-hover' : ''}`}
>
{isDropTarget && (
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-x-2 z-10 h-0.5 rounded bg-accent ${dropTarget.placement === 'before' ? 'top-0' : 'bottom-0'}`}
/>
)}
<div className="group flex w-full items-center gap-2.5 rounded-[12px] px-2 py-1.5">
<span
draggable={canEdit && !isSaving}
aria-hidden="true"
data-testid="tour-planner-waypoint-drag-handle"
className={`grid h-6 w-5 flex-none place-items-center text-content-faint ${canEdit && !isSaving ? 'cursor-grab active:cursor-grabbing' : 'opacity-35'}`}
>
<GripVertical size={14} strokeWidth={1.8} />
</span>
<span
aria-label={t('tours.planner.waypointLabel', { n: index + 1 })}
data-waypoint-number={index + 1}
className={`grid h-7 w-7 flex-none place-items-center rounded-[9px] font-geist font-semibold tabular-nums shadow-sm ${selected ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'}`}
style={fs(11.5)}
>
{index + 1}
</span>
<button
type="button"
onClick={() => planner.setSelectedWaypointId(point.id)}
className="flex min-w-0 flex-1 flex-col text-start outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)]"
aria-current={selected ? 'true' : undefined}
>
<span className="block font-semibold leading-tight text-content" style={fs(12.5, 'body')}>
{roleLabel(point.role, t)}
</span>
<span
className="mt-0.5 block truncate font-geist tabular-nums text-content-faint"
style={fs(10.5)}
>
{point.lat.toFixed(5)}, {point.lng.toFixed(5)}
</span>
</button>
{canEdit && (
<span
className={`flex flex-none items-center gap-0.5 transition-opacity ${selected ? '' : 'opacity-50 group-focus-within:opacity-100 group-hover:opacity-100'}`}
>
<WaypointAction
label={t('tours.planner.moveUp')}
disabled={isSaving || index === 0}
onAct={() => planner.moveWaypoint(point.id, -1)}
>
<ArrowUp size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction
label={t('tours.planner.moveDown')}
disabled={isSaving || index === lastIndex}
onAct={() => planner.moveWaypoint(point.id, 1)}
>
<ArrowDown size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction
label={t('tours.planner.remove')}
disabled={isSaving}
danger
onAct={() => planner.removeWaypoint(point.id)}
>
<Trash2 size={13} strokeWidth={2} />
</WaypointAction>
</span>
)}
</div>
</li>
);
})}
</ol>
)}
</TourSection>
)
}
/** A quiet icon control on a waypoint row; a span, because the row itself is the button. */
function WaypointAction({ label, disabled, danger = false, onAct, children }: { label: string; disabled: boolean; danger?: boolean; onAct: () => void; children: ReactNode }) {
return (
<Tooltip label={label}>
<button type="button" disabled={disabled}
aria-label={label}
aria-disabled={disabled}
onClick={event => { event.stopPropagation(); if (!disabled) onAct() }}
className={`grid h-6 w-6 place-items-center rounded-full transition-colors ${disabled ? 'pointer-events-none opacity-25' : danger ? 'text-danger hover:bg-surface-card' : 'text-content-muted hover:bg-surface-card hover:text-content'}`}
>
{children}
</button>
</Tooltip>
)
}
/** The elevation profile under its label, folded away with the chevron beside it. */
function ElevationSection({ id, samples, gradientId, planner }: {
id: string
@@ -640,6 +314,12 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
const hasRoute = (planner.mode.type === 'new-draft' || planner.mode.type === 'edit-saved') && planner.route !== null
const routeAnalysis = planner.routeAnalysis
// A finished save is said once, as the app says it everywhere: a toast.
const savedTour = planner.saveOutcome
useEffect(() => {
if (savedTour) toast.success(t('tours.planner.saved'))
}, [savedTour, t, toast])
const openTour = async (tour: TourListItem) => {
const opened = await planner.openTour(tour)
if (!opened) toast.error(t('tours.planner.openFailed'))
@@ -667,11 +347,11 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
: formatPlannedTourDuration(planner.walkingDurationMinutes),
},
{
label: t('tours.planner.breaksAdditional'),
label: t('tours.planner.breaksShort'),
value: planner.breakAdditionalMinutes == null ? '—' : formatPlannedTourDuration(planner.breakAdditionalMinutes),
},
{
label: `${t('tours.planner.plannedTotalDuration')}${planner.plannedDurationMinutes != null ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}`,
label: `${t('tours.planner.plannedShort')}${planner.plannedDurationMinutes != null ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`,
value: planner.plannedTotalMinutes == null ? t('tours.planner.walkingTimeUnknown') : formatPlannedTourDuration(planner.plannedTotalMinutes),
},
]
@@ -679,12 +359,21 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
return (
<aside className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.tripTours')}>
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<RailTitle title={t('tours.planner.tripTours')} sub={t('tours.subtitle')} />
<RailTitle title={t('tours.planner.tripTours')} />
{canEdit && planner.mode.type !== 'neutral' && (
<Tooltip label={t('tours.planner.newTour')}>
<button type="button" aria-label={t('tours.planner.newTour')} data-testid="tour-rail-new-tour"
disabled={isSaving} onClick={() => { if (!isSaving) planner.startNewTour() }}
className="mt-1 grid h-[26px] w-[26px] flex-none place-items-center rounded-full bg-accent text-accent-text shadow-sm transition-opacity hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-default disabled:opacity-40">
<Plus size={14} strokeWidth={2.4} />
</button>
</Tooltip>
)}
</div>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 py-3.5">
{hasRoute && (
<>
<div data-testid="tour-planner-overview" className="flex flex-col gap-4 rounded-[14px] border border-edge-faint bg-surface-secondary p-3">
<TourSection label={t('tours.planner.inspector.title')}>
<TourMetricFields analysis={routeAnalysis} unit={distanceUnit} extra={tourTimeFields} columns={2} />
</TourSection>
@@ -696,18 +385,6 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
planner={planner}
/>
)}
</>
)}
{planner.saveOutcome && (
<div className={`${BOX} flex flex-col gap-3 p-3`} role="status">
<div className="flex items-center gap-2 font-semibold text-content" style={fs(13, 'body')}>
<CheckCircle2 size={16} strokeWidth={2} className="flex-none text-success" />
{t('tours.planner.saved')}
</div>
<DialogButton onClick={() => { if (canEdit && !isSaving) planner.startNewTour() }} disabled={!canEdit || isSaving}>
{t('tours.planner.planAnother')}
</DialogButton>
</div>
)}
@@ -0,0 +1,256 @@
import { ArrowDown, ArrowUp, GripVertical, MapPin, MapPinPlus, Trash2 } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { toggleTourPlacing, useTourPlaceToolStore } from '../../../store/tourPlaceToolStore';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { TourSection } from '../tourParts';
import type { TourPlannerController } from './useTourPlanner';
import { useWaypointPlaceNames } from './useWaypointPlaceNames';
type DropTarget = { id: string; placement: 'before' | 'after' };
const ROLE_KEY = {
start: 'tours.planner.start',
via: 'tours.planner.via',
end: 'tours.planner.end',
} as const;
/** A quiet icon control on a waypoint row; it stops the click so the row is not selected too. */
function WaypointAction({
label,
disabled,
danger = false,
onAct,
children,
}: {
label: string;
disabled: boolean;
danger?: boolean;
onAct: () => void;
children: ReactNode;
}) {
return (
<Tooltip label={label}>
<button
type="button"
disabled={disabled}
aria-label={label}
aria-disabled={disabled}
onClick={(event) => {
event.stopPropagation();
if (!disabled) onAct();
}}
className={`grid h-6 w-6 place-items-center rounded-full transition-colors ${disabled ? 'pointer-events-none opacity-25' : danger ? 'text-danger hover:bg-surface-card' : 'text-content-muted hover:bg-surface-card hover:text-content'}`}
>
{children}
</button>
</Tooltip>
);
}
/** The quiet hint and first-use guidance before a route exists. */
function WaypointEmptyState() {
const { t } = useTranslation();
return (
<div
data-testid="tour-planner-empty-state"
className="flex items-start gap-2 rounded-[12px] border border-dashed border-edge px-3 py-3 leading-snug text-content-muted"
style={fs(12, 'body')}
>
<MapPin size={14} strokeWidth={2} className="mt-px flex-none text-content-faint" />
<p className="m-0">{t('tours.planner.emptyHint')}</p>
</div>
);
}
/**
* The row under the last waypoint that turns on "set points" for the map the
* planner shows, the same switch as the one in the map's layer pill; while it
* is on, the row says where to click and a second click turns it off again.
*/
function AddPointRow({ disabled }: { disabled: boolean }) {
const { t } = useTranslation();
const active = useTourPlaceToolStore((state) => state.placing[state.mode]);
return (
<button
type="button"
data-testid="tour-planner-add-point"
aria-pressed={active}
disabled={disabled}
onClick={() => toggleTourPlacing()}
className={`mt-1.5 flex w-full items-center justify-center gap-1.5 rounded-[12px] border border-dashed px-3 py-2.5 font-semibold transition-colors disabled:cursor-default disabled:opacity-40 ${active ? 'border-accent bg-surface-card text-content' : 'border-edge text-content-muted enabled:hover:border-content-faint enabled:hover:text-content'}`}
style={fs(12, 'body')}
>
<MapPinPlus size={14} strokeWidth={2} className="flex-none" />
<span className="min-w-0 truncate">{t(active ? 'tours.planner.addPointActive' : 'tours.planner.addPoint')}</span>
</button>
);
}
/**
* The ordered waypoints, each under its place name with its role as a badge,
* reorderable by drag or by the arrow controls on each row. The coordinates
* stand in for the name while it loads, offline, or when the lookup has none.
*/
export default function TourWaypointList({
planner,
canEdit,
isSaving,
}: {
planner: TourPlannerController;
canEdit: boolean;
isSaving: boolean;
}) {
const { t } = useTranslation();
const [draggedWaypointId, setDraggedWaypointId] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
const placeNames = useWaypointPlaceNames(planner.waypoints);
const lastIndex = planner.waypoints.length - 1;
const movable = canEdit && !isSaving;
const endDrag = () => {
setDraggedWaypointId(null);
setDropTarget(null);
};
if (planner.waypoints.length === 0) {
return (
<TourSection label={t('tours.planner.waypoints')}>
<WaypointEmptyState />
{canEdit && <AddPointRow disabled={isSaving} />}
</TourSection>
);
}
return (
<TourSection label={t('tours.planner.waypoints')}>
<ol data-touch-drag className="m-0 flex list-none flex-col gap-px p-0">
{planner.waypoints.map((point, index) => {
const selected = planner.selectedWaypointId === point.id;
const isDropTarget = dropTarget?.id === point.id && draggedWaypointId !== point.id;
const placeName = placeNames[point.id];
const coordinates = `${point.lat.toFixed(5)}, ${point.lng.toFixed(5)}`;
return (
<li
key={point.id}
data-testid="tour-planner-waypoint-row"
onDragStart={(event) => {
if (!movable) {
event.preventDefault();
return;
}
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', point.id);
}
setDraggedWaypointId(point.id);
setDropTarget(null);
}}
onDragOver={(event) => {
if (!movable || !draggedWaypointId || draggedWaypointId === point.id) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
const bounds = event.currentTarget.getBoundingClientRect();
const placement = event.clientY >= bounds.top + bounds.height / 2 ? 'after' : 'before';
setDropTarget((current) =>
current?.id === point.id && current.placement === placement ? current : { id: point.id, placement }
);
}}
onDrop={(event) => {
event.preventDefault();
const draggedId = draggedWaypointId || event.dataTransfer.getData('text/plain');
if (movable && draggedId && draggedId !== point.id && dropTarget?.id === point.id) {
planner.reorderWaypoint(draggedId, point.id, dropTarget.placement);
}
endDrag();
}}
onDragEnd={endDrag}
className={`relative rounded-[12px] ${draggedWaypointId === point.id ? 'opacity-45' : ''} ${isDropTarget ? 'bg-surface-hover' : ''}`}
>
{isDropTarget && (
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-x-2 z-10 h-0.5 rounded bg-accent ${dropTarget.placement === 'before' ? 'top-0' : 'bottom-0'}`}
/>
)}
<div className="group flex w-full items-center gap-2.5 rounded-[12px] px-2 py-1.5">
<span
draggable={movable}
aria-hidden="true"
data-testid="tour-planner-waypoint-drag-handle"
className={`grid h-6 w-5 flex-none place-items-center text-content-faint ${movable ? 'cursor-grab active:cursor-grabbing' : 'opacity-35'}`}
>
<GripVertical size={14} strokeWidth={1.8} />
</span>
<span
aria-label={t('tours.planner.waypointLabel', { n: index + 1 })}
data-waypoint-number={index + 1}
className={`grid h-7 w-7 flex-none place-items-center rounded-[9px] font-geist font-semibold tabular-nums shadow-sm ${selected ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'}`}
style={fs(11.5)}
>
{index + 1}
</span>
<button
type="button"
onClick={() => planner.setSelectedWaypointId(point.id)}
className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-start outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)]"
aria-current={selected ? 'true' : undefined}
>
<span className="flex w-full min-w-0 items-center gap-1.5">
<span
data-testid="tour-planner-waypoint-name"
className={`block min-w-0 truncate leading-tight ${placeName ? 'font-semibold text-content' : 'font-geist tabular-nums text-content-muted'}`}
style={fs(12.5, 'body')}
>
{placeName ?? coordinates}
</span>
<SoftPill>{t(ROLE_KEY[point.role])}</SoftPill>
</span>
{placeName && (
<span
data-testid="tour-planner-waypoint-coordinates"
className="block min-w-0 truncate font-geist tabular-nums leading-tight text-content-faint"
style={fs(10.5)}
>
{coordinates}
</span>
)}
</button>
{canEdit && (
<span
className={`flex flex-none items-center gap-0.5 transition-opacity ${selected ? '' : 'opacity-50 group-focus-within:opacity-100 group-hover:opacity-100'}`}
>
<WaypointAction
label={t('tours.planner.moveUp')}
disabled={isSaving || index === 0}
onAct={() => planner.moveWaypoint(point.id, -1)}
>
<ArrowUp size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction
label={t('tours.planner.moveDown')}
disabled={isSaving || index === lastIndex}
onAct={() => planner.moveWaypoint(point.id, 1)}
>
<ArrowDown size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction
label={t('tours.planner.remove')}
disabled={isSaving}
danger
onAct={() => planner.removeWaypoint(point.id)}
>
<Trash2 size={13} strokeWidth={2} />
</WaypointAction>
</span>
)}
</div>
</li>
);
})}
</ol>
{canEdit && <AddPointRow disabled={isSaving} />}
</TourSection>
);
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import { formatTourDuration, parseTourDuration } from './tourDurationInput';
describe('parseTourDuration', () => {
it.each([
['176', 176],
['0', 0],
[' 45 ', 45],
['2:56', 176],
['0:05', 5],
['2:5', 125],
['2 h 56', 176],
['2h56', 176],
['2 h 56 min', 176],
['2H 56MIN', 176],
['2 h', 120],
['3h', 180],
['1,5 h', 90],
['1.5h', 90],
['2 Std 10', 130],
['2 Std. 10 Min.', 130],
['45 min', 45],
['45m', 45],
['2 h 5', 125],
])('reads %j as %d minutes', (text, minutes) => {
expect(parseTourDuration(text)).toBe(minutes);
});
it('clears the value for an empty field', () => {
expect(parseTourDuration('')).toBeNull();
expect(parseTourDuration(' ')).toBeNull();
});
it.each(['h', 'min', 'abc', '2:60', '2:', ':30', '-5', '2 56', '2,5', '1 h 2 h', '12345678901234567'])(
'rejects %j',
(text) => {
expect(parseTourDuration(text)).toBe('invalid');
},
);
it('reads back everything it formats', () => {
for (const minutes of [0, 5, 59, 60, 61, 176, 1440]) {
expect(parseTourDuration(formatTourDuration(minutes))).toBe(minutes);
}
});
});
describe('formatTourDuration', () => {
it('writes hours and minutes the way the planner shows durations', () => {
expect(formatTourDuration(176)).toBe('2 h 56 min');
expect(formatTourDuration(120)).toBe('2 h');
expect(formatTourDuration(45)).toBe('45 min');
expect(formatTourDuration(0)).toBe('0 min');
});
it('writes nothing for an empty or unreadable value', () => {
expect(formatTourDuration(null)).toBe('');
expect(formatTourDuration(Number.NaN)).toBe('');
});
});
@@ -0,0 +1,45 @@
import { formatPlannedTourDuration } from '../tourPresentation';
/** What a typed duration means: nothing (cleared), a number of minutes, or no duration at all. */
export type ParsedTourDuration = number | null | 'invalid';
/** The longest text a duration field takes; nobody types more for a day's walk. */
export const TOUR_DURATION_INPUT_MAX_LENGTH = 16;
const PLAIN_MINUTES = /^\d+$/;
const CLOCK = /^(\d+):(\d{1,2})$/;
// Hours with a unit, then minutes with an optional unit, or minutes alone with a unit:
// "2 h 56", "2h56min", "1,5 h", "45 min". Each part is anchored on its unit, so the
// pattern cannot match one text two ways.
const WITH_UNITS = /^(?:(\d+(?:[.,]\d+)?) ?(?:h|hr|hrs|hours?|std|stunden?)\.?)? ?(?:(\d+) ?(?:m|min|mins|minutes?|minuten?)?\.?)?$/;
/**
* Reads what somebody typed into a duration field as whole minutes.
*
* "176" is minutes, "2:56" is hours and minutes, and "2 h 56", "2h 56 min" or
* "1,5 h" spell the units out. An empty field clears the value. Anything else,
* a clock with 60 minutes or more included, is invalid. The range is the
* planner's to check, not this one's.
*/
export function parseTourDuration(text: string): ParsedTourDuration {
const value = text.trim().toLowerCase().replace(/\s+/g, ' ');
if (value === '') return null;
if (value.length > TOUR_DURATION_INPUT_MAX_LENGTH) return 'invalid';
if (PLAIN_MINUTES.test(value)) return Number(value);
const clock = CLOCK.exec(value);
if (clock) {
const minutes = Number(clock[2]);
return minutes < 60 ? Number(clock[1]) * 60 + minutes : 'invalid';
}
const units = WITH_UNITS.exec(value);
if (!units || (units[1] === undefined && units[2] === undefined)) return 'invalid';
const hours = units[1] === undefined ? 0 : Number(units[1].replace(',', '.'));
const minutes = units[2] === undefined ? 0 : Number(units[2]);
return Math.round(hours * 60 + minutes);
}
/** "2 h 56 min" for a duration in minutes, and nothing for an empty or unreadable one. */
export function formatTourDuration(minutes: number | null): string {
if (minutes === null || !Number.isFinite(minutes)) return '';
return formatPlannedTourDuration(minutes);
}
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { usePlaceLanguage } from '../../../hooks/usePlaceLanguage';
import { reverseGeocodeRepo } from '../../../repo/reverseGeocodeRepo';
/** How long a point has to rest before it is looked up, so a drag does not ask at every step. */
const SETTLE_MS = 350;
interface WaypointCoordinate {
id: string;
lat: number;
lng: number;
}
const coordinateKey = (point: WaypointCoordinate) => `${point.lat.toFixed(5)},${point.lng.toFixed(5)}`;
/**
* The place name of every waypoint by its id, as far as the reverse lookup has
* answered. A waypoint without an entry is still loading, offline, or has no
* name; the caller shows its coordinates then. A name is kept with the
* coordinate it belongs to, so a moved point never shows its old name.
*/
export function useWaypointPlaceNames(waypoints: WaypointCoordinate[]): Record<string, string> {
const lang = usePlaceLanguage();
const [names, setNames] = useState<Record<string, { key: string; name: string }>>({});
const signature = waypoints.map((point) => `${point.id}@${coordinateKey(point)}`).join('|');
useEffect(() => {
if (!signature) return undefined;
let cancelled = false;
const points = signature.split('|').map((entry) => {
const [id, key] = entry.split('@');
const [lat, lng] = key.split(',').map(Number);
return { id, key, lat, lng };
});
const timer = window.setTimeout(() => {
for (const point of points) {
reverseGeocodeRepo.placeName(point.lat, point.lng, lang).then(
(name) => {
if (cancelled || !name) return;
setNames((current) =>
current[point.id]?.key === point.key && current[point.id]?.name === name
? current
: { ...current, [point.id]: { key: point.key, name } },
);
},
() => {
// Offline or a failed lookup: the row keeps showing the coordinates.
},
);
}
}, SETTLE_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [lang, signature]);
const result: Record<string, string> = {};
for (const point of waypoints) {
const entry = names[point.id];
if (entry && entry.key === coordinateKey(point)) result[point.id] = entry.name;
}
return result;
}
@@ -1,4 +1,4 @@
// FE-COMP-TERRAIN-001 to FE-COMP-TERRAIN-014
// FE-COMP-TERRAIN-001 to FE-COMP-TERRAIN-015
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render';
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry';
@@ -372,4 +372,24 @@ describe('TourTerrainMap', () => {
expect(gl.map.remove).toHaveBeenCalledTimes(1);
expect(pin.remove).toHaveBeenCalled();
});
it('FE-COMP-TERRAIN-015: the "set points" switch rides in the layer pill, and while it is off the hint says so', async () => {
const onToggle = vi.fn();
const { rerender } = await renderTerrain({ placeTool: { active: false, onToggle } });
const tool = screen.getByRole('button', { name: 'Set points' });
expect(tool).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByText('Turn on Set points to click points onto the terrain.')).toBeInTheDocument();
fireEvent.click(tool);
expect(onToggle).toHaveBeenCalledTimes(1);
rerender(
<TourTerrainMap
{...props({
placeTool: { active: true, onToggle },
waypoints: { points: [], selectedId: null, onSelect: vi.fn(), onAdd: vi.fn() },
})}
/>
);
expect(screen.getByRole('button', { name: 'Set points' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText(/Click the terrain to set points/)).toBeInTheDocument();
});
});
@@ -6,7 +6,12 @@ import type { DistanceUnit } from '../../../types';
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry';
import { formatElevation } from '../../../utils/units';
import type { FerrataMapLayer } from '../../Ferratas/useFerrataMapLayer';
import { MAP_LAYER_SWITCHER_INSET, TourMapLayerSwitcher, type TourBaseLayer } from '../../Map/MapLayerSwitcher';
import {
MAP_LAYER_SWITCHER_INSET,
TourMapLayerSwitcher,
type MapPlaceTool,
type TourBaseLayer,
} from '../../Map/MapLayerSwitcher';
import { MAP_CONTROL_SHADOW } from '../../Map/mapControlShadow';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
@@ -37,6 +42,8 @@ export interface TourTerrainMapProps {
onViewportChange?: (bbox: FerrataBbox) => void;
/** How high the controls along the bottom edge reach, every row of them, for what floats above them. */
onControlsHeightChange?: (px: number) => void;
/** The "set points" switch in the layer pill, while a tour is being edited. */
placeTool?: MapPlaceTool;
}
const SPEEDS = [1, 2, 4] as const;
@@ -94,6 +101,7 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
terrainActive
onToggleTerrain={onExit}
ferrataLayer={props.ferrataLayer}
placeTool={props.placeTool}
compact={fit !== 'full'}
/>
</div>
@@ -106,7 +114,7 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
className="pointer-events-auto rounded-full px-4 py-2.5 text-content-muted"
style={{ ...GLASS, ...fs(12, 'body') }}
>
<PickHint editing={!!props.waypoints.onAdd} />
<PickHint editing={!!props.waypoints.onAdd} placeOff={props.placeTool?.active === false} />
</div>
)}
<div className="flex justify-end">{!legendAbove && legend}</div>
@@ -117,8 +125,10 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
);
}
function PickHint({ editing }: { editing: boolean }) {
/** What a click on the terrain does now: plan, nothing until the tool is on, or nothing to plan. */
function PickHint({ editing, placeOff }: { editing: boolean; placeOff: boolean }) {
const { t } = useTranslation();
if (placeOff) return <>{t('tours.terrain.placeOff')}</>;
return <>{t(editing ? 'tours.terrain.clickToPlan' : 'tours.terrain.pickTour')}</>;
}
@@ -1,10 +1,12 @@
import type { Marker as MapLibreMarker } from 'maplibre-gl';
import { useEffect, useRef, type RefObject } from 'react';
import { bindPinHoverLabel, mapHoverLabel } from '../../Map/mapHoverLabel';
import { waypointPinElement } from './terrainLayers';
import type { TerrainGl } from './useTerrainMap';
export interface TerrainWaypoints {
points: Array<{ id: string; lat: number; lng: number }>;
/** `name` and `detail` (its role) show on hover. */
points: Array<{ id: string; lat: number; lng: number; name?: string; detail?: string }>;
selectedId: string | null;
onSelect: (id: string) => void;
/** Absent while the tour cannot be edited; the pins then stay put. */
@@ -30,8 +32,13 @@ export function useTerrainPins(
useEffect(() => {
if (!gl || !ready) return;
pinsRef.current.forEach((pin) => pin.remove());
const hoverLabel = mapHoverLabel(gl.map, 'planner-waypoint-label');
const unbind: (() => void)[] = [];
pinsRef.current = points.map((point, index) => {
const el = waypointPinElement(index, point.id === selectedId, editable);
const name = point.name ?? String(index + 1);
el.setAttribute('aria-label', name);
unbind.push(bindPinHoverLabel(el, hoverLabel, gl.map.getContainer(), { name, detail: point.detail ?? null }));
el.addEventListener('click', (event) => {
event.stopPropagation();
handlers.current.onSelect(point.id);
@@ -43,8 +50,13 @@ export function useTerrainPins(
const at = pin.getLngLat();
if (!handlers.current.onMove?.(point.id, at.lat, at.lng)) pin.setLngLat([point.lng, point.lat]);
});
pin.on('dragstart', () => hoverLabel.hide());
return pin;
});
return () => {
unbind.forEach((off) => off());
hoverLabel.remove();
};
}, [gl, ready, points, selectedId, editable, handlers]);
// The pins go with the map, not with each redraw.
+56 -46
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import type { TourListItem } from '@trek/shared'
import { AlertTriangle, ArrowDown, ArrowUp, ChevronDown, Clock, Mountain, Plus, Ruler } from 'lucide-react'
import { AlertTriangle, ArrowDown, ArrowUp, ChevronDown, Clock, Mountain, Plus, Ruler, type LucideIcon } from 'lucide-react'
import type { Day, DistanceUnit } from '../../types'
import type { RouteGeometryAnalysis } from '../../utils/routeGeometry'
import { useTranslation } from '../../i18n'
@@ -13,7 +13,7 @@ import { Tooltip } from '../shared/Tooltip'
import { useTripStore } from '../../store/tripStore'
import { BOX, Eyebrow } from '../Planner/bookings/bookingParts'
import { SoftPill } from '../Planner/planParts'
import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourPlannedTimes } from './tourPresentation'
import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourPlannedTimes, tourSource } from './tourPresentation'
type Translate = (key: string, params?: Record<string, unknown>) => string
@@ -26,62 +26,72 @@ export function TourTile({ size = 34, color, raised = false }: { size?: number;
)
}
/** The T-scale badge; a GPX import carries no difficulty, so it gets none. */
function TourDifficultyPill({ level, t }: { level: TourListItem['max_hiking_difficulty']; t: Translate }) {
if (level == null) return null
const label = t(`tours.planner.difficulty.t${level}`)
/**
* A tour's tile in a list: its T-scale grade in place of the mountain, named in
* full by its tooltip (a GPX import carries no grade and keeps the mountain),
* and where it came from, when that was not TREK, as a small badge over its foot.
*/
export function TourGradeTile({ tour }: { tour: Pick<TourListItem, 'max_hiking_difficulty' | 'wanderer_ref' | 'has_waypoints'> }) {
const { t } = useTranslation()
const level = tour.max_hiking_difficulty
const source = tourSource(tour)
const label = level == null ? null : t(`tours.planner.difficulty.t${level}`)
return (
<Tooltip label={label}>
<span aria-label={label} className="inline-flex">
<SoftPill>T{level}</SoftPill>
</span>
</Tooltip>
<span className="relative flex-none">
<Tooltip label={label ?? ''} disabled={label == null}>
<span aria-label={label ?? undefined} className="grid h-[34px] w-[34px] place-items-center rounded-full bg-edge font-geist font-semibold tabular-nums text-content" style={fs(12, 'body')}>
{level == null ? <Mountain size={15} strokeWidth={1.9} className="text-content-secondary" /> : `T${level}`}
</span>
</Tooltip>
{source !== 'trek' && (
<span className="absolute inset-x-0 -bottom-1.5 flex justify-center">
<Tooltip label={hikeSourceBadgeLabel(tour, t)}>
<span data-testid="tour-source-badge" aria-label={hikeSourceBadgeLabel(tour, t)}
className="rounded-full bg-surface-card px-1 font-geist font-semibold uppercase leading-[14px] tracking-[.04em] text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint"
style={fs(8.5)}>
{t(`tours.badgeShort.${source}`)}
</span>
</Tooltip>
</span>
)}
</span>
)
}
/** The facts of a tour as white pills: how far, how much up and down, how hard, where it came from. */
/** One measured fact of a tour as a white pill: its icon, an optional short word and the value, named in full for a screen reader. */
function TourMetricPill({ icon: Icon, label, value, prefix }: { icon: LucideIcon; label: string; value: string; prefix?: string }) {
return (
<SoftPill className="tabular-nums" icon={<Icon size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
<span aria-label={`${label}: ${value}`}>{prefix ? `${prefix} ${value}` : value}</span>
</SoftPill>
)
}
/** The facts of a tour as white pills, kept to one short row: how far, up and down, how long, how hard, and where it came from when that was not TREK. */
export function TourFactPills({ tour, className = '' }: { tour: TourListItem; className?: string }) {
const { t } = useTranslation()
const unit = useSettingsStore(state => state.settings.distance_unit)
const times = tourPlannedTimes(tour)
const shownMinutes = times.plannedTotalMinutes ?? times.walkingMinutes
const timeParts = [
times.walkingMinutes != null ? `${t('tours.planner.walkingShort')} ${formatPlannedTourDuration(times.walkingMinutes)}` : null,
times.breakMinutes != null ? `${t('tours.planner.breaksShort')} ${formatPlannedTourDuration(times.breakMinutes)}` : null,
times.plannedTotalMinutes != null
? `${t('tours.planner.plannedShort')} ${formatPlannedTourDuration(times.plannedTotalMinutes)}${times.manuallyOverridden ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`
: null,
].filter((part): part is string => part !== null)
return (
<div data-testid="tour-metrics" className={`flex min-w-0 flex-wrap items-center gap-1 ${className}`}>
<SoftPill className="tabular-nums" icon={<Ruler size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
<span aria-label={`${t('tours.detail.distance')}: ${formatDistance(tour.distance ?? 0, unit)}`}>{formatDistance(tour.distance ?? 0, unit)}</span>
</SoftPill>
{tour.elevation_gain != null && (
<SoftPill className="tabular-nums" icon={<ArrowUp size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
<span aria-label={`${t('tours.detail.ascent')}: ${formatElevation(tour.elevation_gain, unit)}`}>{formatElevation(tour.elevation_gain, unit)}</span>
</SoftPill>
)}
{tour.elevation_loss != null && (
<SoftPill className="tabular-nums" icon={<ArrowDown size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
<span aria-label={`${t('tours.detail.descent')}: ${formatElevation(tour.elevation_loss, unit)}`}>{formatElevation(tour.elevation_loss, unit)}</span>
</SoftPill>
)}
{times.walkingMinutes != null && (
<SoftPill className="tabular-nums" icon={<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
<span aria-label={`${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(times.walkingMinutes)}`}>
{t('tours.planner.walkingShort')} {formatPlannedTourDuration(times.walkingMinutes)}
<TourMetricPill icon={Ruler} label={t('tours.detail.distance')} value={formatDistance(tour.distance ?? 0, unit)} />
{tour.elevation_gain != null && <TourMetricPill icon={ArrowUp} label={t('tours.detail.ascent')} value={formatElevation(tour.elevation_gain, unit)} />}
{tour.elevation_loss != null && <TourMetricPill icon={ArrowDown} label={t('tours.detail.descent')} value={formatElevation(tour.elevation_loss, unit)} />}
{shownMinutes != null && (
<Tooltip label={timeParts.join(', ')}>
<span className="inline-flex">
<TourMetricPill icon={Clock} label={timeParts.join(', ')} value={formatPlannedTourDuration(shownMinutes)} />
</span>
</SoftPill>
</Tooltip>
)}
{times.breakMinutes != null && (
<SoftPill className="tabular-nums" icon={<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
<span aria-label={`${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(times.breakMinutes)}`}>
{t('tours.planner.breaksShort')} {formatPlannedTourDuration(times.breakMinutes)}
</span>
</SoftPill>
)}
{times.plannedTotalMinutes != null && (
<SoftPill className="tabular-nums" icon={<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
<span aria-label={`${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(times.plannedTotalMinutes)}${times.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}>
{t('tours.planner.plannedShort')} {formatPlannedTourDuration(times.plannedTotalMinutes)}{times.manuallyOverridden ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}
</span>
</SoftPill>
)}
<TourDifficultyPill level={tour.max_hiking_difficulty} t={t} />
<SoftPill>{hikeSourceBadgeLabel(tour, t)}</SoftPill>
{tour.caution && (
<Tooltip label={t('tours.caution.tooltip')}>
<span aria-label={t('tours.caution.badge')} className="inline-flex">
@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { within } from '@testing-library/react'
import en from '@trek/shared/i18n/en'
import de from '@trek/shared/i18n/de'
import fr from '@trek/shared/i18n/fr'
@@ -68,7 +67,7 @@ describe('Tours English and German copy', () => {
expect(en[`tours.planner.difficulty.t${level}`]).toMatch(new RegExp(`^T${level} `))
expect(de[`tours.planner.difficulty.t${level}`]).toMatch(new RegExp(`^T${level} `))
}
expect(de['tours.planner.safetyNoteDetails']).toMatch(/OSM.*Wetter.*Sperrungen/)
expect(de['tours.planner.safetyNote']).toMatch(/Sicherheit/)
expect(de['tours.planner.difficulty.t3Warning']).toMatch(/Trittsicherheit/)
expect(de['tours.planner.difficulty.alpineBody']).toMatch(/OSM/)
expect(toursText(en, 'tours.planner.breaksAdditionalInfo')).toBe('Optional whole minutes added to calculated walking time.')
@@ -94,10 +93,11 @@ 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.restored'))).toBeInTheDocument()
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.getByText(toursText(strings, 'tours.planner.difficulty.t3Warning'))).toBeInTheDocument()
expect(screen.getByText((_, element) => element?.tagName === 'SPAN' && element.textContent?.includes(toursText(strings, 'tours.planner.safetyNoteDetails')) === true)).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()
expect(screen.getByRole('button', { name: language === 'de' ? 'Maximale Wegschwierigkeit' : 'Maximum trail difficulty' })).toBeInTheDocument()
assertNoRawToursKeys()
@@ -125,9 +125,7 @@ describe('Tours English and German copy', () => {
await waitFor(() => expect(screen.getByLabelText(toursText(de, 'tours.planner.difficulty.t2'))).toBeInTheDocument())
const onAssignToDay = vi.fn()
rerender(<TourPlannerToursRail planner={planner({ saveOutcome: tour, mode: { type: 'edit-saved', placeId: 42 } })} tours={[tour]} days={[{ id: 7, title: 'Gipfeltag', date: '2026-05-15' } as never]} loading={false} onAssignToDay={onAssignToDay} onViewGpxTour={vi.fn()} />)
const saveBanner = screen.getByText(toursText(de, 'tours.planner.saved')).closest('[role="status"]') as HTMLElement
expect(within(saveBanner).getByRole('button', { name: toursText(de, 'tours.planner.planAnother') })).toBeInTheDocument()
expect(within(saveBanner).queryByRole('button', { name: toursText(de, 'tours.planner.assignToDay') })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: toursText(de, 'tours.planner.assignToDay') })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: `${toursText(de, 'tours.addToDay')}: ${tour.name}` }))
const dayOption = screen.getByRole('button', { name: /Gipfeltag/ })
expect(dayOption).toHaveTextContent(/Tag 1/)
@@ -1,4 +1,4 @@
import { act, cleanup, fireEvent, render, renderHook, screen, within } from '@testing-library/react'
import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TourListItem } from '@trek/shared'
@@ -140,9 +140,7 @@ describe('Tours permissions without database setup', () => {
} as unknown as TourPlannerController
const assign = vi.fn()
render(<><TourPlannerRail planner={planner} canEdit={canEdit} canAssign={canAssign} /><TourPlannerToursRail planner={planner} tours={[tour]} days={days} loading={false} onAssignToDay={assign} onViewGpxTour={vi.fn()} canEdit={canEdit} canAssign={canAssign} /></>)
const saveBanner = screen.getByText('tours.planner.saved').closest('[role="status"]') as HTMLElement
expect(within(saveBanner).getByRole('button', { name: 'tours.planner.planAnother' })).toBeInTheDocument()
expect(within(saveBanner).queryByRole('button', { name: 'tours.planner.assignToDay' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'tours.planner.assignToDay' })).not.toBeInTheDocument()
const save = screen.getByRole('button', { name: 'common.save' }) as HTMLButtonElement
expect(save.disabled).toBe(!canEdit)
fireEvent.click(save)
@@ -80,9 +80,7 @@ describe('CustomSelect consumer contracts', () => {
planner={planner({ saveOutcome: tour, editingPlaceId: 42 })}
tours={[tour]} days={days} loading={false} onAssignToDay={onAssignToDay} onViewGpxTour={vi.fn()}
/></TranslationProvider>)
const saveBanner = screen.getByText('Tour saved').closest('[role="status"]') as HTMLElement
expect(within(saveBanner).getByRole('button', { name: 'Plan another' })).toBeInTheDocument()
expect(within(saveBanner).queryByRole('button', { name: 'Assign to a day' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Assign to a day' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Add to day: Ridge walk' }))
const dayOption = screen.getByRole('button', { name: /Summit day/ })
expect(dayOption).toHaveTextContent(/Day 1/)
@@ -60,7 +60,7 @@ describe('ElevationProfile', () => {
expect(onFocus).toHaveBeenLastCalledWith({ distanceMeters: 150, elevationMeters: 187.5, lat: 48.015, lng: 11.015, sampleIndex: 1 })
expect(profile.querySelector('[data-profile-focus-line]')).toBeInTheDocument()
expect(profile.querySelector('[data-profile-focus-point]')).toBeInTheDocument()
expect(profile.parentElement?.querySelector('[data-profile-focus-point]')).toBeInTheDocument()
const readout = profile.parentElement?.querySelector('[data-profile-focus-readout]')
expect(readout?.textContent).toBe('150 m · 187.5 m')
expect(readout).toHaveClass('rounded-lg', 'border-edge-faint', 'bg-surface-card', 'text-content', 'shadow-elevated')
@@ -68,7 +68,7 @@ describe('ElevationProfile', () => {
expect(readout).toHaveStyle({ maxWidth: 'min(164px, calc(100% - 8px))' })
expect(profile).toHaveStyle({ fontFamily: 'var(--font-system)' })
expect(profile.querySelector('[data-profile-focus-line]')).toHaveAttribute('stroke', 'var(--text-muted)')
expect(profile.querySelector('[data-profile-focus-point]')).toHaveAttribute('stroke', 'var(--bg-card)')
expect(profile.parentElement?.querySelector('[data-profile-focus-point]')).toHaveAttribute('stroke', 'var(--bg-card)')
expect(profile.parentElement?.querySelector('[aria-live="polite"]')?.textContent).toBe('')
fireEvent.pointerMove(profile, { clientX: -50, pointerType: 'mouse' })
act(() => frames.shift()?.(32))
@@ -122,10 +122,15 @@ export default function ElevationProfile({ samples, color, height = 100, gradien
{focusX != null && (
<g pointerEvents="none" aria-hidden="true">
<line data-profile-focus-line="true" x1={focusX} x2={focusX} y1={4} y2={height - 4} stroke="var(--text-muted)" strokeOpacity="0.85" strokeWidth={1} strokeDasharray="3 3" vectorEffect="non-scaling-stroke" />
{focusY != null && <circle data-profile-focus-point="true" cx={focusX} cy={focusY} r={4} fill={color} stroke="var(--bg-card)" strokeWidth={2} vectorEffect="non-scaling-stroke" />}
</g>
)}
</svg>
{/* The point in an unscaled layer of its own: the chart above stretches to the width, which would squash a circle drawn in it into an ellipse. */}
{focusX != null && focusY != null && (
<svg width="100%" height={height} aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 overflow-visible">
<circle data-profile-focus-point="true" cx={`${(focusX / width) * 100}%`} cy={focusY} r={4} fill={color} stroke="var(--bg-card)" strokeWidth={2} />
</svg>
)}
{interactive && focusX != null && (
<div
data-profile-focus-readout="true"
@@ -331,7 +331,7 @@ describe('TripPlannerPage — shell', () => {
expect(plannerActions.addWaypoint).toHaveBeenCalledWith(35.02, 135.77)
})
it('preserves the visible TOUR-PLANNER camera when Plan another starts a new Tour', async () => {
it('preserves the visible TOUR-PLANNER camera when a new Tour starts after a save', async () => {
const savedTour = {
place_id: 82,
name: 'Saved Kyoto tour',
@@ -363,7 +363,7 @@ describe('TripPlannerPage — shell', () => {
const mapElement = screen.getByTestId('map-view')
expect(props('map').focusPoints).toEqual([[35.01, 135.76], [35.02, 135.77]])
fireEvent.click(await screen.findByRole('button', { name: 'Plan another' }))
act(() => { plannerActions.startNewTour() })
expect(plannerActions.startNewTour).toHaveBeenCalledOnce()
view.rerender(<TripPlannerPage />)
@@ -1,4 +1,4 @@
// FE-PAGE-TOURSTAGE-001 to FE-PAGE-TOURSTAGE-012
// FE-PAGE-TOURSTAGE-001 to FE-PAGE-TOURSTAGE-016
import type { Ferrata } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
@@ -99,6 +99,8 @@ function planner(
setSelectedWaypointId: vi.fn(),
setWaypointPosition: vi.fn(() => true),
addWaypoint: vi.fn(),
undo: vi.fn(),
maxHikingDifficulty: null,
routeAnalysis: null,
readOnlyGpxAnalysis: null,
mapBaseLayer: 'default',
@@ -109,8 +111,8 @@ function planner(
} as unknown as Planner;
}
function renderStage(tourPlanner: Planner, canPlaceEdit = true) {
const props = {
function stageProps(tourPlanner: Planner, canPlaceEdit = true) {
return {
tripId: 7,
mapTileUrl: '',
leftHidden: false,
@@ -123,7 +125,24 @@ function renderStage(tourPlanner: Planner, canPlaceEdit = true) {
leftPanelPx: 320,
rightPanelPx: 380,
} as unknown as TourPlannerMapStageProps;
return render(<TourPlannerMapStage {...props} />);
}
function renderStage(tourPlanner: Planner, canPlaceEdit = true) {
return render(<TourPlannerMapStage {...stageProps(tourPlanner, canPlaceEdit)} />);
}
function pointer(type: string, x: number, y: number): Event {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperties(event, { clientX: { value: x }, clientY: { value: y }, pointerId: { value: 1 } });
return event;
}
/** A press on the map surface that travels `dx` pixels before it is let go. */
function press(dx: number) {
const surface = screen.getByTestId('tour-map-surface');
surface.dispatchEvent(pointer('pointerdown', 100, 100));
surface.dispatchEvent(pointer('pointermove', 100 + dx, 100));
surface.dispatchEvent(pointer('pointerup', 100 + dx, 100));
}
/** Switches the flat map to 3D and waits for the GL engine to load. */
@@ -133,6 +152,7 @@ async function openTerrain() {
}
beforeEach(() => {
window.sessionStorage.clear();
stage.listeners.clear();
stage.flatMap.props = null;
stage.search.props = null;
@@ -157,7 +177,7 @@ describe('TourPlannerMapStage', () => {
const tourPlanner = planner('new-draft');
renderStage(tourPlanner);
const props = stage.flatMap.props;
expect(props?.plannerWaypoints).toEqual([{ id: 'w1', lat: 46, lng: 7 }]);
expect(props?.plannerWaypoints).toEqual([expect.objectContaining({ id: 'w1', lat: 46, lng: 7 })]);
expect(props?.selectedPlannerWaypointId).toBe('w1');
expect(props?.onPlannerWaypointMove).toBe(tourPlanner.setWaypointPosition);
props?.onMapClick?.({ latlng: { lat: 46.2, lng: 7.2 } } as never);
@@ -173,11 +193,21 @@ describe('TourPlannerMapStage', () => {
expect(props?.onPlannerWaypointMove).toBeUndefined();
});
it('FE-PAGE-TOURSTAGE-003: the 3D switch opens the terrain, where a click plans the next point', async () => {
it('FE-PAGE-TOURSTAGE-003: the 3D switch opens the terrain, where a click plans once "set points" is on', async () => {
const tourPlanner = planner('edit-saved');
renderStage(tourPlanner);
await openTerrain();
expect(screen.queryByRole('button', { name: 'flat map' })).toBeNull();
// Off to begin with in 3D, where every look around is a drag.
const tool = screen.getByRole('button', { name: 'Set points' });
expect(tool).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByText('Turn on Set points to click points onto the terrain.')).toBeInTheDocument();
act(() => stage.fire('click', { lngLat: { lat: 46.3, lng: 7.3 } }));
expect(tourPlanner.addWaypoint).not.toHaveBeenCalled();
fireEvent.click(tool);
expect(screen.getByRole('button', { name: 'Set points' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByTestId('tour-map-surface')).toHaveAttribute('data-placing', 'true');
expect(screen.getByText(/Click the terrain to set points/)).toBeInTheDocument();
act(() => stage.fire('click', { lngLat: { lat: 46.3, lng: 7.3 } }));
expect(tourPlanner.addWaypoint).toHaveBeenCalledWith(46.3, 7.3);
@@ -317,4 +347,62 @@ describe('TourPlannerMapStage', () => {
act(() => stage.flatMap.props?.onMapClick?.({ latlng: { lat: 46.3, lng: 7.3 } } as never));
expect(tourPlanner.addWaypoint).toHaveBeenCalledWith(46.3, 7.3);
});
it('FE-PAGE-TOURSTAGE-013: on the flat map "set points" starts on, with the crosshair, and off it takes no clicks', () => {
renderStage(planner('new-draft'));
expect(stage.flatMap.props?.placeTool?.active).toBe(true);
expect(screen.getByTestId('tour-map-surface')).toHaveAttribute('data-placing', 'true');
act(() => stage.flatMap.props?.placeTool?.onToggle());
expect(stage.flatMap.props?.placeTool?.active).toBe(false);
expect(stage.flatMap.props?.onMapClick).toBeUndefined();
expect(screen.getByTestId('tour-map-surface')).not.toHaveAttribute('data-placing');
});
it('FE-PAGE-TOURSTAGE-014: the end of a pan or a rotation never places a point, a click does, in 2D and 3D', async () => {
const tourPlanner = planner('new-draft');
renderStage(tourPlanner);
press(12);
act(() => stage.flatMap.props?.onMapClick?.({ latlng: { lat: 46.2, lng: 7.2 } } as never));
expect(tourPlanner.addWaypoint).not.toHaveBeenCalled();
press(1);
act(() => stage.flatMap.props?.onMapClick?.({ latlng: { lat: 46.2, lng: 7.2 } } as never));
expect(tourPlanner.addWaypoint).toHaveBeenCalledTimes(1);
await openTerrain();
fireEvent.click(screen.getByRole('button', { name: 'Set points' }));
press(30);
act(() => stage.fire('click', { lngLat: { lat: 46.3, lng: 7.3 } }));
expect(tourPlanner.addWaypoint).toHaveBeenCalledTimes(1);
press(0);
act(() => stage.fire('click', { lngLat: { lat: 46.3, lng: 7.3 } }));
expect(tourPlanner.addWaypoint).toHaveBeenLastCalledWith(46.3, 7.3);
});
it('FE-PAGE-TOURSTAGE-015: "point set" shows above the bottom row after a click, and its undo takes the point back', () => {
const tourPlanner = planner('new-draft');
const view = renderStage(tourPlanner);
act(() => stage.flatMap.props?.onMapClick?.({ latlng: { lat: 46.2, lng: 7.2 } } as never));
const placed = planner('new-draft', {
waypoints: [
{ id: 'w1', lat: 46, lng: 7 },
{ id: 'w2', lat: 46.2, lng: 7.2 },
],
undo: tourPlanner.undo,
});
view.rerender(<TourPlannerMapStage {...stageProps(placed)} />);
const hint = screen.getByTestId('map-undo-hint');
expect(hint).toHaveTextContent('tours.planner.pointPlaced');
// Clear of the 2D row: its 12px gap, the 42px pill, the same gap again.
expect(hint.parentElement?.parentElement?.style.bottom).toBe('66px');
fireEvent.click(screen.getByRole('button', { name: 'undo.button' }));
expect(tourPlanner.undo).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId('map-undo-hint')).toBeNull();
});
it('FE-PAGE-TOURSTAGE-016: without edit rights there is no "set points" switch and no crosshair', () => {
renderStage(planner('new-draft'), false);
expect(stage.flatMap.props?.placeTool).toBeUndefined();
expect(screen.getByTestId('tour-map-surface')).not.toHaveAttribute('data-placing');
});
});
@@ -3,10 +3,12 @@ import FerrataDetailOverlay from '../../components/Ferratas/FerrataDetailOverlay
import { useFerrataMapLayer } from '../../components/Ferratas/useFerrataMapLayer';
import { useOutsidePressGuard } from '../../components/Ferratas/useOutsidePressGuard';
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../components/Map/MapLayerSwitcher';
import { MapUndoHint } from '../../components/Map/MapUndoHint';
import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto';
import LocationSelect from '../../components/Planner/LocationSelect';
import { isRtlLanguage, useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { useTourMapPoints } from './useTourMapPoints';
import type { useTripPlanner } from './useTripPlanner';
import type { useTripPlannerPage } from './useTripPlannerPage';
@@ -19,6 +21,9 @@ const TOUR_TRACK_COLOR = '#0a84ff';
const CONTROL_GAP = 12;
/** A via ferrata's card floats above the bottom row of controls, at the gap the row keeps to the edge. */
const FERRATA_CARD_BOTTOM = CONTROL_GAP + MAP_ROUND_CONTROL_SIZE + CONTROL_GAP;
/** The crosshair over either engine's map while a click sets a point (markers keep their own cursor). */
const PLACING_CURSOR =
'[&_.leaflet-container]:!cursor-crosshair [&_.maplibregl-canvas-container]:!cursor-crosshair [&_.mapboxgl-canvas-container]:!cursor-crosshair';
type Planner = ReturnType<typeof useTripPlanner>;
type PageState = ReturnType<typeof useTripPlannerPage>;
@@ -65,70 +70,80 @@ export function TourPlannerMapStage({
const { language } = useTranslation();
const distanceUnit = useSettingsStore((s) => s.settings.distance_unit);
const analysis = tourPlanner.mode.type === 'view-gpx' ? tourPlanner.readOnlyGpxAnalysis : tourPlanner.routeAnalysis;
const editing = tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved';
const canEditRoute = canPlaceEdit && !tourPlanner.isSaving && editing;
const waypoints = {
points: editing ? tourPlanner.waypoints : [],
selectedId: editing ? tourPlanner.selectedWaypointId : null,
onSelect: tourPlanner.setSelectedWaypointId,
onMove: canEditRoute ? tourPlanner.setWaypointPosition : undefined,
onAdd: closingPress.guard(canEditRoute ? tourPlanner.addWaypoint : undefined),
};
// The "set points" tool, per mode; only a real click places, never the end of a drag.
const { waypoints, placeTool, placeOn, attachSurface, placedHint } = useTourMapPoints({
tourPlanner,
canPlaceEdit,
mode: terrain3d ? '3d' : '2d',
ferrataOpen: !!ferrata.selected,
guard: closingPress.guard,
});
const onAdd = waypoints.onAdd;
// What each panel covers of the map, a collapsed one its rail.
const leftInset = leftHidden ? 56 : leftPanelPx;
const rightInset = rightHidden ? 56 : rightPanelPx;
const bottomRowTop = terrain3d ? CONTROL_GAP + terrainControlsPx + CONTROL_GAP : FERRATA_CARD_BOTTOM;
return (
<>
{terrain3d ? (
<Suspense fallback={null}>
<TourTerrainMap
samples={analysis?.distanceIndexedProfileSamples ?? []}
routes={tourMap.route}
trackColor={TOUR_TRACK_COLOR}
unit={distanceUnit}
baseLayer={tourPlanner.mapBaseLayer}
onBaseLayerChange={tourPlanner.setMapBaseLayer}
onExit={() => setTerrain3d(false)}
focus={tourPlanner.routeProfileFocus}
onFocusChange={tourPlanner.setRouteProfileFocus}
leftWidth={leftInset}
rightWidth={rightInset}
waypoints={waypoints}
framePoints={tourMap.focusPoints}
{/* Around either map, to follow its presses and show the crosshair while placing. */}
<div
ref={attachSurface}
className={`absolute inset-0 ${placeOn ? PLACING_CURSOR : ''}`}
data-testid="tour-map-surface"
data-placing={placeOn || undefined}
>
{terrain3d ? (
<Suspense fallback={null}>
<TourTerrainMap
samples={analysis?.distanceIndexedProfileSamples ?? []}
routes={tourMap.route}
trackColor={TOUR_TRACK_COLOR}
unit={distanceUnit}
baseLayer={tourPlanner.mapBaseLayer}
onBaseLayerChange={tourPlanner.setMapBaseLayer}
onExit={() => setTerrain3d(false)}
focus={tourPlanner.routeProfileFocus}
onFocusChange={tourPlanner.setRouteProfileFocus}
leftWidth={leftInset}
rightWidth={rightInset}
waypoints={waypoints}
framePoints={tourMap.focusPoints}
ferrataLayer={ferrata.layer}
onViewportChange={ferrata.onViewportChange}
onControlsHeightChange={setTerrainControlsPx}
placeTool={placeTool}
/>
</Suspense>
) : (
<MapView
tripId={tripId}
places={[]}
route={tourMap.route}
followSelection={false}
onMapClick={
onAdd
? ({ latlng }: { latlng: { lat: number; lng: number } }) => onAdd(latlng.lat, latlng.lng)
: undefined
}
tileUrl={mapTileUrl}
leftWidth={leftPanelPx}
rightWidth={rightPanelPx}
focusPoints={tourMap.focusPoints}
focusKey={tourMap.focusKey}
plannerWaypoints={waypoints.points}
selectedPlannerWaypointId={waypoints.selectedId}
onPlannerWaypointClick={tourPlanner.setSelectedWaypointId}
onPlannerWaypointMove={waypoints.onMove}
routeProfileFocus={tourPlanner.routeProfileFocus}
viewBaseLayer={tourPlanner.mapBaseLayer}
onViewBaseLayerChange={tourPlanner.setMapBaseLayer}
onToggleTerrain3d={() => setTerrain3d(true)}
ferrataLayer={ferrata.layer}
onViewportChange={ferrata.onViewportChange}
onControlsHeightChange={setTerrainControlsPx}
placeTool={placeTool}
/>
</Suspense>
) : (
<MapView
tripId={tripId}
places={[]}
route={tourMap.route}
followSelection={false}
onMapClick={closingPress.guard(
canEditRoute
? ({ latlng }: { latlng: { lat: number; lng: number } }) =>
tourPlanner.addWaypoint(latlng.lat, latlng.lng)
: undefined
)}
tileUrl={mapTileUrl}
leftWidth={leftPanelPx}
rightWidth={rightPanelPx}
focusPoints={tourMap.focusPoints}
focusKey={tourMap.focusKey}
plannerWaypoints={waypoints.points}
selectedPlannerWaypointId={waypoints.selectedId}
onPlannerWaypointClick={tourPlanner.setSelectedWaypointId}
onPlannerWaypointMove={waypoints.onMove}
routeProfileFocus={tourPlanner.routeProfileFocus}
viewBaseLayer={tourPlanner.mapBaseLayer}
onViewBaseLayerChange={tourPlanner.setMapBaseLayer}
onToggleTerrain3d={() => setTerrain3d(true)}
ferrataLayer={ferrata.layer}
onViewportChange={ferrata.onViewportChange}
/>
)}
)}
</div>
{/* The card sits at the inline end; the panels keep their physical sides, so in a
right-to-left language that end is the left panel's. */}
<FerrataDetailOverlay
@@ -136,8 +151,31 @@ export function TourPlannerMapStage({
onClose={ferrata.close}
onOutsidePress={closingPress.arm}
insetEnd={(isRtlLanguage(language) ? leftInset : rightInset) + MAP_LAYER_SWITCHER_INSET}
bottom={terrain3d ? CONTROL_GAP + terrainControlsPx + CONTROL_GAP : FERRATA_CARD_BOTTOM}
bottom={bottomRowTop}
/>
{/* "Point set", with the way back, above the bottom row and between the panels. */}
{placedHint.shown && (
<div
className="pointer-events-none absolute inset-x-0 z-[19] flex justify-center"
style={{ bottom: bottomRowTop }}
>
<div
className="flex justify-center"
style={{
transform: `translateX(${(leftInset - rightInset) / 2}px)`,
maxWidth: `calc(100% - ${leftInset + rightInset + 2 * MAP_LAYER_SWITCHER_INSET}px)`,
}}
>
<MapUndoHint
key={placedHint.key}
message={t('tours.planner.pointPlaced')}
undoLabel={t('undo.button')}
onUndo={placedHint.undoPlacement}
onHold={placedHint.hold}
/>
</div>
</div>
)}
{/* Centred over the map between the two panels, which sit on fixed physical sides. */}
<div
style={{
@@ -0,0 +1,95 @@
// FE-HOOK-PLACEDHINT-001 to FE-HOOK-PLACEDHINT-006: "point set" after a click on the map, and its undo.
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PLACED_HINT_MS, usePlacedPointHint } from './usePlacedPointHint';
interface Props {
waypoints: readonly unknown[];
difficulty: unknown;
enabled: boolean;
}
const A = { id: 'a' };
const B = { id: 'b' };
function setup(initial: Partial<Props> = {}) {
const undo = vi.fn();
const view = renderHook((props: Props) => usePlacedPointHint({ ...props, undo }), {
initialProps: { waypoints: [A], difficulty: 'T2', enabled: true, ...initial },
});
/** Notes the click and lands the add in the same update, the way the map does. */
const place = (after: readonly unknown[], rest: Partial<Props> = {}) => {
act(() => view.result.current.notePlacement());
view.rerender({ waypoints: after, difficulty: 'T2', enabled: true, ...rest });
};
return { ...view, undo, place };
}
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('usePlacedPointHint', () => {
it('FE-HOOK-PLACEDHINT-001: shows once the point is in, and its undo takes back exactly that add', () => {
const { result, undo, place } = setup();
expect(result.current.shown).toBe(false);
place([A, B]);
expect(result.current.shown).toBe(true);
act(() => result.current.undoPlacement());
expect(undo).toHaveBeenCalledTimes(1);
expect(result.current.shown).toBe(false);
});
it('FE-HOOK-PLACEDHINT-002: goes by itself after a few seconds, but not while the pointer rests on it', () => {
const { result, place } = setup();
place([A, B]);
act(() => result.current.hold(true));
act(() => vi.advanceTimersByTime(PLACED_HINT_MS * 2));
expect(result.current.shown).toBe(true);
act(() => result.current.hold(false));
act(() => vi.advanceTimersByTime(PLACED_HINT_MS - 1));
expect(result.current.shown).toBe(true);
act(() => vi.advanceTimersByTime(1));
expect(result.current.shown).toBe(false);
});
it('FE-HOOK-PLACEDHINT-003: any later change to the points ends it, so undo never reverts something else', () => {
const { result, rerender, undo, place } = setup();
place([A, B]);
rerender({ waypoints: [A, { id: 'b', moved: true }], difficulty: 'T2', enabled: true });
expect(result.current.shown).toBe(false);
act(() => result.current.undoPlacement());
expect(undo).not.toHaveBeenCalled();
});
it('FE-HOOK-PLACEDHINT-004: so does a new difficulty, which is in the same undo history', () => {
const { result, rerender, place } = setup();
const after = [A, B];
place(after);
rerender({ waypoints: after, difficulty: 'T4', enabled: true });
expect(result.current.shown).toBe(false);
});
it('FE-HOOK-PLACEDHINT-005: a refused add shows nothing, not even at the next change', () => {
const { result, rerender } = setup();
act(() => result.current.notePlacement());
rerender({ waypoints: [A], difficulty: 'T2', enabled: true });
expect(result.current.shown).toBe(false);
rerender({ waypoints: [{ id: 'a', moved: true }], difficulty: 'T2', enabled: true });
expect(result.current.shown).toBe(false);
});
it('FE-HOOK-PLACEDHINT-006: stays hidden while it must give way, and shows again after', () => {
const { result, rerender, place } = setup();
const after = [A, B];
place(after);
rerender({ waypoints: after, difficulty: 'T2', enabled: false });
expect(result.current.shown).toBe(false);
rerender({ waypoints: after, difficulty: 'T2', enabled: true });
expect(result.current.shown).toBe(true);
});
});
@@ -0,0 +1,65 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/** How long the hint stays up after a point was set, unless the pointer rests on it. */
export const PLACED_HINT_MS = 4000;
interface Hint {
id: number;
/** The points before the click, to spot the list the new point produced. */
before: readonly unknown[];
/** The list right after the point went in; any other list means something came after it. */
after: readonly unknown[] | null;
difficulty: unknown;
}
interface Options {
waypoints: readonly unknown[];
/** Part of the editor's undo history too: a change to it after the point makes undo mean that. */
difficulty: unknown;
undo: () => void;
/** False while the hint must not show (not editing, a ferrata card open). */
enabled: boolean;
}
/**
* The "point set" hint after a click on the map, with an undo that takes back
* exactly that point. It goes as soon as anything else changes the tour, so
* the editor's undo, which reverts the latest change, always means the point.
*/
export function usePlacedPointHint({ waypoints, difficulty, undo, enabled }: Options) {
const [hint, setHint] = useState<Hint | null>(null);
const [held, setHeld] = useState(false);
const count = useRef(0);
// Adjusted while rendering: the add lands in the same batch as the click that noted it.
// A list that did not grow by one was no add of this click's point (it was refused).
if (hint && hint.after === null && waypoints !== hint.before) {
setHint(waypoints.length === hint.before.length + 1 ? { ...hint, after: waypoints } : null);
} else if (hint?.after && (waypoints !== hint.after || difficulty !== hint.difficulty)) {
setHint(null);
}
const shown = enabled && !!hint?.after && hint.after === waypoints && hint.difficulty === difficulty;
const hintId = shown ? hint.id : null;
useEffect(() => {
if (hintId === null || held) return;
const timer = window.setTimeout(() => setHint(null), PLACED_HINT_MS);
return () => window.clearTimeout(timer);
}, [hintId, held]);
/** Called right before the point is added. */
const notePlacement = useCallback(() => {
count.current += 1;
setHeld(false);
setHint({ id: count.current, before: waypoints, after: null, difficulty });
}, [waypoints, difficulty]);
const undoPlacement = useCallback(() => {
if (!shown) return;
setHint(null);
undo();
}, [shown, undo]);
return { shown, key: hintId, notePlacement, undoPlacement, hold: setHeld };
}
@@ -0,0 +1,101 @@
// FE-HOOK-TOURPLACING-001 to FE-HOOK-TOURPLACING-004: the tour map's "set points" tool and its click gate.
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { initialTourPlaceToolState, useTourPlaceToolStore } from '../../store/tourPlaceToolStore';
import { useTourMapPlacing, type TourMapMode } from './useTourMapPlacing';
function pointer(type: string, x: number, y: number): Event {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperties(event, { clientX: { value: x }, clientY: { value: y }, pointerId: { value: 1 } });
return event;
}
beforeEach(() => {
window.sessionStorage.clear();
useTourPlaceToolStore.setState(initialTourPlaceToolState());
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('useTourMapPlacing', () => {
it('FE-HOOK-TOURPLACING-001: on in 2D and off in 3D to begin with', () => {
const { result, rerender } = renderHook(({ mode }: { mode: TourMapMode }) => useTourMapPlacing(mode), {
initialProps: { mode: '2d' },
});
expect(result.current.active).toBe(true);
rerender({ mode: '3d' });
expect(result.current.active).toBe(false);
});
it('FE-HOOK-TOURPLACING-002: each mode keeps its own choice, for the rest of the session', () => {
const { result, rerender, unmount } = renderHook(({ mode }: { mode: TourMapMode }) => useTourMapPlacing(mode), {
initialProps: { mode: '3d' },
});
act(() => result.current.toggle());
expect(result.current.active).toBe(true);
rerender({ mode: '2d' });
act(() => result.current.toggle());
expect(result.current.active).toBe(false);
unmount();
const again = renderHook(({ mode }: { mode: TourMapMode }) => useTourMapPlacing(mode), {
initialProps: { mode: '3d' },
});
expect(again.result.current.active).toBe(true);
again.rerender({ mode: '2d' });
expect(again.result.current.active).toBe(false);
});
it('FE-HOOK-TOURPLACING-003: the gate lets a click through and drops the end of a drag', () => {
const el = document.createElement('div');
let clock = 0;
vi.spyOn(performance, 'now').mockImplementation(() => clock);
const { result } = renderHook(() => useTourMapPlacing('2d'));
act(() => result.current.attach(el));
const handler = vi.fn();
const gated = result.current.gate(handler);
el.dispatchEvent(pointer('pointerdown', 10, 10));
clock = 90;
el.dispatchEvent(pointer('pointerup', 11, 10));
gated?.(1, 2);
expect(handler).toHaveBeenCalledWith(1, 2);
el.dispatchEvent(pointer('pointerdown', 10, 10));
el.dispatchEvent(pointer('pointermove', 30, 10));
clock = 150;
el.dispatchEvent(pointer('pointerup', 30, 10));
gated?.(3, 4);
expect(handler).toHaveBeenCalledTimes(1);
// A hold that never moved, longer than a click.
clock = 1000;
el.dispatchEvent(pointer('pointerdown', 10, 10));
clock = 1600;
el.dispatchEvent(pointer('pointerup', 10, 10));
gated?.(5, 6);
expect(handler).toHaveBeenCalledTimes(1);
});
it('FE-HOOK-TOURPLACING-004: no handler, no gate; without storage the choice holds while the stage is up', () => {
const { result } = renderHook(() => useTourMapPlacing('2d'));
expect(result.current.gate(undefined)).toBeUndefined();
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('blocked');
});
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('blocked');
});
const blocked = renderHook(({ mode }: { mode: TourMapMode }) => useTourMapPlacing(mode), {
initialProps: { mode: '3d' },
});
expect(blocked.result.current.active).toBe(false);
act(() => blocked.result.current.toggle());
blocked.rerender({ mode: '2d' });
blocked.rerender({ mode: '3d' });
expect(blocked.result.current.active).toBe(true);
});
});
@@ -0,0 +1,48 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPressTracker, isPlacementTap, type PressTracker } from '../../components/Map/mapPress';
import {
setTourPlaceToolMode,
toggleTourPlacing,
useTourPlaceToolStore,
type TourMapMode,
} from '../../store/tourPlaceToolStore';
export type { TourMapMode } from '../../store/tourPlaceToolStore';
/**
* The tour map's "set points" tool: whether a click on the map places a point
* in the current mode, remembered per mode for the session, and the gate that
* lets only a real click through, never the end of a pan, a rotation or a tilt.
* `attach` goes on the element around the map, where the presses are followed.
*/
export function useTourMapPlacing(mode: TourMapMode) {
const active = useTourPlaceToolStore((state) => state.placing[mode]);
const [element, attach] = useState<HTMLElement | null>(null);
const tracker = useRef<PressTracker | null>(null);
useEffect(() => {
if (!element) return;
const next = createPressTracker(element);
tracker.current = next;
return () => {
next.dispose();
if (tracker.current === next) tracker.current = null;
};
}, [element]);
// The editor's "add point" row switches the tool for whichever mode the map shows.
useEffect(() => {
setTourPlaceToolMode(mode);
}, [mode]);
/** Lets a handler run only for a click that was a click, not a drag or a long hold. */
const gate = useCallback(<A extends unknown[]>(handler: ((...args: A) => void) | undefined) => {
if (!handler) return undefined;
return (...args: A) => {
if (!isPlacementTap(tracker.current?.take() ?? null)) return;
handler(...args);
};
}, []);
return { active, toggle: toggleTourPlacing, gate, attach };
}
@@ -0,0 +1,86 @@
import type { useOutsidePressGuard } from '../../components/Ferratas/useOutsidePressGuard';
import type { TourPlannerController } from '../../components/Tours/planner/useTourPlanner';
import { useWaypointPlaceNames } from '../../components/Tours/planner/useWaypointPlaceNames';
import { useTranslation } from '../../i18n';
import { usePlacedPointHint } from './usePlacedPointHint';
import { useTourMapPlacing, type TourMapMode } from './useTourMapPlacing';
const ROLE_KEY = {
start: 'tours.planner.start',
via: 'tours.planner.via',
end: 'tours.planner.end',
} as const;
type PointsPlanner = Pick<
TourPlannerController,
| 'mode'
| 'isSaving'
| 'waypoints'
| 'selectedWaypointId'
| 'setSelectedWaypointId'
| 'setWaypointPosition'
| 'addWaypoint'
| 'undo'
| 'maxHikingDifficulty'
>;
interface Options {
tourPlanner: PointsPlanner;
canPlaceEdit: boolean;
mode: TourMapMode;
/** A via ferrata card is open: the "point set" hint gives way to it. */
ferrataOpen: boolean;
/** Drops the click that closed a ferrata card. */
guard: ReturnType<typeof useOutsidePressGuard>['guard'];
}
/**
* The tour's control points on the planner map, for either mode: what the map
* shows and may change, the "set points" tool, and the hint with its undo
* after a point went in. A click places a point only while the tool is on,
* only when it was a click and not a drag, and never when it closed a card.
*/
export function useTourMapPoints({ tourPlanner, canPlaceEdit, mode, ferrataOpen, guard }: Options) {
const { t } = useTranslation();
const editing = tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved';
// The same lookup as the waypoint list, so a pin is named on hover as its row is.
const placeNames = useWaypointPlaceNames(editing ? tourPlanner.waypoints : []);
const canEditRoute = canPlaceEdit && !tourPlanner.isSaving && editing;
const { active, toggle, gate, attach } = useTourMapPlacing(mode);
const placeOn = canEditRoute && active;
const placedHint = usePlacedPointHint({
waypoints: tourPlanner.waypoints,
difficulty: tourPlanner.maxHikingDifficulty,
undo: tourPlanner.undo,
enabled: editing && !ferrataOpen,
});
const { notePlacement } = placedHint;
const { addWaypoint } = tourPlanner;
const addPoint = (lat: number, lng: number) => {
notePlacement();
addWaypoint(lat, lng);
};
const onAdd = guard(gate(placeOn ? addPoint : undefined));
return {
waypoints: {
points: editing
? tourPlanner.waypoints.map((point, index) => ({
...point,
name: placeNames[point.id] ?? t('tours.planner.waypointLabel', { n: index + 1 }),
detail: t(ROLE_KEY[point.role]),
}))
: [],
selectedId: editing ? tourPlanner.selectedWaypointId : null,
onSelect: tourPlanner.setSelectedWaypointId,
onMove: canEditRoute ? tourPlanner.setWaypointPosition : undefined,
onAdd,
},
/** The switch for the layer pill, there only while the tour can be edited. */
placeTool: canEditRoute ? { active, onToggle: toggle } : undefined,
placeOn,
/** Goes on the element around the map, where the presses are followed. */
attachSurface: attach,
placedHint,
};
}
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { reverseGeocodeRepo } from './reverseGeocodeRepo';
import { isEffectivelyOffline } from '../sync/networkMode';
import { mapsApi } from '../api/client';
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) }));
vi.mock('../api/client', () => ({ mapsApi: { reverse: vi.fn() } }));
beforeEach(() => {
vi.clearAllMocks();
reverseGeocodeRepo.clear();
vi.mocked(isEffectivelyOffline).mockReturnValue(false);
vi.mocked(mapsApi.reverse).mockResolvedValue({ name: 'Zugspitze', address: 'Zugspitze, Grainau, Bayern' });
});
describe('reverse geocoded place names', () => {
it('FE-REVERSE-GEOCODE-001: asks the maps route with the coordinate and the place language', async () => {
await expect(reverseGeocodeRepo.placeName(47.42123, 10.98456, 'de')).resolves.toBe('Zugspitze');
expect(mapsApi.reverse).toHaveBeenCalledWith(47.42123, 10.98456, 'de');
});
it('FE-REVERSE-GEOCODE-002: the same point twice is one request, another language or point is another', async () => {
const first = reverseGeocodeRepo.placeName(47.4, 11.1, 'de');
const again = reverseGeocodeRepo.placeName(47.400001, 11.100001, 'de');
expect(await first).toBe(await again);
expect(mapsApi.reverse).toHaveBeenCalledTimes(1);
await reverseGeocodeRepo.placeName(47.4, 11.1, 'en');
await reverseGeocodeRepo.placeName(47.41, 11.1, 'de');
expect(mapsApi.reverse).toHaveBeenCalledTimes(3);
});
it('FE-REVERSE-GEOCODE-003: falls back to the first part of the address and to null', async () => {
vi.mocked(mapsApi.reverse).mockResolvedValueOnce({ name: null, address: 'Höllentalanger, Grainau' });
await expect(reverseGeocodeRepo.placeName(47.1, 11, 'de')).resolves.toBe('Höllentalanger');
vi.mocked(mapsApi.reverse).mockResolvedValueOnce({ name: ' ', address: null });
await expect(reverseGeocodeRepo.placeName(47.2, 11, 'de')).resolves.toBeNull();
});
it('FE-REVERSE-GEOCODE-004: a failed lookup is not cached, so the point is asked again', async () => {
vi.mocked(mapsApi.reverse).mockRejectedValueOnce(new Error('boom'));
await expect(reverseGeocodeRepo.placeName(47.3, 11, 'de')).rejects.toThrow('boom');
await expect(reverseGeocodeRepo.placeName(47.3, 11, 'de')).resolves.toBe('Zugspitze');
expect(mapsApi.reverse).toHaveBeenCalledTimes(2);
});
it('FE-REVERSE-GEOCODE-005: offline never reaches the network', async () => {
vi.mocked(isEffectivelyOffline).mockReturnValue(true);
await expect(reverseGeocodeRepo.placeName(47.5, 11, 'de')).rejects.toThrow('Offline');
expect(mapsApi.reverse).not.toHaveBeenCalled();
});
it('FE-REVERSE-GEOCODE-006: runs at most two lookups at once and starts the rest as they finish', async () => {
const resolvers: Array<(value: { name: string; address: null }) => void> = [];
vi.mocked(mapsApi.reverse).mockImplementation(
() =>
new Promise((resolve) => {
resolvers.push(resolve);
}),
);
const names = [1, 2, 3].map((n) => reverseGeocodeRepo.placeName(46 + n, 11, 'de'));
expect(mapsApi.reverse).toHaveBeenCalledTimes(2);
resolvers[0]({ name: 'One', address: null });
await names[0];
await vi.waitFor(() => expect(mapsApi.reverse).toHaveBeenCalledTimes(3));
resolvers[1]({ name: 'Two', address: null });
resolvers[2]({ name: 'Three', address: null });
await expect(Promise.all(names)).resolves.toEqual(['One', 'Two', 'Three']);
});
});
+68
View File
@@ -0,0 +1,68 @@
import { mapsApi } from '../api/client';
import { isEffectivelyOffline } from '../sync/networkMode';
/** How many coordinates the cache remembers before it starts over. */
const CACHE_LIMIT = 500;
/** How many lookups may be in flight at once, so a long tour does not flood the geocoder. */
const CONCURRENCY = 2;
const cache = new Map<string, Promise<string | null>>();
const queue: Array<() => void> = [];
let running = 0;
/**
* Five decimals are about a metre: a point nudged by less than that is the same
* place, and a point moved further is asked for again.
*/
function keyOf(lat: number, lng: number, lang: string): string {
return `${lat.toFixed(5)},${lng.toFixed(5)}|${lang}`;
}
function runQueued<T>(task: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const start = () => {
running += 1;
task()
.then(resolve, reject)
.finally(() => {
running -= 1;
queue.shift()?.();
});
};
if (running < CONCURRENCY) start();
else queue.push(start);
});
}
/** The name a reverse lookup gives, or the first part of its address when it has no name. */
function placeLabel(result: { name: string | null; address: string | null }): string | null {
const name = result.name?.trim();
if (name) return name;
const first = result.address?.split(',')[0]?.trim();
return first || null;
}
export const reverseGeocodeRepo = {
/**
* A short place name for a coordinate, or null when the geocoder knows none.
* Rejects offline and on a failed lookup; a failure is not cached, so the
* same point is asked again the next time it is shown.
*/
placeName(lat: number, lng: number, lang: string): Promise<string | null> {
if (isEffectivelyOffline()) return Promise.reject(new Error('Offline'));
const key = keyOf(lat, lng, lang);
const cached = cache.get(key);
if (cached !== undefined) return cached;
if (cache.size >= CACHE_LIMIT) cache.clear();
const value = runQueued(() => mapsApi.reverse(lat, lng, lang)).then(placeLabel);
cache.set(key, value);
void value.catch(() => {
if (cache.get(key) === value) cache.delete(key);
});
return value;
},
/** Forgets every remembered name; for tests. */
clear() {
cache.clear();
},
};
+57
View File
@@ -0,0 +1,57 @@
import { create } from 'zustand';
export type TourMapMode = '2d' | '3d';
/** In 2D a click is how a tour is drawn; in 3D every look around is a drag, so placing starts off. */
const DEFAULT_PLACING: Record<TourMapMode, boolean> = { '2d': true, '3d': false };
const STORAGE_KEY = 'trek.tours.placePoints.';
function readPlacing(mode: TourMapMode): boolean {
try {
const stored = window.sessionStorage.getItem(STORAGE_KEY + mode);
if (stored === '1' || stored === '0') return stored === '1';
} catch {
// Storage blocked: the choice then lasts as long as the page.
}
return DEFAULT_PLACING[mode];
}
function writePlacing(mode: TourMapMode, on: boolean): void {
try {
window.sessionStorage.setItem(STORAGE_KEY + mode, on ? '1' : '0');
} catch {
// Storage blocked: the store still holds it while the page is up.
}
}
interface TourPlaceToolState {
/** Whether a click on the map sets a point, per mode, remembered for the session. */
placing: Record<TourMapMode, boolean>;
/** The mode the tour map shows now, so a control outside the map switches the tool for it. */
mode: TourMapMode;
}
export function initialTourPlaceToolState(): TourPlaceToolState {
return { placing: { '2d': readPlacing('2d'), '3d': readPlacing('3d') }, mode: '2d' };
}
/**
* The tour map's "set points" tool, shared by its switch in the layer pill and
* the "add point" row under the waypoints in the editor.
*/
export const useTourPlaceToolStore = create<TourPlaceToolState>(() => initialTourPlaceToolState());
export function setTourPlaceToolMode(mode: TourMapMode): void {
if (useTourPlaceToolStore.getState().mode !== mode) useTourPlaceToolStore.setState({ mode });
}
export function setTourPlacing(mode: TourMapMode, on: boolean): void {
writePlacing(mode, on);
useTourPlaceToolStore.setState((state) => ({ placing: { ...state.placing, [mode]: on } }));
}
/** Switches the tool for the mode the map shows now. */
export function toggleTourPlacing(): void {
const { mode, placing } = useTourPlaceToolStore.getState();
setTourPlacing(mode, !placing[mode]);
}
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'الجولات',
'tours.mode.label': 'عرض الإضافة',
'tours.title': 'الجولات',
'tours.subtitle': 'جولات المشي الطويل وغيرها من الجولات اليومية ضمن هذه الرحلة',
'tours.empty.title': 'لا توجد جولات بعد',
'tours.empty.body': 'خطّط لجولة أو استورد ملف GPX للبدء.',
'tours.addToDay': 'إضافة إلى يوم',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'استيراد GPX',
'tours.badge.planned': 'مخطّطة في TREK',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': 'يُرجى الاستخدام بحذر',
'tours.caution.tooltip': 'بيانات الارتفاع أو المقاييس غير مكتملة؛ تم الاستيراد مع تنبيه للحذر',
'tours.import.caution': 'تم الاستيراد مع تنبيه للحذر؛ بيانات الارتفاع أو المقاييس غير مكتملة',
@@ -57,15 +58,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmTitle': 'هل تريد بدء جولة جديدة؟',
'tours.planner.newTourConfirmBody': 'ستُحذف تغييراتك غير المحفوظة على هذه الجولة. لن تتغيّر الجولات المحفوظة.',
'tours.planner.newTourConfirm': 'تجاهل التغييرات وبدء جولة جديدة',
'tours.planner.startingPoint': 'انقر على الخريطة لإضافة نقطة البداية.',
'tours.planner.firstUseDetails':
'أضف نقطة أخرى لحساب المسار. يتم تحديث المسار والارتفاع تلقائيًا؛ احفظ الجولة عندما تصبح جاهزة.',
'tours.planner.collapseElevation': 'طيّ مخطط الارتفاع',
'tours.planner.expandElevation': 'توسيع مخطط الارتفاع',
'tours.planner.name': 'اسم الجولة',
'tours.planner.namePlaceholder': 'جولة مشي بلا اسم',
'tours.planner.mapHint': 'انقر على الخريطة لإضافة نقاط.',
'tours.planner.mapSearch': 'ابحث عن مكان أو عنوان',
'tours.planner.placePoints': 'تعيين النقاط',
'tours.planner.pointPlaced': 'تم تعيين النقطة',
'tours.planner.details': 'التفاصيل',
'tours.planner.expandDetails': 'إظهار التفاصيل',
'tours.planner.collapseDetails': 'إخفاء التفاصيل',
'tours.planner.safetyInfoLabel': 'حول السلامة',
'tours.planner.restoredShort': 'مسودة',
'tours.planner.addPoint': 'إضافة نقطة',
'tours.planner.addPointActive': 'انقر على الخريطة',
'tours.planner.emptyHint': 'فعّل «إضافة نقطة» وانقر على الخريطة. من نقطتين فصاعدًا يحسب TREK المسار.',
'tours.planner.waypoints': 'نقاط المرور',
'tours.planner.waypointLabel': 'نقطة المرور {n}',
'tours.durationMinutes': '{count} د',
@@ -103,12 +110,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
'ستُحذف تعديلاتك غير المحفوظة. لن تتغيّر الجولة المحفوظة أو نقاط مرورها أو ملفاتها أو تعييناتها للأيام.',
'tours.planner.saved': 'تم حفظ الجولة',
'tours.planner.planAnother': 'تخطيط جولة أخرى',
'tours.planner.assignToDay': 'تعيين ليوم',
'tours.planner.tripTours': 'جولات هذه الرحلة',
'tours.planner.safetyNote': 'حساب المسارات يساعد على التخطيط، ولا يضمن السلامة.',
'tours.planner.safetyNoteDetails':
'قد تكون وسوم الصعوبة في OSM مفقودة أو غير صحيحة. لا يتم تقييم الطقس أو إغلاق الدروب أو الظروف الحالية.',
'tours.planner.maxDifficulty': 'أقصى مستوى لصعوبة الدرب',
'tours.planner.difficulty.t1': 'T1: المشي الطويل',
'tours.planner.difficulty.t2': 'T2: المشي الجبلي',
@@ -128,7 +132,6 @@ const tours: TranslationStrings = {
'tours.planner.status.routingFailed': 'فشل حساب المسار. لا يزال آخر مسار حُسب بنجاح معروضًا.',
'tours.planner.status.elevation': 'جارٍ تحميل الارتفاع…',
'tours.planner.status.elevationFailed': 'بيانات الارتفاع غير متاحة. لا يزال المسار المحسوب معروضًا.',
'tours.planner.status.ready': 'المسار والارتفاع جاهزان',
'tours.planner.status.saving': 'جارٍ الحفظ…',
'tours.planner.status.saveFailed': 'تعذّر حفظ الجولة. لا تزال مسودتك متاحة هنا.',
'tours.planner.retry': 'إعادة المحاولة',
@@ -166,6 +169,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': 'افتح جولة من اليمين للتحليق فوقها بالأبعاد الثلاثية وتلوينها حسب الانحدار.',
'tours.terrain.steepestShort': 'الأقصى {grade} %',
'tours.terrain.preloading': 'جارٍ تحميل المشهد: {percent} %',
'tours.terrain.placeOff': 'فعّل «تعيين النقاط» لوضع النقاط على التضاريس بالنقر.',
'tours.terrain.clickToPlan': 'انقر على التضاريس لإضافة نقاط. بمجرد وجود مسار يمكن التحليق فوقه وتلوينه حسب الانحدار.',
};
export default tours;
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'Turlar',
'tours.mode.label': 'Əlavə etmə görünüşü',
'tours.title': 'Turlar',
'tours.subtitle': 'Bu səyahət üçün yürüşlər və digər birgünlük turlar',
'tours.empty.title': 'Hələ tur yoxdur',
'tours.empty.body': 'Başlamaq üçün tur planlaşdırın və ya GPX faylı idxal edin.',
'tours.addToDay': 'Günə əlavə et',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'GPX idxalı',
'tours.badge.planned': 'TREK-də planlaşdırılıb',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': 'ehtiyatla istifadə edin',
'tours.caution.tooltip': 'Yüksəklik və göstəricilər natamamdır — ehtiyatla idxal edilib',
'tours.import.caution': 'Ehtiyatla idxal edilib — yüksəklik və göstəricilər natamamdır',
@@ -55,15 +56,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmBody':
'Bu turda saxlanılmamış dəyişiklikləriniz ləğv ediləcək. Saxlanılmış turlar dəyişməyəcək.',
'tours.planner.newTourConfirm': 'Dəyişiklikləri ləğv et və yeni tur başlat',
'tours.planner.startingPoint': 'Başlanğıc nöqtəsini əlavə etmək üçün xəritəyə klikləyin.',
'tours.planner.firstUseDetails':
'Marşrutu hesablamaq üçün başqa bir nöqtə əlavə edin. Marşrut və yüksəklik avtomatik yenilənir; hazır olduqda saxlayın.',
'tours.planner.collapseElevation': 'Yüksəklik profilini yığ',
'tours.planner.expandElevation': 'Yüksəklik profilini aç',
'tours.planner.name': 'Turun adı',
'tours.planner.namePlaceholder': 'Adsız yürüş',
'tours.planner.mapHint': 'Nöqtə əlavə etmək üçün xəritəyə klikləyin.',
'tours.planner.mapSearch': 'Yer və ya ünvan axtar',
'tours.planner.placePoints': 'Nöqtə qoy',
'tours.planner.pointPlaced': 'Nöqtə qoyuldu',
'tours.planner.details': 'Təfərrüatlar',
'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.waypoints': 'Yol nöqtələri',
'tours.planner.waypointLabel': 'Yol nöqtəsi {n}',
'tours.durationMinutes': '{count} dəq.',
@@ -98,12 +105,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
'Saxlanılmamış düzəlişləriniz ləğv ediləcək. Saxlanılmış tur, onun yol nöqtələri, faylları və günlərə təyinatları dəyişməyəcək.',
'tours.planner.saved': 'Tur saxlanıldı',
'tours.planner.planAnother': 'Başqa tur planlaşdır',
'tours.planner.assignToDay': 'Günə təyin et',
'tours.planner.tripTours': 'Bu səyahətin turları',
'tours.planner.safetyNote': 'Marşrutlaşdırma planlaşdırma köməyidir, təhlükəsizlik zəmanəti deyil.',
'tours.planner.safetyNoteDetails':
'OSM çətinlik etiketləri çatışmaya və ya yanlış ola bilər. Hava, yol bağlanmaları və cari şərait qiymətləndirilmir.',
'tours.planner.maxDifficulty': 'Maksimum cığır çətinliyi',
'tours.planner.difficulty.t1': 'T1 — Yürüş',
'tours.planner.difficulty.t2': 'T2 — Dağ yürüşü',
@@ -123,7 +127,6 @@ const tours: TranslationStrings = {
'tours.planner.status.routingFailed': 'Marşrut hesablanmadı. Son uğurlu marşrut hələ göstərilir.',
'tours.planner.status.elevation': 'Yüksəklik yüklənir…',
'tours.planner.status.elevationFailed': 'Yüksəklik əlçatan deyil. Hesablanmış marşrut hələ göstərilir.',
'tours.planner.status.ready': 'Marşrut və yüksəklik hazırdır',
'tours.planner.status.saving': 'Saxlanılır…',
'tours.planner.status.saveFailed': 'Turu saxlamaq mümkün olmadı. Qaralamanız hələ buradadır.',
'tours.planner.retry': 'Yenidən cəhd et',
@@ -161,6 +164,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': 'Turu 3D-də uçmaq və meyilə görə rəngləmək üçün sağ tərəfdə açın.',
'tours.terrain.steepestShort': 'maks. {grade}%',
'tours.terrain.preloading': 'Mənzərə yüklənir: {percent}%',
'tours.terrain.placeOff': 'Relyefə klikləyib nöqtə qoymaq üçün «Nöqtə qoy» alətini aç.',
'tours.terrain.clickToPlan':
'Nöqtə qoymaq üçün əraziyə klikləyin. Marşrut yarandıqdan sonra onun üzərindən uçmaq və meyilə görə rəngləmək olar.',
};
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'Passeios',
'tours.mode.label': 'Adicionar visualização',
'tours.title': 'Passeios',
'tours.subtitle': 'Trilhas e outros passeios de um dia para esta viagem',
'tours.empty.title': 'Nenhum passeio ainda',
'tours.empty.body': 'Planeje um passeio ou importe um arquivo GPX para começar.',
'tours.addToDay': 'Adicionar a um dia',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'Importação GPX',
'tours.badge.planned': 'planejado no TREK',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': 'use com cautela',
'tours.caution.tooltip': 'Elevação ou métricas incompletas: importado com ressalvas',
'tours.import.caution': 'Importado com ressalvas: elevação ou métricas incompletas',
@@ -56,15 +57,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmBody':
'As alterações não salvas deste passeio serão descartadas. Os passeios salvos não serão alterados.',
'tours.planner.newTourConfirm': 'Descartar e começar novo passeio',
'tours.planner.startingPoint': 'Clique no mapa para adicionar o ponto de partida.',
'tours.planner.firstUseDetails':
'Adicione outro ponto para calcular uma rota. A rota e a elevação são atualizadas automaticamente; salve quando terminar.',
'tours.planner.collapseElevation': 'Recolher perfil de elevação',
'tours.planner.expandElevation': 'Expandir perfil de elevação',
'tours.planner.name': 'Nome do passeio',
'tours.planner.namePlaceholder': 'Trilha sem título',
'tours.planner.mapHint': 'Clique no mapa para adicionar pontos.',
'tours.planner.mapSearch': 'Buscar lugar ou endereço',
'tours.planner.placePoints': 'Marcar pontos',
'tours.planner.pointPlaced': 'Ponto marcado',
'tours.planner.details': 'Detalhes',
'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.waypoints': 'Pontos de passagem',
'tours.planner.waypointLabel': 'Ponto de passagem {n}',
'tours.durationMinutes': '{count} min',
@@ -98,12 +105,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
'As alterações não salvas serão descartadas. O passeio salvo, seus pontos de passagem, arquivos e atribuições não serão alterados.',
'tours.planner.saved': 'Passeio salvo',
'tours.planner.planAnother': 'Planejar outro',
'tours.planner.assignToDay': 'Atribuir a um dia',
'tours.planner.tripTours': 'Passeios desta viagem',
'tours.planner.safetyNote': 'O cálculo de rotas auxilia o planejamento, mas não garante a segurança.',
'tours.planner.safetyNoteDetails':
'As etiquetas de dificuldade do OSM podem estar ausentes ou incorretas. O clima, as interdições e as condições atuais não são avaliados.',
'tours.planner.maxDifficulty': 'Dificuldade máxima da trilha',
'tours.planner.difficulty.t1': 'T1 — Caminhada',
'tours.planner.difficulty.t2': 'T2 — Caminhada em montanha',
@@ -123,7 +127,6 @@ const tours: TranslationStrings = {
'tours.planner.status.routingFailed': 'O cálculo da rota falhou. A última rota válida continua sendo exibida.',
'tours.planner.status.elevation': 'Carregando elevação…',
'tours.planner.status.elevationFailed': 'A elevação não está disponível. A rota calculada continua sendo exibida.',
'tours.planner.status.ready': 'Rota e elevação prontas',
'tours.planner.status.saving': 'Salvando…',
'tours.planner.status.saveFailed': 'Não foi possível salvar o passeio. Seu rascunho continua disponível.',
'tours.planner.retry': 'Tentar novamente',
@@ -162,6 +165,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': 'Abra um passeio à direita para sobrevoá-lo em 3D e colori-lo pela inclinação.',
'tours.terrain.steepestShort': 'máx. {grade} %',
'tours.terrain.preloading': 'Carregando a paisagem: {percent} %',
'tours.terrain.placeOff': 'Ative Marcar pontos para clicar pontos no terreno.',
'tours.terrain.clickToPlan':
'Clique no terreno para adicionar pontos. Assim que houver uma rota, ela pode ser sobrevoada e colorida pela inclinação.',
};
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'Excursions',
'tours.mode.label': 'Afegeix una vista',
'tours.title': 'Excursions',
'tours.subtitle': "Rutes de senderisme i altres excursions d'un dia per a aquest viatge",
'tours.empty.title': 'Encara no hi ha excursions',
'tours.empty.body': 'Planifica una excursió o importa un fitxer GPX per començar.',
'tours.addToDay': 'Afegeix a un dia',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'Importació GPX',
'tours.badge.planned': 'planificada a TREK',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': 'fes-la servir amb precaució',
'tours.caution.tooltip': 'Altitud o mètriques incompletes: importada amb reserves',
'tours.import.caution': 'Importada amb reserves: altitud o mètriques incompletes',
@@ -55,15 +56,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmBody':
"Es descartaran els canvis sense desar d'aquesta excursió. Les excursions desades no es modificaran.",
'tours.planner.newTourConfirm': 'Descarta i comença una nova excursió',
'tours.planner.startingPoint': "Fes clic al mapa per afegir el punt d'inici.",
'tours.planner.firstUseDetails':
"Afegeix un altre punt per calcular una ruta. La ruta i l'altitud s'actualitzen automàticament; desa quan hagis acabat.",
'tours.planner.collapseElevation': "Replega el perfil d'altitud",
'tours.planner.expandElevation': "Desplega el perfil d'altitud",
'tours.planner.name': "Nom de l'excursió",
'tours.planner.namePlaceholder': 'Ruta de senderisme sense títol',
'tours.planner.mapHint': 'Fes clic al mapa per afegir punts.',
'tours.planner.mapSearch': 'Cerca un lloc o una adreça',
'tours.planner.placePoints': 'Posar punts',
'tours.planner.pointPlaced': 'Punt posat',
'tours.planner.details': 'Detalls',
'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.waypoints': 'Punts de pas',
'tours.planner.waypointLabel': 'Punt de pas {n}',
'tours.durationMinutes': '{count} min',
@@ -97,12 +104,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
"Es descartaran els canvis sense desar. L'excursió desada, els seus punts de pas, fitxers i assignacions no es modificaran.",
'tours.planner.saved': 'Excursió desada',
'tours.planner.planAnother': "Planifica'n una altra",
'tours.planner.assignToDay': 'Assigna a un dia',
'tours.planner.tripTours': "Excursions d'aquest viatge",
'tours.planner.safetyNote': 'El càlcul de rutes ajuda a planificar, però no garanteix la seguretat.',
'tours.planner.safetyNoteDetails':
"Les etiquetes de dificultat d'OSM poden faltar o ser incorrectes. No s'avaluen el temps, els tancaments ni les condicions actuals.",
'tours.planner.maxDifficulty': 'Dificultat màxima del sender',
'tours.planner.difficulty.t1': 'T1 — Senderisme',
'tours.planner.difficulty.t2': 'T2 — Senderisme de muntanya',
@@ -122,7 +126,6 @@ const tours: TranslationStrings = {
'tours.planner.status.routingFailed': "El càlcul de la ruta ha fallat. Es continua mostrant l'última ruta vàlida.",
'tours.planner.status.elevation': 'Carregant altitud…',
'tours.planner.status.elevationFailed': "L'altitud no està disponible. Es continua mostrant la ruta calculada.",
'tours.planner.status.ready': 'Ruta i altitud a punt',
'tours.planner.status.saving': 'Desant…',
'tours.planner.status.saveFailed': "No s'ha pogut desar l'excursió. L'esborrany continua disponible.",
'tours.planner.retry': 'Torna-ho a provar',
@@ -161,6 +164,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': 'Obre una excursió a la dreta per sobrevolar-la en 3D i acolorir-la segons el pendent.',
'tours.terrain.steepestShort': 'màx. {grade} %',
'tours.terrain.preloading': 'Carregant el paisatge: {percent} %',
'tours.terrain.placeOff': 'Activa Posar punts per afegir punts al terreny amb un clic.',
'tours.terrain.clickToPlan':
'Fes clic al terreny per afegir punts. Quan hi hagi una ruta, es podrà sobrevolar i acolorir segons el pendent.',
};
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'Výlety',
'tours.mode.label': 'Zobrazení pro přidávání',
'tours.title': 'Výlety',
'tours.subtitle': 'Pěší túry a další jednodenní výlety během této cesty',
'tours.empty.title': 'Zatím žádné výlety',
'tours.empty.body': 'Začněte naplánováním výletu nebo importem souboru GPX.',
'tours.addToDay': 'Přidat ke dni',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'Import GPX',
'tours.badge.planned': 'naplánováno v TREK',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': 'používejte s opatrností',
'tours.caution.tooltip': 'Neúplné výškové údaje nebo metriky – importováno s výhradou',
'tours.import.caution': 'Importováno s výhradou – neúplné výškové údaje nebo metriky',
@@ -54,15 +55,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmTitle': 'Začít nový výlet?',
'tours.planner.newTourConfirmBody': 'Neuložené změny tohoto výletu budou zahozeny. Uložené výlety se nezmění.',
'tours.planner.newTourConfirm': 'Zahodit změny a začít nový výlet',
'tours.planner.startingPoint': 'Kliknutím na mapu přidejte výchozí bod.',
'tours.planner.firstUseDetails':
'Přidejte další bod pro výpočet trasy. Trasa a výškové údaje se aktualizují automaticky; hotový výlet uložte.',
'tours.planner.collapseElevation': 'Sbalit výškový profil',
'tours.planner.expandElevation': 'Rozbalit výškový profil',
'tours.planner.name': 'Název výletu',
'tours.planner.namePlaceholder': 'Túra bez názvu',
'tours.planner.mapHint': 'Kliknutím do mapy přidáte body.',
'tours.planner.mapSearch': 'Hledat místo nebo adresu',
'tours.planner.placePoints': 'Přidávat body',
'tours.planner.pointPlaced': 'Bod přidán',
'tours.planner.details': 'Podrobnosti',
'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.waypoints': 'Průjezdní body',
'tours.planner.waypointLabel': 'Bod {n}',
'tours.durationMinutes': '{count} min',
@@ -97,12 +104,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
'Neuložené úpravy budou zahozeny. Uložený výlet, jeho body, soubory a přiřazení se nezmění.',
'tours.planner.saved': 'Výlet uložen',
'tours.planner.planAnother': 'Naplánovat další',
'tours.planner.assignToDay': 'Přiřadit ke dni',
'tours.planner.tripTours': 'Výlety této cesty',
'tours.planner.safetyNote': 'Výpočet trasy pomáhá s plánováním, nezaručuje bezpečnost.',
'tours.planner.safetyNoteDetails':
'Značky obtížnosti OSM mohou chybět nebo být nesprávné. Počasí, uzavírky a aktuální podmínky se nevyhodnocují.',
'tours.planner.maxDifficulty': 'Maximální obtížnost stezky',
'tours.planner.difficulty.t1': 'T1 – Pěší turistika',
'tours.planner.difficulty.t2': 'T2 – Horská turistika',
@@ -122,7 +126,6 @@ const tours: TranslationStrings = {
'tours.planner.status.routingFailed': 'Výpočet trasy selhal. Stále se zobrazuje poslední platná trasa.',
'tours.planner.status.elevation': 'Načítání výškových údajů…',
'tours.planner.status.elevationFailed': 'Výškové údaje nejsou dostupné. Vypočtená trasa se stále zobrazuje.',
'tours.planner.status.ready': 'Trasa a výškové údaje jsou připraveny',
'tours.planner.status.saving': 'Ukládání…',
'tours.planner.status.saveFailed': 'Výlet se nepodařilo uložit. Koncept zůstává zachován.',
'tours.planner.retry': 'Zkusit znovu',
@@ -160,6 +163,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': 'Otevřete vpravo výlet, abyste ho mohli proletět ve 3D a obarvit podle sklonu.',
'tours.terrain.steepestShort': 'max. {grade} %',
'tours.terrain.preloading': 'Načítání krajiny: {percent} %',
'tours.terrain.placeOff': 'Zapni Přidávat body a klikáním je přidávej do terénu.',
'tours.terrain.clickToPlan':
'Kliknutím do terénu přidáte body. Jakmile vznikne trasa, lze ji proletět a obarvit podle sklonu.',
};
+14 -10
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'Touren',
'tours.mode.label': 'Ansicht zum Hinzufügen',
'tours.title': 'Touren',
'tours.subtitle': 'Wanderungen und andere Tagestouren dieser Reise',
'tours.empty.title': 'Noch keine Touren',
'tours.empty.body': 'Plane eine Tour oder importiere eine GPX-Datei.',
'tours.addToDay': 'Zum Tag hinzufügen',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'GPX-Import',
'tours.badge.planned': 'in TREK geplant',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': 'mit Vorsicht verwenden',
'tours.caution.tooltip': 'Höhendaten oder Streckenwerte unvollständig – mit Vorsicht importiert',
'tours.import.caution': 'Mit Vorsicht importiert – Höhendaten oder Streckenwerte unvollständig',
@@ -55,15 +56,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmBody':
'Ungespeicherte Änderungen an dieser Tour gehen verloren. Gespeicherte Touren bleiben erhalten.',
'tours.planner.newTourConfirm': 'Änderungen verwerfen und neue Tour beginnen',
'tours.planner.startingPoint': 'Klicke auf die Karte, um deinen Startpunkt zu setzen.',
'tours.planner.firstUseDetails':
'Setze einen weiteren Punkt, um eine Route zu berechnen. Route und Höhenprofil werden automatisch aktualisiert. Speichere die Tour, wenn sie fertig ist.',
'tours.planner.collapseElevation': 'Höhenprofil einklappen',
'tours.planner.expandElevation': 'Höhenprofil ausklappen',
'tours.planner.name': 'Name der Tour',
'tours.planner.namePlaceholder': 'Wanderung ohne Titel',
'tours.planner.mapHint': 'Klicke auf die Karte, um Punkte zu setzen.',
'tours.planner.mapSearch': 'Ort oder Adresse suchen',
'tours.planner.placePoints': 'Punkte setzen',
'tours.planner.pointPlaced': 'Punkt gesetzt',
'tours.planner.details': 'Details',
'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.waypoints': 'Wegpunkte',
'tours.planner.waypointLabel': 'Wegpunkt {n}',
'tours.durationMinutes': '{count} Min.',
@@ -86,7 +93,7 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'Informationen zur geplanten Gesamtdauer',
'tours.planner.empty': 'Setze mindestens zwei Punkte, um eine Wanderroute zu berechnen.',
'tours.planner.start': 'Start',
'tours.planner.via': 'Zwischenstopp',
'tours.planner.via': 'Stopp',
'tours.planner.end': 'Ziel',
'tours.planner.moveUp': 'Wegpunkt nach oben verschieben',
'tours.planner.moveDown': 'Wegpunkt nach unten verschieben',
@@ -97,12 +104,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
'Ungespeicherte Änderungen gehen verloren. Die gespeicherte Tour, ihre Wegpunkte, Dateien und Tageszuordnungen bleiben erhalten.',
'tours.planner.saved': 'Tour gespeichert',
'tours.planner.planAnother': 'Weitere Tour planen',
'tours.planner.assignToDay': 'Einem Tag zuordnen',
'tours.planner.tripTours': 'Touren dieser Reise',
'tours.planner.safetyNote': 'TREK hilft bei der Planung, bewertet aber nicht die Sicherheit einer Route.',
'tours.planner.safetyNoteDetails':
'OSM-Angaben zur Schwierigkeit können fehlen oder falsch sein. Wetter, Sperrungen und aktuelle Bedingungen werden nicht berücksichtigt.',
'tours.planner.maxDifficulty': 'Maximale Wegschwierigkeit',
'tours.planner.difficulty.t1': 'T1 – Wandern',
'tours.planner.difficulty.t2': 'T2 – Bergwandern',
@@ -123,7 +127,6 @@ const tours: TranslationStrings = {
'Route konnte nicht berechnet werden. Die letzte gültige Route bleibt sichtbar.',
'tours.planner.status.elevation': 'Höhendaten werden geladen...',
'tours.planner.status.elevationFailed': 'Höhendaten sind nicht verfügbar. Die berechnete Route bleibt sichtbar.',
'tours.planner.status.ready': 'Route und Höhendaten bereit',
'tours.planner.status.saving': 'Tour wird gespeichert...',
'tours.planner.status.saveFailed': 'Tour konnte nicht gespeichert werden. Dein Entwurf ist weiterhin vorhanden.',
'tours.planner.retry': 'Erneut versuchen',
@@ -162,6 +165,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': 'Öffne rechts eine Tour, um sie in 3D abzufliegen und nach Steigung einzufärben.',
'tours.terrain.steepestShort': 'max. {grade} %',
'tours.terrain.preloading': 'Landschaft wird vorgeladen: {percent} %',
'tours.terrain.placeOff': 'Schalte „Punkte setzen“ ein, um Punkte ins Gelände zu klicken.',
'tours.terrain.clickToPlan':
'Klicke ins Gelände, um Punkte zu setzen. Sobald eine Route steht, lässt sie sich abfliegen und nach Steigung einfärben.',
};
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'Tours',
'tours.mode.label': 'Add view',
'tours.title': 'Tours',
'tours.subtitle': 'Hikes and other single-day tours for this trip',
'tours.empty.title': 'No tours yet',
'tours.empty.body': 'Plan a tour or import a GPX file to get started.',
'tours.addToDay': 'Add to day',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'GPX import',
'tours.badge.planned': 'planned in TREK',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': 'use with caution',
'tours.caution.tooltip': 'Elevation/metrics incomplete — imported with caution',
'tours.import.caution': 'Imported with caution — elevation/metrics incomplete',
@@ -54,15 +55,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmBody':
'Your unsaved changes to this tour will be discarded. Saved tours will not be changed.',
'tours.planner.newTourConfirm': 'Discard changes and start new tour',
'tours.planner.startingPoint': 'Click the map to add your starting point.',
'tours.planner.firstUseDetails':
'Add another point to calculate a route. The route and elevation update automatically; save when ready.',
'tours.planner.collapseElevation': 'Collapse elevation profile',
'tours.planner.expandElevation': 'Expand elevation profile',
'tours.planner.name': 'Tour name',
'tours.planner.namePlaceholder': 'Untitled hike',
'tours.planner.mapHint': 'Click the map to add points.',
'tours.planner.mapSearch': 'Search place or address',
'tours.planner.placePoints': 'Set points',
'tours.planner.pointPlaced': 'Point set',
'tours.planner.details': 'Details',
'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.waypoints': 'Waypoints',
'tours.planner.waypointLabel': 'Waypoint {n}',
'tours.durationMinutes': '{count} min',
@@ -96,12 +103,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
'Your unsaved edits will be discarded. The saved Tour, its waypoints, files and assignments will not be changed.',
'tours.planner.saved': 'Tour saved',
'tours.planner.planAnother': 'Plan another',
'tours.planner.assignToDay': 'Assign to a day',
'tours.planner.tripTours': 'Tours of this trip',
'tours.planner.safetyNote': 'Routing is planning assistance, not a safety guarantee.',
'tours.planner.safetyNoteDetails':
'OSM difficulty tags may be missing or incorrect. Weather, closures and current conditions are not evaluated.',
'tours.planner.maxDifficulty': 'Maximum trail difficulty',
'tours.planner.difficulty.t1': 'T1 — Hiking',
'tours.planner.difficulty.t2': 'T2 — Mountain hiking',
@@ -121,7 +125,6 @@ const tours: TranslationStrings = {
'tours.planner.status.routingFailed': 'Route calculation failed. The last good route is still shown.',
'tours.planner.status.elevation': 'Loading elevation…',
'tours.planner.status.elevationFailed': 'Elevation is unavailable. The calculated route is still shown.',
'tours.planner.status.ready': 'Route and elevation ready',
'tours.planner.status.saving': 'Saving…',
'tours.planner.status.saveFailed': 'Tour could not be saved. Your draft is still here.',
'tours.planner.retry': 'Retry',
@@ -159,6 +162,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': 'Open a tour on the right to fly it in 3D and colour it by grade.',
'tours.terrain.steepestShort': 'max {grade} %',
'tours.terrain.preloading': 'Loading the landscape: {percent} %',
'tours.terrain.placeOff': 'Turn on Set points to click points onto the terrain.',
'tours.terrain.clickToPlan':
'Click the terrain to set points. Once there is a route, it can be flown and coloured by grade.',
};
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'Excursiones',
'tours.mode.label': 'Añadir vista',
'tours.title': 'Excursiones',
'tours.subtitle': 'Rutas de senderismo y otras excursiones de un día para este viaje',
'tours.empty.title': 'Aún no hay excursiones',
'tours.empty.body': 'Planifica una excursión o importa un archivo GPX para empezar.',
'tours.addToDay': 'Añadir a un día',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'Importación GPX',
'tours.badge.planned': 'planificada en TREK',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': 'usar con precaución',
'tours.caution.tooltip': 'Altitud o métricas incompletas: importada con reservas',
'tours.import.caution': 'Importada con reservas: altitud o métricas incompletas',
@@ -55,15 +56,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmBody':
'Se descartarán los cambios sin guardar de esta excursión. Las excursiones guardadas no se modificarán.',
'tours.planner.newTourConfirm': 'Descartar y empezar nueva excursión',
'tours.planner.startingPoint': 'Haz clic en el mapa para añadir el punto de inicio.',
'tours.planner.firstUseDetails':
'Añade otro punto para calcular una ruta. La ruta y la altitud se actualizan automáticamente; guarda cuando termines.',
'tours.planner.collapseElevation': 'Contraer perfil de altitud',
'tours.planner.expandElevation': 'Expandir perfil de altitud',
'tours.planner.name': 'Nombre de la excursión',
'tours.planner.namePlaceholder': 'Ruta de senderismo sin título',
'tours.planner.mapHint': 'Haz clic en el mapa para añadir puntos.',
'tours.planner.mapSearch': 'Buscar lugar o dirección',
'tours.planner.placePoints': 'Colocar puntos',
'tours.planner.pointPlaced': 'Punto colocado',
'tours.planner.details': 'Detalles',
'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.waypoints': 'Puntos de paso',
'tours.planner.waypointLabel': 'Punto de paso {n}',
'tours.durationMinutes': '{count} min',
@@ -97,12 +104,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
'Se descartarán los cambios sin guardar. La excursión guardada, sus puntos de paso, archivos y asignaciones no se modificarán.',
'tours.planner.saved': 'Excursión guardada',
'tours.planner.planAnother': 'Planificar otra',
'tours.planner.assignToDay': 'Asignar a un día',
'tours.planner.tripTours': 'Excursiones de este viaje',
'tours.planner.safetyNote': 'El cálculo de rutas ayuda a planificar, pero no garantiza la seguridad.',
'tours.planner.safetyNoteDetails':
'Las etiquetas de dificultad de OSM pueden faltar o ser incorrectas. No se evalúan el tiempo, los cierres ni las condiciones actuales.',
'tours.planner.maxDifficulty': 'Dificultad máxima del sendero',
'tours.planner.difficulty.t1': 'T1 — Senderismo',
'tours.planner.difficulty.t2': 'T2 — Senderismo de montaña',
@@ -122,7 +126,6 @@ const tours: TranslationStrings = {
'tours.planner.status.routingFailed': 'No se ha podido calcular la ruta. Se sigue mostrando la última ruta válida.',
'tours.planner.status.elevation': 'Cargando altitud…',
'tours.planner.status.elevationFailed': 'La altitud no está disponible. Se sigue mostrando la ruta calculada.',
'tours.planner.status.ready': 'Ruta y altitud listas',
'tours.planner.status.saving': 'Guardando…',
'tours.planner.status.saveFailed': 'No se ha podido guardar la excursión. El borrador sigue disponible.',
'tours.planner.retry': 'Reintentar',
@@ -160,6 +163,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': 'Abre una excursión a la derecha para sobrevolarla en 3D y colorearla según la pendiente.',
'tours.terrain.steepestShort': 'máx. {grade} %',
'tours.terrain.preloading': 'Cargando el paisaje: {percent} %',
'tours.terrain.placeOff': 'Activa Colocar puntos para añadir puntos en el terreno con un clic.',
'tours.terrain.clickToPlan':
'Haz clic en el terreno para añadir puntos. En cuanto haya una ruta, se podrá sobrevolar y colorear según la pendiente.',
};
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'Retked',
'tours.mode.label': 'Lisamise vaade',
'tours.title': 'Retked',
'tours.subtitle': 'Selle reisi matkad ja muud ühepäevased retked',
'tours.empty.title': 'Retki veel pole',
'tours.empty.body': 'Alustamiseks planeeri retk või impordi GPX-fail.',
'tours.addToDay': 'Lisa päevale',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'GPX-import',
'tours.badge.planned': 'Planeeritud rakenduses TREK',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': 'Kasuta ettevaatlikult',
'tours.caution.tooltip': 'Kõrgusandmed või mõõdikud on puudulikud — imporditud hoiatusega',
'tours.import.caution': 'Imporditud hoiatusega — kõrgusandmed või mõõdikud on puudulikud',
@@ -54,15 +55,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmBody':
'Selle retke salvestamata muudatused lähevad kaotsi. Salvestatud retki ei muudeta.',
'tours.planner.newTourConfirm': 'Loobu muudatustest ja alusta uut retke',
'tours.planner.startingPoint': 'Klõpsa kaardil alguspunkti lisamiseks.',
'tours.planner.firstUseDetails':
'Lisa marsruudi arvutamiseks veel üks punkt. Marsruut ja kõrgusandmed uuenevad automaatselt; salvesta, kui oled valmis.',
'tours.planner.collapseElevation': 'Ahenda kõrgusprofiil',
'tours.planner.expandElevation': 'Laienda kõrgusprofiil',
'tours.planner.name': 'Retke nimi',
'tours.planner.namePlaceholder': 'Nimetu matk',
'tours.planner.mapHint': 'Klõpsa kaardil punktide lisamiseks.',
'tours.planner.mapSearch': 'Otsi kohta või aadressi',
'tours.planner.placePoints': 'Lisa punkte',
'tours.planner.pointPlaced': 'Punkt lisatud',
'tours.planner.details': 'Üksikasjad',
'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.waypoints': 'Teekonnapunktid',
'tours.planner.waypointLabel': 'Teekonnapunkt {n}',
'tours.durationMinutes': '{count} min',
@@ -96,12 +103,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
'Salvestamata muudatused lähevad kaotsi. Salvestatud retke, selle teekonnapunkte, faile ega päevadele lisamisi ei muudeta.',
'tours.planner.saved': 'Retk salvestatud',
'tours.planner.planAnother': 'Planeeri järgmine',
'tours.planner.assignToDay': 'Lisa päevale',
'tours.planner.tripTours': 'Selle reisi retked',
'tours.planner.safetyNote': 'Marsruudi arvutamine aitab planeerida, kuid ei taga ohutust.',
'tours.planner.safetyNoteDetails':
'OSM-i raskusastme märgendid võivad puududa või olla valed. Ilma, teesulge ega hetkeolusid ei hinnata.',
'tours.planner.maxDifficulty': 'Raja suurim raskusaste',
'tours.planner.difficulty.t1': 'T1 — Matkamine',
'tours.planner.difficulty.t2': 'T2 — Mägimatkamine',
@@ -122,7 +126,6 @@ const tours: TranslationStrings = {
'Marsruudi arvutamine ebaõnnestus. Viimane õnnestunud marsruut on endiselt nähtav.',
'tours.planner.status.elevation': 'Kõrgusandmete laadimine…',
'tours.planner.status.elevationFailed': 'Kõrgusandmed pole saadaval. Arvutatud marsruut on endiselt nähtav.',
'tours.planner.status.ready': 'Marsruut ja kõrgusandmed on valmis',
'tours.planner.status.saving': 'Salvestamine…',
'tours.planner.status.saveFailed': 'Retke salvestamine ebaõnnestus. Mustand on alles.',
'tours.planner.retry': 'Proovi uuesti',
@@ -160,6 +163,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': 'Ava paremal retk, et seda 3D-s üle lennata ja kalde järgi värvida.',
'tours.terrain.steepestShort': 'kuni {grade}%',
'tours.terrain.preloading': 'Maastiku laadimine: {percent}%',
'tours.terrain.placeOff': 'Lülita sisse Lisa punkte, et maastikule klõpsates punkte lisada.',
'tours.terrain.clickToPlan':
'Klõpsa maastikul, et punkte lisada. Kui marsruut on olemas, saab selle üle lennata ja kalde järgi värvida.',
};
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'Sorties',
'tours.mode.label': 'Ajouter une vue',
'tours.title': 'Sorties',
'tours.subtitle': 'Randonnées et autres sorties à la journée pour ce voyage',
'tours.empty.title': 'Aucune sortie pour le moment',
'tours.empty.body': 'Planifiez une sortie ou importez un fichier GPX pour commencer.',
'tours.addToDay': 'Ajouter à une journée',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'Import GPX',
'tours.badge.planned': 'planifiée dans TREK',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': 'à utiliser avec prudence',
'tours.caution.tooltip': 'Altitude ou mesures incomplètes : importée avec réserves',
'tours.import.caution': 'Importée avec réserves : altitude ou mesures incomplètes',
@@ -57,15 +58,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmBody':
'Les modifications non enregistrées de cette sortie seront perdues. Les sorties enregistrées resteront inchangées.',
'tours.planner.newTourConfirm': 'Abandonner et commencer une nouvelle sortie',
'tours.planner.startingPoint': 'Cliquez sur la carte pour ajouter votre point de départ.',
'tours.planner.firstUseDetails':
"Ajoutez un autre point pour calculer un itinéraire. L'itinéraire et l'altitude se mettent à jour automatiquement ; enregistrez quand vous avez terminé.",
'tours.planner.collapseElevation': 'Réduire le profil altimétrique',
'tours.planner.expandElevation': 'Déployer le profil altimétrique',
'tours.planner.name': 'Nom de la sortie',
'tours.planner.namePlaceholder': 'Randonnée sans titre',
'tours.planner.mapHint': 'Cliquez sur la carte pour ajouter des points.',
'tours.planner.mapSearch': 'Rechercher un lieu ou une adresse',
'tours.planner.placePoints': 'Placer des points',
'tours.planner.pointPlaced': 'Point placé',
'tours.planner.details': 'Détails',
'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.waypoints': 'Points de passage',
'tours.planner.waypointLabel': 'Point de passage {n}',
'tours.durationMinutes': '{count} min',
@@ -99,12 +106,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
'Les modifications non enregistrées seront perdues. La sortie enregistrée, ses points de passage, ses fichiers et ses affectations resteront inchangés.',
'tours.planner.saved': 'Sortie enregistrée',
'tours.planner.planAnother': 'En planifier une autre',
'tours.planner.assignToDay': 'Affecter à une journée',
'tours.planner.tripTours': 'Sorties de ce voyage',
'tours.planner.safetyNote': "Le calcul d'itinéraire aide à planifier, mais ne garantit pas la sécurité.",
'tours.planner.safetyNoteDetails':
'Les indications de difficulté dans OSM peuvent être absentes ou erronées. La météo, les fermetures et les conditions actuelles ne sont pas évaluées.',
'tours.planner.maxDifficulty': 'Difficulté maximale du sentier',
'tours.planner.difficulty.t1': 'T1 — Randonnée',
'tours.planner.difficulty.t2': 'T2 — Randonnée en montagne',
@@ -125,7 +129,6 @@ const tours: TranslationStrings = {
"Le calcul de l'itinéraire a échoué. Le dernier itinéraire valide reste affiché.",
'tours.planner.status.elevation': "Chargement de l'altitude…",
'tours.planner.status.elevationFailed': "L'altitude est indisponible. L'itinéraire calculé reste affiché.",
'tours.planner.status.ready': 'Itinéraire et altitude prêts',
'tours.planner.status.saving': 'Enregistrement…',
'tours.planner.status.saveFailed': "La sortie n'a pas pu être enregistrée. Votre brouillon est toujours disponible.",
'tours.planner.retry': 'Réessayer',
@@ -165,6 +168,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': 'Ouvrez une sortie à droite pour la survoler en 3D et la colorer selon la pente.',
'tours.terrain.steepestShort': 'max. {grade} %',
'tours.terrain.preloading': 'Chargement du paysage : {percent} %',
'tours.terrain.placeOff': 'Activez Placer des points pour cliquer des points sur le terrain.',
'tours.terrain.clickToPlan':
"Cliquez sur le relief pour placer des points. Dès qu'un itinéraire existe, il peut être survolé et coloré selon la pente.",
};
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'Εκδρομές',
'tours.mode.label': 'Προβολή προσθήκης',
'tours.title': 'Εκδρομές',
'tours.subtitle': 'Πεζοπορίες και άλλες ημερήσιες εκδρομές σε αυτό το ταξίδι',
'tours.empty.title': 'Δεν υπάρχουν εκδρομές ακόμη',
'tours.empty.body': 'Σχεδιάστε μια εκδρομή ή εισαγάγετε ένα αρχείο GPX για να ξεκινήσετε.',
'tours.addToDay': 'Προσθήκη σε ημέρα',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'Εισαγωγή GPX',
'tours.badge.planned': 'σχεδιάστηκε στο TREK',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': 'χρήση με προσοχή',
'tours.caution.tooltip': 'Ελλιπή υψομετρικά δεδομένα ή μετρήσεις – εισαγωγή με επιφύλαξη',
'tours.import.caution': 'Εισαγωγή με επιφύλαξη – ελλιπή υψομετρικά δεδομένα ή μετρήσεις',
@@ -57,15 +58,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmBody':
'Οι μη αποθηκευμένες αλλαγές αυτής της εκδρομής θα απορριφθούν. Οι αποθηκευμένες εκδρομές δεν θα αλλάξουν.',
'tours.planner.newTourConfirm': 'Απόρριψη αλλαγών και έναρξη νέας εκδρομής',
'tours.planner.startingPoint': 'Κάντε κλικ στον χάρτη για να προσθέσετε την αφετηρία.',
'tours.planner.firstUseDetails':
'Προσθέστε άλλο ένα σημείο για να υπολογιστεί η διαδρομή. Η διαδρομή και τα υψομετρικά δεδομένα ενημερώνονται αυτόματα· αποθηκεύστε όταν είναι έτοιμα.',
'tours.planner.collapseElevation': 'Σύμπτυξη υψομετρικού προφίλ',
'tours.planner.expandElevation': 'Ανάπτυξη υψομετρικού προφίλ',
'tours.planner.name': 'Όνομα εκδρομής',
'tours.planner.namePlaceholder': 'Πεζοπορία χωρίς όνομα',
'tours.planner.mapHint': 'Κάντε κλικ στον χάρτη για να προσθέσετε σημεία.',
'tours.planner.mapSearch': 'Αναζήτηση τόπου ή διεύθυνσης',
'tours.planner.placePoints': 'Τοποθέτηση σημείων',
'tours.planner.pointPlaced': 'Το σημείο τοποθετήθηκε',
'tours.planner.details': 'Λεπτομέρειες',
'tours.planner.expandDetails': 'Εμφάνιση λεπτομερειών',
'tours.planner.collapseDetails': 'Απόκρυψη λεπτομερειών',
'tours.planner.safetyInfoLabel': 'Σχετικά με την ασφάλεια',
'tours.planner.restoredShort': 'Πρόχειρο',
'tours.planner.addPoint': 'Προσθήκη σημείου',
'tours.planner.addPointActive': 'Κλικ στον χάρτη',
'tours.planner.emptyHint': 'Ενεργοποιήστε την «Προσθήκη σημείου» και κάντε κλικ στον χάρτη. Από δύο σημεία και πάνω, το TREK υπολογίζει τη διαδρομή.',
'tours.planner.waypoints': 'Σημεία διέλευσης',
'tours.planner.waypointLabel': 'Σημείο διέλευσης {n}',
'tours.durationMinutes': '{count} λεπτά',
@@ -102,12 +109,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
'Οι μη αποθηκευμένες αλλαγές θα απορριφθούν. Η αποθηκευμένη εκδρομή, τα σημεία, τα αρχεία και οι αντιστοιχίσεις της δεν θα αλλάξουν.',
'tours.planner.saved': 'Η εκδρομή αποθηκεύτηκε',
'tours.planner.planAnother': 'Σχεδιασμός άλλης',
'tours.planner.assignToDay': 'Αντιστοίχιση σε ημέρα',
'tours.planner.tripTours': 'Εκδρομές αυτού του ταξιδιού',
'tours.planner.safetyNote': 'Ο υπολογισμός διαδρομών βοηθά στον σχεδιασμό, αλλά δεν εγγυάται την ασφάλεια.',
'tours.planner.safetyNoteDetails':
'Οι ετικέτες δυσκολίας του OSM ενδέχεται να λείπουν ή να είναι λανθασμένες. Ο καιρός, οι κλειστές διαδρομές και οι τρέχουσες συνθήκες δεν αξιολογούνται.',
'tours.planner.maxDifficulty': 'Μέγιστη δυσκολία μονοπατιού',
'tours.planner.difficulty.t1': 'T1 – Πεζοπορία',
'tours.planner.difficulty.t2': 'T2 – Ορεινή πεζοπορία',
@@ -129,7 +133,6 @@ const tours: TranslationStrings = {
'tours.planner.status.elevation': 'Φόρτωση υψομετρικών δεδομένων…',
'tours.planner.status.elevationFailed':
'Τα υψομετρικά δεδομένα δεν είναι διαθέσιμα. Η υπολογισμένη διαδρομή εξακολουθεί να εμφανίζεται.',
'tours.planner.status.ready': 'Η διαδρομή και τα υψομετρικά δεδομένα είναι έτοιμα',
'tours.planner.status.saving': 'Αποθήκευση…',
'tours.planner.status.saveFailed': 'Δεν ήταν δυνατή η αποθήκευση της εκδρομής. Το πρόχειρό σας παραμένει διαθέσιμο.',
'tours.planner.retry': 'Επανάληψη',
@@ -169,6 +172,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': 'Άνοιξε μια εκδρομή δεξιά για να την πετάξεις σε 3D και να τη χρωματίσεις κατά κλίση.',
'tours.terrain.steepestShort': 'έως {grade} %',
'tours.terrain.preloading': 'Φόρτωση τοπίου: {percent} %',
'tours.terrain.placeOff': 'Ενεργοποίησε την Τοποθέτηση σημείων για να βάζεις σημεία στο έδαφος με κλικ.',
'tours.terrain.clickToPlan':
'Κάνε κλικ στο έδαφος για να ορίσεις σημεία. Μόλις υπάρξει διαδρομή, μπορείς να την πετάξεις και να τη χρωματίσεις κατά κλίση.',
};
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'Túrák',
'tours.mode.label': 'Hozzáadási nézet',
'tours.title': 'Túrák',
'tours.subtitle': 'Gyalogtúrák és más egynapos túrák ezen az utazáson',
'tours.empty.title': 'Még nincsenek túrák',
'tours.empty.body': 'A kezdéshez tervezz egy túrát, vagy importálj egy GPX-fájlt.',
'tours.addToDay': 'Hozzáadás egy naphoz',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'GPX-import',
'tours.badge.planned': 'TREK-ben tervezve',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': 'óvatosan használd',
'tours.caution.tooltip': 'Hiányos magassági adatok vagy mutatók – fenntartással importálva',
'tours.import.caution': 'Fenntartással importálva – hiányos magassági adatok vagy mutatók',
@@ -54,15 +55,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmTitle': 'Új túrát kezdesz?',
'tours.planner.newTourConfirmBody': 'A túra nem mentett módosításai elvesznek. A mentett túrák nem változnak.',
'tours.planner.newTourConfirm': 'Módosítások elvetése és új túra kezdése',
'tours.planner.startingPoint': 'Kattints a térképre a kiindulópont hozzáadásához.',
'tours.planner.firstUseDetails':
'Adj hozzá még egy pontot az útvonal kiszámításához. Az útvonal és a magassági adatok automatikusan frissülnek; mentsd a túrát, ha elkészült.',
'tours.planner.collapseElevation': 'Magassági profil összecsukása',
'tours.planner.expandElevation': 'Magassági profil kibontása',
'tours.planner.name': 'Túra neve',
'tours.planner.namePlaceholder': 'Névtelen gyalogtúra',
'tours.planner.mapHint': 'Kattints a térképre pontok hozzáadásához.',
'tours.planner.mapSearch': 'Hely vagy cím keresése',
'tours.planner.placePoints': 'Pontok elhelyezése',
'tours.planner.pointPlaced': 'Pont elhelyezve',
'tours.planner.details': 'Részletek',
'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.waypoints': 'Útvonalpontok',
'tours.planner.waypointLabel': '{n}. útvonalpont',
'tours.durationMinutes': '{count} perc',
@@ -96,12 +103,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
'A nem mentett módosítások elvesznek. A mentett túra, útvonalpontjai, fájljai és napokhoz rendelései nem változnak.',
'tours.planner.saved': 'Túra mentve',
'tours.planner.planAnother': 'Másik túra tervezése',
'tours.planner.assignToDay': 'Naphoz rendelés',
'tours.planner.tripTours': 'Az utazás túrái',
'tours.planner.safetyNote': 'Az útvonaltervezés tervezési segítség, nem biztonsági garancia.',
'tours.planner.safetyNoteDetails':
'Az OSM nehézségi címkéi hiányozhatnak vagy hibásak lehetnek. Az időjárást, lezárásokat és aktuális körülményeket a rendszer nem értékeli.',
'tours.planner.maxDifficulty': 'Ösvény legnagyobb nehézsége',
'tours.planner.difficulty.t1': 'T1 – Gyalogtúra',
'tours.planner.difficulty.t2': 'T2 – Hegyi túra',
@@ -123,7 +127,6 @@ const tours: TranslationStrings = {
'tours.planner.status.elevation': 'Magassági adatok betöltése…',
'tours.planner.status.elevationFailed':
'A magassági adatok nem érhetők el. A kiszámított útvonal továbbra is látható.',
'tours.planner.status.ready': 'Az útvonal és a magassági adatok elkészültek',
'tours.planner.status.saving': 'Mentés…',
'tours.planner.status.saveFailed': 'A túrát nem sikerült menteni. A vázlat megmaradt.',
'tours.planner.retry': 'Újra',
@@ -163,6 +166,7 @@ const tours: TranslationStrings = {
'Nyiss meg jobb oldalt egy túrát, hogy 3D-ben átrepülhesd és meredekség szerint színezhesd.',
'tours.terrain.steepestShort': 'max. {grade}%',
'tours.terrain.preloading': 'A táj betöltése: {percent}%',
'tours.terrain.placeOff': 'Kapcsold be a Pontok elhelyezése eszközt, hogy kattintással pontokat tegyél a terepre.',
'tours.terrain.clickToPlan':
'Kattints a terepre pontok elhelyezéséhez. Ha már van útvonal, átrepülheted és meredekség szerint színezheted.',
};
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'Tur',
'tours.mode.label': 'Tampilan penambahan',
'tours.title': 'Tur',
'tours.subtitle': 'Pendakian dan tur sehari lainnya untuk perjalanan ini',
'tours.empty.title': 'Belum ada tur',
'tours.empty.body': 'Rencanakan tur atau impor berkas GPX untuk memulai.',
'tours.addToDay': 'Tambahkan ke hari',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'Impor GPX',
'tours.badge.planned': 'direncanakan di TREK',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': 'gunakan dengan hati-hati',
'tours.caution.tooltip': 'Data elevasi atau metrik belum lengkap; diimpor dengan peringatan',
'tours.import.caution': 'Diimpor dengan peringatan; data elevasi atau metrik belum lengkap',
@@ -54,15 +55,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmBody':
'Perubahan yang belum disimpan pada tur ini akan dibuang. Tur yang sudah disimpan tidak akan diubah.',
'tours.planner.newTourConfirm': 'Buang perubahan dan mulai tur baru',
'tours.planner.startingPoint': 'Klik peta untuk menambahkan titik awal.',
'tours.planner.firstUseDetails':
'Tambahkan satu titik lagi untuk menghitung rute. Rute dan elevasi diperbarui otomatis; simpan setelah selesai.',
'tours.planner.collapseElevation': 'Ciutkan profil elevasi',
'tours.planner.expandElevation': 'Perluas profil elevasi',
'tours.planner.name': 'Nama tur',
'tours.planner.namePlaceholder': 'Pendakian tanpa judul',
'tours.planner.mapHint': 'Klik peta untuk menambahkan titik.',
'tours.planner.mapSearch': 'Cari tempat atau alamat',
'tours.planner.placePoints': 'Tempatkan titik',
'tours.planner.pointPlaced': 'Titik ditempatkan',
'tours.planner.details': 'Detail',
'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.waypoints': 'Titik lintasan',
'tours.planner.waypointLabel': 'Titik lintasan {n}',
'tours.durationMinutes': '{count} menit',
@@ -95,12 +102,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
'Edit yang belum disimpan akan dibuang. Tur tersimpan beserta titik lintasan, berkas, dan penetapan harinya tidak akan diubah.',
'tours.planner.saved': 'Tur disimpan',
'tours.planner.planAnother': 'Rencanakan tur lain',
'tours.planner.assignToDay': 'Tetapkan ke hari',
'tours.planner.tripTours': 'Tur dalam perjalanan ini',
'tours.planner.safetyNote': 'Perhitungan rute hanya membantu perencanaan, bukan jaminan keselamatan.',
'tours.planner.safetyNoteDetails':
'Tag tingkat kesulitan OSM mungkin tidak tersedia atau keliru. Cuaca, penutupan jalur, dan kondisi terkini tidak dievaluasi.',
'tours.planner.maxDifficulty': 'Tingkat kesulitan jalur maksimum',
'tours.planner.difficulty.t1': 'T1 - Pendakian',
'tours.planner.difficulty.t2': 'T2 - Pendakian gunung',
@@ -121,7 +125,6 @@ const tours: TranslationStrings = {
'Perhitungan rute gagal. Rute terakhir yang berhasil dihitung tetap ditampilkan.',
'tours.planner.status.elevation': 'Memuat elevasi...',
'tours.planner.status.elevationFailed': 'Elevasi tidak tersedia. Rute yang telah dihitung tetap ditampilkan.',
'tours.planner.status.ready': 'Rute dan elevasi siap',
'tours.planner.status.saving': 'Menyimpan...',
'tours.planner.status.saveFailed': 'Tur tidak dapat disimpan. Draf Anda tetap tersedia.',
'tours.planner.retry': 'Coba lagi',
@@ -160,6 +163,7 @@ const tours: TranslationStrings = {
'Buka tur di sebelah kanan untuk menerbanginya dalam 3D dan mewarnainya menurut kemiringan.',
'tours.terrain.steepestShort': 'maks. {grade}%',
'tours.terrain.preloading': 'Memuat lanskap: {percent}%',
'tours.terrain.placeOff': 'Aktifkan Tempatkan titik untuk menaruh titik di medan dengan klik.',
'tours.terrain.clickToPlan':
'Klik medan untuk menambahkan titik. Setelah ada rute, rute itu bisa diterbangi dan diwarnai menurut kemiringan.',
};
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'Escursioni',
'tours.mode.label': 'Aggiungi vista',
'tours.title': 'Escursioni',
'tours.subtitle': 'Escursioni a piedi e altre uscite in giornata per questo viaggio',
'tours.empty.title': 'Nessuna escursione ancora',
'tours.empty.body': "Pianifica un'escursione o importa un file GPX per iniziare.",
'tours.addToDay': 'Aggiungi a un giorno',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'Importazione GPX',
'tours.badge.planned': 'pianificata in TREK',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': 'usare con cautela',
'tours.caution.tooltip': 'Quote o metriche incomplete: importata con riserva',
'tours.import.caution': 'Importata con riserva: quote o metriche incomplete',
@@ -56,15 +57,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmBody':
'Le modifiche non salvate a questa escursione verranno scartate. Le escursioni salvate non verranno modificate.',
'tours.planner.newTourConfirm': 'Scarta e inizia nuova escursione',
'tours.planner.startingPoint': 'Fai clic sulla mappa per aggiungere il punto di partenza.',
'tours.planner.firstUseDetails':
'Aggiungi un altro punto per calcolare un percorso. Il percorso e le quote si aggiornano automaticamente; salva quando hai finito.',
'tours.planner.collapseElevation': 'Comprimi profilo altimetrico',
'tours.planner.expandElevation': 'Espandi profilo altimetrico',
'tours.planner.name': "Nome dell'escursione",
'tours.planner.namePlaceholder': 'Escursione a piedi senza titolo',
'tours.planner.mapHint': 'Fai clic sulla mappa per aggiungere punti.',
'tours.planner.mapSearch': 'Cerca luogo o indirizzo',
'tours.planner.placePoints': 'Posiziona punti',
'tours.planner.pointPlaced': 'Punto posizionato',
'tours.planner.details': 'Dettagli',
'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.waypoints': 'Punti di passaggio',
'tours.planner.waypointLabel': 'Punto di passaggio {n}',
'tours.durationMinutes': '{count} min',
@@ -98,12 +105,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
"Le modifiche non salvate verranno scartate. L'escursione salvata, i suoi punti di passaggio, file e assegnazioni non verranno modificati.",
'tours.planner.saved': 'Escursione salvata',
'tours.planner.planAnother': "Pianificane un'altra",
'tours.planner.assignToDay': 'Assegna a un giorno',
'tours.planner.tripTours': 'Escursioni di questo viaggio',
'tours.planner.safetyNote': 'Il calcolo del percorso aiuta a pianificare, ma non garantisce la sicurezza.',
'tours.planner.safetyNoteDetails':
'Le indicazioni di difficoltà di OSM possono essere assenti o errate. Meteo, chiusure e condizioni attuali non vengono valutati.',
'tours.planner.maxDifficulty': 'Difficoltà massima del sentiero',
'tours.planner.difficulty.t1': 'T1 — Escursionismo',
'tours.planner.difficulty.t2': 'T2 — Escursionismo di montagna',
@@ -123,7 +127,6 @@ const tours: TranslationStrings = {
'tours.planner.status.routingFailed': "Calcolo del percorso non riuscito. L'ultimo percorso valido resta visibile.",
'tours.planner.status.elevation': 'Caricamento delle quote…',
'tours.planner.status.elevationFailed': 'Le quote non sono disponibili. Il percorso calcolato resta visibile.',
'tours.planner.status.ready': 'Percorso e quote pronti',
'tours.planner.status.saving': 'Salvataggio…',
'tours.planner.status.saveFailed': "Impossibile salvare l'escursione. La bozza è ancora disponibile.",
'tours.planner.retry': 'Riprova',
@@ -162,6 +165,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': "Apri un'escursione a destra per sorvolarla in 3D e colorarla in base alla pendenza.",
'tours.terrain.steepestShort': 'mass. {grade} %',
'tours.terrain.preloading': 'Caricamento del paesaggio: {percent} %',
'tours.terrain.placeOff': 'Attiva Posiziona punti per aggiungere punti sul terreno con un clic.',
'tours.terrain.clickToPlan':
"Fai clic sul terreno per aggiungere punti. Appena c'è un percorso, puoi sorvolarlo e colorarlo in base alla pendenza.",
};
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'ツアー',
'tours.mode.label': '追加する項目',
'tours.title': 'ツアー',
'tours.subtitle': 'この旅行のハイキングと日帰りツアー',
'tours.empty.title': 'ツアーはまだありません',
'tours.empty.body': 'ツアーを計画するか、GPXファイルをインポートしてください。',
'tours.addToDay': '日程に追加',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'GPXインポート',
'tours.badge.planned': 'TREKで計画',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': '利用時は注意',
'tours.caution.tooltip': '標高・各種データが不完全です。注意が必要な状態でインポートされました。',
'tours.import.caution': '標高・各種データが不完全なため、注意が必要な状態でインポートしました。',
@@ -52,15 +53,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmTitle': '新しいツアーを開始しますか?',
'tours.planner.newTourConfirmBody': 'このツアーの未保存の変更は破棄されます。保存済みのツアーは変更されません。',
'tours.planner.newTourConfirm': '変更を破棄して新しいツアーを開始',
'tours.planner.startingPoint': '地図をクリックして出発地点を追加してください。',
'tours.planner.firstUseDetails':
'もう1地点追加するとルートを計算します。ルートと標高は自動更新されます。準備ができたら保存してください。',
'tours.planner.collapseElevation': '標高グラフを折りたたむ',
'tours.planner.expandElevation': '標高グラフを展開',
'tours.planner.name': 'ツアー名',
'tours.planner.namePlaceholder': '名前のないハイキング',
'tours.planner.mapHint': '地図をクリックして地点を追加します。',
'tours.planner.mapSearch': '場所や住所を検索',
'tours.planner.placePoints': 'ポイントを配置',
'tours.planner.pointPlaced': 'ポイントを配置しました',
'tours.planner.details': '詳細',
'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.waypoints': 'ルート上の地点',
'tours.planner.waypointLabel': '地点{n}',
'tours.durationMinutes': '{count}分',
@@ -93,12 +100,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
'未保存の編集内容は破棄されます。保存済みのツアー、地点、ファイル、日程への割り当ては変更されません。',
'tours.planner.saved': 'ツアーを保存しました',
'tours.planner.planAnother': '別のツアーを計画',
'tours.planner.assignToDay': '日程に割り当て',
'tours.planner.tripTours': 'この旅行のツアー',
'tours.planner.safetyNote': 'ルート検索は計画の補助であり、安全を保証するものではありません。',
'tours.planner.safetyNoteDetails':
'OSMの難易度タグは欠落していたり、誤っていたりする場合があります。天候、通行止め、現地の最新状況は評価されません。',
'tours.planner.maxDifficulty': '登山道の難易度上限',
'tours.planner.difficulty.t1': 'T1:ハイキング',
'tours.planner.difficulty.t2': 'T2:山岳ハイキング',
@@ -118,7 +122,6 @@ const tours: TranslationStrings = {
'tours.planner.status.routingFailed': 'ルートの計算に失敗しました。直前に計算できたルートを引き続き表示しています。',
'tours.planner.status.elevation': '標高を読み込み中…',
'tours.planner.status.elevationFailed': '標高を取得できません。計算済みのルートを引き続き表示しています。',
'tours.planner.status.ready': 'ルートと標高の準備完了',
'tours.planner.status.saving': '保存中…',
'tours.planner.status.saveFailed': 'ツアーを保存できませんでした。下書きは保持されています。',
'tours.planner.retry': '再試行',
@@ -156,6 +159,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': '右側でツアーを開くと、3D で飛行し勾配で色分けできます。',
'tours.terrain.steepestShort': '最大 {grade} %',
'tours.terrain.preloading': '地形を読み込み中: {percent} %',
'tours.terrain.placeOff': '地形をクリックしてポイントを置くには「ポイントを配置」をオンにしてください。',
'tours.terrain.clickToPlan': '地形をクリックしてポイントを置きます。ルートができると、飛行して勾配で色分けできます。',
};
export default tours;
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': '투어',
'tours.mode.label': '추가 항목 보기',
'tours.title': '투어',
'tours.subtitle': '이 여행의 하이킹과 당일 투어',
'tours.empty.title': '아직 투어가 없습니다',
'tours.empty.body': '투어를 계획하거나 GPX 파일을 가져와 시작하세요.',
'tours.addToDay': '일정에 추가',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'GPX 가져오기',
'tours.badge.planned': 'TREK에서 계획됨',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': '이용 시 주의',
'tours.caution.tooltip': '고도 또는 측정 데이터가 불완전하여 주의가 필요한 상태로 가져왔습니다',
'tours.import.caution': '고도 또는 측정 데이터가 불완전하여 주의가 필요한 상태로 가져왔습니다',
@@ -53,15 +54,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmBody':
'이 투어의 저장하지 않은 변경 사항은 삭제됩니다. 저장된 투어는 변경되지 않습니다.',
'tours.planner.newTourConfirm': '변경 사항을 버리고 새 투어 시작',
'tours.planner.startingPoint': '지도를 클릭해 출발 지점을 추가하세요.',
'tours.planner.firstUseDetails':
'지점을 하나 더 추가하면 경로를 계산합니다. 경로와 고도는 자동으로 업데이트됩니다. 준비가 되면 저장하세요.',
'tours.planner.collapseElevation': '고도 그래프 접기',
'tours.planner.expandElevation': '고도 그래프 펼치기',
'tours.planner.name': '투어 이름',
'tours.planner.namePlaceholder': '이름 없는 하이킹',
'tours.planner.mapHint': '지도를 클릭해 지점을 추가하세요.',
'tours.planner.mapSearch': '장소 또는 주소 검색',
'tours.planner.placePoints': '지점 찍기',
'tours.planner.pointPlaced': '지점을 찍었습니다',
'tours.planner.details': '세부 정보',
'tours.planner.expandDetails': '세부 정보 표시',
'tours.planner.collapseDetails': '세부 정보 숨기기',
'tours.planner.safetyInfoLabel': '안전 안내',
'tours.planner.restoredShort': '초안',
'tours.planner.addPoint': '지점 추가',
'tours.planner.addPointActive': '지도를 클릭',
'tours.planner.emptyHint': '“지점 추가”를 켜고 지도를 클릭하세요. 지점이 두 개가 되면 TREK이 경로를 계산합니다.',
'tours.planner.waypoints': '경로 지점',
'tours.planner.waypointLabel': '지점 {n}',
'tours.durationMinutes': '{count}분',
@@ -94,12 +101,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
'저장하지 않은 편집 내용은 삭제됩니다. 저장된 투어와 지점, 파일, 일정 배정은 변경되지 않습니다.',
'tours.planner.saved': '투어를 저장했습니다',
'tours.planner.planAnother': '다른 투어 계획',
'tours.planner.assignToDay': '날짜에 배정',
'tours.planner.tripTours': '이 여행의 투어',
'tours.planner.safetyNote': '경로 계산은 계획을 돕는 기능이며 안전을 보장하지 않습니다.',
'tours.planner.safetyNoteDetails':
'OSM 난이도 태그는 누락되거나 잘못될 수 있습니다. 날씨, 통행 제한, 현재 현장 상태는 평가하지 않습니다.',
'tours.planner.maxDifficulty': '탐방로 최대 난이도',
'tours.planner.difficulty.t1': 'T1 - 하이킹',
'tours.planner.difficulty.t2': 'T2 - 산악 하이킹',
@@ -119,7 +123,6 @@ const tours: TranslationStrings = {
'tours.planner.status.routingFailed': '경로 계산에 실패했습니다. 마지막으로 계산에 성공한 경로를 계속 표시합니다.',
'tours.planner.status.elevation': '고도 불러오는 중…',
'tours.planner.status.elevationFailed': '고도를 불러올 수 없습니다. 계산된 경로를 계속 표시합니다.',
'tours.planner.status.ready': '경로와 고도 준비 완료',
'tours.planner.status.saving': '저장 중…',
'tours.planner.status.saveFailed': '투어를 저장하지 못했습니다. 초안은 그대로 남아 있습니다.',
'tours.planner.retry': '다시 시도',
@@ -157,6 +160,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': '오른쪽에서 투어를 열면 3D로 비행하고 경사별로 색을 입힐 수 있습니다.',
'tours.terrain.steepestShort': '최대 {grade} %',
'tours.terrain.preloading': '지형 불러오는 중: {percent} %',
'tours.terrain.placeOff': '지형을 클릭해 지점을 찍으려면 지점 찍기를 켜세요.',
'tours.terrain.clickToPlan':
'지형을 클릭해 지점을 추가하세요. 경로가 생기면 비행하고 경사별로 색을 입힐 수 있습니다.',
};
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'Tochten',
'tours.mode.label': 'Weergave toevoegen',
'tours.title': 'Tochten',
'tours.subtitle': 'Wandelingen en andere dagtochten voor deze reis',
'tours.empty.title': 'Nog geen tochten',
'tours.empty.body': 'Plan een tocht of importeer een GPX-bestand om te beginnen.',
'tours.addToDay': 'Aan dag toevoegen',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'GPX-import',
'tours.badge.planned': 'gepland in TREK',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': 'voorzichtig gebruiken',
'tours.caution.tooltip': 'Hoogtegegevens of meetwaarden onvolledig: met voorbehoud geïmporteerd',
'tours.import.caution': 'Met voorbehoud geïmporteerd: hoogtegegevens of meetwaarden onvolledig',
@@ -55,15 +56,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmBody':
'Je niet-opgeslagen wijzigingen aan deze tocht worden verworpen. Opgeslagen tochten blijven ongewijzigd.',
'tours.planner.newTourConfirm': 'Verwerpen en nieuwe tocht starten',
'tours.planner.startingPoint': 'Klik op de kaart om je startpunt toe te voegen.',
'tours.planner.firstUseDetails':
'Voeg nog een punt toe om een route te berekenen. De route en hoogtegegevens worden automatisch bijgewerkt; sla op wanneer je klaar bent.',
'tours.planner.collapseElevation': 'Hoogteprofiel inklappen',
'tours.planner.expandElevation': 'Hoogteprofiel uitklappen',
'tours.planner.name': 'Naam van de tocht',
'tours.planner.namePlaceholder': 'Naamloze wandeling',
'tours.planner.mapHint': 'Klik op de kaart om punten toe te voegen.',
'tours.planner.mapSearch': 'Zoek plaats of adres',
'tours.planner.placePoints': 'Punten plaatsen',
'tours.planner.pointPlaced': 'Punt geplaatst',
'tours.planner.details': 'Details',
'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.waypoints': 'Routepunten',
'tours.planner.waypointLabel': 'Routepunt {n}',
'tours.durationMinutes': '{count} min',
@@ -97,12 +104,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
'Je niet-opgeslagen wijzigingen worden verworpen. De opgeslagen tocht, de routepunten, bestanden en toewijzingen blijven ongewijzigd.',
'tours.planner.saved': 'Tocht opgeslagen',
'tours.planner.planAnother': 'Nog een tocht plannen',
'tours.planner.assignToDay': 'Aan een dag toewijzen',
'tours.planner.tripTours': 'Tochten van deze reis',
'tours.planner.safetyNote': 'Routeberekening helpt bij het plannen, maar biedt geen veiligheidsgarantie.',
'tours.planner.safetyNoteDetails':
'Moeilijkheidslabels in OSM kunnen ontbreken of onjuist zijn. Weer, afsluitingen en actuele omstandigheden worden niet beoordeeld.',
'tours.planner.maxDifficulty': 'Maximale moeilijkheid van het pad',
'tours.planner.difficulty.t1': 'T1 — Wandelen',
'tours.planner.difficulty.t2': 'T2 — Bergwandelen',
@@ -122,7 +126,6 @@ const tours: TranslationStrings = {
'tours.planner.status.routingFailed': 'Routeberekening mislukt. De laatste geldige route blijft zichtbaar.',
'tours.planner.status.elevation': 'Hoogtegegevens laden…',
'tours.planner.status.elevationFailed': 'Hoogtegegevens zijn niet beschikbaar. De berekende route blijft zichtbaar.',
'tours.planner.status.ready': 'Route en hoogtegegevens gereed',
'tours.planner.status.saving': 'Opslaan…',
'tours.planner.status.saveFailed': 'De tocht kon niet worden opgeslagen. Je concept is nog beschikbaar.',
'tours.planner.retry': 'Opnieuw proberen',
@@ -161,6 +164,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': 'Open rechts een tocht om er in 3D overheen te vliegen en hem op helling te kleuren.',
'tours.terrain.steepestShort': 'max. {grade} %',
'tours.terrain.preloading': 'Landschap wordt geladen: {percent} %',
'tours.terrain.placeOff': 'Zet Punten plaatsen aan om punten op het terrein te klikken.',
'tours.terrain.clickToPlan':
'Klik in het terrein om punten te zetten. Zodra er een route is, kun je eroverheen vliegen en hem op helling kleuren.',
};
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'Wycieczki',
'tours.mode.label': 'Widok dodawania',
'tours.title': 'Wycieczki',
'tours.subtitle': 'Wędrówki i inne jednodniowe wycieczki podczas tej podróży',
'tours.empty.title': 'Nie ma jeszcze wycieczek',
'tours.empty.body': 'Zaplanuj wycieczkę lub zaimportuj plik GPX, aby rozpocząć.',
'tours.addToDay': 'Dodaj do dnia',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'Import GPX',
'tours.badge.planned': 'zaplanowano w TREK',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': 'korzystaj ostrożnie',
'tours.caution.tooltip': 'Niepełne dane wysokościowe lub parametry – import z zastrzeżeniami',
'tours.import.caution': 'Import z zastrzeżeniami – niepełne dane wysokościowe lub parametry',
@@ -57,15 +58,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmBody':
'Niezapisane zmiany tej wycieczki zostaną odrzucone. Zapisane wycieczki nie zostaną zmienione.',
'tours.planner.newTourConfirm': 'Odrzuć zmiany i rozpocznij nową wycieczkę',
'tours.planner.startingPoint': 'Kliknij mapę, aby dodać punkt początkowy.',
'tours.planner.firstUseDetails':
'Dodaj kolejny punkt, aby obliczyć trasę. Trasa i wysokości aktualizują się automatycznie; zapisz wycieczkę, gdy będzie gotowa.',
'tours.planner.collapseElevation': 'Zwiń profil wysokościowy',
'tours.planner.expandElevation': 'Rozwiń profil wysokościowy',
'tours.planner.name': 'Nazwa wycieczki',
'tours.planner.namePlaceholder': 'Wędrówka bez nazwy',
'tours.planner.mapHint': 'Kliknij mapę, aby dodać punkty.',
'tours.planner.mapSearch': 'Szukaj miejsca lub adresu',
'tours.planner.placePoints': 'Ustawiaj punkty',
'tours.planner.pointPlaced': 'Punkt ustawiony',
'tours.planner.details': 'Szczegóły',
'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.waypoints': 'Punkty trasy',
'tours.planner.waypointLabel': 'Punkt trasy {n}',
'tours.durationMinutes': '{count} min',
@@ -101,12 +108,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
'Niezapisane zmiany zostaną odrzucone. Zapisana wycieczka, jej punkty, pliki i przypisania pozostaną bez zmian.',
'tours.planner.saved': 'Wycieczka zapisana',
'tours.planner.planAnother': 'Zaplanuj kolejną',
'tours.planner.assignToDay': 'Przypisz do dnia',
'tours.planner.tripTours': 'Wycieczki w tej podróży',
'tours.planner.safetyNote': 'Wyznaczanie trasy pomaga w planowaniu, ale nie gwarantuje bezpieczeństwa.',
'tours.planner.safetyNoteDetails':
'Oznaczeń trudności w OSM może brakować lub mogą być błędne. Pogoda, zamknięcia szlaków i aktualne warunki nie są oceniane.',
'tours.planner.maxDifficulty': 'Maksymalna trudność szlaku',
'tours.planner.difficulty.t1': 'T1 – Turystyka piesza',
'tours.planner.difficulty.t2': 'T2 – Turystyka górska',
@@ -126,7 +130,6 @@ const tours: TranslationStrings = {
'tours.planner.status.routingFailed': 'Nie udało się obliczyć trasy. Nadal widoczna jest ostatnia poprawna trasa.',
'tours.planner.status.elevation': 'Wczytywanie wysokości…',
'tours.planner.status.elevationFailed': 'Dane wysokościowe są niedostępne. Obliczona trasa nadal jest widoczna.',
'tours.planner.status.ready': 'Trasa i wysokości są gotowe',
'tours.planner.status.saving': 'Zapisywanie…',
'tours.planner.status.saveFailed': 'Nie udało się zapisać wycieczki. Szkic został zachowany.',
'tours.planner.retry': 'Spróbuj ponownie',
@@ -165,6 +168,7 @@ const tours: TranslationStrings = {
'Otwórz wycieczkę po prawej, aby przelecieć nad nią w 3D i pokolorować ją według nachylenia.',
'tours.terrain.steepestShort': 'maks. {grade}%',
'tours.terrain.preloading': 'Wczytywanie krajobrazu: {percent}%',
'tours.terrain.placeOff': 'Włącz Ustawiaj punkty, aby klikaniem dodawać punkty w terenie.',
'tours.terrain.clickToPlan':
'Kliknij teren, aby dodać punkty. Gdy powstanie trasa, można nad nią przelecieć i pokolorować ją według nachylenia.',
};
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'Туры',
'tours.mode.label': 'Режим добавления',
'tours.title': 'Туры',
'tours.subtitle': 'Походы и другие однодневные туры в этой поездке',
'tours.empty.title': 'Туров пока нет',
'tours.empty.body': 'Для начала спланируйте тур или импортируйте файл GPX.',
'tours.addToDay': 'Добавить к дню',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'Импорт GPX',
'tours.badge.planned': 'спланировано в TREK',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': 'используйте с осторожностью',
'tours.caution.tooltip': 'Данные о высоте или показателях неполные — импорт с оговорками',
'tours.import.caution': 'Импорт с оговорками — данные о высоте или показателях неполные',
@@ -57,15 +58,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmBody':
'Несохранённые изменения этого тура будут отменены. Сохранённые туры не изменятся.',
'tours.planner.newTourConfirm': 'Отменить изменения и начать новый тур',
'tours.planner.startingPoint': 'Нажмите на карту, чтобы добавить начальную точку.',
'tours.planner.firstUseDetails':
'Добавьте ещё одну точку для расчёта маршрута. Маршрут и высоты обновляются автоматически; сохраните тур, когда он будет готов.',
'tours.planner.collapseElevation': 'Свернуть профиль высот',
'tours.planner.expandElevation': 'Развернуть профиль высот',
'tours.planner.name': 'Название тура',
'tours.planner.namePlaceholder': 'Поход без названия',
'tours.planner.mapHint': 'Нажмите на карту, чтобы добавить точки.',
'tours.planner.mapSearch': 'Поиск места или адреса',
'tours.planner.placePoints': 'Ставить точки',
'tours.planner.pointPlaced': 'Точка поставлена',
'tours.planner.details': 'Подробности',
'tours.planner.expandDetails': 'Показать подробности',
'tours.planner.collapseDetails': 'Скрыть подробности',
'tours.planner.safetyInfoLabel': 'О безопасности',
'tours.planner.restoredShort': 'Черновик',
'tours.planner.addPoint': 'Добавить точку',
'tours.planner.addPointActive': 'Нажмите на карту',
'tours.planner.emptyHint': 'Включите «Добавить точку» и нажмите на карту. Начиная с двух точек TREK рассчитает маршрут.',
'tours.planner.waypoints': 'Путевые точки',
'tours.planner.waypointLabel': 'Путевая точка {n}',
'tours.durationMinutes': '{count} мин',
@@ -101,12 +108,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
'Несохранённые изменения будут отменены. Сохранённый тур, его точки, файлы и назначения на дни не изменятся.',
'tours.planner.saved': 'Тур сохранён',
'tours.planner.planAnother': 'Спланировать ещё',
'tours.planner.assignToDay': 'Назначить на день',
'tours.planner.tripTours': 'Туры этой поездки',
'tours.planner.safetyNote': 'Расчёт маршрута помогает в планировании, но не гарантирует безопасность.',
'tours.planner.safetyNoteDetails':
'Теги сложности OSM могут отсутствовать или быть неверными. Погода, закрытые участки и текущие условия не оцениваются.',
'tours.planner.maxDifficulty': 'Максимальная сложность тропы',
'tours.planner.difficulty.t1': 'T1 — Пеший поход',
'tours.planner.difficulty.t2': 'T2 — Горный поход',
@@ -127,7 +131,6 @@ const tours: TranslationStrings = {
'Не удалось рассчитать маршрут. По-прежнему отображается последний успешно рассчитанный маршрут.',
'tours.planner.status.elevation': 'Загрузка высот…',
'tours.planner.status.elevationFailed': 'Данные о высоте недоступны. Рассчитанный маршрут по-прежнему отображается.',
'tours.planner.status.ready': 'Маршрут и высоты готовы',
'tours.planner.status.saving': 'Сохранение…',
'tours.planner.status.saveFailed': 'Не удалось сохранить тур. Черновик по-прежнему доступен здесь.',
'tours.planner.retry': 'Повторить',
@@ -165,6 +168,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': 'Откройте тур справа, чтобы облететь его в 3D и раскрасить по уклону.',
'tours.terrain.steepestShort': 'макс. {grade} %',
'tours.terrain.preloading': 'Загрузка ландшафта: {percent} %',
'tours.terrain.placeOff': 'Включите «Ставить точки», чтобы ставить точки на рельефе щелчком.',
'tours.terrain.clickToPlan':
'Нажмите на рельеф, чтобы поставить точки. Когда появится маршрут, его можно облететь и раскрасить по уклону.',
};
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'Túry',
'tours.mode.label': 'Zobrazenie pridávania',
'tours.title': 'Túry',
'tours.subtitle': 'Pešie a ďalšie jednodňové túry počas tejto cesty',
'tours.empty.title': 'Zatiaľ žiadne túry',
'tours.empty.body': 'Začnite plánovaním túry alebo importom súboru GPX.',
'tours.addToDay': 'Pridať k dňu',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'Import GPX',
'tours.badge.planned': 'Naplánované v TREK',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': 'Používajte opatrne',
'tours.caution.tooltip': 'Neúplné výškové údaje alebo metriky – importované s upozornením',
'tours.import.caution': 'Importované s upozornením – neúplné výškové údaje alebo metriky',
@@ -54,15 +55,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmTitle': 'Začať novú túru?',
'tours.planner.newTourConfirmBody': 'Neuložené zmeny tejto túry sa zahodia. Uložené túry zostanú nezmenené.',
'tours.planner.newTourConfirm': 'Zahodiť zmeny a začať novú túru',
'tours.planner.startingPoint': 'Kliknite na mapu a pridajte začiatočný bod.',
'tours.planner.firstUseDetails':
'Pridajte ďalší bod na výpočet trasy. Trasa a výškové údaje sa aktualizujú automaticky; hotový návrh uložte.',
'tours.planner.collapseElevation': 'Zbaliť výškový profil',
'tours.planner.expandElevation': 'Rozbaliť výškový profil',
'tours.planner.name': 'Názov túry',
'tours.planner.namePlaceholder': 'Pešia túra bez názvu',
'tours.planner.mapHint': 'Kliknutím do mapy pridáte body.',
'tours.planner.mapSearch': 'Hľadať miesto alebo adresu',
'tours.planner.placePoints': 'Pridávať body',
'tours.planner.pointPlaced': 'Bod pridaný',
'tours.planner.details': 'Podrobnosti',
'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.waypoints': 'Body trasy',
'tours.planner.waypointLabel': 'Bod trasy {n}',
'tours.durationMinutes': '{count} min',
@@ -97,12 +104,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
'Neuložené úpravy sa zahodia. Uložená túra, jej body trasy, súbory a priradenia zostanú nezmenené.',
'tours.planner.saved': 'Túra uložená',
'tours.planner.planAnother': 'Naplánovať ďalšiu túru',
'tours.planner.assignToDay': 'Priradiť k dňu',
'tours.planner.tripTours': 'Túry tejto cesty',
'tours.planner.safetyNote': 'Výpočet trasy pomáha pri plánovaní, nezaručuje bezpečnosť.',
'tours.planner.safetyNoteDetails':
'Značky náročnosti v OSM môžu chýbať alebo byť nesprávne. Počasie, uzávery a aktuálne podmienky sa nevyhodnocujú.',
'tours.planner.maxDifficulty': 'Maximálna náročnosť chodníka',
'tours.planner.difficulty.t1': 'T1 – Pešia turistika',
'tours.planner.difficulty.t2': 'T2 – Horská turistika',
@@ -122,7 +126,6 @@ const tours: TranslationStrings = {
'tours.planner.status.routingFailed': 'Výpočet trasy zlyhal. Naďalej sa zobrazuje posledná úspešne vypočítaná trasa.',
'tours.planner.status.elevation': 'Načítavajú sa výškové údaje…',
'tours.planner.status.elevationFailed': 'Výškové údaje nie sú dostupné. Vypočítaná trasa sa naďalej zobrazuje.',
'tours.planner.status.ready': 'Trasa a výškové údaje sú pripravené',
'tours.planner.status.saving': 'Ukladá sa…',
'tours.planner.status.saveFailed': 'Túru sa nepodarilo uložiť. Návrh zostáva zachovaný.',
'tours.planner.retry': 'Skúsiť znova',
@@ -160,6 +163,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': 'Otvorte vpravo túru, aby ste ju mohli preletieť v 3D a zafarbiť podľa sklonu.',
'tours.terrain.steepestShort': 'max. {grade} %',
'tours.terrain.preloading': 'Načítava sa krajina: {percent} %',
'tours.terrain.placeOff': 'Zapni Pridávať body a klikaním ich pridávaj do terénu.',
'tours.terrain.clickToPlan':
'Kliknutím do terénu pridáte body. Keď vznikne trasa, dá sa preletieť a zafarbiť podľa sklonu.',
};
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'Turer',
'tours.mode.label': 'Lägg till vy',
'tours.title': 'Turer',
'tours.subtitle': 'Vandringar och andra dagsturer för den här resan',
'tours.empty.title': 'Inga turer ännu',
'tours.empty.body': 'Planera en tur eller importera en GPX-fil för att komma igång.',
'tours.addToDay': 'Lägg till på en dag',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'GPX-import',
'tours.badge.planned': 'planerad i TREK',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': 'använd med försiktighet',
'tours.caution.tooltip': 'Ofullständiga höjddata eller mätvärden: importerad med förbehåll',
'tours.import.caution': 'Importerad med förbehåll: ofullständiga höjddata eller mätvärden',
@@ -54,15 +55,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmTitle': 'Börja på en ny tur?',
'tours.planner.newTourConfirmBody': 'Dina osparade ändringar i den här turen kastas. Sparade turer ändras inte.',
'tours.planner.newTourConfirm': 'Kasta ändringar och börja på ny tur',
'tours.planner.startingPoint': 'Klicka på kartan för att lägga till din startpunkt.',
'tours.planner.firstUseDetails':
'Lägg till en punkt till för att beräkna en rutt. Rutten och höjddata uppdateras automatiskt; spara när du är klar.',
'tours.planner.collapseElevation': 'Fäll ihop höjdprofil',
'tours.planner.expandElevation': 'Fäll ut höjdprofil',
'tours.planner.name': 'Turens namn',
'tours.planner.namePlaceholder': 'Vandring utan namn',
'tours.planner.mapHint': 'Klicka på kartan för att lägga till punkter.',
'tours.planner.mapSearch': 'Sök plats eller adress',
'tours.planner.placePoints': 'Sätt punkter',
'tours.planner.pointPlaced': 'Punkt satt',
'tours.planner.details': 'Detaljer',
'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.waypoints': 'Ruttpunkter',
'tours.planner.waypointLabel': 'Ruttpunkt {n}',
'tours.durationMinutes': '{count} min',
@@ -96,12 +103,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
'Dina osparade ändringar kastas. Den sparade turen, dess ruttpunkter, filer och tilldelningar ändras inte.',
'tours.planner.saved': 'Tur sparad',
'tours.planner.planAnother': 'Planera en till',
'tours.planner.assignToDay': 'Tilldela till en dag',
'tours.planner.tripTours': 'Turer för den här resan',
'tours.planner.safetyNote': 'Ruttberäkningen hjälper till med planeringen, men garanterar inte säkerheten.',
'tours.planner.safetyNoteDetails':
'Svårighetstaggar i OSM kan saknas eller vara felaktiga. Väder, avstängningar och aktuella förhållanden bedöms inte.',
'tours.planner.maxDifficulty': 'Högsta svårighetsgrad för leden',
'tours.planner.difficulty.t1': 'T1 — Vandring',
'tours.planner.difficulty.t2': 'T2 — Bergsvandring',
@@ -121,7 +125,6 @@ const tours: TranslationStrings = {
'tours.planner.status.routingFailed': 'Ruttberäkningen misslyckades. Den senaste giltiga rutten visas fortfarande.',
'tours.planner.status.elevation': 'Läser in höjddata…',
'tours.planner.status.elevationFailed': 'Höjddata är inte tillgängliga. Den beräknade rutten visas fortfarande.',
'tours.planner.status.ready': 'Rutt och höjddata klara',
'tours.planner.status.saving': 'Sparar…',
'tours.planner.status.saveFailed': 'Turen kunde inte sparas. Ditt utkast finns kvar.',
'tours.planner.retry': 'Försök igen',
@@ -159,6 +162,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': 'Öppna en tur till höger för att flyga över den i 3D och färga den efter lutning.',
'tours.terrain.steepestShort': 'högst {grade} %',
'tours.terrain.preloading': 'Landskapet laddas: {percent} %',
'tours.terrain.placeOff': 'Slå på Sätt punkter för att klicka ut punkter i terrängen.',
'tours.terrain.clickToPlan':
'Klicka i terrängen för att sätta ut punkter. När det finns en rutt kan du flyga över den och färga den efter lutning.',
};
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'ทัวร์',
'tours.mode.label': 'มุมมองการเพิ่ม',
'tours.title': 'ทัวร์',
'tours.subtitle': 'เดินป่าและทัวร์แบบวันเดียวอื่นๆ ในทริปนี้',
'tours.empty.title': 'ยังไม่มีทัวร์',
'tours.empty.body': 'วางแผนทัวร์หรือนำเข้าไฟล์ GPX เพื่อเริ่มต้น',
'tours.addToDay': 'เพิ่มไปยังวัน',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'นำเข้าจาก GPX',
'tours.badge.planned': 'วางแผนใน TREK',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': 'ใช้ด้วยความระมัดระวัง',
'tours.caution.tooltip': 'ข้อมูลระดับความสูงหรือค่าสถิติไม่ครบถ้วน ใช้ข้อมูลที่นำเข้าด้วยความระมัดระวัง',
'tours.import.caution': 'นำเข้าแล้ว แต่ข้อมูลระดับความสูงหรือค่าสถิติไม่ครบถ้วน โปรดใช้ด้วยความระมัดระวัง',
@@ -53,15 +54,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmBody':
'การเปลี่ยนแปลงของทัวร์นี้ที่ยังไม่บันทึกจะถูกทิ้ง ทัวร์ที่บันทึกแล้วจะไม่เปลี่ยนแปลง',
'tours.planner.newTourConfirm': 'ทิ้งการเปลี่ยนแปลงและเริ่มทัวร์ใหม่',
'tours.planner.startingPoint': 'คลิกแผนที่เพื่อเพิ่มจุดเริ่มต้น',
'tours.planner.firstUseDetails':
'เพิ่มอีกจุดเพื่อคำนวณเส้นทาง เส้นทางและระดับความสูงจะอัปเดตอัตโนมัติ บันทึกเมื่อพร้อม',
'tours.planner.collapseElevation': 'ยุบกราฟระดับความสูง',
'tours.planner.expandElevation': 'ขยายกราฟระดับความสูง',
'tours.planner.name': 'ชื่อทัวร์',
'tours.planner.namePlaceholder': 'เส้นทางเดินป่าที่ยังไม่มีชื่อ',
'tours.planner.mapHint': 'คลิกแผนที่เพื่อเพิ่มจุด',
'tours.planner.mapSearch': 'ค้นหาสถานที่หรือที่อยู่',
'tours.planner.placePoints': 'วางจุด',
'tours.planner.pointPlaced': 'วางจุดแล้ว',
'tours.planner.details': 'รายละเอียด',
'tours.planner.expandDetails': 'แสดงรายละเอียด',
'tours.planner.collapseDetails': 'ซ่อนรายละเอียด',
'tours.planner.safetyInfoLabel': 'เกี่ยวกับความปลอดภัย',
'tours.planner.restoredShort': 'ฉบับร่าง',
'tours.planner.addPoint': 'เพิ่มจุด',
'tours.planner.addPointActive': 'คลิกบนแผนที่',
'tours.planner.emptyHint': 'เปิด “เพิ่มจุด” แล้วคลิกบนแผนที่ เมื่อมีสองจุดขึ้นไป TREK จะคำนวณเส้นทาง',
'tours.planner.waypoints': 'จุดแวะ',
'tours.planner.waypointLabel': 'จุดแวะ {n}',
'tours.durationMinutes': '{count} นาที',
@@ -94,12 +101,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
'การแก้ไขที่ยังไม่บันทึกจะถูกทิ้ง ทัวร์ที่บันทึกแล้ว รวมถึงจุดแวะ ไฟล์ และการจัดลงวันจะไม่เปลี่ยนแปลง',
'tours.planner.saved': 'บันทึกทัวร์แล้ว',
'tours.planner.planAnother': 'วางแผนทัวร์อื่น',
'tours.planner.assignToDay': 'เพิ่มไปยังวัน',
'tours.planner.tripTours': 'ทัวร์ในทริปนี้',
'tours.planner.safetyNote': 'การคำนวณเส้นทางเป็นตัวช่วยวางแผน ไม่ใช่การรับประกันความปลอดภัย',
'tours.planner.safetyNoteDetails':
'แท็กระดับความยากใน OSM อาจไม่มีหรือไม่ถูกต้อง ระบบไม่ได้ประเมินสภาพอากาศ การปิดเส้นทาง หรือสภาพพื้นที่ปัจจุบัน',
'tours.planner.maxDifficulty': 'ระดับความยากสูงสุดของทางเดิน',
'tours.planner.difficulty.t1': 'T1 - เดินป่า',
'tours.planner.difficulty.t2': 'T2 - เดินป่าบนภูเขา',
@@ -119,7 +123,6 @@ const tours: TranslationStrings = {
'tours.planner.status.routingFailed': 'คำนวณเส้นทางไม่สำเร็จ ยังคงแสดงเส้นทางล่าสุดที่คำนวณสำเร็จ',
'tours.planner.status.elevation': 'กำลังโหลดระดับความสูง...',
'tours.planner.status.elevationFailed': 'ไม่มีข้อมูลระดับความสูง ยังคงแสดงเส้นทางที่คำนวณแล้ว',
'tours.planner.status.ready': 'เส้นทางและระดับความสูงพร้อมแล้ว',
'tours.planner.status.saving': 'กำลังบันทึก...',
'tours.planner.status.saveFailed': 'ไม่สามารถบันทึกทัวร์ได้ ฉบับร่างยังอยู่',
'tours.planner.retry': 'ลองอีกครั้ง',
@@ -156,6 +159,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': 'เปิดทัวร์ทางด้านขวาเพื่อบินผ่านแบบ 3D และระบายสีตามความชัน',
'tours.terrain.steepestShort': 'สูงสุด {grade} %',
'tours.terrain.preloading': 'กำลังโหลดภูมิทัศน์: {percent} %',
'tours.terrain.placeOff': 'เปิด วางจุด เพื่อคลิกวางจุดบนภูมิประเทศ',
'tours.terrain.clickToPlan': 'คลิกบนภูมิประเทศเพื่อวางจุด เมื่อมีเส้นทางแล้ว จะบินผ่านและระบายสีตามความชันได้',
};
export default tours;
-1
View File
@@ -83,7 +83,6 @@ describe('Tours locale contracts', () => {
}
for (const key of [
'tours.planner.safetyNote',
'tours.planner.safetyNoteDetails',
'tours.planner.difficulty.t3Warning',
'tours.planner.difficulty.alpineBody',
]) {
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'Turlar',
'tours.mode.label': 'Ekleme görünümü',
'tours.title': 'Turlar',
'tours.subtitle': 'Bu seyahatteki doğa yürüyüşleri ve diğer günübirlik turlar',
'tours.empty.title': 'Henüz tur yok',
'tours.empty.body': 'Başlamak için bir tur planlayın veya GPX dosyası içe aktarın.',
'tours.addToDay': 'Güne ekle',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'GPX içe aktarımı',
'tours.badge.planned': 'TREK ile planlandı',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': 'dikkatli kullanın',
'tours.caution.tooltip': 'Yükseklik verileri veya ölçümler eksik – çekinceyle içe aktarıldı',
'tours.import.caution': 'Çekinceyle içe aktarıldı – yükseklik verileri veya ölçümler eksik',
@@ -54,15 +55,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmBody':
'Bu turdaki kaydedilmemiş değişiklikler silinecek. Kaydedilmiş turlar değişmeyecek.',
'tours.planner.newTourConfirm': 'Değişiklikleri sil ve yeni tur başlat',
'tours.planner.startingPoint': 'Başlangıç noktasını eklemek için haritaya tıklayın.',
'tours.planner.firstUseDetails':
'Rotayı hesaplamak için bir nokta daha ekleyin. Rota ve yükseklik verileri otomatik güncellenir; hazır olduğunda kaydedin.',
'tours.planner.collapseElevation': 'Yükseklik profilini daralt',
'tours.planner.expandElevation': 'Yükseklik profilini genişlet',
'tours.planner.name': 'Tur adı',
'tours.planner.namePlaceholder': 'Adsız doğa yürüyüşü',
'tours.planner.mapHint': 'Nokta eklemek için haritaya tıklayın.',
'tours.planner.mapSearch': 'Yer veya adres ara',
'tours.planner.placePoints': 'Nokta koy',
'tours.planner.pointPlaced': 'Nokta kondu',
'tours.planner.details': 'Ayrıntılar',
'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.waypoints': 'Rota noktaları',
'tours.planner.waypointLabel': 'Rota noktası {n}',
'tours.durationMinutes': '{count} dk',
@@ -96,12 +103,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
'Kaydedilmemiş düzenlemeler silinecek. Kaydedilmiş tur, rota noktaları, dosyaları ve gün atamaları değişmeyecek.',
'tours.planner.saved': 'Tur kaydedildi',
'tours.planner.planAnother': 'Başka tur planla',
'tours.planner.assignToDay': 'Güne ata',
'tours.planner.tripTours': 'Bu seyahatin turları',
'tours.planner.safetyNote': 'Rota hesaplama planlamaya yardımcı olur, güvenlik garantisi vermez.',
'tours.planner.safetyNoteDetails':
'OSM zorluk etiketleri eksik veya hatalı olabilir. Hava durumu, kapalı yollar ve güncel koşullar değerlendirilmez.',
'tours.planner.maxDifficulty': 'En yüksek patika zorluğu',
'tours.planner.difficulty.t1': 'T1 – Doğa yürüyüşü',
'tours.planner.difficulty.t2': 'T2 – Dağ yürüyüşü',
@@ -121,7 +125,6 @@ const tours: TranslationStrings = {
'tours.planner.status.routingFailed': 'Rota hesaplanamadı. Son başarılı rota hâlâ gösteriliyor.',
'tours.planner.status.elevation': 'Yükseklik verileri yükleniyor…',
'tours.planner.status.elevationFailed': 'Yükseklik verileri kullanılamıyor. Hesaplanan rota hâlâ gösteriliyor.',
'tours.planner.status.ready': 'Rota ve yükseklik verileri hazır',
'tours.planner.status.saving': 'Kaydediliyor…',
'tours.planner.status.saveFailed': 'Tur kaydedilemedi. Taslağınız hâlâ burada.',
'tours.planner.retry': 'Yeniden dene',
@@ -160,6 +163,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': 'Bir turu 3D olarak uçmak ve eğime göre renklendirmek için sağ taraftan açın.',
'tours.terrain.steepestShort': 'en fazla %{grade}',
'tours.terrain.preloading': 'Manzara yükleniyor: %{percent}',
'tours.terrain.placeOff': 'Araziye tıklayarak nokta koymak için Nokta koy aracını aç.',
'tours.terrain.clickToPlan':
'Nokta eklemek için araziye tıklayın. Bir rota oluştuğunda üzerinden uçabilir ve eğime göre renklendirebilirsiniz.',
};
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'Тури',
'tours.mode.label': 'Режим додавання',
'tours.title': 'Тури',
'tours.subtitle': 'Походи та інші одноденні тури під час цієї подорожі',
'tours.empty.title': 'Турів поки немає',
'tours.empty.body': 'Для початку сплануйте тур або імпортуйте файл GPX.',
'tours.addToDay': 'Додати до дня',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'Імпорт GPX',
'tours.badge.planned': 'сплановано в TREK',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': 'використовуйте обережно',
'tours.caution.tooltip': 'Дані про висоту або показники неповні — імпорт із застереженнями',
'tours.import.caution': 'Імпорт із застереженнями — дані про висоту або показники неповні',
@@ -56,15 +57,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmTitle': 'Почати новий тур?',
'tours.planner.newTourConfirmBody': 'Незбережені зміни цього туру буде скасовано. Збережені тури не зміняться.',
'tours.planner.newTourConfirm': 'Скасувати зміни й почати новий тур',
'tours.planner.startingPoint': 'Натисніть на карту, щоб додати початкову точку.',
'tours.planner.firstUseDetails':
'Додайте ще одну точку для розрахунку маршруту. Маршрут і висоти оновлюються автоматично; збережіть тур, коли він буде готовий.',
'tours.planner.collapseElevation': 'Згорнути профіль висот',
'tours.planner.expandElevation': 'Розгорнути профіль висот',
'tours.planner.name': 'Назва туру',
'tours.planner.namePlaceholder': 'Похід без назви',
'tours.planner.mapHint': 'Натисніть на карту, щоб додати точки.',
'tours.planner.mapSearch': 'Пошук місця або адреси',
'tours.planner.placePoints': 'Ставити точки',
'tours.planner.pointPlaced': 'Точку поставлено',
'tours.planner.details': 'Подробиці',
'tours.planner.expandDetails': 'Показати подробиці',
'tours.planner.collapseDetails': 'Сховати подробиці',
'tours.planner.safetyInfoLabel': 'Про безпеку',
'tours.planner.restoredShort': 'Чернетка',
'tours.planner.addPoint': 'Додати точку',
'tours.planner.addPointActive': 'Натисніть на карту',
'tours.planner.emptyHint': 'Увімкніть «Додати точку» і натисніть на карту. Від двох точок TREK розрахує маршрут.',
'tours.planner.waypoints': 'Маршрутні точки',
'tours.planner.waypointLabel': 'Маршрутна точка {n}',
'tours.durationMinutes': '{count} хв',
@@ -100,12 +107,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
'Незбережені зміни буде скасовано. Збережений тур, його точки, файли та призначення на дні не зміняться.',
'tours.planner.saved': 'Тур збережено',
'tours.planner.planAnother': 'Спланувати ще',
'tours.planner.assignToDay': 'Призначити на день',
'tours.planner.tripTours': 'Тури цієї подорожі',
'tours.planner.safetyNote': 'Розрахунок маршруту допомагає в плануванні, але не гарантує безпеки.',
'tours.planner.safetyNoteDetails':
'Теги складності OSM можуть бути відсутні або хибні. Погода, закриті ділянки й поточні умови не оцінюються.',
'tours.planner.maxDifficulty': 'Максимальна складність стежки',
'tours.planner.difficulty.t1': 'T1 — Піший похід',
'tours.planner.difficulty.t2': 'T2 — Гірський похід',
@@ -126,7 +130,6 @@ const tours: TranslationStrings = {
'Не вдалося розрахувати маршрут. Далі відображається останній успішно розрахований маршрут.',
'tours.planner.status.elevation': 'Завантаження висот…',
'tours.planner.status.elevationFailed': 'Дані про висоту недоступні. Розрахований маршрут далі відображається.',
'tours.planner.status.ready': 'Маршрут і висоти готові',
'tours.planner.status.saving': 'Збереження…',
'tours.planner.status.saveFailed': 'Не вдалося зберегти тур. Чернетка залишилася доступною.',
'tours.planner.retry': 'Повторити',
@@ -164,6 +167,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': 'Відкрийте тур праворуч, щоб облетіти його в 3D і розфарбувати за ухилом.',
'tours.terrain.steepestShort': 'макс. {grade} %',
'tours.terrain.preloading': 'Завантаження ландшафту: {percent} %',
'tours.terrain.placeOff': 'Увімкніть «Ставити точки», щоб ставити точки на рельєфі клацанням.',
'tours.terrain.clickToPlan':
"Натисніть на рельєф, щоб поставити точки. Коли з'явиться маршрут, його можна облетіти й розфарбувати за ухилом.",
};
+13 -9
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': 'Chuyến tham quan',
'tours.mode.label': 'Chế độ thêm',
'tours.title': 'Chuyến tham quan',
'tours.subtitle': 'Các chuyến đi bộ đường dài và chuyến tham quan trong ngày của chuyến đi này',
'tours.empty.title': 'Chưa có chuyến tham quan',
'tours.empty.body': 'Lên kế hoạch cho một chuyến tham quan hoặc nhập tệp GPX để bắt đầu.',
'tours.addToDay': 'Thêm vào ngày',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'Nhập từ GPX',
'tours.badge.planned': 'lên kế hoạch trong TREK',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': 'cần thận trọng khi sử dụng',
'tours.caution.tooltip': 'Dữ liệu độ cao hoặc thông số chưa đầy đủ; đã nhập kèm cảnh báo',
'tours.import.caution': 'Đã nhập kèm cảnh báo; dữ liệu độ cao hoặc thông số chưa đầy đủ',
@@ -54,15 +55,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmBody':
'Các thay đổi chưa lưu của chuyến tham quan này sẽ bị bỏ. Các chuyến tham quan đã lưu sẽ không thay đổi.',
'tours.planner.newTourConfirm': 'Bỏ thay đổi và bắt đầu chuyến tham quan mới',
'tours.planner.startingPoint': 'Nhấp vào bản đồ để thêm điểm xuất phát.',
'tours.planner.firstUseDetails':
'Thêm một điểm nữa để tính tuyến đường. Tuyến đường và độ cao được cập nhật tự động; hãy lưu khi hoàn tất.',
'tours.planner.collapseElevation': 'Thu gọn biểu đồ độ cao',
'tours.planner.expandElevation': 'Mở rộng biểu đồ độ cao',
'tours.planner.name': 'Tên chuyến tham quan',
'tours.planner.namePlaceholder': 'Chuyến đi bộ đường dài chưa có tên',
'tours.planner.mapHint': 'Nhấp vào bản đồ để thêm điểm.',
'tours.planner.mapSearch': 'Tìm địa điểm hoặc địa chỉ',
'tours.planner.placePoints': 'Đặt điểm',
'tours.planner.pointPlaced': 'Đã đặt điểm',
'tours.planner.details': 'Chi tiết',
'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.waypoints': 'Các điểm trên tuyến',
'tours.planner.waypointLabel': 'Điểm {n}',
'tours.durationMinutes': '{count} phút',
@@ -96,12 +103,9 @@ const tours: TranslationStrings = {
'tours.planner.discardBody':
'Các chỉnh sửa chưa lưu sẽ bị bỏ. Chuyến tham quan đã lưu cùng các điểm, tệp và ngày được gán sẽ không thay đổi.',
'tours.planner.saved': 'Đã lưu chuyến tham quan',
'tours.planner.planAnother': 'Lên kế hoạch chuyến khác',
'tours.planner.assignToDay': 'Gán vào ngày',
'tours.planner.tripTours': 'Các chuyến tham quan của chuyến đi này',
'tours.planner.safetyNote': 'Việc tính tuyến đường chỉ hỗ trợ lập kế hoạch, không bảo đảm an toàn.',
'tours.planner.safetyNoteDetails':
'Nhãn độ khó trên OSM có thể bị thiếu hoặc sai. Thời tiết, việc đóng đường và điều kiện hiện tại không được đánh giá.',
'tours.planner.maxDifficulty': 'Độ khó tối đa của đường mòn',
'tours.planner.difficulty.t1': 'T1 - Đi bộ đường dài',
'tours.planner.difficulty.t2': 'T2 - Đi bộ đường dài trên núi',
@@ -122,7 +126,6 @@ const tours: TranslationStrings = {
'Tính tuyến đường thất bại. Tuyến được tính thành công gần nhất vẫn được hiển thị.',
'tours.planner.status.elevation': 'Đang tải độ cao...',
'tours.planner.status.elevationFailed': 'Không có dữ liệu độ cao. Tuyến đường đã tính vẫn được hiển thị.',
'tours.planner.status.ready': 'Tuyến đường và độ cao đã sẵn sàng',
'tours.planner.status.saving': 'Đang lưu...',
'tours.planner.status.saveFailed': 'Không thể lưu chuyến tham quan. Bản nháp vẫn được giữ lại.',
'tours.planner.retry': 'Thử lại',
@@ -160,6 +163,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': 'Mở một chuyến tham quan ở bên phải để bay qua ở chế độ 3D và tô màu theo độ dốc.',
'tours.terrain.steepestShort': 'tối đa {grade} %',
'tours.terrain.preloading': 'Đang tải cảnh quan: {percent} %',
'tours.terrain.placeOff': 'Bật Đặt điểm để nhấp đặt điểm trên địa hình.',
'tours.terrain.clickToPlan':
'Nhấp vào địa hình để đặt điểm. Khi đã có tuyến đường, bạn có thể bay qua và tô màu theo độ dốc.',
};
+13 -7
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': '遊程',
'tours.mode.label': '新增檢視',
'tours.title': '遊程',
'tours.subtitle': '這趟旅行的健行與其他一日遊活動',
'tours.empty.title': '尚無遊程',
'tours.empty.body': '規劃遊程或匯入 GPX 檔案,即可開始。',
'tours.addToDay': '加入某一天',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'GPX 匯入',
'tours.badge.planned': '在 TREK 中規劃',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': '請謹慎使用',
'tours.caution.tooltip': '海拔或指標資料不完整,已附警告匯入',
'tours.import.caution': '已附警告匯入,海拔或指標資料不完整',
@@ -51,14 +52,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmTitle': '開始新的遊程?',
'tours.planner.newTourConfirmBody': '這個遊程中尚未儲存的變更將被捨棄。已儲存的遊程不會變更。',
'tours.planner.newTourConfirm': '捨棄變更並開始新遊程',
'tours.planner.startingPoint': '點選地圖加入起點。',
'tours.planner.firstUseDetails': '再加入一個點即可計算路線。路線與海拔會自動更新,完成後請儲存。',
'tours.planner.collapseElevation': '收合海拔剖面圖',
'tours.planner.expandElevation': '展開海拔剖面圖',
'tours.planner.name': '遊程名稱',
'tours.planner.namePlaceholder': '未命名的健行',
'tours.planner.mapHint': '點選地圖以加入地點。',
'tours.planner.mapSearch': '搜尋地點或地址',
'tours.planner.placePoints': '放置點',
'tours.planner.pointPlaced': '已放置點',
'tours.planner.details': '詳細資訊',
'tours.planner.expandDetails': '顯示詳細資訊',
'tours.planner.collapseDetails': '隱藏詳細資訊',
'tours.planner.safetyInfoLabel': '關於安全',
'tours.planner.restoredShort': '草稿',
'tours.planner.addPoint': '新增地點',
'tours.planner.addPointActive': '點擊地圖',
'tours.planner.emptyHint': '開啟「新增地點」,然後點擊地圖。有兩個地點後,TREK 會計算路線。',
'tours.planner.waypoints': '路線點',
'tours.planner.waypointLabel': '路線點 {n}',
'tours.durationMinutes': '{count} 分鐘',
@@ -90,11 +98,9 @@ const tours: TranslationStrings = {
'tours.planner.discardTitle': '捨棄遊程中尚未儲存的變更?',
'tours.planner.discardBody': '尚未儲存的編輯內容將被捨棄。已儲存的遊程及其路線點、檔案與日期安排不會變更。',
'tours.planner.saved': '遊程已儲存',
'tours.planner.planAnother': '規劃另一個遊程',
'tours.planner.assignToDay': '安排到某一天',
'tours.planner.tripTours': '這趟旅行的遊程',
'tours.planner.safetyNote': '路線計算僅協助規劃,不保證安全。',
'tours.planner.safetyNoteDetails': 'OSM 難度標籤可能缺漏或有誤。系統不會評估天候、封路情形及當前現場狀況。',
'tours.planner.maxDifficulty': '步道最高難度',
'tours.planner.difficulty.t1': 'T1:健行',
'tours.planner.difficulty.t2': 'T2:山區健行',
@@ -114,7 +120,6 @@ const tours: TranslationStrings = {
'tours.planner.status.routingFailed': '路線計算失敗。仍顯示上次成功計算的路線。',
'tours.planner.status.elevation': '正在載入海拔…',
'tours.planner.status.elevationFailed': '無法取得海拔資料。仍顯示已計算的路線。',
'tours.planner.status.ready': '路線與海拔已就緒',
'tours.planner.status.saving': '正在儲存…',
'tours.planner.status.saveFailed': '無法儲存遊程。草稿仍保留在這裡。',
'tours.planner.retry': '重試',
@@ -151,6 +156,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': '在右側開啟一個遊程,即可在 3D 中飛越並依坡度著色。',
'tours.terrain.steepestShort': '最大 {grade} %',
'tours.terrain.preloading': '正在載入地形:{percent} %',
'tours.terrain.placeOff': '開啟「放置點」後,點擊地形即可放置點。',
'tours.terrain.clickToPlan': '點擊地形來設定點。有了路線後,就可以飛越並依坡度著色。',
};
export default tours;
+13 -7
View File
@@ -6,7 +6,6 @@ const tours: TranslationStrings = {
'tours.mode.tours': '游览',
'tours.mode.label': '添加视图',
'tours.title': '游览',
'tours.subtitle': '本次旅行的徒步及其他一日游活动',
'tours.empty.title': '暂无游览活动',
'tours.empty.body': '规划游览路线或导入 GPX 文件,即可开始。',
'tours.addToDay': '添加到某天',
@@ -16,6 +15,8 @@ const tours: TranslationStrings = {
'tours.badge.gpx': 'GPX 导入',
'tours.badge.planned': '在 TREK 中规划',
'tours.badge.trek': 'TREK',
'tours.badgeShort.gpx': 'GPX',
'tours.badgeShort.wanderer': 'wanderer',
'tours.caution.badge': '请谨慎使用',
'tours.caution.tooltip': '海拔或指标数据不完整,已带警告导入',
'tours.import.caution': '已带警告导入,海拔或指标数据不完整',
@@ -51,14 +52,21 @@ const tours: TranslationStrings = {
'tours.planner.newTourConfirmTitle': '开始规划新游览路线?',
'tours.planner.newTourConfirmBody': '此游览路线中未保存的更改将被丢弃。已保存的游览路线不会改变。',
'tours.planner.newTourConfirm': '放弃更改并开始新游览路线',
'tours.planner.startingPoint': '点击地图添加起点。',
'tours.planner.firstUseDetails': '再添加一个点即可计算路线。路线和海拔会自动更新,完成后请保存。',
'tours.planner.collapseElevation': '收起海拔剖面图',
'tours.planner.expandElevation': '展开海拔剖面图',
'tours.planner.name': '游览名称',
'tours.planner.namePlaceholder': '未命名的徒步活动',
'tours.planner.mapHint': '点击地图添加地点。',
'tours.planner.mapSearch': '搜索地点或地址',
'tours.planner.placePoints': '放置点',
'tours.planner.pointPlaced': '已放置点',
'tours.planner.details': '详情',
'tours.planner.expandDetails': '显示详情',
'tours.planner.collapseDetails': '隐藏详情',
'tours.planner.safetyInfoLabel': '关于安全',
'tours.planner.restoredShort': '草稿',
'tours.planner.addPoint': '添加地点',
'tours.planner.addPointActive': '点击地图',
'tours.planner.emptyHint': '打开“添加地点”,然后点击地图。有两个地点后,TREK 会计算路线。',
'tours.planner.waypoints': '路线点',
'tours.planner.waypointLabel': '路线点 {n}',
'tours.durationMinutes': '{count} 分钟',
@@ -90,11 +98,9 @@ const tours: TranslationStrings = {
'tours.planner.discardTitle': '放弃游览路线中未保存的更改?',
'tours.planner.discardBody': '未保存的编辑内容将被丢弃。已保存的游览路线及其路线点、文件和日期安排不会改变。',
'tours.planner.saved': '游览路线已保存',
'tours.planner.planAnother': '规划另一条路线',
'tours.planner.assignToDay': '安排到某天',
'tours.planner.tripTours': '本次旅行的游览活动',
'tours.planner.safetyNote': '路线计算仅辅助规划,不保证安全。',
'tours.planner.safetyNoteDetails': 'OSM 难度标签可能缺失或有误。系统不会评估天气、道路封闭情况及当前环境状况。',
'tours.planner.maxDifficulty': '步道最高难度',
'tours.planner.difficulty.t1': 'T1:徒步',
'tours.planner.difficulty.t2': 'T2:山地徒步',
@@ -114,7 +120,6 @@ const tours: TranslationStrings = {
'tours.planner.status.routingFailed': '路线计算失败。仍显示上次成功计算的路线。',
'tours.planner.status.elevation': '正在加载海拔…',
'tours.planner.status.elevationFailed': '无法获取海拔数据。仍显示已计算的路线。',
'tours.planner.status.ready': '路线和海拔已就绪',
'tours.planner.status.saving': '正在保存…',
'tours.planner.status.saveFailed': '无法保存游览路线。草稿仍保留在此处。',
'tours.planner.retry': '重试',
@@ -151,6 +156,7 @@ const tours: TranslationStrings = {
'tours.terrain.pickTour': '在右侧打开一条游览路线,即可在 3D 中飞越并按坡度着色。',
'tours.terrain.steepestShort': '最大 {grade} %',
'tours.terrain.preloading': '正在加载地形:{percent} %',
'tours.terrain.placeOff': '开启“放置点”后,点击地形即可放置点。',
'tours.terrain.clickToPlan': '点击地形来设置点。有了路线后,就可以飞越并按坡度着色。',
};
export default tours;