mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(tours): plan paddle tours, hand-drawn legs, reverse and return to start, maps per tour type
- Paddle as a plannable tour type: one boat (kayak, canoe, SUP, other), river or lake, a pace that a boat change never overwrites; time is distance over pace - Hand-drawn legs beside routed ones, drawn dotted and labelled as not routed, kept through save, reopen and edits elsewhere - Reverse a tour and close it back to its start, each one undo step - GPX import shows a preview first (line, measures, duplicates, type) and only the confirmation stores the tour - Base maps and overlays per tour type in the planner: hike keeps topo and the via ferrata layer, paddle gets the OpenSeaMap seamark overlay
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"e2e/help/fixtures.ts": 2291,
|
||||
"src/api/client.ts": 1916,
|
||||
"src/api/client.ts": 1910,
|
||||
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2764,
|
||||
"src/components/Budget/CostsPanel.tsx": 1816,
|
||||
"src/components/Map/MapView.tsx": 1409,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2920,
|
||||
"src/components/Map/MapViewGL.tsx": 2371,
|
||||
"src/components/Map/MapViewGL.tsx": 2299,
|
||||
"src/components/Map/RouteCalculator.ts": 1195,
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { z } from 'zod'
|
||||
import type { Day, Place, Trip } from '../types'
|
||||
import type { MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider, GoogleQuotaUpdateRequest, NotificationDefaultsUpdateRequest, TourListItem, TourImportGpxResponse, TourCreateRequest, TourCreateResponse, TourDetailResponse } from '@trek/shared'
|
||||
import type { MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider, GoogleQuotaUpdateRequest, NotificationDefaultsUpdateRequest, TourListItem, TourCreateRequest, TourCreateResponse, TourDetailResponse } from '@trek/shared'
|
||||
import { randomId } from '../utils/randomId'
|
||||
import { postProviderPhotosInBatches } from './providerPhotoBatches'
|
||||
import {
|
||||
@@ -525,11 +525,6 @@ export const toursApi = {
|
||||
apiClient.post(`/trips/${tripId}/tours`, body).then(r => r.data),
|
||||
update: (tripId: number | string, placeId: number | string, body: TourCreateRequest): Promise<TourDetailResponse> =>
|
||||
apiClient.put(`/trips/${tripId}/tours/${placeId}`, body).then(r => r.data),
|
||||
importGpx: (tripId: number | string, file: File): Promise<TourImportGpxResponse> => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return postMultipart(`/trips/${tripId}/tours/import/gpx`, fd)
|
||||
},
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { TourGpxImportFields, TourGpxPreviewResponse, TourImportGpxResponse } from '@trek/shared';
|
||||
import { postMultipart } from './client';
|
||||
|
||||
/** A GPX file and the fields beside it, as the Tours import routes read a multipart body. */
|
||||
function gpxForm(file: File, fields?: TourGpxImportFields): FormData {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
for (const [key, value] of Object.entries(fields ?? {})) if (value !== undefined) fd.append(key, value);
|
||||
return fd;
|
||||
}
|
||||
|
||||
/** The Tours GPX import: a file read for its preview first (PAD-02), then imported as the type chosen. */
|
||||
export const tourGpxApi = {
|
||||
/** What the file holds, read by the import's own parser; nothing is stored. */
|
||||
preview: (tripId: number | string, file: File): Promise<TourGpxPreviewResponse> =>
|
||||
postMultipart(`/trips/${tripId}/tours/import/gpx/preview`, gpxForm(file)),
|
||||
import: (tripId: number | string, file: File, fields?: TourGpxImportFields): Promise<TourImportGpxResponse> =>
|
||||
postMultipart(`/trips/${tripId}/tours/import/gpx`, gpxForm(file, fields)),
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react'
|
||||
import { Anchor, Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { FerrataLineIcon } from '../Ferratas/FerrataGlyph'
|
||||
@@ -7,10 +7,17 @@ import type { FerrataStatus } from '../Ferratas/useTripFerratas'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
|
||||
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
|
||||
|
||||
export type BaseLayer = 'default' | 'satellite'
|
||||
export type TourBaseLayer = BaseLayer | 'topo'
|
||||
|
||||
/** What a tour's type offers on the planner map (P2B-06): its base layers, and the overlays beside the ferratas. */
|
||||
export interface TourLayerOffer {
|
||||
bases?: readonly TourBaseLayer[]
|
||||
overlays?: readonly ToggleableRasterOverlay[]
|
||||
}
|
||||
|
||||
const FROSTED_SHELL = {
|
||||
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto' as const,
|
||||
background: 'var(--sidebar-bg)',
|
||||
@@ -178,6 +185,51 @@ function PlaceToolToggle({ tool }: { tool: MapPlaceTool }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** How each overlay the planner can offer is named and drawn on its switch. */
|
||||
const OVERLAY_LOOK: Record<string, { Icon: typeof Anchor; labelKey: string; downKey: string }> = {
|
||||
seamarks: { Icon: Anchor, labelKey: 'tours.map.seamarks', downKey: 'tours.map.seamarksDown' },
|
||||
}
|
||||
|
||||
/**
|
||||
* The switch for a transparent overlay (the seamarks), the way the via
|
||||
* ferrata switch looks: on in the accent, and in the warning tone with a badge
|
||||
* once its source stopped answering, which the tooltip names. The map and the
|
||||
* planning go on without it.
|
||||
*/
|
||||
function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay; compact: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const look = OVERLAY_LOOK[overlay.id]
|
||||
if (!look) return null
|
||||
const label = t(look.labelKey)
|
||||
const down = overlay.status === 'down'
|
||||
const on = overlay.status !== 'off'
|
||||
return (
|
||||
<>
|
||||
<Tooltip label={down ? t(look.downKey) : label} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={overlay.onToggle}
|
||||
aria-label={label}
|
||||
aria-pressed={on}
|
||||
data-testid={`map-overlay-${overlay.id}`}
|
||||
data-status={overlay.status}
|
||||
className={on ? 'bg-accent text-accent-text' : 'text-content-muted'}
|
||||
style={segmentStyle(on)}
|
||||
>
|
||||
<look.Icon size={15} strokeWidth={2} />
|
||||
{!compact && <span>{label}</span>}
|
||||
{down && (
|
||||
<span aria-hidden className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-warning-soft px-1 font-semibold leading-none text-warning" style={fs(10)}>
|
||||
!
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<span role="status" aria-live="polite" className="sr-only">{down ? t(look.downKey) : ''}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -191,10 +243,13 @@ export function TourMapLayerSwitcher({
|
||||
terrainActive = false,
|
||||
ferrataLayer,
|
||||
placeTool,
|
||||
layers,
|
||||
compact = false,
|
||||
}: {
|
||||
active: TourBaseLayer
|
||||
onChange: (layer: TourBaseLayer) => void
|
||||
/** The base layers and overlays this tour's type offers; all three bases and no overlay when left out. */
|
||||
layers?: TourLayerOffer
|
||||
/** Offers the 3D terrain switch beside the base layers. */
|
||||
onToggleTerrain?: () => void
|
||||
terrainActive?: boolean
|
||||
@@ -205,10 +260,12 @@ export function TourMapLayerSwitcher({
|
||||
compact?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const bases = layers?.bases
|
||||
const overlays = layers?.overlays
|
||||
|
||||
return (
|
||||
<div style={{ ...FROSTED_SHELL, gap: 2 }} role="group" aria-label={t('settings.mapStyle')}>
|
||||
{TOUR_LAYERS.map(({ id, labelKey, Icon }) => {
|
||||
{TOUR_LAYERS.filter(({ id }) => !bases || bases.includes(id)).map(({ id, labelKey, Icon }) => {
|
||||
const selected = active === id
|
||||
const label = labelKey ? t(labelKey) : 'Topo'
|
||||
return (
|
||||
@@ -227,9 +284,10 @@ export function TourMapLayerSwitcher({
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
{(ferrataLayer || onToggleTerrain || placeTool) && <span aria-hidden className="mx-0.5 h-5 w-px bg-edge" />}
|
||||
{(ferrataLayer || onToggleTerrain || placeTool || !!overlays?.length) && <span aria-hidden className="mx-0.5 h-5 w-px bg-edge" />}
|
||||
{placeTool && <PlaceToolToggle tool={placeTool} />}
|
||||
{ferrataLayer && <FerrataToggle layer={ferrataLayer} compact={compact} />}
|
||||
{overlays?.map(overlay => <OverlayToggle key={overlay.id} overlay={overlay} compact={compact} />)}
|
||||
{onToggleTerrain && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -8,7 +8,6 @@ import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupL
|
||||
import RoadtripViaMarkers from './RoadtripViaMarkers'
|
||||
import PlannerWaypointMarker from './PlannerWaypointMarker'
|
||||
import HazardLayers from './HazardLayers'
|
||||
import { FerrataMapLayerLeaflet } from '../Ferratas/FerrataLayerLeaflet'
|
||||
import { formatViaDwell } from './routeViaDwell'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
@@ -68,6 +67,7 @@ import { crsForBasemap } from './gcj02Crs'
|
||||
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
|
||||
import VectorBasemap from './VectorBasemap'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { TourMapLayersLeaflet } from './RasterOverlayLeaflet'
|
||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type TourBaseLayer } from './MapLayerSwitcher'
|
||||
import { MapLockPill } from './MapLockPill'
|
||||
import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport'
|
||||
@@ -831,7 +831,7 @@ export const MapView = memo(function MapView({
|
||||
onPlannerWaypointClick,
|
||||
onPlannerWaypointMove,
|
||||
routeProfileFocus = null,
|
||||
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool,
|
||||
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool, tourLayers,
|
||||
}: MapViewProps) {
|
||||
const globalBaseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
|
||||
const updateSetting = useSettingsStore(s => s.updateSetting)
|
||||
@@ -1291,7 +1291,7 @@ export const MapView = memo(function MapView({
|
||||
/>
|
||||
|
||||
{hazards?.length > 0 && <HazardLayers hazards={hazards} />}
|
||||
<FerrataMapLayerLeaflet layer={ferrataLayer} />
|
||||
<TourMapLayersLeaflet ferrataLayer={ferrataLayer} overlays={tourLayers?.overlays} />
|
||||
|
||||
{/* The route as it was actually recorded (#2279). Drawn in the casing
|
||||
pane's sibling order so it sits under the planned route rather than
|
||||
@@ -1339,7 +1339,7 @@ export const MapView = memo(function MapView({
|
||||
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
|
||||
{hasViewBaseLayer
|
||||
? <TourMapLayerSwitcher active={baseLayer} onChange={onViewBaseLayerChange}
|
||||
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} />
|
||||
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} layers={tourLayers} />
|
||||
: <MapLayerSwitcher active={globalBaseLayer} onToggle={toggleBaseLayer} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,9 @@ import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { applySatellite } from './glRasterLayers'
|
||||
import { useTourMapLayersGL } from './mapRasterOverlay'
|
||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
|
||||
import { OPENTOPOMAP_TILE_URL, OPENTOPOMAP_TILE_ATTRIBUTION, OPENTOPOMAP_TILE_MAXZOOM } from '../../constants/mapDefaults'
|
||||
import { MapLockPill } from './MapLockPill'
|
||||
import { useMarkerThumbs } from './useMarkerThumbs'
|
||||
import { placeMarkerLook, type PlaceMarkerFlags } from './markerLook'
|
||||
@@ -32,7 +33,7 @@ import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
|
||||
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
|
||||
import { groupCoincidentPlaces } from './coincidentPlaces'
|
||||
import { useHazardLayerGL } from './useHazardLayerGL'
|
||||
import { type FerrataGlMap, isFerrataHit, useFerrataMapLayerGL } from '../Ferratas/useFerrataLayerGL'
|
||||
import { type FerrataGlMap, isFerrataHit } from '../Ferratas/useFerrataLayerGL'
|
||||
import { formatViaDwell } from './routeViaDwell'
|
||||
import { useDawarichTrailGL } from './useDawarichTrailGL'
|
||||
import { bindDayBoundaryDrag } from './dayBoundaryDrag'
|
||||
@@ -44,7 +45,7 @@ import { poiPinParts } from './poiMarker'
|
||||
import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack, setGpxTracks } from './gpxTrackLayers'
|
||||
import { buildPoiPopupHtml } from './placePopup'
|
||||
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
|
||||
import { selectedPlaceTarget } from './selectedPlaceTarget'
|
||||
|
||||
@@ -65,25 +66,6 @@ const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters'
|
||||
const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle'
|
||||
const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count'
|
||||
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
|
||||
/**
|
||||
* The satellite base layer, the GL twin of the Leaflet one.
|
||||
*
|
||||
* Leaflet swaps its whole tile layer for the imagery; a GL map cannot, because the
|
||||
* basemap is a style with dozens of layers in it. So the imagery goes on as a raster
|
||||
* layer of its own, drawn over the style and under everything TREK adds — the route,
|
||||
* the pins and the tracks stay on top and stay legible, which is the same order the
|
||||
* Leaflet map ends up with.
|
||||
*
|
||||
* Same tokenless ESRI source as Leaflet, so the two look alike and neither needs a key.
|
||||
*/
|
||||
const SATELLITE_SOURCE_ID = 'trip-satellite'
|
||||
const SATELLITE_LAYER_ID = 'trip-satellite-raster'
|
||||
/** Everything TREK draws is prefixed; the imagery is inserted before the first of them. */
|
||||
// Everything TREK draws on a GL map, so the satellite raster can be slipped underneath all
|
||||
// of it. `route-alt-` earns its place here: the roads offered for a leg are added before the
|
||||
// route source so they sit under the current route, which also puts them before the first
|
||||
// `trip-` layer, and anchoring the imagery there painted right over them.
|
||||
const OWN_LAYER_PREFIXES = ['trip-', 'trek-', 'route-alt-']
|
||||
|
||||
type PlaceWithCoords = Place & { lat: number; lng: number }
|
||||
|
||||
@@ -201,59 +183,6 @@ function addPlaceClusterLayers(map: any): void {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the imagery on the map, or takes it off again.
|
||||
*
|
||||
* Built on demand rather than once at load: a style change drops every source the map
|
||||
* had, and a hot reload does the same. Visibility comes first: a layer that is already
|
||||
* there only needs flipping, so the usual pass is one call and a tap answers at once.
|
||||
*
|
||||
* Deliberately not gated on `isStyleLoaded()`. That reports false for as long as any
|
||||
* source has tiles or a setData in flight (see useDawarichTrailGL), which on a phone is
|
||||
* the usual state, and the only retry here is `styledata`, which fires on style edits
|
||||
* and not when those finish. Behind that gate a stored choice met a busy map on load,
|
||||
* and a tap on the road trip stage met the sources the same render had just set again,
|
||||
* so the imagery was never drawn. The gate was also the wrong question: this only runs
|
||||
* after `load`, and addSource and addLayer refuse only a style document that is not in
|
||||
* yet. The catch covers that case, and `styledata` brings the pass back once it is in.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function applySatellite(map: any, on: boolean, topo = false): void {
|
||||
const layerId = topo ? 'trip-topo-raster' : SATELLITE_LAYER_ID
|
||||
const sourceId = topo ? 'trip-topo' : SATELLITE_SOURCE_ID
|
||||
try {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, 'visibility', on ? 'visible' : 'none')
|
||||
return
|
||||
}
|
||||
if (!on) return // Nothing to build while it is switched off.
|
||||
// The layer is what is missing, not necessarily the source: a pass that got the
|
||||
// source in and then failed on the layer would otherwise leave a source that stops
|
||||
// every later pass from ever building the layer.
|
||||
if (!map.getSource(sourceId)) {
|
||||
map.addSource(sourceId, {
|
||||
type: 'raster',
|
||||
tiles: topo
|
||||
? ['a', 'b', 'c'].map(subdomain => OPENTOPOMAP_TILE_URL.replace('{s}', subdomain))
|
||||
: [SATELLITE_TILE_URL],
|
||||
tileSize: 256,
|
||||
maxzoom: topo ? OPENTOPOMAP_TILE_MAXZOOM : SATELLITE_TILE_MAXZOOM,
|
||||
attribution: topo ? OPENTOPOMAP_TILE_ATTRIBUTION : SATELLITE_TILE_ATTRIBUTION,
|
||||
})
|
||||
}
|
||||
// Under the first thing TREK draws, over everything the basemap style draws.
|
||||
// Without the anchor the imagery lands on top and buries the route.
|
||||
const layers = map.getStyle?.()?.layers ?? []
|
||||
const firstOwn = layers.find((l: { id: string }) =>
|
||||
OWN_LAYER_PREFIXES.some(prefix => l.id.startsWith(prefix)))
|
||||
map.addLayer({
|
||||
id: layerId,
|
||||
type: 'raster',
|
||||
source: sourceId,
|
||||
paint: { 'raster-opacity': 1 },
|
||||
}, firstOwn?.id)
|
||||
} catch { /* a style that refuses the layer keeps the plain basemap */ }
|
||||
}
|
||||
|
||||
function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||
// A stop that interrupts the drive gets its own small disc, decided before the photo
|
||||
@@ -624,7 +553,7 @@ export function MapViewGL({
|
||||
onPlannerWaypointClick,
|
||||
onPlannerWaypointMove,
|
||||
routeProfileFocus = null,
|
||||
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool,
|
||||
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool, tourLayers,
|
||||
roadtripVias,
|
||||
onMoveVia,
|
||||
onRemoveVia,
|
||||
@@ -683,7 +612,7 @@ export function MapViewGL({
|
||||
const mapRef = useRef<any | null>(null)
|
||||
const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl])
|
||||
useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory)
|
||||
useFerrataMapLayerGL(mapReady ? readyMap : null, ferrataLayer)
|
||||
useTourMapLayersGL(mapReady ? readyMap : null, ferrataLayer, tourLayers?.overlays)
|
||||
// Beneath the planned route's casing, the GL twin of the Leaflet pane order.
|
||||
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -2311,7 +2240,7 @@ export function MapViewGL({
|
||||
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
|
||||
{hasViewBaseLayer
|
||||
? <TourMapLayerSwitcher active={baseLayer as TourBaseLayer} onChange={onViewBaseLayerChange}
|
||||
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} />
|
||||
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} layers={tourLayers} />
|
||||
: <MapLayerSwitcher active={globalBaseLayer as BaseLayer} onToggle={toggleBaseLayer} />}
|
||||
</div>
|
||||
{/* Hover tooltip — cursor-following name/category/address card, identical to
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { TileLayer } from 'react-leaflet';
|
||||
import { FerrataMapLayerLeaflet } from '../Ferratas/FerrataLayerLeaflet';
|
||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
|
||||
import type { MapRasterOverlay } from './mapRasterOverlay';
|
||||
|
||||
/** Over the basemap's tiles (z-index 1) and every basemap raster, under the route's own panes. */
|
||||
const OVERLAY_Z_INDEX = 300;
|
||||
|
||||
/** The overlays on the Leaflet map: a tile layer each while it is on, nothing while it is off. */
|
||||
function RasterOverlaysLeaflet({ overlays }: { overlays?: readonly MapRasterOverlay[] }) {
|
||||
return (
|
||||
<>
|
||||
{overlays
|
||||
?.filter((overlay) => overlay.active)
|
||||
.map((overlay) => (
|
||||
<TileLayer
|
||||
key={overlay.id}
|
||||
url={overlay.url}
|
||||
attribution={overlay.attribution}
|
||||
minZoom={overlay.minZoom}
|
||||
maxNativeZoom={overlay.maxZoom}
|
||||
maxZoom={22}
|
||||
zIndex={OVERLAY_Z_INDEX}
|
||||
eventHandlers={{ tileerror: overlay.onTileError, tileload: overlay.onTileLoad }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The tour planner's layers on the Leaflet map: the via ferratas and the raster overlays its type offers. */
|
||||
export function TourMapLayersLeaflet({
|
||||
ferrataLayer,
|
||||
overlays,
|
||||
}: {
|
||||
ferrataLayer?: FerrataMapLayer;
|
||||
overlays?: readonly MapRasterOverlay[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<FerrataMapLayerLeaflet layer={ferrataLayer} />
|
||||
<RasterOverlaysLeaflet overlays={overlays} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
OPENTOPOMAP_TILE_ATTRIBUTION,
|
||||
OPENTOPOMAP_TILE_MAXZOOM,
|
||||
OPENTOPOMAP_TILE_URL,
|
||||
SATELLITE_TILE_ATTRIBUTION,
|
||||
SATELLITE_TILE_MAXZOOM,
|
||||
SATELLITE_TILE_URL,
|
||||
} from '../../constants/mapDefaults';
|
||||
|
||||
/*
|
||||
* The raster layers a GL map draws between its basemap style and what TREK
|
||||
* adds on top: the satellite and topo base layers, and (in mapRasterOverlay.ts)
|
||||
* the transparent overlays such as the seamarks.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The satellite base layer, the GL twin of the Leaflet one.
|
||||
*
|
||||
* Leaflet swaps its whole tile layer for the imagery; a GL map cannot, because the
|
||||
* basemap is a style with dozens of layers in it. So the imagery goes on as a raster
|
||||
* layer of its own, drawn over the style and under everything TREK adds — the route,
|
||||
* the pins and the tracks stay on top and stay legible, which is the same order the
|
||||
* Leaflet map ends up with.
|
||||
*
|
||||
* Same tokenless ESRI source as Leaflet, so the two look alike and neither needs a key.
|
||||
*/
|
||||
const SATELLITE_SOURCE_ID = 'trip-satellite';
|
||||
const SATELLITE_LAYER_ID = 'trip-satellite-raster';
|
||||
/** Everything TREK draws is prefixed; the imagery is inserted before the first of them. */
|
||||
// Everything TREK draws on a GL map, so the satellite raster can be slipped underneath all
|
||||
// of it. `route-alt-` earns its place here: the roads offered for a leg are added before the
|
||||
// route source so they sit under the current route, which also puts them before the first
|
||||
// `trip-` layer, and anchoring the imagery there painted right over them.
|
||||
export const OWN_LAYER_PREFIXES = ['trip-', 'trek-', 'route-alt-'];
|
||||
|
||||
/**
|
||||
* Puts the imagery on the map, or takes it off again.
|
||||
*
|
||||
* Built on demand rather than once at load: a style change drops every source the map
|
||||
* had, and a hot reload does the same. Visibility comes first: a layer that is already
|
||||
* there only needs flipping, so the usual pass is one call and a tap answers at once.
|
||||
*
|
||||
* Deliberately not gated on `isStyleLoaded()`. That reports false for as long as any
|
||||
* source has tiles or a setData in flight (see useDawarichTrailGL), which on a phone is
|
||||
* the usual state, and the only retry here is `styledata`, which fires on style edits
|
||||
* and not when those finish. Behind that gate a stored choice met a busy map on load,
|
||||
* and a tap on the road trip stage met the sources the same render had just set again,
|
||||
* so the imagery was never drawn. The gate was also the wrong question: this only runs
|
||||
* after `load`, and addSource and addLayer refuse only a style document that is not in
|
||||
* yet. The catch covers that case, and `styledata` brings the pass back once it is in.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function applySatellite(map: any, on: boolean, topo = false): void {
|
||||
const layerId = topo ? 'trip-topo-raster' : SATELLITE_LAYER_ID;
|
||||
const sourceId = topo ? 'trip-topo' : SATELLITE_SOURCE_ID;
|
||||
try {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, 'visibility', on ? 'visible' : 'none');
|
||||
return;
|
||||
}
|
||||
if (!on) return; // Nothing to build while it is switched off.
|
||||
// The layer is what is missing, not necessarily the source: a pass that got the
|
||||
// source in and then failed on the layer would otherwise leave a source that stops
|
||||
// every later pass from ever building the layer.
|
||||
if (!map.getSource(sourceId)) {
|
||||
map.addSource(sourceId, {
|
||||
type: 'raster',
|
||||
tiles: topo
|
||||
? ['a', 'b', 'c'].map((subdomain) => OPENTOPOMAP_TILE_URL.replace('{s}', subdomain))
|
||||
: [SATELLITE_TILE_URL],
|
||||
tileSize: 256,
|
||||
maxzoom: topo ? OPENTOPOMAP_TILE_MAXZOOM : SATELLITE_TILE_MAXZOOM,
|
||||
attribution: topo ? OPENTOPOMAP_TILE_ATTRIBUTION : SATELLITE_TILE_ATTRIBUTION,
|
||||
});
|
||||
}
|
||||
// Under the first thing TREK draws, over everything the basemap style draws.
|
||||
// Without the anchor the imagery lands on top and buries the route.
|
||||
const layers = map.getStyle?.()?.layers ?? [];
|
||||
const firstOwn = layers.find((l: { id: string }) => OWN_LAYER_PREFIXES.some((prefix) => l.id.startsWith(prefix)));
|
||||
map.addLayer(
|
||||
{
|
||||
id: layerId,
|
||||
type: 'raster',
|
||||
source: sourceId,
|
||||
paint: { 'raster-opacity': 1 },
|
||||
},
|
||||
firstOwn?.id
|
||||
);
|
||||
} catch {
|
||||
/* a style that refuses the layer keeps the plain basemap */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Map } from 'mapbox-gl';
|
||||
import { useEffect } from 'react';
|
||||
import { useFerrataMapLayerGL } from '../Ferratas/useFerrataLayerGL';
|
||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
|
||||
import { OWN_LAYER_PREFIXES } from './glRasterLayers';
|
||||
|
||||
/**
|
||||
* A transparent raster layer laid over the basemap and under the route, such
|
||||
* as OpenSeaMap's seamarks over a paddle tour. Only the tiles in view are
|
||||
* fetched while it is on: nothing is prefetched or stored for offline use, and
|
||||
* the browser's own cache follows the answer's headers.
|
||||
*/
|
||||
export interface MapRasterOverlay {
|
||||
id: string;
|
||||
url: string;
|
||||
attribution: string;
|
||||
minZoom: number;
|
||||
maxZoom: number;
|
||||
active: boolean;
|
||||
/** A tile failed to load; the layer's owner decides when that means the source is down. */
|
||||
onTileError: () => void;
|
||||
/** A tile arrived; the source answers again. */
|
||||
onTileLoad: () => void;
|
||||
}
|
||||
|
||||
type RasterOverlayStatus = 'off' | 'on' | 'down';
|
||||
|
||||
/** An overlay with its switch: whether it is on, and whether its source stopped answering. */
|
||||
export interface ToggleableRasterOverlay extends MapRasterOverlay {
|
||||
status: RasterOverlayStatus;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
/** One map type for both engines, as the ferrata layer does: MapLibre answers every call made here. */
|
||||
export type RasterOverlayGlMap = Map;
|
||||
|
||||
/**
|
||||
* The layer id. It carries TREK's own prefix, so a basemap raster added later
|
||||
* (satellite, topo) is put under it, and it goes in under the first other own
|
||||
* layer, so the route and its markers stay on top.
|
||||
*/
|
||||
function overlayLayerId(id: string): string {
|
||||
return `trek-overlay-${id}`;
|
||||
}
|
||||
|
||||
function applyOverlay(map: RasterOverlayGlMap, overlay: MapRasterOverlay): void {
|
||||
const layerId = overlayLayerId(overlay.id);
|
||||
try {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, 'visibility', overlay.active ? 'visible' : 'none');
|
||||
return;
|
||||
}
|
||||
if (!overlay.active) return;
|
||||
if (!map.getSource(layerId)) {
|
||||
map.addSource(layerId, {
|
||||
type: 'raster',
|
||||
tiles: [overlay.url],
|
||||
tileSize: 256,
|
||||
minzoom: overlay.minZoom,
|
||||
maxzoom: overlay.maxZoom,
|
||||
attribution: overlay.attribution,
|
||||
});
|
||||
}
|
||||
const firstOwn = (map.getStyle()?.layers ?? []).find(
|
||||
(layer) =>
|
||||
OWN_LAYER_PREFIXES.some((prefix) => layer.id.startsWith(prefix)) && !layer.id.startsWith('trek-overlay-')
|
||||
);
|
||||
map.addLayer({ id: layerId, type: 'raster', source: layerId, paint: { 'raster-opacity': 1 } }, firstOwn?.id);
|
||||
} catch {
|
||||
// A style that refuses the layer keeps the plain map; the switch reports nothing loaded.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The overlays on a GL map, kept through a basemap change (which rebuilds the
|
||||
* style and drops them) and reporting their tiles' arrivals and failures.
|
||||
*/
|
||||
export function useRasterOverlaysGL(map: RasterOverlayGlMap | null, overlays: readonly MapRasterOverlay[] | undefined) {
|
||||
useEffect(() => {
|
||||
if (!map || !overlays?.length) return;
|
||||
const apply = () => overlays.forEach((overlay) => applyOverlay(map, overlay));
|
||||
const sourceOf = (event: { sourceId?: string }) =>
|
||||
overlays.find((overlay) => overlayLayerId(overlay.id) === event.sourceId);
|
||||
// A tile's failure carries the source it came from; other errors carry none and are not ours.
|
||||
const failed = (event: { error?: Error; sourceId?: string }) => {
|
||||
const overlay = sourceOf(event);
|
||||
if (overlay?.active) overlay.onTileError();
|
||||
};
|
||||
const loaded = (event: { sourceId?: string; tile?: unknown }) => {
|
||||
const overlay = sourceOf(event);
|
||||
if (overlay?.active && event.tile) overlay.onTileLoad();
|
||||
};
|
||||
apply();
|
||||
map.on('styledata', apply);
|
||||
map.on('error', failed);
|
||||
map.on('sourcedata', loaded);
|
||||
return () => {
|
||||
map.off('styledata', apply);
|
||||
map.off('error', failed);
|
||||
map.off('sourcedata', loaded);
|
||||
};
|
||||
}, [map, overlays]);
|
||||
}
|
||||
|
||||
/** The tour planner's layers on a GL map: the via ferratas and the raster overlays its type offers. */
|
||||
export function useTourMapLayersGL(
|
||||
map: RasterOverlayGlMap | null,
|
||||
ferrataLayer: FerrataMapLayer | undefined,
|
||||
overlays: readonly MapRasterOverlay[] | undefined
|
||||
) {
|
||||
useFerrataMapLayerGL(map, ferrataLayer);
|
||||
useRasterOverlaysGL(map, overlays);
|
||||
}
|
||||
@@ -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 { MapPlaceTool, TourBaseLayer } from './MapLayerSwitcher';
|
||||
import type { MapPlaceTool, TourBaseLayer, TourLayerOffer } from './MapLayerSwitcher';
|
||||
import type { Poi } from './poiCategories';
|
||||
|
||||
/*
|
||||
@@ -129,6 +129,11 @@ export interface MapViewProps {
|
||||
* Ferratas modules.
|
||||
*/
|
||||
ferrataLayer?: FerrataMapLayer;
|
||||
/**
|
||||
* What the tour's type offers in the tour layer pill (P2B-06): its base layers,
|
||||
* and transparent overlays drawn over the basemap and under the route.
|
||||
*/
|
||||
tourLayers?: TourLayerOffer;
|
||||
/**
|
||||
* An explicit stretch of map to frame, independent of the day being shown.
|
||||
*
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Category, Place } from '../../types';
|
||||
import { formatTime } from '../../utils/formatters';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { TourDayRowPills } from '../Tours/TourDayRowFacts';
|
||||
import { movingTimeLabel } from '../Tours/tourFacts';
|
||||
import { formatPlannedTourDuration, type TourPlannedTimes } from '../Tours/tourPresentation';
|
||||
import { TimePill } from './planParts';
|
||||
|
||||
@@ -35,9 +36,10 @@ function PlannedTourPills({ tourTimes }: { tourTimes: DayRowTourTimes }) {
|
||||
<TimePill>
|
||||
<span
|
||||
data-testid="trip-plan-tour-walking-time"
|
||||
aria-label={`${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(tourTimes.walkingMinutes)}`}
|
||||
aria-label={`${movingTimeLabel(tourTimes.tour, t('tours.planner.inspector.duration'), t)}: ${formatPlannedTourDuration(tourTimes.walkingMinutes)}`}
|
||||
>
|
||||
{t('tours.planner.walkingShort')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)}
|
||||
{movingTimeLabel(tourTimes.tour, t('tours.planner.walkingShort'), t)}:{' '}
|
||||
{formatPlannedTourDuration(tourTimes.walkingMinutes)}
|
||||
</span>
|
||||
</TimePill>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { ArrowUp, Clock, Footprints, Pause, Ruler, type LucideIcon } from 'lucide-react';
|
||||
import { ArrowUp, Clock, Pause, Ruler, type LucideIcon } from 'lucide-react';
|
||||
import { createElement, useId, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -8,6 +8,7 @@ import { WhiteBadge } from '../Planner/planParts';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { tourDayRowIcon } from './tourDayRowIcon';
|
||||
import { movingTimeLabel } from './tourFacts';
|
||||
import { formatPlannedTourDuration, tourPlannedTimes } from './tourPresentation';
|
||||
|
||||
export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: number }) {
|
||||
@@ -151,7 +152,7 @@ export function TourDayRowPills({ tour }: { tour: TourListItem }) {
|
||||
const shownMinutes = times.plannedTotalMinutes ?? times.walkingMinutes;
|
||||
const timeParts = [
|
||||
times.walkingMinutes != null
|
||||
? `${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(times.walkingMinutes)}`
|
||||
? `${movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}: ${formatPlannedTourDuration(times.walkingMinutes)}`
|
||||
: null,
|
||||
times.breakMinutes
|
||||
? `${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(times.breakMinutes)}`
|
||||
@@ -203,9 +204,9 @@ export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourLi
|
||||
add(ArrowUp, formatElevation(tour.elevation_gain, unit), t('tours.detail.ascent'), 'plan-tour-ascent');
|
||||
if (times.walkingMinutes != null)
|
||||
add(
|
||||
Footprints,
|
||||
tourDayRowIcon(tour.tour_type === 'kayak' ? 'kayak' : 'walk'),
|
||||
formatPlannedTourDuration(times.walkingMinutes),
|
||||
t('tours.planner.inspector.duration'),
|
||||
movingTimeLabel(tour, t('tours.planner.inspector.duration'), t),
|
||||
'trip-plan-tour-walking-time'
|
||||
);
|
||||
if (times.breakMinutes != null && times.breakMinutes > 0)
|
||||
|
||||
@@ -141,6 +141,16 @@ describe('TourDetailDialog', () => {
|
||||
expect(screen.getByText('Planned (Manual override)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('PAD-01: a paddle tour without heights shows distance, paddling and total side by side in one row', () => {
|
||||
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, tour_type: 'kayak', has_waypoints: true, duration: 50 }} />)
|
||||
|
||||
const row = screen.getByText('Distance').closest('dl')
|
||||
expect(row).toHaveClass('grid-cols-3')
|
||||
expect(row).toContainElement(screen.getByText('Paddling'))
|
||||
expect(row).toContainElement(screen.getByText('Planned'))
|
||||
expect(screen.queryByText('Ascent')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('RS-01: rejects an unsafe legacy Tour website for rendering', () => {
|
||||
render(
|
||||
<TourDetailDialog
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
FileText,
|
||||
Loader2,
|
||||
Minus,
|
||||
Mountain,
|
||||
Navigation,
|
||||
Pencil,
|
||||
Plus,
|
||||
@@ -23,7 +22,7 @@ import {
|
||||
Ruler,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createElement, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
@@ -49,7 +48,9 @@ import TrackColorPicker from '../shared/TrackColorPicker';
|
||||
import { markdownLinkComponents } from '../shared/markdownLink';
|
||||
import { TourDayRowAvatar } from './TourDayRowFacts';
|
||||
import { TourProvenanceBlock } from './provenance/TourProvenanceSection';
|
||||
import { tourDayRowIcon } from './tourDayRowIcon';
|
||||
import { tourDaysOf } from './tourDays';
|
||||
import { heightsUnknown, movingTimeLabel, withoutHeights } from './tourFacts';
|
||||
import { TourDayPills, TourFactFields, TourMetricFields, type TourMetricField } from './tourParts';
|
||||
import {
|
||||
formatPlannedTourDuration,
|
||||
@@ -116,10 +117,17 @@ function assignmentForSelectedDay(
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
/** The parts of the time a tour takes, as fields: walking, breaks, the planned total. */
|
||||
function tourTimeFields(times: ReturnType<typeof tourPlannedTimes>, t: Translate): TourMetricField[] {
|
||||
function tourTimeFields(
|
||||
times: ReturnType<typeof tourPlannedTimes>,
|
||||
t: Translate,
|
||||
tour: Pick<TourListItem, 'tour_type'>
|
||||
): TourMetricField[] {
|
||||
return [
|
||||
times.walkingMinutes != null
|
||||
? { label: t('tours.planner.walkingShort'), value: formatPlannedTourDuration(times.walkingMinutes) }
|
||||
? {
|
||||
label: movingTimeLabel(tour, t('tours.planner.walkingShort'), t),
|
||||
value: formatPlannedTourDuration(times.walkingMinutes),
|
||||
}
|
||||
: null,
|
||||
times.breakMinutes
|
||||
? { label: t('tours.planner.breaksShort'), value: formatPlannedTourDuration(times.breakMinutes) }
|
||||
@@ -166,9 +174,25 @@ function TourHeadPills({
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<span className={HEAD_PILL} style={fs(10.5)}>
|
||||
<Mountain size={11} strokeWidth={2} className="flex-none" style={{ color: trackColor }} />
|
||||
{createElement(tourDayRowIcon(tour.tour_type), {
|
||||
size: 11,
|
||||
strokeWidth: 2,
|
||||
className: 'flex-none',
|
||||
style: { color: trackColor },
|
||||
})}
|
||||
{t(`tourTypes.${tour.tour_type}`)}
|
||||
</span>
|
||||
{/* A paddle tour names its boat and its water, each a badge of its own. */}
|
||||
{tour.boat_type && (
|
||||
<span className={HEAD_PILL} style={fs(10.5)} data-testid="tour-detail-boat">
|
||||
{t(`tours.paddle.boat.${tour.boat_type}`)}
|
||||
</span>
|
||||
)}
|
||||
{tour.water_context && (
|
||||
<span className={HEAD_PILL} style={fs(10.5)} data-testid="tour-detail-water">
|
||||
{t(`tours.paddle.water.${tour.water_context}`)}
|
||||
</span>
|
||||
)}
|
||||
{source !== 'trek' && (
|
||||
<span className={HEAD_PILL} style={fs(10.5)}>
|
||||
<FileDown size={11} strokeWidth={2} className="flex-none text-content-faint" />
|
||||
@@ -337,7 +361,11 @@ export default function TourDetailDialog({
|
||||
);
|
||||
useDesktopEscape(desktopNonModal, onClose, desktopFocusReturnTarget);
|
||||
|
||||
const analysis = useMemo(() => analyzeRouteGeometry(place.route_geometry), [place.route_geometry]);
|
||||
const heightsHidden = heightsUnknown(tour);
|
||||
const analysis = useMemo(() => {
|
||||
const measured = analyzeRouteGeometry(place.route_geometry);
|
||||
return measured && heightsHidden ? withoutHeights(measured) : measured;
|
||||
}, [heightsHidden, place.route_geometry]);
|
||||
const trackColor = resolveTrackColor(place);
|
||||
const inheritedColor = inheritedTrackColor(place);
|
||||
const assignment = assignmentForSelectedDay(assignments, selectedDayId, selectedAssignmentId, place.id);
|
||||
@@ -350,7 +378,7 @@ export default function TourDetailDialog({
|
||||
const website = tourWebsitePresentation(tour.website !== undefined ? tour.website : place.website);
|
||||
const times = tourPlannedTimes(tour);
|
||||
|
||||
const timeFields = tourTimeFields(times, t);
|
||||
const timeFields = tourTimeFields(times, t, tour);
|
||||
|
||||
const header = (
|
||||
<>
|
||||
@@ -563,8 +591,20 @@ export default function TourDetailDialog({
|
||||
|
||||
{analysis && (
|
||||
<div className="flex flex-none flex-col gap-2">
|
||||
<TourMetricFields analysis={analysis} unit={distanceUnit} columns={5} />
|
||||
{timeFields.length > 0 && <TourFactFields fields={timeFields} columns={3} />}
|
||||
{analysis.minEle == null && analysis.gain == null ? (
|
||||
// Without heights the distance is the only measure: it joins the times in one row.
|
||||
<TourMetricFields
|
||||
analysis={analysis}
|
||||
unit={distanceUnit}
|
||||
extra={timeFields}
|
||||
columns={timeFields.length === 1 ? 2 : timeFields.length === 2 ? 3 : undefined}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<TourMetricFields analysis={analysis} unit={distanceUnit} columns={5} />
|
||||
{timeFields.length > 0 && <TourFactFields fields={timeFields} columns={3} />}
|
||||
</>
|
||||
)}
|
||||
{analysis.distanceIndexedProfileSamples.length >= 2 && (
|
||||
<div className={`${BOX} p-2`}>
|
||||
<ElevationProfile
|
||||
|
||||
@@ -6,7 +6,15 @@ import TourGpxDropZone from './TourGpxDropZone';
|
||||
import type { TourGpxImport } from './useTourGpxImport';
|
||||
|
||||
function gpxImport(overrides: Partial<TourGpxImport> = {}): TourGpxImport {
|
||||
return { importing: false, importFiles: vi.fn().mockResolvedValue(undefined), pick: vi.fn(), ...overrides };
|
||||
return {
|
||||
importing: false,
|
||||
importFiles: vi.fn().mockResolvedValue(undefined),
|
||||
pick: vi.fn(),
|
||||
preview: null,
|
||||
confirm: vi.fn().mockResolvedValue(undefined),
|
||||
cancel: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const file = new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Ban, FileDown, Loader2 } from 'lucide-react';
|
||||
import { useRef, useState, type DragEvent, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import TourGpxPreviewDialog from './TourGpxPreviewDialog';
|
||||
import type { TourGpxImport } from './useTourGpxImport';
|
||||
|
||||
/** Only a drag of files from the desktop; a waypoint or a tour row being dragged is not one. */
|
||||
@@ -64,6 +65,7 @@ export default function TourGpxDropZone({
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<TourGpxPreviewDialog gpxImport={gpxImport} />
|
||||
{(over || gpxImport.importing) && (
|
||||
<div
|
||||
data-testid="tour-gpx-drop"
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import type { PaddleBoatType, PaddleWaterContext, PlannableTourType, TourGpxImportFields } from '@trek/shared';
|
||||
import { AlertTriangle, FileDown, Info, Mountain, Waves } from 'lucide-react';
|
||||
import { useId, useMemo, useState } from 'react';
|
||||
import { OFM_POSITRON } from '../../constants/mapDefaults';
|
||||
import { useTileUrl } from '../../hooks/useTileUrl';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { formatDistance, formatElevation } from '../../utils/units';
|
||||
import { MapViewAuto } from '../Map/MapViewAuto';
|
||||
import { SoftPill } from '../Planner/planParts';
|
||||
import { LABEL, Segmented } from '../shared/dialogParts';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
fs,
|
||||
NEUTRAL_TINT,
|
||||
} from '../shared/DialogShell';
|
||||
import { TourNotice } from './tourParts';
|
||||
import type { GpxPreviewFile, TourGpxImport } from './useTourGpxImport';
|
||||
|
||||
const BOATS: readonly PaddleBoatType[] = ['kayak', 'canoe', 'sup', 'other'];
|
||||
const WATERS: readonly PaddleWaterContext[] = ['river', 'lake'];
|
||||
const BADGE =
|
||||
'inline-flex flex-none items-center rounded-full bg-surface-secondary px-2 font-geist tabular-nums leading-[20px] text-content-secondary';
|
||||
|
||||
/** One track of the file as a row: its name, and what it measures as badges; a track the trip holds says so. */
|
||||
function PreviewTrackRow({ name, track }: { name: string; track: GpxPreviewFile['tracks'][number] }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const heights = track.elevation_gain_m != null && track.elevation_loss_m != null;
|
||||
return (
|
||||
<li
|
||||
data-testid="gpx-preview-track"
|
||||
data-duplicate={track.duplicate || undefined}
|
||||
className={`flex min-w-0 flex-col gap-1.5 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5 ${track.duplicate ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||
{track.name || name}
|
||||
</span>
|
||||
{track.duplicate && <SoftPill tone="warning">{t('tours.import.previewDuplicate')}</SoftPill>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5" style={fs(11)}>
|
||||
<span className={BADGE}>{formatDistance(track.distance_km, unit)}</span>
|
||||
{heights ? (
|
||||
<>
|
||||
<span className={BADGE}>↑ {formatElevation(track.elevation_gain_m ?? 0, unit)}</span>
|
||||
<span className={BADGE}>↓ {formatElevation(track.elevation_loss_m ?? 0, unit)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={BADGE}>{t('tours.import.previewNoHeights')}</span>
|
||||
)}
|
||||
<span className={BADGE}>{t('tours.import.previewPoints', { count: track.point_count })}</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-02: a GPX file shown before anything is stored. The whole course on a
|
||||
* map, every track with its measures, the tracks the trip already holds named
|
||||
* as skipped, and the type to import them as; a paddle import names its boat
|
||||
* and water, as a planned paddle tour does. Closing it stores nothing.
|
||||
*/
|
||||
export default function TourGpxPreviewDialog({ gpxImport }: { gpxImport: TourGpxImport }) {
|
||||
const { preview } = gpxImport;
|
||||
if (!preview) return null;
|
||||
return <PreviewDialog gpxImport={gpxImport} files={preview} />;
|
||||
}
|
||||
|
||||
function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files: GpxPreviewFile[] }) {
|
||||
const { t } = useTranslation();
|
||||
const labelId = useId();
|
||||
const tileUrl = useTileUrl(OFM_POSITRON);
|
||||
const [type, setType] = useState<PlannableTourType>('hike');
|
||||
const [boat, setBoat] = useState<PaddleBoatType | null>(null);
|
||||
const [water, setWater] = useState<PaddleWaterContext | null>(null);
|
||||
|
||||
const tracks = files.flatMap((file) => file.tracks.map((track) => ({ file: file.file, track })));
|
||||
const fresh = tracks.filter(({ track }) => !track.duplicate).length;
|
||||
const lines = useMemo(
|
||||
() =>
|
||||
files.flatMap((file) =>
|
||||
file.tracks.map((track) => track.geometry.map(([lat, lng]): [number, number] => [lat, lng]))
|
||||
),
|
||||
[files]
|
||||
);
|
||||
const focusPoints = useMemo(() => lines.flat(), [lines]);
|
||||
const paddleIncomplete = type === 'kayak' && (boat === null || water === null);
|
||||
const canImport = fresh > 0 && !paddleIncomplete && !gpxImport.importing;
|
||||
const fields: TourGpxImportFields =
|
||||
type === 'kayak' && boat && water
|
||||
? { tour_type: 'kayak', boat_type: boat, water_context: water }
|
||||
: { tour_type: 'hike' };
|
||||
const title = files.length === 1 ? files[0].file.name : t('tours.import.previewFiles', { count: files.length });
|
||||
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={gpxImport.cancel}
|
||||
labelledBy={labelId}
|
||||
width="editor"
|
||||
blocked={gpxImport.importing}
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<FileDown size={20} strokeWidth={2} className="text-content" />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={gpxImport.cancel}
|
||||
eyebrow={t('tours.import.previewTitle')}
|
||||
title={title}
|
||||
sub={t('tours.import.previewHint')}
|
||||
subWraps
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={gpxImport.cancel} disabled={gpxImport.importing}>
|
||||
{t('common.cancel')}
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
data-testid="gpx-preview-import"
|
||||
icon={<FileDown size={14} strokeWidth={2} />}
|
||||
disabled={!canImport}
|
||||
onClick={() => void gpxImport.confirm(fields)}
|
||||
>
|
||||
{t('tours.import.previewConfirm', { count: fresh })}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-testid="gpx-preview-map"
|
||||
className="relative h-[280px] flex-none overflow-hidden rounded-[14px] border border-edge-faint"
|
||||
>
|
||||
<MapViewAuto
|
||||
places={[]}
|
||||
route={lines}
|
||||
tileUrl={tileUrl}
|
||||
focusPoints={focusPoints}
|
||||
focusKey={files.map(({ file }) => file.name).join('|')}
|
||||
followSelection={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogSection label={t('tours.import.previewTracks', { count: tracks.length })}>
|
||||
<ul className="m-0 flex list-none flex-col gap-1.5 p-0">
|
||||
{tracks.map(({ file, track }, index) => (
|
||||
<PreviewTrackRow key={`${file.name}-${index}`} name={file.name} track={track} />
|
||||
))}
|
||||
</ul>
|
||||
{fresh === 0 && (
|
||||
<div className="mt-2">
|
||||
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
|
||||
{t('tours.import.previewAllDuplicates')}
|
||||
</TourNotice>
|
||||
</div>
|
||||
)}
|
||||
</DialogSection>
|
||||
|
||||
<DialogSection label={t('tours.import.previewAs')}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Segmented<PlannableTourType>
|
||||
value={type}
|
||||
onChange={setType}
|
||||
label={t('tours.import.previewAs')}
|
||||
fill
|
||||
options={[
|
||||
{ value: 'hike', label: t('tourTypes.hike'), icon: <Mountain size={13} strokeWidth={2} /> },
|
||||
{ value: 'kayak', label: t('tourTypes.kayak'), icon: <Waves size={13} strokeWidth={2} /> },
|
||||
]}
|
||||
/>
|
||||
{type === 'kayak' && (
|
||||
<div data-testid="gpx-preview-paddle" className="grid grid-cols-1 gap-3 sm:grid-cols-[3fr_2fr]">
|
||||
<div className="min-w-0">
|
||||
<span className={LABEL}>{t('tours.paddle.boat')}</span>
|
||||
<Segmented<PaddleBoatType>
|
||||
value={(boat ?? '') as PaddleBoatType}
|
||||
onChange={setBoat}
|
||||
label={t('tours.paddle.boat')}
|
||||
fill
|
||||
options={BOATS.map((value) => ({ value, label: t(`tours.paddle.boat.${value}`) }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className={LABEL}>{t('tours.paddle.water')}</span>
|
||||
<Segmented<PaddleWaterContext>
|
||||
value={(water ?? '') as PaddleWaterContext}
|
||||
onChange={setWater}
|
||||
label={t('tours.paddle.water')}
|
||||
fill
|
||||
options={WATERS.map((value) => ({ value, label: t(`tours.paddle.water.${value}`) }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{paddleIncomplete && (
|
||||
<TourNotice role="status" icon={<Info size={13} strokeWidth={2} />}>
|
||||
{t('tours.paddle.chooseBoatImport')}
|
||||
</TourNotice>
|
||||
)}
|
||||
</div>
|
||||
</DialogSection>
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,13 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTourMapVisibilityStore } from '../../store/tourMapVisibilityStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { toursApi } from '../../api/client'
|
||||
import { tourGpxApi } from '../../api/tourGpx'
|
||||
import ToursSidebar from './ToursSidebar'
|
||||
import { pickFiles } from '../../../tests/helpers/filePicker'
|
||||
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }))
|
||||
|
||||
vi.mock('../Map/MapViewAuto', () => ({ MapViewAuto: () => <div data-testid="gpx-preview-map-stub" /> }))
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => toast }))
|
||||
|
||||
vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
|
||||
@@ -41,6 +42,19 @@ beforeEach(() => {
|
||||
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } })
|
||||
})
|
||||
|
||||
function previewTrack(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: 'Ridge',
|
||||
geometry: [[47, 11, 600], [47.01, 11.01, 650], [47.02, 11.02, 640]] as [number, number, number][],
|
||||
point_count: 3,
|
||||
distance_km: 2.4,
|
||||
elevation_gain_m: 50,
|
||||
elevation_loss_m: 10,
|
||||
duplicate: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ToursSidebar', () => {
|
||||
it('uses the compact counted dropdown for All, Unplanned and Planned', () => {
|
||||
render(<ToursSidebar tripId={1} days={[]} tours={[tour, { ...tour, place_id: 43, name: 'Planned walk', planned: true }]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
|
||||
@@ -205,7 +219,8 @@ describe('ToursSidebar', () => {
|
||||
})
|
||||
|
||||
it('quarantines returned Tour ids before loading the imported Places', async () => {
|
||||
const importCall = vi.spyOn(toursApi, 'importGpx').mockResolvedValue({
|
||||
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] })
|
||||
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({
|
||||
tours: [tour], caution: false, skipped: 0,
|
||||
})
|
||||
const onToursChanged = vi.fn()
|
||||
@@ -224,6 +239,7 @@ describe('ToursSidebar', () => {
|
||||
|
||||
const file = new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })
|
||||
expect(pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])).toHaveAttribute('accept', '.gpx')
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
|
||||
|
||||
// Only the places are read again, not the whole trip: that would rebuild the workspace.
|
||||
await waitFor(() => expect(refreshPlaces).toHaveBeenCalledWith(1))
|
||||
@@ -231,11 +247,13 @@ describe('ToursSidebar', () => {
|
||||
expect(onToursChanged).toHaveBeenCalledWith([42])
|
||||
expect(onToursChanged.mock.invocationCallOrder[0]).toBeLessThan(refreshPlaces.mock.invocationCallOrder[0])
|
||||
expect(importCall).toHaveBeenCalledOnce()
|
||||
expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'hike' })
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reports duplicate-only GPX success without refetching or invalidating unchanged data', async () => {
|
||||
vi.spyOn(toursApi, 'importGpx').mockResolvedValue({ tours: [], caution: false, skipped: 3 })
|
||||
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] })
|
||||
vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [], caution: false, skipped: 3 })
|
||||
const onToursChanged = vi.fn()
|
||||
const loadTrip = vi.spyOn(useTripStore.getState(), 'loadTrip').mockResolvedValue(undefined)
|
||||
render(<ToursSidebar
|
||||
@@ -250,6 +268,7 @@ describe('ToursSidebar', () => {
|
||||
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'already-imported.gpx', { type: 'application/gpx+xml' }),
|
||||
])
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
|
||||
|
||||
await waitFor(() => expect(toast.warning).toHaveBeenCalledWith('Everything in that file is already in this trip.'))
|
||||
expect(onToursChanged).not.toHaveBeenCalled()
|
||||
@@ -257,9 +276,10 @@ describe('ToursSidebar', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
it('RC-04: names each file that fails and still imports the rest', async () => {
|
||||
vi.spyOn(toursApi, 'importGpx')
|
||||
vi.spyOn(tourGpxApi, 'preview')
|
||||
.mockRejectedValueOnce({ response: { data: { error: 'No track or route found in GPX file' } } })
|
||||
.mockResolvedValueOnce({ tours: [tour], caution: true, skipped: 0 })
|
||||
.mockResolvedValueOnce({ tracks: [previewTrack()] })
|
||||
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValueOnce({ tours: [tour], caution: true, skipped: 0 })
|
||||
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
@@ -269,15 +289,78 @@ describe('ToursSidebar', () => {
|
||||
new File(['<gpx/>'], 'empty.gpx', { type: 'application/gpx+xml' }),
|
||||
new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' }),
|
||||
])
|
||||
// The file without a track is named at once and left out of the preview.
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('No track or route found in empty.gpx'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 tour imported from GPX'))
|
||||
expect(toast.error).toHaveBeenCalledWith('No track or route found in empty.gpx')
|
||||
expect(importCall).toHaveBeenCalledOnce()
|
||||
expect(toast.info).toHaveBeenCalledWith('Imported with caution — elevation/metrics incomplete')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('PAD-02: shows the file before storing anything, and closing the preview stores nothing', async () => {
|
||||
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack(), previewTrack({ name: 'Back', duplicate: true })] })
|
||||
const importCall = vi.spyOn(tourGpxApi, 'import')
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
)
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(dialog).toHaveTextContent('GPX preview')
|
||||
expect(dialog).toHaveTextContent('ridge.gpx')
|
||||
expect(screen.getAllByTestId('gpx-preview-track')).toHaveLength(2)
|
||||
expect(screen.getAllByTestId('gpx-preview-track')[1]).toHaveTextContent('Already in the trip')
|
||||
expect(screen.getAllByTestId('gpx-preview-track')[0]).toHaveTextContent('3 points')
|
||||
// Only the track the trip does not hold yet is imported.
|
||||
expect(screen.getByRole('button', { name: 'Import 1 tour' })).toBeEnabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
expect(importCall).not.toHaveBeenCalled()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('PAD-02: a file the trip holds completely cannot be imported again, and says why', async () => {
|
||||
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack({ duplicate: true })] })
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
)
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
|
||||
expect(await screen.findByText('Every track is already in the trip, so there is nothing to import.')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('gpx-preview-import')).toBeDisabled()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('PAD-02: a paddle import waits for its boat and water, then sends them along', async () => {
|
||||
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack({ elevation_gain_m: null, elevation_loss_m: null })] })
|
||||
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
|
||||
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
)
|
||||
const file = new File(['<gpx/>'], 'lake.gpx', { type: 'application/gpx+xml' })
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])
|
||||
await screen.findByRole('dialog')
|
||||
// A file without heights says so instead of showing zero.
|
||||
expect(screen.getByTestId('gpx-preview-track')).toHaveTextContent('No heights in the file')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Paddle tour' }))
|
||||
const confirm = screen.getByTestId('gpx-preview-import')
|
||||
expect(confirm).toBeDisabled()
|
||||
expect(screen.getByText('Choose the boat and the water to import as a paddle tour.')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'SUP' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Lake' }))
|
||||
expect(confirm).toBeEnabled()
|
||||
fireEvent.click(confirm)
|
||||
|
||||
await waitFor(() => expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'kayak', boat_type: 'sup', water_context: 'lake' }))
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('RC-04: refuses files that are not GPX before uploading anything', async () => {
|
||||
const importCall = vi.spyOn(toursApi, 'importGpx')
|
||||
const importCall = vi.spyOn(tourGpxApi, 'import')
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import EmptyState from '../shared/EmptyState'
|
||||
import { TourDayMenu } from './tourParts'
|
||||
import TourListFilterMenu from './TourListFilterMenu'
|
||||
import { useTourListFilters } from './useTourListFilters'
|
||||
import TourGpxPreviewDialog from './TourGpxPreviewDialog'
|
||||
import { useTourGpxImport } from './useTourGpxImport'
|
||||
import { type AssignTourToDay, useTourDayAssignment } from './useTourDayAssignment'
|
||||
import { useTourMapVisibility } from './useTourMapVisibility'
|
||||
@@ -130,6 +131,7 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<TourGpxPreviewDialog gpxImport={gpxImport} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function TourConversionSection({ conversion }: { conversion: GpxC
|
||||
return card(
|
||||
<div className="flex flex-col items-start gap-2.5">
|
||||
<p className="m-0 leading-snug text-content-secondary" style={fs(12, 'body')}>
|
||||
{t('tours.conversion.intro')}
|
||||
{t(conversion.paddle ? 'tours.conversion.introPaddle' : 'tours.conversion.intro')}
|
||||
</p>
|
||||
<CardAction onClick={() => void conversion.analyze()} icon={<Route size={12} strokeWidth={2.2} />}>
|
||||
{t('tours.conversion.analyze')}
|
||||
@@ -138,7 +138,13 @@ export default function TourConversionSection({ conversion }: { conversion: GpxC
|
||||
)
|
||||
}
|
||||
>
|
||||
{t(faithful ? 'tours.conversion.close' : 'tours.conversion.different')}
|
||||
{t(
|
||||
faithful
|
||||
? conversion.paddle
|
||||
? 'tours.conversion.closePaddle'
|
||||
: 'tours.conversion.close'
|
||||
: 'tours.conversion.different'
|
||||
)}
|
||||
</StatusLine>
|
||||
<div className="flex flex-col gap-2 border-t border-edge-faint pt-3">
|
||||
<Tooltip label={t('tours.conversion.replaceHint')}>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TourMaxHikingDifficulty } from '@trek/shared';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { freeLegBetween } from '../planner/tourRouteActions';
|
||||
import { routeWalkingTour } from '../planner/tourRouting';
|
||||
import { paddleFieldsOf } from '../planner/usePaddleFields';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { compareRoutes, deriveControlPoints, type RouteComparison, type TrackPoint } from './gpxConversion';
|
||||
|
||||
@@ -37,6 +39,8 @@ export function useGpxConversion(planner: TourPlannerController) {
|
||||
const track = planner.readOnlyGpxAnalysis?.routeCoordinates ?? null;
|
||||
const current = tour !== null && state.placeId === tour.place_id ? state : IDLE;
|
||||
const difficulty = (tour?.max_hiking_difficulty ?? DEFAULT_DIFFICULTY) as TourMaxHikingDifficulty;
|
||||
// A paddle import is planned as a paddle tour is: every leg drawn by hand, no router asked.
|
||||
const paddle = tour?.tour_type === 'kayak';
|
||||
|
||||
useEffect(() => () => abort.current?.abort(), []);
|
||||
|
||||
@@ -53,7 +57,13 @@ export function useGpxConversion(planner: TourPlannerController) {
|
||||
return;
|
||||
}
|
||||
setState({ ...base, status: 'analyzing' });
|
||||
const routed = await routeWalkingTour(points, controller.signal, difficulty);
|
||||
const legs = paddle
|
||||
? points.map((point, index) => ({
|
||||
...point,
|
||||
fixedLeg: index < points.length - 1 ? freeLegBetween(point, points[index + 1]) : null,
|
||||
}))
|
||||
: points;
|
||||
const routed = await routeWalkingTour(legs, controller.signal, difficulty);
|
||||
if (controller.signal.aborted) return;
|
||||
if (!routed) {
|
||||
setState({ ...base, status: 'failed', error: 'routing' });
|
||||
@@ -66,7 +76,7 @@ export function useGpxConversion(planner: TourPlannerController) {
|
||||
line: routed.coordinates,
|
||||
comparison: compareRoutes(trackPoints, suggestion),
|
||||
});
|
||||
}, [difficulty, tour, track]);
|
||||
}, [difficulty, paddle, tour, track]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
abort.current?.abort();
|
||||
@@ -89,11 +99,12 @@ export function useGpxConversion(planner: TourPlannerController) {
|
||||
points: current.points,
|
||||
sourcePlaceId: tour.place_id,
|
||||
inPlace: how === 'replace',
|
||||
...(paddle ? { tourType: 'kayak' as const, paddle: paddleFieldsOf(tour) } : {}),
|
||||
});
|
||||
if (started) setState(IDLE);
|
||||
return started;
|
||||
},
|
||||
[current.points, current.status, difficulty, startDerivedTour, tour]
|
||||
[current.points, current.status, difficulty, paddle, startDerivedTour, tour]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
@@ -102,11 +113,13 @@ export function useGpxConversion(planner: TourPlannerController) {
|
||||
/** The import shown can be planned from: it has a track and the user may add tours. */
|
||||
available: tour !== null && (track?.length ?? 0) >= 2 && planner.canEdit,
|
||||
difficulty,
|
||||
/** The import is a paddle tour: its version is drawn with straight legs, not routed. */
|
||||
paddle,
|
||||
analyze,
|
||||
cancel,
|
||||
adopt,
|
||||
}),
|
||||
[adopt, analyze, cancel, current, difficulty, planner.canEdit, tour, track]
|
||||
[adopt, analyze, cancel, current, difficulty, paddle, planner.canEdit, tour, track]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -348,7 +348,10 @@ export function TourDetailsSection({
|
||||
<>
|
||||
<TourDescriptionField planner={planner} locked={locked} />
|
||||
<TourWebsiteField planner={planner} locked={locked} />
|
||||
<TourDifficultyField planner={planner} locked={locked} onDifficultyChange={onDifficultyChange} />
|
||||
{/* A paddle tour is not graded on the hiking scale. */}
|
||||
{!planner.isPaddle && (
|
||||
<TourDifficultyField planner={planner} locked={locked} onDifficultyChange={onDifficultyChange} />
|
||||
)}
|
||||
<TourTimeFields planner={planner} locked={locked} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { TourPlannerController } from './useTourPlanner'
|
||||
import type { Day } from '../../../types'
|
||||
import { publishTourFerrataCrossings } from '../../../store/tourFerrataCrossingStore'
|
||||
import { TourPlannerRail, TourPlannerToursRail } from './TourPlannerPanels'
|
||||
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub'
|
||||
|
||||
vi.mock('../../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
|
||||
vi.mock('../../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') })
|
||||
@@ -43,6 +44,7 @@ const routeAnalysis = analyzeRouteGeometry(JSON.stringify([[48, 11, 500], [48.03
|
||||
|
||||
function planner(overrides: Partial<TourPlannerController> = {}): TourPlannerController {
|
||||
const state = {
|
||||
...hikePlannerDefaults(),
|
||||
canEdit: true,
|
||||
canAssign: true,
|
||||
isSaving: false,
|
||||
|
||||
@@ -17,8 +17,9 @@ import TourListRow from '../TourListRow'
|
||||
import ElevationProfile from '../../shared/ElevationProfile'
|
||||
import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourSource } from '../tourPresentation'
|
||||
import { CardAction, FoldButton, RailCard, RailFacts, TourDayMenu, TourListCard, TourNotice, TourSection } from '../tourParts'
|
||||
import { tourMetricFacts } from '../tourFacts'
|
||||
import { movingTimeLabel, tourMetricFacts, withoutHeights } from '../tourFacts'
|
||||
import { TourDetailsSection, TourNameField } from './TourEditorFields'
|
||||
import { TourTypeFields } from './TourTypeFields'
|
||||
import TourWaypointList from './TourWaypointList'
|
||||
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry'
|
||||
import type { TourPlannerController, TourPlannerStatus } from './useTourPlanner'
|
||||
@@ -239,7 +240,7 @@ function GpxViewRail({
|
||||
tour.duration != null
|
||||
? [
|
||||
{
|
||||
label: t('tours.planner.inspector.duration'),
|
||||
label: movingTimeLabel(tour, t('tours.planner.inspector.duration'), t),
|
||||
value: t('tours.durationMinutes', { count: Math.max(1, Math.round(tour.duration)) }),
|
||||
},
|
||||
]
|
||||
@@ -304,6 +305,8 @@ export function TourPlannerRail({ planner,
|
||||
const [pendingDifficulty, setPendingDifficulty] = useState<1 | 2 | 3 | 4 | 5 | 6 | null>(null)
|
||||
const alpineAcknowledged = useRef(false)
|
||||
const isSaving = planner.isSaving
|
||||
// Nothing in the editor changes while the viewer may not edit or a save is in flight.
|
||||
const locked = !canEdit || isSaving
|
||||
const requestClose = () => {
|
||||
if (isSaving) return
|
||||
if (planner.hasUnsavedChanges) setConfirmDiscard(true)
|
||||
@@ -330,22 +333,24 @@ export function TourPlannerRail({ planner,
|
||||
<ArrowLeft size={16} strokeWidth={2} />
|
||||
</BarButton>
|
||||
<RailTitle title={t(isSavedEdit ? 'tours.planner.editTitle' : 'tours.planner.newTitle')} aside={<RailStatusBadges planner={planner} />} />
|
||||
<BarButton label={t('tours.planner.undo')} onClick={() => { if (canEdit && !isSaving) planner.undo() }} disabled={!canEdit || isSaving || !planner.canUndo}>
|
||||
<BarButton label={t('tours.planner.undo')} onClick={() => { if (canEdit && !isSaving) planner.undo() }} disabled={locked || !planner.canUndo}>
|
||||
<Undo2 size={15} strokeWidth={2} />
|
||||
</BarButton>
|
||||
<BarButton label={t('tours.planner.redo')} onClick={() => { if (canEdit && !isSaving) planner.redo() }} disabled={!canEdit || isSaving || !planner.canRedo}>
|
||||
<BarButton label={t('tours.planner.redo')} onClick={() => { if (canEdit && !isSaving) planner.redo() }} disabled={locked || !planner.canRedo}>
|
||||
<Redo2 size={15} strokeWidth={2} />
|
||||
</BarButton>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 py-3.5">
|
||||
<TourNameField planner={planner} locked={!canEdit || isSaving} />
|
||||
<TourNameField planner={planner} locked={locked} />
|
||||
|
||||
<TourTypeFields planner={planner} locked={locked} />
|
||||
|
||||
{recordedTrack?.available && <RecordedTrackCard recordedTrack={recordedTrack} disabled={isSaving} />}
|
||||
|
||||
<TourWaypointList planner={planner} canEdit={canEdit} isSaving={isSaving} />
|
||||
|
||||
<TourDetailsSection planner={planner} locked={!canEdit || isSaving} onDifficultyChange={changeDifficulty} />
|
||||
<TourDetailsSection planner={planner} locked={locked} onDifficultyChange={changeDifficulty} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none flex-col border-t border-edge-faint px-3 py-3">
|
||||
@@ -353,7 +358,7 @@ export function TourPlannerRail({ planner,
|
||||
<DialogButton icon={<RotateCcw size={14} strokeWidth={2} />} onClick={requestClose} disabled={isSaving || (!planner.hasUnsavedChanges && !isSavedEdit)}>
|
||||
{t('common.discard')}
|
||||
</DialogButton>
|
||||
<DialogButton variant="primary" icon={<Save size={14} strokeWidth={2} />} onClick={() => { if (canEdit && !isSaving) void planner.save() }} disabled={!canEdit || isSaving || !planner.canSave}>
|
||||
<DialogButton variant="primary" icon={<Save size={14} strokeWidth={2} />} onClick={() => { if (canEdit && !isSaving) void planner.save() }} disabled={locked || !planner.canSave}>
|
||||
{t('common.save')}
|
||||
</DialogButton>
|
||||
</div>
|
||||
@@ -518,6 +523,35 @@ interface TourPlannerToursRailProps extends TourPermissionProps {
|
||||
gpxImport?: TourGpxImport
|
||||
}
|
||||
|
||||
/** The time facts of the overview: the estimate (walking, or paddling for a paddle tour), the breaks and the planned total. */
|
||||
function plannerTimeFields(planner: TourPlannerController, t: Translate) {
|
||||
return [
|
||||
planner.isPaddle ? {
|
||||
label: t('tours.paddle.timeEstimated'),
|
||||
shortLabel: t('tours.paddle.timeEstimatedShort'),
|
||||
info: { label: t('tours.paddle.timeEstimatedInfoLabel'), description: t('tours.paddle.timeEstimatedInfo') },
|
||||
value: planner.walkingDurationMinutes == null
|
||||
? t('tours.planner.walkingTimeUnknown')
|
||||
: formatPlannedTourDuration(planner.walkingDurationMinutes),
|
||||
} : {
|
||||
label: t('tours.planner.walkingEstimated'),
|
||||
shortLabel: t('tours.planner.walkingEstimatedShort'),
|
||||
info: { label: t('tours.planner.walkingEstimatedInfoLabel'), description: t('tours.planner.walkingEstimatedInfo') },
|
||||
value: planner.walkingDurationMinutes == null
|
||||
? t('tours.planner.walkingTimeUnknown')
|
||||
: formatPlannedTourDuration(planner.walkingDurationMinutes),
|
||||
},
|
||||
{
|
||||
label: t('tours.planner.breaksShort'),
|
||||
value: planner.breakAdditionalMinutes == null ? '—' : formatPlannedTourDuration(planner.breakAdditionalMinutes),
|
||||
},
|
||||
{
|
||||
label: `${t('tours.planner.plannedShort')}${planner.plannedDurationMinutes != null ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`,
|
||||
value: planner.plannedTotalMinutes == null ? t('tours.planner.walkingTimeUnknown') : formatPlannedTourDuration(planner.plannedTotalMinutes),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function TourPlannerToursRail({ planner, tours, days, loading, onAssignToDay, onViewGpxTour, onDeleteTour,
|
||||
gpxImport, canEdit: editPermission, canAssign: assignPermission }: TourPlannerToursRailProps) {
|
||||
const { canEdit, canAssign } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign })
|
||||
@@ -528,7 +562,8 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
|
||||
const dayAssignment = useTourDayAssignment({ canAssign, onAssignToDay })
|
||||
const isSaving = planner.isSaving
|
||||
const hasRoute = (planner.mode.type === 'new-draft' || planner.mode.type === 'edit-saved') && planner.route !== null
|
||||
const routeAnalysis = planner.routeAnalysis
|
||||
// A paddle tour's heights are read off the banks, not the water: they stay unknown.
|
||||
const routeAnalysis = planner.isPaddle && planner.routeAnalysis ? withoutHeights(planner.routeAnalysis) : planner.routeAnalysis
|
||||
|
||||
// A finished save is said once, as the app says it everywhere: a toast.
|
||||
// Once per save: the toast helper and t are fresh objects on every render, so the
|
||||
@@ -561,24 +596,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
|
||||
selectTour(tour)
|
||||
}
|
||||
|
||||
const tourTimeFields = [
|
||||
{
|
||||
label: t('tours.planner.walkingEstimated'),
|
||||
shortLabel: t('tours.planner.walkingEstimatedShort'),
|
||||
info: { label: t('tours.planner.walkingEstimatedInfoLabel'), description: t('tours.planner.walkingEstimatedInfo') },
|
||||
value: planner.walkingDurationMinutes == null
|
||||
? t('tours.planner.walkingTimeUnknown')
|
||||
: formatPlannedTourDuration(planner.walkingDurationMinutes),
|
||||
},
|
||||
{
|
||||
label: t('tours.planner.breaksShort'),
|
||||
value: planner.breakAdditionalMinutes == null ? '—' : formatPlannedTourDuration(planner.breakAdditionalMinutes),
|
||||
},
|
||||
{
|
||||
label: `${t('tours.planner.plannedShort')}${planner.plannedDurationMinutes != null ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`,
|
||||
value: planner.plannedTotalMinutes == null ? t('tours.planner.walkingTimeUnknown') : formatPlannedTourDuration(planner.plannedTotalMinutes),
|
||||
},
|
||||
]
|
||||
const tourTimeFields = plannerTimeFields(planner, t)
|
||||
|
||||
return (
|
||||
<aside className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.tripTours')}>
|
||||
@@ -603,7 +621,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
|
||||
<div className="flex flex-col gap-3">
|
||||
<FerrataCrossingBadges />
|
||||
<RailFacts facts={tourMetricFacts(routeAnalysis, distanceUnit, t, tourTimeFields)} />
|
||||
{routeAnalysis && (
|
||||
{routeAnalysis && !planner.isPaddle && (
|
||||
<ElevationSection
|
||||
id="tour-planner-editable-elevation-profile"
|
||||
samples={routeAnalysis.distanceIndexedProfileSamples}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
MAX_PADDLE_SPEED_KMH,
|
||||
type PaddleBoatType,
|
||||
type PaddleWaterContext,
|
||||
type PlannableTourType,
|
||||
} from '@trek/shared';
|
||||
import { AlertTriangle, Info, Mountain, Waves } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import { INPUT, LABEL, Segmented } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { TourNotice } from '../tourParts';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
|
||||
const BOATS: readonly PaddleBoatType[] = ['kayak', 'canoe', 'sup', 'other'];
|
||||
const WATERS: readonly PaddleWaterContext[] = ['river', 'lake'];
|
||||
|
||||
interface TourTypeFieldsProps {
|
||||
planner: TourPlannerController;
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
/** "4" or "4.5", as a speed is typed and read back. */
|
||||
function speedText(value: number | null): string {
|
||||
return value === null || Number.isNaN(value) ? '' : String(Math.round(value * 10) / 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* The paddler's own speed. Empty means the boat's suggestion applies; text
|
||||
* that is no speed reaches the planner as NaN while it is typed, so the
|
||||
* planner marks it and holds Save back, and the text stays for the user to fix.
|
||||
*/
|
||||
function PaddleSpeedInput({ planner, locked }: TourTypeFieldsProps) {
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const { paddle } = planner;
|
||||
const commit = (text: string) => {
|
||||
const trimmed = text.trim().replace(',', '.');
|
||||
if (trimmed === '') return paddle.setSpeedKmh(null);
|
||||
const value = Number(trimmed);
|
||||
paddle.setSpeedKmh(Number.isFinite(value) ? value : Number.NaN);
|
||||
};
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
id="tour-planner-paddle-speed"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
maxLength={5}
|
||||
disabled={locked}
|
||||
placeholder={speedText(paddle.recommendedSpeedKmh)}
|
||||
value={draft ?? speedText(paddle.speedKmh)}
|
||||
onFocus={() => setDraft(speedText(paddle.speedKmh))}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
commit(event.target.value);
|
||||
}}
|
||||
onBlur={() => setDraft(null)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur();
|
||||
}}
|
||||
aria-invalid={planner.paddleSpeedInvalid || undefined}
|
||||
aria-describedby={planner.paddleSpeedInvalid ? 'tour-planner-paddle-speed-error' : undefined}
|
||||
className={`${INPUT} pe-12 tabular-nums`}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 end-3 flex items-center text-content-faint"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
km/h
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-01: what the tour is. A hike keeps its fields where they were; a paddle
|
||||
* tour asks for exactly one boat and the water it is on, and offers the
|
||||
* boat's planning speed beside the paddler's own. Nothing here routes the
|
||||
* tour or puts it on a day.
|
||||
*/
|
||||
export function TourTypeFields({ planner, locked }: TourTypeFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { paddle } = planner;
|
||||
const ownSpeed = paddle.speedKmh !== null;
|
||||
const suggestion = paddle.recommendedSpeedKmh;
|
||||
return (
|
||||
<fieldset disabled={locked} className="m-0 flex min-w-0 flex-col gap-3 border-0 p-0">
|
||||
<div className="min-w-0">
|
||||
<span className={LABEL} id="tour-planner-type-label">
|
||||
{t('tours.planner.tourType')}
|
||||
</span>
|
||||
<Segmented<PlannableTourType>
|
||||
value={planner.tourType}
|
||||
onChange={planner.setTourType}
|
||||
label={t('tours.planner.tourType')}
|
||||
fill
|
||||
options={[
|
||||
{ value: 'hike', label: t('tourTypes.hike'), icon: <Mountain size={13} strokeWidth={2} /> },
|
||||
{ value: 'kayak', label: t('tourTypes.kayak'), icon: <Waves size={13} strokeWidth={2} /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{planner.isPaddle && (
|
||||
<div data-testid="tour-planner-paddle-fields" className="flex min-w-0 flex-col gap-3">
|
||||
<div className="min-w-0">
|
||||
<span className={LABEL}>{t('tours.paddle.boat')}</span>
|
||||
<Segmented<PaddleBoatType>
|
||||
value={(paddle.boatType ?? '') as PaddleBoatType}
|
||||
onChange={paddle.setBoatType}
|
||||
label={t('tours.paddle.boat')}
|
||||
fill
|
||||
options={BOATS.map((boat) => ({ value: boat, label: t(`tours.paddle.boat.${boat}`) }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className={LABEL}>{t('tours.paddle.water')}</span>
|
||||
<Segmented<PaddleWaterContext>
|
||||
value={(paddle.waterContext ?? '') as PaddleWaterContext}
|
||||
onChange={paddle.setWaterContext}
|
||||
label={t('tours.paddle.water')}
|
||||
fill
|
||||
options={WATERS.map((water) => ({ value: water, label: t(`tours.paddle.water.${water}`) }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-[5px] flex min-h-5 min-w-0 items-center gap-1.5">
|
||||
<label className={`${LABEL} !mb-0 min-w-0 truncate leading-5`} htmlFor="tour-planner-paddle-speed">
|
||||
{t('tours.paddle.speed')}
|
||||
</label>
|
||||
{ownSpeed && (
|
||||
<span className="ms-auto inline-flex min-w-0">
|
||||
<SoftPill tone="info">{t('tours.paddle.speedOwn')}</SoftPill>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<PaddleSpeedInput planner={planner} locked={locked} />
|
||||
{planner.paddleSpeedInvalid ? (
|
||||
<span id="tour-planner-paddle-speed-error" role="alert" className="mt-1 block text-danger" style={fs(11)}>
|
||||
{t('tours.paddle.speedInvalid', { max: MAX_PADDLE_SPEED_KMH })}
|
||||
</span>
|
||||
) : ownSpeed && suggestion !== null && suggestion !== paddle.speedKmh ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={paddle.adoptRecommendedSpeed}
|
||||
className="mt-1 inline-flex items-center gap-1 rounded-full font-semibold text-accent hover:underline"
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
{t('tours.paddle.speedUseSuggestion', { speed: speedText(suggestion) })}
|
||||
</button>
|
||||
) : (
|
||||
paddle.boatType === 'other' &&
|
||||
!ownSpeed && (
|
||||
<span className="mt-1 block text-content-faint" style={fs(11, 'body')}>
|
||||
{t('tours.paddle.speedNeeded')}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{/* PAD-04: what the map does not know yet stays visible, never read as open water. */}
|
||||
<TourNotice icon={<AlertTriangle size={13} strokeWidth={2} />}>{t('tours.paddle.noWaterData')}</TourNotice>
|
||||
{planner.paddleIncomplete && (
|
||||
<TourNotice role="status" icon={<Info size={13} strokeWidth={2} />}>
|
||||
{t('tours.paddle.chooseBoat')}
|
||||
</TourNotice>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,16 @@
|
||||
import { ArrowDown, ArrowUp, GripVertical, MapPin, MapPinPlus, Trash2 } from 'lucide-react';
|
||||
import type { TourFixedLeg } from '@trek/shared';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowDownUp,
|
||||
ArrowUp,
|
||||
GripVertical,
|
||||
Info,
|
||||
MapPin,
|
||||
MapPinPlus,
|
||||
Repeat,
|
||||
Spline,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Fragment, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
@@ -13,7 +25,8 @@ import { FerrataGlyph } from '../../Ferratas/FerrataGlyph';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { RailCard } from '../tourParts';
|
||||
import { RailCard, TourNotice } from '../tourParts';
|
||||
import { lineLengthM } from './tourFixedLegs';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
import { useWaypointPlaceNames } from './useWaypointPlaceNames';
|
||||
|
||||
@@ -30,12 +43,15 @@ function WaypointAction({
|
||||
label,
|
||||
disabled,
|
||||
danger = false,
|
||||
active,
|
||||
onAct,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
danger?: boolean;
|
||||
/** A switch rather than a one-off action: pressed while on. */
|
||||
active?: boolean;
|
||||
onAct: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
@@ -46,11 +62,12 @@ function WaypointAction({
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
aria-disabled={disabled}
|
||||
aria-pressed={active}
|
||||
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'}`}
|
||||
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' : active ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:bg-surface-card hover:text-content'}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
@@ -58,6 +75,67 @@ function WaypointAction({
|
||||
);
|
||||
}
|
||||
|
||||
/** A route tool in the card's head: an icon with its words in the tooltip, dimmed while it has nothing to do. */
|
||||
function RouteToolButton({
|
||||
label,
|
||||
disabled,
|
||||
onAct,
|
||||
testId,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
onAct: () => void;
|
||||
testId: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testId}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={onAct}
|
||||
className="grid h-6 w-6 flex-none place-items-center rounded-full text-content-muted transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent enabled:hover:bg-surface-card enabled:hover:text-content disabled:cursor-default disabled:opacity-35"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A leg drawn by hand, as a badge on that leg in the dashed look the map draws
|
||||
* it in: no router planned it, and the badge says so. Its length is the line
|
||||
* the user drew, the only measure it has.
|
||||
*/
|
||||
function FreeLegRow({ leg }: { leg: TourFixedLeg }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit);
|
||||
// Read as plain numbers: this workspace compiles without strict mode, where the schema's tuples are "maybe two".
|
||||
const line = leg.geometry.map(([lat, lng]): [number, number] => [Number(lat), Number(lng)]);
|
||||
const distance = formatDistance(lineLengthM(line) / 1000, unit ?? 'metric');
|
||||
return (
|
||||
<li data-testid="tour-planner-free-leg" className="flex min-w-0 py-0.5 ps-[34px]">
|
||||
<Tooltip label={t('tours.planner.freeLegInfo')}>
|
||||
<span className="flex h-[26px] w-full min-w-0 items-center gap-1.5 rounded-full border border-dashed border-edge bg-surface-card pe-1 ps-2">
|
||||
<Spline size={13} strokeWidth={2} className="flex-none text-content-muted" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(11.5, 'body')}>
|
||||
{t('tours.planner.freeLegBadge')}
|
||||
</span>
|
||||
<span
|
||||
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist tabular-nums leading-[18px] text-content-secondary"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{distance}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** The quiet hint and first-use guidance before a route exists. */
|
||||
function WaypointEmptyState() {
|
||||
const { t } = useTranslation();
|
||||
@@ -121,12 +199,26 @@ function WaypointNumber({
|
||||
index,
|
||||
selected,
|
||||
ferrata,
|
||||
loopEnd = false,
|
||||
}: {
|
||||
index: number;
|
||||
selected: boolean;
|
||||
ferrata: TourWaypointFerrata | null;
|
||||
/** The way back to the start of a loop: shown as the return, not as one more stop. */
|
||||
loopEnd?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (loopEnd) {
|
||||
return (
|
||||
<span
|
||||
aria-label={t('tours.planner.loopEnd')}
|
||||
data-waypoint-loop-end="true"
|
||||
className={`grid h-7 w-7 flex-none place-items-center rounded-full ${selected ? 'bg-accent text-accent-text' : 'bg-edge text-content'}`}
|
||||
>
|
||||
<Repeat size={13} strokeWidth={2.2} aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const label = t('tours.planner.waypointLabel', { n: index + 1 });
|
||||
const number = (
|
||||
<span
|
||||
@@ -145,6 +237,111 @@ function WaypointNumber({
|
||||
return <Tooltip label={`${t('tours.ferrata.waypointAtEnd', { name: ferrata.name })}${grade}`}>{number}</Tooltip>;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a waypoint row says: its place name with the coordinates under it, or
|
||||
* the coordinates alone while the name loads, offline or when the lookup has
|
||||
* none; start and end as a badge. The way back of a loop is named as such.
|
||||
*/
|
||||
function WaypointRowLabel({
|
||||
point,
|
||||
placeName,
|
||||
loopEnd,
|
||||
}: {
|
||||
point: TourPlannerController['waypoints'][number];
|
||||
placeName: string | undefined;
|
||||
loopEnd: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const coordinates = `${point.lat.toFixed(5)}, ${point.lng.toFixed(5)}`;
|
||||
const name = loopEnd ? t('tours.planner.loopEnd') : placeName;
|
||||
return (
|
||||
<>
|
||||
<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 ${name ? 'font-semibold text-content' : 'font-geist tabular-nums text-content-muted'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{name ?? coordinates}
|
||||
</span>
|
||||
{point.role !== 'via' && !loopEnd && <SoftPill>{t(ROLE_KEY[point.role])}</SoftPill>}
|
||||
</span>
|
||||
{name && !loopEnd && (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The controls of a waypoint row, out on hover as a plan row's are: move it up
|
||||
* or down, draw the leg after it by hand (not on a paddle tour, where every leg
|
||||
* is, nor on a via ferrata), and remove it.
|
||||
*/
|
||||
function WaypointRowActions({
|
||||
planner,
|
||||
point,
|
||||
index,
|
||||
lastIndex,
|
||||
selected,
|
||||
isSaving,
|
||||
}: {
|
||||
planner: TourPlannerController;
|
||||
point: TourPlannerController['waypoints'][number];
|
||||
index: number;
|
||||
lastIndex: number;
|
||||
selected: boolean;
|
||||
isSaving: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const free = point.fixedLeg?.kind === 'free';
|
||||
const canDrawLeg = !planner.isPaddle && index < lastIndex && point.fixedLeg?.kind !== 'ferrata';
|
||||
return (
|
||||
<span
|
||||
className={`flex-none items-center gap-0.5 group-focus-within:flex group-hover:flex ${selected ? 'flex' : 'hidden'}`}
|
||||
>
|
||||
<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>
|
||||
{canDrawLeg && (
|
||||
<WaypointAction
|
||||
label={t(free ? 'tours.planner.routeLeg' : 'tours.planner.freeLeg')}
|
||||
disabled={isSaving}
|
||||
active={free}
|
||||
onAct={() => planner.toggleWaypointFreeLeg(point.id)}
|
||||
>
|
||||
<Spline 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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -202,14 +399,39 @@ export default function TourWaypointList({
|
||||
};
|
||||
|
||||
// One card like a day in the plan: the head counts the points, the rows sit below.
|
||||
const tools = canEdit && planner.waypoints.length > 0 && (
|
||||
<span className="flex flex-none items-center gap-0.5">
|
||||
<RouteToolButton
|
||||
testId="tour-planner-reverse"
|
||||
label={t('tours.planner.reverse')}
|
||||
disabled={isSaving || planner.waypoints.length < 2}
|
||||
onAct={planner.reverse}
|
||||
>
|
||||
<ArrowDownUp size={13} strokeWidth={2.2} />
|
||||
</RouteToolButton>
|
||||
<RouteToolButton
|
||||
testId="tour-planner-return-to-start"
|
||||
label={t(planner.isLoop ? 'tours.planner.alreadyLoop' : 'tours.planner.returnToStart')}
|
||||
disabled={isSaving || planner.waypoints.length < 2 || planner.isLoop}
|
||||
onAct={planner.returnToStart}
|
||||
>
|
||||
<Repeat size={13} strokeWidth={2.2} />
|
||||
</RouteToolButton>
|
||||
</span>
|
||||
);
|
||||
// The way back of a loop is no stop of its own, so the count leaves it out.
|
||||
const stops = planner.waypoints.length - (planner.isLoop ? 1 : 0);
|
||||
const card = (children: ReactNode) => (
|
||||
<RailCard
|
||||
icon={<MapPin size={14} strokeWidth={2} />}
|
||||
title={t('tours.planner.waypoints')}
|
||||
aside={
|
||||
planner.waypoints.length > 0 && (
|
||||
<span className="flex-none tabular-nums text-content-faint" style={fs(11.5, 'body')}>
|
||||
{planner.waypoints.length}
|
||||
<span className="flex flex-none items-center gap-1.5">
|
||||
{tools}
|
||||
<span className="flex-none tabular-nums text-content-faint" style={fs(11.5, 'body')}>
|
||||
{stops}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -233,8 +455,7 @@ export default function TourWaypointList({
|
||||
{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)}`;
|
||||
const loopEnd = planner.isLoop && index === lastIndex;
|
||||
return (
|
||||
<Fragment key={point.id}>
|
||||
<li
|
||||
@@ -290,71 +511,47 @@ export default function TourWaypointList({
|
||||
>
|
||||
<GripVertical size={14} strokeWidth={1.8} />
|
||||
</span>
|
||||
<WaypointNumber index={index} selected={selected} ferrata={ferrataEnds[point.id] ?? null} />
|
||||
<WaypointNumber
|
||||
index={index}
|
||||
selected={selected}
|
||||
ferrata={ferrataEnds[point.id] ?? null}
|
||||
loopEnd={loopEnd}
|
||||
/>
|
||||
<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>
|
||||
{point.role !== 'via' && <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>
|
||||
)}
|
||||
<WaypointRowLabel point={point} placeName={placeNames[point.id]} loopEnd={loopEnd} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<span
|
||||
className={`flex-none items-center gap-0.5 group-focus-within:flex group-hover:flex ${selected ? 'flex' : 'hidden'}`}
|
||||
>
|
||||
<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>
|
||||
<WaypointRowActions
|
||||
planner={planner}
|
||||
point={point}
|
||||
index={index}
|
||||
lastIndex={lastIndex}
|
||||
selected={selected}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
{(legCrossings.get(point.id) ?? []).map((crossing) => (
|
||||
<FerrataLegRow key={`${crossing.id}-${crossing.fromM}`} crossing={crossing} />
|
||||
))}
|
||||
{/* A paddle tour is drawn by hand throughout; the notice says it once instead of on every leg. */}
|
||||
{point.fixedLeg?.kind === 'free' && !planner.isPaddle && <FreeLegRow leg={point.fixedLeg} />}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{canEdit && <AddPointRow disabled={isSaving} />}
|
||||
{planner.isPaddle && (
|
||||
<div className="mt-2">
|
||||
<TourNotice icon={<Info size={13} strokeWidth={2} />}>{t('tours.paddle.drawnByHand')}</TourNotice>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FE-TOURS-FIXEDLEG-001 to -002: a fixed leg holds only while it joins its two waypoints.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { keepJoinedFixedLegs, lineLengthM } from './tourFixedLegs';
|
||||
import { densifyLine, keepJoinedFixedLegs, lineLengthM } from './tourFixedLegs';
|
||||
|
||||
const leg = {
|
||||
kind: 'ferrata' as const,
|
||||
@@ -41,3 +41,26 @@ describe('keepJoinedFixedLegs', () => {
|
||||
expect(lineLengthM([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('densifyLine', () => {
|
||||
it('puts a point every 25 m on a straight leg and keeps its ends and its length', () => {
|
||||
const line: [number, number][] = [
|
||||
[47, 11],
|
||||
[47.001, 11],
|
||||
];
|
||||
const dense = densifyLine(line);
|
||||
expect(dense[0]).toEqual(line[0]);
|
||||
expect(dense[dense.length - 1]).toEqual(line[1]);
|
||||
// About 111 m: four stretches of under 28 m.
|
||||
expect(dense).toHaveLength(5);
|
||||
expect(Math.round(lineLengthM(dense))).toBe(Math.round(lineLengthM(line)));
|
||||
});
|
||||
|
||||
it('leaves a leg shorter than one step as it was', () => {
|
||||
const line: [number, number][] = [
|
||||
[47, 11],
|
||||
[47.0001, 11],
|
||||
];
|
||||
expect(densifyLine(line)).toEqual(line);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,3 +50,25 @@ export function lineLengthM(line: readonly (readonly [number, number])[]): numbe
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** The longest stretch, in metres, a hand-drawn leg runs without a point of its own. */
|
||||
const FREE_LEG_SAMPLE_M = 25;
|
||||
|
||||
/**
|
||||
* A straight line with a point every `stepM` metres, so the heights read for
|
||||
* it follow the ground under the whole stretch and not only its two ends. The
|
||||
* line itself does not change; a short one comes back as it was.
|
||||
*/
|
||||
export function densifyLine(line: readonly [number, number][], stepM = FREE_LEG_SAMPLE_M): [number, number][] {
|
||||
const dense: [number, number][] = [];
|
||||
line.forEach((point, index) => {
|
||||
const next = line[index + 1];
|
||||
dense.push(point);
|
||||
if (!next) return;
|
||||
const parts = Math.floor(lineLengthM([point, next]) / stepM);
|
||||
for (let i = 1; i < parts; i += 1) {
|
||||
dense.push([point[0] + ((next[0] - point[0]) * i) / parts, point[1] + ((next[1] - point[1]) * i) / parts]);
|
||||
}
|
||||
});
|
||||
return dense;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
alignFreeLegs,
|
||||
closeLoop,
|
||||
freeLegBetween,
|
||||
isClosedLoop,
|
||||
reverseRoute,
|
||||
toggleFreeLeg,
|
||||
type RoutePoint,
|
||||
} from './tourRouteActions';
|
||||
|
||||
const A: RoutePoint = { id: 'a', lat: 47, lng: 11 };
|
||||
const B: RoutePoint = { id: 'b', lat: 47.01, lng: 11.01 };
|
||||
const C: RoutePoint = { id: 'c', lat: 47.02, lng: 11.03 };
|
||||
const FERRATA = {
|
||||
kind: 'ferrata' as const,
|
||||
ref: 'way/1',
|
||||
name: 'Via',
|
||||
geometry: [
|
||||
[47.01, 11.01],
|
||||
[47.015, 11.02],
|
||||
[47.02, 11.03],
|
||||
] as [number, number][],
|
||||
};
|
||||
|
||||
describe('isClosedLoop', () => {
|
||||
it('reads a loop from the points: the end on the spot of the start', () => {
|
||||
expect(isClosedLoop([A, B, { ...A, id: 'a2' }])).toBe(true);
|
||||
expect(isClosedLoop([A, B, C])).toBe(false);
|
||||
});
|
||||
|
||||
it('is no loop with two points on one spot, or none', () => {
|
||||
expect(isClosedLoop([A, { ...A, id: 'a2' }])).toBe(false);
|
||||
expect(isClosedLoop([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeLoop (PR1-01)', () => {
|
||||
it('adds one end on the start, under a new id, and keeps every stop', () => {
|
||||
const closed = closeLoop([A, B, C], 'back');
|
||||
expect(closed.map((point) => point.id)).toEqual(['a', 'b', 'c', 'back']);
|
||||
expect(closed[3]).toMatchObject({ lat: A.lat, lng: A.lng });
|
||||
expect(isClosedLoop(closed)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not copy the start a second time, or close a tour with one point', () => {
|
||||
const closed = closeLoop([A, B, C], 'back');
|
||||
expect(closeLoop(closed, 'again')).toEqual(closed);
|
||||
expect(closeLoop([A], 'back')).toEqual([A]);
|
||||
});
|
||||
|
||||
it('does not carry the start leg onto the return point', () => {
|
||||
const closed = closeLoop([{ ...A, fixedLeg: freeLegBetween(A, B) }, B], 'back');
|
||||
expect(closed[2].fixedLeg).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reverseRoute (PR1-02)', () => {
|
||||
it('swaps start and end and runs the stops backwards', () => {
|
||||
expect(reverseRoute([A, B, C]).map((point) => point.id)).toEqual(['c', 'b', 'a']);
|
||||
});
|
||||
|
||||
it('keeps a via ferrata on the stretch it covers, its line turned round', () => {
|
||||
const reversed = reverseRoute([A, { ...B, fixedLeg: FERRATA }, C]);
|
||||
expect(reversed.map((point) => point.id)).toEqual(['c', 'b', 'a']);
|
||||
expect(reversed[0].fixedLeg).toEqual({ ...FERRATA, geometry: [...FERRATA.geometry].reverse() });
|
||||
expect(reversed[1].fixedLeg).toBeUndefined();
|
||||
expect(reversed[2].fixedLeg).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a loop a loop from the same spot', () => {
|
||||
const loop = closeLoop([A, B, C], 'back');
|
||||
const reversed = reverseRoute(loop);
|
||||
expect(reversed.map((point) => point.id)).toEqual(['back', 'c', 'b', 'a']);
|
||||
expect(isClosedLoop(reversed)).toBe(true);
|
||||
});
|
||||
|
||||
it('twice is the tour as it was', () => {
|
||||
const route = [{ ...A, fixedLeg: freeLegBetween(A, B) }, { ...B, fixedLeg: FERRATA }, C];
|
||||
expect(reverseRoute(reverseRoute(route))).toEqual(route);
|
||||
});
|
||||
});
|
||||
|
||||
describe('free legs (P2A-04)', () => {
|
||||
it('draws the leg after a point by hand, and routes it again on the second press', () => {
|
||||
const drawn = toggleFreeLeg([A, B, C], 'a');
|
||||
expect(drawn[0].fixedLeg).toEqual(freeLegBetween(A, B));
|
||||
expect(toggleFreeLeg(drawn, 'a')[0].fixedLeg).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves a via ferrata and the last point alone', () => {
|
||||
const route = [A, { ...B, fixedLeg: FERRATA }, C];
|
||||
expect(toggleFreeLeg(route, 'b')).toEqual(route);
|
||||
expect(toggleFreeLeg(route, 'c')).toEqual(route);
|
||||
});
|
||||
|
||||
it('follows a moved point instead of tearing off', () => {
|
||||
const moved = alignFreeLegs([{ ...A, fixedLeg: freeLegBetween(A, B) }, { ...B, lat: 47.05 }, C]);
|
||||
expect(moved[0].fixedLeg).toEqual(freeLegBetween(A, { lat: 47.05, lng: B.lng }));
|
||||
});
|
||||
|
||||
it('drops a free leg that leads nowhere, and keeps routed legs routed', () => {
|
||||
const aligned = alignFreeLegs([A, B, { ...C, fixedLeg: freeLegBetween(C, A) }]);
|
||||
expect(aligned.map((point) => point.fixedLeg)).toEqual([undefined, undefined, undefined]);
|
||||
});
|
||||
|
||||
it('draws every leg by hand for a paddle tour', () => {
|
||||
const aligned = alignFreeLegs([A, { ...B, fixedLeg: FERRATA }, C], true);
|
||||
expect(aligned.map((point) => point.fixedLeg?.kind)).toEqual(['free', 'free', undefined]);
|
||||
expect(aligned[1].fixedLeg).toEqual(freeLegBetween(B, C));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { TourFixedLeg } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The route tools of the tour planner as plain list operations: reversing the
|
||||
* tour (PR1-02), closing it back to its start (PR1-01) and drawing a leg by
|
||||
* hand (P2A-04). Each takes the ordered waypoints and returns the new order;
|
||||
* the planner commits the result as one step of its history, so one undo
|
||||
* takes the whole action back.
|
||||
*/
|
||||
export interface RoutePoint {
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
fixedLeg?: TourFixedLeg | null;
|
||||
}
|
||||
|
||||
/** Closer than this, in degrees, two waypoints stand on the same spot (about a centimetre). */
|
||||
const SAME_SPOT_DEG = 1e-7;
|
||||
|
||||
function sameSpot(a: { lat: number; lng: number }, b: { lat: number; lng: number }): boolean {
|
||||
return Math.abs(a.lat - b.lat) < SAME_SPOT_DEG && Math.abs(a.lng - b.lng) < SAME_SPOT_DEG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the tour ends where it starts. Read from the points themselves, so a
|
||||
* saved loop opens as a loop again without a flag of its own; a tour of two
|
||||
* points on one spot is no loop, there is nothing to return from.
|
||||
*/
|
||||
export function isClosedLoop(points: readonly { lat: number; lng: number }[]): boolean {
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
return points.length >= 3 && !!first && !!last && sameSpot(first, last);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tour taken back from its last point to its start: one end point on the
|
||||
* start's spot, not a second copy of the start among the stops. `newId` names
|
||||
* it; a tour that already returns, or has nothing to return from, is left as it is.
|
||||
*/
|
||||
export function closeLoop<T extends RoutePoint>(points: readonly T[], newId: string): T[] {
|
||||
const first = points[0];
|
||||
if (!first || points.length < 2 || isClosedLoop(points)) return [...points];
|
||||
const { fixedLeg: _leg, ...start } = first;
|
||||
return [...points, { ...start, id: newId } as T];
|
||||
}
|
||||
|
||||
/**
|
||||
* The same tour the other way round: the last point starts it, the stops run
|
||||
* backwards, and a fixed leg (a via ferrata, a hand-drawn stretch) stays on the
|
||||
* stretch it covered, its line turned round with it. A loop stays a loop from
|
||||
* the same spot.
|
||||
*/
|
||||
export function reverseRoute<T extends RoutePoint>(points: readonly T[]): T[] {
|
||||
return points.map((_, index) => {
|
||||
const point = points[points.length - 1 - index];
|
||||
// In the new order the leg after this point is the one that led into it before.
|
||||
const before = points[points.length - 2 - index];
|
||||
const { fixedLeg: _old, ...rest } = point;
|
||||
const leg = before?.fixedLeg;
|
||||
if (!leg || index === points.length - 1) return rest as T;
|
||||
return { ...rest, fixedLeg: { ...leg, geometry: [...leg.geometry].reverse() } } as T;
|
||||
});
|
||||
}
|
||||
|
||||
/** A hand-drawn leg from this point straight to the next. */
|
||||
export function freeLegBetween(from: { lat: number; lng: number }, to: { lat: number; lng: number }): TourFixedLeg {
|
||||
return {
|
||||
kind: 'free',
|
||||
geometry: [
|
||||
[from.lat, from.lng],
|
||||
[to.lat, to.lng],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every hand-drawn leg redrawn straight to the waypoint that now follows it,
|
||||
* so moving a point drags its free legs along instead of tearing them off. A
|
||||
* free leg on the last point leads nowhere and goes. With `allFree` every leg
|
||||
* becomes hand-drawn, the way a paddle tour is planned.
|
||||
*/
|
||||
export function alignFreeLegs<T extends RoutePoint>(points: readonly T[], allFree = false): T[] {
|
||||
return points.map((point, index) => {
|
||||
const next = points[index + 1];
|
||||
const free = allFree || point.fixedLeg?.kind === 'free';
|
||||
if (!free) return point;
|
||||
if (!next) {
|
||||
if (!point.fixedLeg) return point;
|
||||
const { fixedLeg: _dropped, ...rest } = point;
|
||||
return rest as T;
|
||||
}
|
||||
return { ...point, fixedLeg: freeLegBetween(point, next) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The leg after this waypoint drawn by hand, or routed again. A via ferrata
|
||||
* keeps its line; the last point has no leg to change.
|
||||
*/
|
||||
export function toggleFreeLeg<T extends RoutePoint>(points: readonly T[], id: string): T[] {
|
||||
const index = points.findIndex((point) => point.id === id);
|
||||
const point = points[index];
|
||||
const next = points[index + 1];
|
||||
if (!point || !next || point.fixedLeg?.kind === 'ferrata') return [...points];
|
||||
const copy = [...points];
|
||||
if (point.fixedLeg?.kind === 'free') {
|
||||
const { fixedLeg: _dropped, ...rest } = point;
|
||||
copy[index] = rest as T;
|
||||
} else {
|
||||
copy[index] = { ...point, fixedLeg: freeLegBetween(point, next) };
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
@@ -70,6 +70,44 @@ describe('routeWalkingTour', () => {
|
||||
expect(await routeWalkingTour([{ lat: 47.38, lng: 11.0 }, { lat: 47.39, lng: 11.01, fixedLeg: leg }, { lat: 47.392, lng: 11.012 }])).toBeNull()
|
||||
})
|
||||
|
||||
it('TOUR-ROUTING-022: hand-drawn legs ask no router and come back as their own parts, routed runs as theirs', async () => {
|
||||
const free = (from: [number, number], to: [number, number]) => ({ kind: 'free' as const, geometry: [from, to] })
|
||||
valhallaRun.mockResolvedValueOnce({ total: { coordinates: [[47.392, 11.012], [47.395, 11.015], [47.4, 11.02]], distance: 900, duration: 700 } })
|
||||
|
||||
const routed = await routeWalkingTour([
|
||||
{ lat: 47.38, lng: 11.0, fixedLeg: free([47.38, 11.0], [47.39, 11.01]) },
|
||||
{ lat: 47.39, lng: 11.01, fixedLeg: free([47.39, 11.01], [47.392, 11.012]) },
|
||||
{ lat: 47.392, lng: 11.012 },
|
||||
{ lat: 47.4, lng: 11.02 },
|
||||
])
|
||||
|
||||
expect(valhallaRun).toHaveBeenCalledTimes(1)
|
||||
expect(routed?.parts.map(part => part.free)).toEqual([true, false])
|
||||
// The drawn stretch is sampled along its length, from its first point through the waypoint between to its last.
|
||||
const drawn = routed?.parts[0].coordinates ?? []
|
||||
expect(drawn[0]).toEqual([47.38, 11.0])
|
||||
expect(drawn).toContainEqual([47.39, 11.01])
|
||||
expect(drawn[drawn.length - 1]).toEqual([47.392, 11.012])
|
||||
expect(drawn.length).toBeGreaterThan(50)
|
||||
expect(routed?.parts[1].coordinates).toEqual([[47.392, 11.012], [47.395, 11.015], [47.4, 11.02]])
|
||||
expect(routed?.coordinates[routed.coordinates.length - 1]).toEqual([47.4, 11.02])
|
||||
})
|
||||
|
||||
it('TOUR-ROUTING-023: a tour drawn wholly by hand, a paddle tour, never asks the router', async () => {
|
||||
const free = (from: [number, number], to: [number, number]) => ({ kind: 'free' as const, geometry: [from, to] })
|
||||
const routed = await routeWalkingTour([
|
||||
{ lat: 53.3, lng: 12.9, fixedLeg: free([53.3, 12.9], [53.31, 12.92]) },
|
||||
{ lat: 53.31, lng: 12.92 },
|
||||
])
|
||||
expect(valhallaRun).not.toHaveBeenCalled()
|
||||
expect(routed?.parts).toHaveLength(1)
|
||||
expect(routed?.parts[0]).toMatchObject({ free: true })
|
||||
expect(routed?.parts[0].coordinates[0]).toEqual([53.3, 12.9])
|
||||
const line = routed?.parts[0].coordinates ?? []
|
||||
expect(line[line.length - 1]).toEqual([53.31, 12.92])
|
||||
expect(routed?.distanceMeters).toBeGreaterThan(1500)
|
||||
})
|
||||
|
||||
it.each([1, 2, 3, 4, 5, 6] as const)('TOUR-ROUTING-002: forwards T%i unchanged to Valhalla', async difficulty => {
|
||||
await routeWalkingTour(waypoints, undefined, difficulty)
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import type { TourFixedLeg } from '@trek/shared'
|
||||
import type { Waypoint } from '../../../types'
|
||||
import { valhallaBase, valhallaRun, valhallaTurn } from '../../Map/valhallaRoute'
|
||||
import { lineLengthM } from './tourFixedLegs'
|
||||
import { densifyLine, lineLengthM } from './tourFixedLegs'
|
||||
|
||||
export interface TourRoutePart {
|
||||
coordinates: [number, number][]
|
||||
/** Drawn by hand: no router planned or checked it. */
|
||||
free: boolean
|
||||
}
|
||||
|
||||
export interface TourRouteResult {
|
||||
coordinates: [number, number][]
|
||||
/** The same line cut where it changes between routed and hand-drawn, for the map to tell them apart. */
|
||||
parts: TourRoutePart[]
|
||||
distanceMeters: number
|
||||
durationSeconds: number
|
||||
}
|
||||
@@ -15,8 +23,8 @@ export interface TourRouteResult {
|
||||
*/
|
||||
const TOURS_MAX_HIKING_DIFFICULTY = 2
|
||||
|
||||
/** How fast a fixed leg is reckoned until the heights are in (a via ferrata climbs slowly). */
|
||||
const FIXED_LEG_KM_PER_HOUR = 1.5
|
||||
/** How fast a fixed leg is reckoned until the heights are in: a via ferrata climbs slowly, a free leg is walked. */
|
||||
const FIXED_LEG_KM_PER_HOUR = { ferrata: 1.5, free: 4 } as const
|
||||
|
||||
/**
|
||||
* Tours deliberately bypasses the OSRM-first RouteCalculator. A waypoint whose
|
||||
@@ -28,7 +36,7 @@ export async function routeWalkingTour(
|
||||
signal?: AbortSignal,
|
||||
maxHikingDifficulty = TOURS_MAX_HIKING_DIFFICULTY,
|
||||
): Promise<TourRouteResult | null> {
|
||||
const lines: [number, number][][] = []
|
||||
const lines: TourRoutePart[] = []
|
||||
let distanceMeters = 0
|
||||
let durationSeconds = 0
|
||||
const routeRun = async (run: Array<Pick<Waypoint, 'lat' | 'lng'>>) => {
|
||||
@@ -37,7 +45,7 @@ export async function routeWalkingTour(
|
||||
max_hiking_difficulty: maxHikingDifficulty,
|
||||
})
|
||||
if (!routed) return false
|
||||
lines.push(routed.total.coordinates)
|
||||
lines.push({ coordinates: routed.total.coordinates, free: false })
|
||||
distanceMeters += routed.total.distance
|
||||
durationSeconds += routed.total.duration
|
||||
return true
|
||||
@@ -51,19 +59,32 @@ export async function routeWalkingTour(
|
||||
continue
|
||||
}
|
||||
if (!(await routeRun(run))) return null
|
||||
const line = leg.geometry.map(([lat, lng]): [number, number] => [lat, lng])
|
||||
const drawn = leg.geometry.map(([lat, lng]): [number, number] => [lat, lng])
|
||||
// A hand-drawn leg is sampled along its length, so its heights follow the ground between its ends.
|
||||
const line = leg.kind === 'free' ? densifyLine(drawn) : drawn
|
||||
const length = lineLengthM(line)
|
||||
lines.push(line)
|
||||
lines.push({ coordinates: line, free: leg.kind === 'free' })
|
||||
distanceMeters += length
|
||||
durationSeconds += (length / 1000 / FIXED_LEG_KM_PER_HOUR) * 3600
|
||||
durationSeconds += (length / 1000 / FIXED_LEG_KM_PER_HOUR[leg.kind]) * 3600
|
||||
run = [waypoints[i + 1]]
|
||||
}
|
||||
if (!(await routeRun(run))) return null
|
||||
if (lines.length === 0) return null
|
||||
|
||||
// One line: each part after the first starts where the one before it ended, so its first point goes.
|
||||
const coordinates = lines.flatMap((line, index) => (index === 0 ? line : line.slice(1)))
|
||||
return { coordinates, distanceMeters, durationSeconds }
|
||||
const coordinates = lines.flatMap((line, index) => (index === 0 ? line.coordinates : line.coordinates.slice(1)))
|
||||
return { coordinates, parts: mergeParts(lines), distanceMeters, durationSeconds }
|
||||
}
|
||||
|
||||
/** Neighbouring parts of the same kind as one, so a run of hand-drawn legs is one dotted line. */
|
||||
function mergeParts(parts: TourRoutePart[]): TourRoutePart[] {
|
||||
const merged: TourRoutePart[] = []
|
||||
for (const part of parts) {
|
||||
const previous = merged[merged.length - 1]
|
||||
if (previous && previous.free === part.free) previous.coordinates = [...previous.coordinates, ...part.coordinates.slice(1)]
|
||||
else merged.push({ coordinates: [...part.coordinates], free: part.free })
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
const HEIGHT_CHUNK_SIZE = 500
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
recommendedPaddleSpeedKmh,
|
||||
type PaddleBoatType,
|
||||
type PaddleWaterContext,
|
||||
type TourListItem,
|
||||
} from '@trek/shared';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export interface PaddleFieldValues {
|
||||
boatType: PaddleBoatType | null;
|
||||
waterContext: PaddleWaterContext | null;
|
||||
/** The speed the paddler chose, km/h; null while the boat's suggested speed applies. */
|
||||
speedKmh: number | null;
|
||||
}
|
||||
|
||||
export const NO_PADDLE_FIELDS: PaddleFieldValues = { boatType: null, waterContext: null, speedKmh: null };
|
||||
|
||||
/** The paddle fields of a saved tour, or none for a hike or a list cached before they existed. */
|
||||
export function paddleFieldsOf(
|
||||
tour: Pick<TourListItem, 'boat_type' | 'water_context' | 'paddle_speed_kmh'>
|
||||
): PaddleFieldValues {
|
||||
return {
|
||||
boatType: tour.boat_type ?? null,
|
||||
waterContext: tour.water_context ?? null,
|
||||
speedKmh: tour.paddle_speed_kmh ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Minutes on the water at a speed, unknown without a distance or a speed. */
|
||||
export function paddleMinutes(distanceKm: number | null | undefined, speedKmh: number | null): number | null {
|
||||
if (distanceKm == null || speedKmh == null || speedKmh <= 0) return null;
|
||||
return Math.round((distanceKm / speedKmh) * 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-01: the boat, the water and the planning speed of a paddle tour. A
|
||||
* speed the paddler typed stays when the boat changes; the boat's suggestion
|
||||
* is offered beside it and only taken on purpose. `onEdit` tells the planner
|
||||
* the draft changed, like any other field.
|
||||
*/
|
||||
export function usePaddleFields(initial: PaddleFieldValues, onEdit: () => boolean) {
|
||||
const [values, setValues] = useState<PaddleFieldValues>(initial);
|
||||
|
||||
// Only a real change marks the draft edited: choosing the boat already chosen is no edit.
|
||||
const edit = useCallback(
|
||||
(next: Partial<PaddleFieldValues>) => {
|
||||
const merged = { ...values, ...next };
|
||||
const same =
|
||||
merged.boatType === values.boatType &&
|
||||
merged.waterContext === values.waterContext &&
|
||||
Object.is(merged.speedKmh, values.speedKmh);
|
||||
if (!same && onEdit()) setValues(merged);
|
||||
},
|
||||
[onEdit, values]
|
||||
);
|
||||
|
||||
const recommended = recommendedPaddleSpeedKmh(values.boatType);
|
||||
return {
|
||||
...values,
|
||||
recommendedSpeedKmh: recommended,
|
||||
/** The speed the time is worked out with: the chosen one, else the boat's suggestion. */
|
||||
effectiveSpeedKmh: values.speedKmh ?? recommended,
|
||||
setBoatType: useCallback((boatType: PaddleBoatType) => edit({ boatType }), [edit]),
|
||||
setWaterContext: useCallback((waterContext: PaddleWaterContext) => edit({ waterContext }), [edit]),
|
||||
setSpeedKmh: useCallback((speedKmh: number | null) => edit({ speedKmh }), [edit]),
|
||||
/** Back to the boat's suggestion, by choice. */
|
||||
adoptRecommendedSpeed: useCallback(() => edit({ speedKmh: null }), [edit]),
|
||||
/** Loaded from a tour or a draft, or cleared; not an edit. */
|
||||
load: useCallback((next: PaddleFieldValues) => setValues(next), []),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { useTourMapView } from './useTourMapView';
|
||||
|
||||
describe('useTourMapView', () => {
|
||||
it('starts on the standard map with no overlay, and switches an overlay on and off again', () => {
|
||||
const { result } = renderHook(() => useTourMapView());
|
||||
expect(result.current.mapBaseLayer).toBe('default');
|
||||
expect(result.current.mapOverlays.size).toBe(0);
|
||||
act(() => result.current.toggleMapOverlay('seamarks'));
|
||||
expect(result.current.mapOverlays.has('seamarks')).toBe(true);
|
||||
act(() => result.current.setMapBaseLayer('satellite'));
|
||||
expect(result.current.mapBaseLayer).toBe('satellite');
|
||||
act(() => result.current.toggleMapOverlay('seamarks'));
|
||||
expect(result.current.mapOverlays.has('seamarks')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { TourBaseLayer } from '../../Map/MapLayerSwitcher';
|
||||
|
||||
/**
|
||||
* How the planner's map looks, kept with the planner so it lasts while tours
|
||||
* are opened, saved and closed: the base layer and the overlays switched on.
|
||||
* Planner-local by design (P2B-07): nothing here touches the global map
|
||||
* setting, and none of it changes a tour.
|
||||
*/
|
||||
export function useTourMapView() {
|
||||
const [mapBaseLayer, setMapBaseLayer] = useState<TourBaseLayer>('default');
|
||||
const [mapOverlays, setMapOverlays] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const toggleMapOverlay = useCallback((id: string) => {
|
||||
setMapOverlays((current) => {
|
||||
const next = new Set(current);
|
||||
if (!next.delete(id)) next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
return { mapBaseLayer, setMapBaseLayer, mapOverlays, toggleMapOverlay };
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(() => ({
|
||||
routeWalkingTour: vi.fn(),
|
||||
enrichTourElevations: vi.fn(),
|
||||
createTour: vi.fn(),
|
||||
detailTour: vi.fn(),
|
||||
updateTour: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./tourRouting', () => ({ routeWalkingTour, enrichTourElevations }));
|
||||
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
|
||||
vi.mock('../useTourPermissions', () => ({
|
||||
useTourPermissions: ({ canEdit = true, canAssign = true }: { canEdit?: boolean; canAssign?: boolean }) => ({
|
||||
canEdit,
|
||||
canAssign,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useTourPlanner } from './useTourPlanner';
|
||||
|
||||
const routed = {
|
||||
coordinates: [
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
] as [number, number][],
|
||||
parts: [
|
||||
{
|
||||
coordinates: [
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
] as [number, number][],
|
||||
free: false,
|
||||
},
|
||||
],
|
||||
distanceMeters: 2200,
|
||||
durationSeconds: 1800,
|
||||
};
|
||||
const enriched = [
|
||||
[48, 11, 500],
|
||||
[48.01, 11.02, 500],
|
||||
] as [number, number, number][];
|
||||
const savedTour = {
|
||||
place_id: 42,
|
||||
name: 'Saved',
|
||||
tour_type: 'hike' as const,
|
||||
distance: 2.2,
|
||||
elevation_gain: 0,
|
||||
elevation_loss: 0,
|
||||
duration: 30,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
};
|
||||
|
||||
function plannerWith(points: Array<[number, number]>) {
|
||||
const hook = renderHook(() => useTourPlanner({ tripId: 31 }));
|
||||
act(() => hook.result.current.startNewTour());
|
||||
for (const [lat, lng] of points) act(() => hook.result.current.addWaypoint(lat, lng));
|
||||
return hook;
|
||||
}
|
||||
|
||||
const coordinatesOf = (points: Array<{ lat: number; lng: number }>) => points.map((point) => [point.lat, point.lng]);
|
||||
|
||||
describe('useTourPlanner route tools', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
routeWalkingTour.mockReset().mockResolvedValue(routed);
|
||||
enrichTourElevations.mockReset().mockResolvedValue(enriched);
|
||||
createTour.mockReset();
|
||||
detailTour.mockReset();
|
||||
updateTour.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('return to start (PR1-01)', () => {
|
||||
it('closes the tour with one return to the start, not a second start stop, and one undo opens it again', () => {
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
[48.02, 11],
|
||||
]);
|
||||
act(() => result.current.returnToStart());
|
||||
expect(result.current.isLoop).toBe(true);
|
||||
expect(coordinatesOf(result.current.waypoints)).toEqual([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
[48.02, 11],
|
||||
[48, 11],
|
||||
]);
|
||||
expect(result.current.waypoints.map((point) => point.role)).toEqual(['start', 'via', 'via', 'end']);
|
||||
|
||||
act(() => result.current.returnToStart());
|
||||
expect(result.current.waypoints).toHaveLength(4);
|
||||
|
||||
act(() => result.current.undo());
|
||||
expect(result.current.isLoop).toBe(false);
|
||||
expect(result.current.waypoints).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('moves the return with the start, and puts a new point in before the way back', () => {
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
]);
|
||||
act(() => result.current.returnToStart());
|
||||
act(() => {
|
||||
result.current.setWaypointPosition(result.current.waypoints[0].id, 48.1, 11.1);
|
||||
});
|
||||
expect(coordinatesOf(result.current.waypoints)).toEqual([
|
||||
[48.1, 11.1],
|
||||
[48.01, 11.02],
|
||||
[48.1, 11.1],
|
||||
]);
|
||||
|
||||
act(() => result.current.addWaypoint(48.03, 11.04));
|
||||
expect(coordinatesOf(result.current.waypoints)).toEqual([
|
||||
[48.1, 11.1],
|
||||
[48.01, 11.02],
|
||||
[48.03, 11.04],
|
||||
[48.1, 11.1],
|
||||
]);
|
||||
expect(result.current.isLoop).toBe(true);
|
||||
});
|
||||
|
||||
it('does nothing on a tour with one point', () => {
|
||||
const { result } = plannerWith([[48, 11]]);
|
||||
act(() => result.current.returnToStart());
|
||||
expect(result.current.waypoints).toHaveLength(1);
|
||||
expect(result.current.canUndo).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reverse (PR1-02)', () => {
|
||||
it('runs the tour backwards as one step, and the next undo brings the old order back', () => {
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
[48.02, 11],
|
||||
]);
|
||||
const ids = result.current.waypoints.map((point) => point.id);
|
||||
act(() => result.current.reverse());
|
||||
expect(result.current.waypoints.map((point) => point.id)).toEqual([...ids].reverse());
|
||||
expect(result.current.waypoints.map((point) => point.role)).toEqual(['start', 'via', 'end']);
|
||||
act(() => result.current.undo());
|
||||
expect(result.current.waypoints.map((point) => point.id)).toEqual(ids);
|
||||
});
|
||||
|
||||
it('keeps the name and the hiking limit', () => {
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
]);
|
||||
act(() => result.current.setName('Ridge'));
|
||||
act(() => result.current.setMaxHikingDifficulty(3));
|
||||
act(() => result.current.reverse());
|
||||
expect(result.current.name).toBe('Ridge');
|
||||
expect(result.current.maxHikingDifficulty).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('free legs (P2A-04)', () => {
|
||||
it('draws one leg by hand and routes it again, each as one step', () => {
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
[48.02, 11],
|
||||
]);
|
||||
const first = result.current.waypoints[0].id;
|
||||
act(() => result.current.toggleWaypointFreeLeg(first));
|
||||
expect(result.current.waypoints[0].fixedLeg).toEqual({
|
||||
kind: 'free',
|
||||
geometry: [
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
],
|
||||
});
|
||||
expect(result.current.waypoints[1].fixedLeg).toBeUndefined();
|
||||
act(() => result.current.undo());
|
||||
expect(result.current.waypoints[0].fixedLeg).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a free leg on save and when the point after it moves', async () => {
|
||||
createTour.mockResolvedValue({ tour: savedTour, waypoints: [] });
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
]);
|
||||
act(() => result.current.setName('Off path'));
|
||||
act(() => result.current.toggleWaypointFreeLeg(result.current.waypoints[0].id));
|
||||
act(() => {
|
||||
result.current.setWaypointPosition(result.current.waypoints[1].id, 48.02, 11.03);
|
||||
});
|
||||
expect(result.current.waypoints[0].fixedLeg).toEqual({
|
||||
kind: 'free',
|
||||
geometry: [
|
||||
[48, 11],
|
||||
[48.02, 11.03],
|
||||
],
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(createTour.mock.calls[0][1].waypoints[0].fixed_leg).toEqual({
|
||||
kind: 'free',
|
||||
geometry: [
|
||||
[48, 11],
|
||||
[48.02, 11.03],
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('paddle tours (PAD-01)', () => {
|
||||
it('draws every leg by hand once paddle is chosen, and one undo takes the type and the legs back', () => {
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
[48.02, 11],
|
||||
]);
|
||||
act(() => result.current.setTourType('kayak'));
|
||||
expect(result.current.isPaddle).toBe(true);
|
||||
expect(result.current.waypoints.map((point) => point.fixedLeg?.kind)).toEqual(['free', 'free', undefined]);
|
||||
act(() => result.current.addWaypoint(48.03, 11.01));
|
||||
expect(result.current.waypoints[2].fixedLeg?.kind).toBe('free');
|
||||
act(() => result.current.undo());
|
||||
act(() => result.current.undo());
|
||||
expect(result.current.tourType).toBe('hike');
|
||||
expect(result.current.waypoints.map((point) => point.fixedLeg)).toEqual([undefined, undefined, undefined]);
|
||||
});
|
||||
|
||||
it('holds Save back until a boat and the water are chosen, then saves them without a hiking limit', async () => {
|
||||
createTour.mockResolvedValue({ tour: { ...savedTour, tour_type: 'kayak' }, waypoints: [] });
|
||||
routeWalkingTour.mockResolvedValue({ ...routed, parts: [{ ...routed.parts[0], free: true }] });
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
]);
|
||||
act(() => result.current.setName('Lake'));
|
||||
act(() => result.current.setTourType('kayak'));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
});
|
||||
expect(result.current.paddleIncomplete).toBe(true);
|
||||
expect(result.current.canSave).toBe(false);
|
||||
|
||||
act(() => result.current.paddle.setBoatType('canoe'));
|
||||
act(() => result.current.paddle.setWaterContext('lake'));
|
||||
expect(result.current.canSave).toBe(true);
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(createTour.mock.calls[0][1]).toMatchObject({
|
||||
tour_type: 'kayak',
|
||||
boat_type: 'canoe',
|
||||
water_context: 'lake',
|
||||
paddle_speed_kmh: null,
|
||||
max_hiking_difficulty: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('times the water at the boat speed, keeps a chosen speed when the boat changes, and takes the suggestion only on purpose', async () => {
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
]);
|
||||
act(() => result.current.setTourType('kayak'));
|
||||
act(() => result.current.paddle.setBoatType('kayak'));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
});
|
||||
const distanceKm = result.current.routeAnalysis?.distanceKm ?? 0;
|
||||
expect(result.current.walkingDurationMinutes).toBe(Math.round((distanceKm / 4) * 60));
|
||||
|
||||
act(() => result.current.paddle.setSpeedKmh(6));
|
||||
act(() => result.current.paddle.setBoatType('other'));
|
||||
expect(result.current.paddle.speedKmh).toBe(6);
|
||||
expect(result.current.walkingDurationMinutes).toBe(Math.round((distanceKm / 6) * 60));
|
||||
|
||||
act(() => result.current.paddle.adoptRecommendedSpeed());
|
||||
expect(result.current.paddle.recommendedSpeedKmh).toBeNull();
|
||||
expect(result.current.walkingDurationMinutes).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a speed outside what a paddler can enter', () => {
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
]);
|
||||
act(() => result.current.setTourType('kayak'));
|
||||
act(() => result.current.paddle.setSpeedKmh(Number.NaN));
|
||||
expect(result.current.paddleSpeedInvalid).toBe(true);
|
||||
act(() => result.current.paddle.setSpeedKmh(30));
|
||||
expect(result.current.paddleSpeedInvalid).toBe(true);
|
||||
});
|
||||
|
||||
it('opens a saved paddle tour with its boat, and a draft from before the type existed as a hike', async () => {
|
||||
detailTour.mockResolvedValue({
|
||||
tour: {
|
||||
...savedTour,
|
||||
tour_type: 'kayak',
|
||||
max_hiking_difficulty: null,
|
||||
boat_type: 'sup',
|
||||
water_context: 'river',
|
||||
paddle_speed_kmh: 3,
|
||||
},
|
||||
waypoints: [
|
||||
{
|
||||
lat: 48,
|
||||
lng: 11,
|
||||
role: 'start',
|
||||
sequence: 0,
|
||||
fixed_leg: {
|
||||
kind: 'free',
|
||||
geometry: [
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
],
|
||||
},
|
||||
},
|
||||
{ lat: 48.01, lng: 11.02, role: 'end', sequence: 1 },
|
||||
],
|
||||
});
|
||||
const { result } = renderHook(() => useTourPlanner({ tripId: 31 }));
|
||||
await act(async () => {
|
||||
await result.current.openTour({ ...savedTour, tour_type: 'kayak' });
|
||||
});
|
||||
expect(result.current.tourType).toBe('kayak');
|
||||
expect(result.current.paddle).toMatchObject({ boatType: 'sup', waterContext: 'river', speedKmh: 3 });
|
||||
|
||||
localStorage.setItem('tour-draft-32', JSON.stringify({ version: 6, name: 'Old', waypoints: [] }));
|
||||
const old = renderHook(() => useTourPlanner({ tripId: 32 }));
|
||||
expect(old.result.current.tourType).toBe('hike');
|
||||
expect(old.result.current.name).toBe('Old');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1596,7 +1596,8 @@ describe('useTourPlanner', () => {
|
||||
|
||||
expect(result.current.mode).toEqual({ type: 'new-draft' })
|
||||
expect(JSON.parse(localStorage.getItem('tour-draft-23') || 'null')).toMatchObject({
|
||||
version: 6,
|
||||
version: 7,
|
||||
tourType: 'hike',
|
||||
name: '',
|
||||
waypoints: [],
|
||||
maxHikingDifficulty: 2,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { MAX_PLANNED_TOUR_DURATION_MINUTES, tourWebsiteSchema, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourWaypointRole, type TourMaxHikingDifficulty } from '@trek/shared'
|
||||
import { MAX_PLANNED_TOUR_DURATION_MINUTES, paddleSpeedKmhSchema, plannableTourTypeSchema, tourWebsiteSchema, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourWaypointRole, type TourMaxHikingDifficulty } from '@trek/shared'
|
||||
import { tourRepo } from '../../../repo/tourRepo'
|
||||
import { randomId } from '../../../utils/randomId'
|
||||
import type { TourBaseLayer } from '../../Map/MapLayerSwitcher'
|
||||
import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/routeGeometry'
|
||||
import { enrichTourElevations, routeWalkingTour, type TourElevationPoint } from './tourRouting'
|
||||
import { enrichTourElevations, routeWalkingTour, type TourElevationPoint, type TourRoutePart } from './tourRouting'
|
||||
import { keepJoinedFixedLegs } from './tourFixedLegs'
|
||||
import { alignFreeLegs, closeLoop, isClosedLoop, reverseRoute, toggleFreeLeg } from './tourRouteActions'
|
||||
import { useTourMapView } from './useTourMapView'
|
||||
import { NO_PADDLE_FIELDS, paddleFieldsOf, paddleMinutes, usePaddleFields, type PaddleFieldValues } from './usePaddleFields'
|
||||
import { hikingTimeMinutes } from '../hikingTime'
|
||||
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
|
||||
|
||||
@@ -32,6 +34,9 @@ export interface DerivedTourDraft {
|
||||
* keeps the recorded track to go back to), rather than as a new tour beside it.
|
||||
*/
|
||||
inPlace: boolean
|
||||
/** A paddle import stays a paddle tour, with its boat and water. */
|
||||
tourType?: PlannableTourType
|
||||
paddle?: PaddleFieldValues
|
||||
}
|
||||
|
||||
export type TourPlannerStatus =
|
||||
@@ -46,7 +51,7 @@ export type TourPlannerStatus =
|
||||
| 'saved'
|
||||
|
||||
interface DraftPayload {
|
||||
version: 1 | 2 | 3 | 4 | 5 | 6
|
||||
version: 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
name: string
|
||||
description?: string
|
||||
website?: string
|
||||
@@ -57,11 +62,16 @@ interface DraftPayload {
|
||||
editingPlaceId?: number | null
|
||||
/** The import a new tour is being planned from; carried until it is saved. */
|
||||
derivedFromPlaceId?: number | null
|
||||
tourType?: PlannableTourType
|
||||
paddle?: PaddleFieldValues
|
||||
}
|
||||
|
||||
/** One step of the history: the route, and what the route depends on. */
|
||||
interface HistoryEntry { waypoints: TourPlannerWaypoint[]; difficulty: TourMaxHikingDifficulty; tourType: PlannableTourType }
|
||||
|
||||
interface HistoryState {
|
||||
past: Array<{ waypoints: TourPlannerWaypoint[]; difficulty: TourMaxHikingDifficulty }>
|
||||
future: Array<{ waypoints: TourPlannerWaypoint[]; difficulty: TourMaxHikingDifficulty }>
|
||||
past: HistoryEntry[]
|
||||
future: HistoryEntry[]
|
||||
}
|
||||
|
||||
const ROUTE_DEBOUNCE_MS = 450
|
||||
@@ -71,22 +81,28 @@ function hasCompleteElevation(points: TourElevationPoint[] | null): points is [n
|
||||
return !!points && points.every(point => point.length === 3 && Number.isFinite(point[2]))
|
||||
}
|
||||
|
||||
export function normalizePlannerWaypoints(points: TourPlannerWaypoint[]): TourPlannerWaypoint[] {
|
||||
return keepJoinedFixedLegs(points).map((point, index) => ({
|
||||
/** Roles from the order; free legs follow their points, and a paddle tour draws every leg by hand. */
|
||||
export function normalizePlannerWaypoints(points: TourPlannerWaypoint[], allFree = false): TourPlannerWaypoint[] {
|
||||
return keepJoinedFixedLegs(alignFreeLegs(points, allFree)).map((point, index) => ({
|
||||
...point,
|
||||
role: index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'via',
|
||||
}))
|
||||
}
|
||||
|
||||
export function tourWaypointKey(points: TourPlannerWaypoint[]): string {
|
||||
return points.map(point => `${point.id}:${point.lat.toFixed(7)}:${point.lng.toFixed(7)}${point.fixedLeg ? `:${point.fixedLeg.ref}` : ''}`).join('|')
|
||||
return points.map(point => `${point.id}:${point.lat.toFixed(7)}:${point.lng.toFixed(7)}${fixedLegKey(point.fixedLeg)}`).join('|')
|
||||
}
|
||||
|
||||
function fixedLegKey(leg: TourFixedLeg | null | undefined): string {
|
||||
if (!leg) return ''
|
||||
return leg.kind === 'ferrata' ? `:${leg.ref}` : ':free'
|
||||
}
|
||||
|
||||
function sameWaypointSnapshot(a: TourPlannerWaypoint[], b: TourPlannerWaypoint[]): boolean {
|
||||
return tourWaypointKey(a) === tourWaypointKey(b)
|
||||
}
|
||||
|
||||
const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6]
|
||||
const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6, 7]
|
||||
|
||||
function optionalString(value: unknown): boolean {
|
||||
return value === undefined || typeof value === 'string'
|
||||
@@ -135,6 +151,7 @@ function validDraft(value: unknown): value is DraftPayload {
|
||||
&& optionalPlaceId(draft.editingPlaceId) &&
|
||||
optionalPlaceId(draft.derivedFromPlaceId)
|
||||
&& optionalDifficulty(draft.maxHikingDifficulty)
|
||||
&& (draft.tourType === undefined || plannableTourTypeSchema.safeParse(draft.tourType).success)
|
||||
&& Array.isArray(draft.waypoints)
|
||||
&& draft.waypoints.every(validDraftWaypoint)
|
||||
}
|
||||
@@ -172,7 +189,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const restored = useMemo(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(storageKey) || 'null') as unknown
|
||||
return validDraft(parsed) ? { ...parsed, waypoints: normalizePlannerWaypoints(parsed.waypoints) } : null
|
||||
return validDraft(parsed) ? { ...parsed, waypoints: normalizePlannerWaypoints(parsed.waypoints, parsed.tourType === 'kayak') } : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -185,10 +202,12 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const [breakAdditionalMinutes, setBreakAdditionalMinutesState] = useState<number | null>(restored?.breakAdditionalMinutes ?? null)
|
||||
const [waypoints, setWaypoints] = useState<TourPlannerWaypoint[]>(restored?.waypoints ?? [])
|
||||
const [maxHikingDifficulty, setMaxHikingDifficultyState] = useState<TourMaxHikingDifficulty>(restored?.maxHikingDifficulty ?? 2)
|
||||
const [tourType, setTourTypeState] = useState<PlannableTourType>(restored?.tourType ?? 'hike')
|
||||
const [selectedWaypointId, setSelectedWaypointId] = useState<string | null>(restored?.waypoints[0]?.id ?? null)
|
||||
const [history, setHistory] = useState<HistoryState>({ past: [], future: [] })
|
||||
const [status, setStatus] = useState<TourPlannerStatus>(restored?.waypoints.length ? 'dirty' : 'empty')
|
||||
const [route, setRoute] = useState<[number, number][] | null>(null)
|
||||
const [routeParts, setRouteParts] = useState<TourRoutePart[] | null>(null)
|
||||
const [enrichedGeometry, setEnrichedGeometry] = useState<TourElevationPoint[] | null>(null)
|
||||
const [routeForKey, setRouteForKey] = useState<string | null>(null)
|
||||
const [distanceMeters, setDistanceMeters] = useState<number | null>(null)
|
||||
@@ -202,7 +221,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const [openingTourId, setOpeningTourId] = useState<number | null>(null)
|
||||
const [saveOutcome, setSaveOutcome] = useState<TourListItem | null>(null)
|
||||
const [retryToken, setRetryToken] = useState(0)
|
||||
const [mapBaseLayer, setMapBaseLayer] = useState<TourBaseLayer>('default')
|
||||
const mapView = useTourMapView()
|
||||
const [readOnlyGpxTour, setReadOnlyGpxTour] = useState<{ tour: TourListItem; routeGeometry: string | null } | null>(null)
|
||||
const [newTourConfirmationOpen, setNewTourConfirmationOpen] = useState(false)
|
||||
const [elevationProfileExpanded, setElevationProfileExpanded] = useState(true)
|
||||
@@ -230,8 +249,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
editingPlaceIdRef.current = editingPlaceId
|
||||
const waypointsRef = useRef(waypoints)
|
||||
const difficultyRef = useRef(maxHikingDifficulty)
|
||||
const tourTypeRef = useRef(tourType)
|
||||
waypointsRef.current = waypoints
|
||||
difficultyRef.current = maxHikingDifficulty
|
||||
const snapshot = useCallback((): HistoryEntry => ({ waypoints: waypointsRef.current, difficulty: difficultyRef.current, tourType: tourTypeRef.current }), [])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
@@ -271,19 +292,20 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const commitWaypoints = useCallback((nextValue: TourPlannerWaypoint[] | ((current: TourPlannerWaypoint[]) => TourPlannerWaypoint[])) => {
|
||||
if (!editPermissionRef.current) return
|
||||
const current = waypointsRef.current
|
||||
const next = normalizePlannerWaypoints(typeof nextValue === 'function' ? nextValue(current) : nextValue)
|
||||
const next = normalizePlannerWaypoints(typeof nextValue === 'function' ? nextValue(current) : nextValue, tourTypeRef.current === 'kayak')
|
||||
if (sameWaypointSnapshot(current, next)) return
|
||||
invalidateDetailRequest()
|
||||
interruptRouting()
|
||||
draftRevisionRef.current += 1
|
||||
setHistory(previous => ({ past: [...previous.past, { waypoints: current, difficulty: difficultyRef.current }].slice(-HISTORY_LIMIT), future: [] }))
|
||||
const entry = snapshot()
|
||||
setHistory(previous => ({ past: [...previous.past, entry].slice(-HISTORY_LIMIT), future: [] }))
|
||||
waypointsRef.current = next
|
||||
setWaypoints(next)
|
||||
setHasUnsavedChanges(true)
|
||||
setSaveOutcome(null)
|
||||
setStatus('dirty')
|
||||
setError(null)
|
||||
}, [invalidateDetailRequest, interruptRouting])
|
||||
}, [invalidateDetailRequest, interruptRouting, snapshot])
|
||||
|
||||
const setMaxHikingDifficulty = useCallback((value: TourMaxHikingDifficulty) => {
|
||||
if (!editPermissionRef.current) return
|
||||
@@ -291,22 +313,23 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
invalidateDetailRequest()
|
||||
interruptRouting()
|
||||
draftRevisionRef.current += 1
|
||||
setHistory(current => ({
|
||||
past: [...current.past, { waypoints: waypointsRef.current, difficulty: difficultyRef.current }].slice(-HISTORY_LIMIT),
|
||||
future: [],
|
||||
}))
|
||||
const entry = snapshot()
|
||||
setHistory(current => ({ past: [...current.past, entry].slice(-HISTORY_LIMIT), future: [] }))
|
||||
difficultyRef.current = value
|
||||
setMaxHikingDifficultyState(value)
|
||||
setHasUnsavedChanges(true)
|
||||
setSaveOutcome(null)
|
||||
setStatus('dirty')
|
||||
setError(null)
|
||||
}, [invalidateDetailRequest, interruptRouting])
|
||||
}, [invalidateDetailRequest, interruptRouting, snapshot])
|
||||
|
||||
const addWaypoint = useCallback((lat: number, lng: number) => {
|
||||
if (!editPermissionRef.current) return
|
||||
const id = randomId()
|
||||
commitWaypoints(current => [...current, { id, lat, lng, role: 'end' }])
|
||||
// A loop keeps returning to its start: the new point goes in before the way back.
|
||||
commitWaypoints(current => isClosedLoop(current)
|
||||
? [...current.slice(0, -1), { id, lat, lng, role: 'via' }, current[current.length - 1]]
|
||||
: [...current, { id, lat, lng, role: 'end' }])
|
||||
setSelectedWaypointId(id)
|
||||
}, [commitWaypoints])
|
||||
|
||||
@@ -334,10 +357,33 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const current = waypointsRef.current.find(point => point.id === id)
|
||||
if (!current) return false
|
||||
if (current.lat === lat && current.lng === lng) return true
|
||||
commitWaypoints(points => points.map(point => point.id === id ? { ...point, lat, lng } : point))
|
||||
const points = waypointsRef.current
|
||||
// In a loop the start and the way back are one spot: moving either moves both.
|
||||
const loopEnds = isClosedLoop(points) && (id === points[0].id || id === points[points.length - 1].id)
|
||||
? new Set([points[0].id, points[points.length - 1].id])
|
||||
: new Set([id])
|
||||
commitWaypoints(list => list.map(point => loopEnds.has(point.id) ? { ...point, lat, lng } : point))
|
||||
return true
|
||||
}, [commitWaypoints])
|
||||
|
||||
/** PR1-01: the tour taken back from its last point to its start, as one step. */
|
||||
const returnToStart = useCallback(() => {
|
||||
if (!editPermissionRef.current || saveInFlightRef.current) return
|
||||
commitWaypoints(current => closeLoop(current, randomId()))
|
||||
}, [commitWaypoints])
|
||||
|
||||
/** PR1-02: the tour the other way round, as one step. */
|
||||
const reverse = useCallback(() => {
|
||||
if (!editPermissionRef.current || saveInFlightRef.current) return
|
||||
commitWaypoints(current => reverseRoute(current))
|
||||
}, [commitWaypoints])
|
||||
|
||||
/** P2A-04: the leg after this waypoint drawn by hand, or routed again. */
|
||||
const toggleWaypointFreeLeg = useCallback((id: string) => {
|
||||
if (!editPermissionRef.current || saveInFlightRef.current || tourTypeRef.current === 'kayak') return
|
||||
commitWaypoints(current => toggleFreeLeg(current, id))
|
||||
}, [commitWaypoints])
|
||||
|
||||
const removeWaypoint = useCallback((id: string) => {
|
||||
if (!editPermissionRef.current) return
|
||||
commitWaypoints(current => current.filter(point => point.id !== id))
|
||||
@@ -380,17 +426,19 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
invalidateDetailRequest()
|
||||
interruptRouting()
|
||||
draftRevisionRef.current += 1
|
||||
const present = { waypoints: waypointsRef.current, difficulty: difficultyRef.current }
|
||||
const present = snapshot()
|
||||
waypointsRef.current = previous.waypoints
|
||||
difficultyRef.current = previous.difficulty
|
||||
tourTypeRef.current = previous.tourType
|
||||
setWaypoints(previous.waypoints)
|
||||
setMaxHikingDifficultyState(previous.difficulty)
|
||||
setTourTypeState(previous.tourType)
|
||||
setSelectedWaypointId(id => previous.waypoints.some(point => point.id === id) ? id : previous.waypoints[0]?.id ?? null)
|
||||
setHistory({ past: history.past.slice(0, -1), future: [present, ...history.future].slice(0, HISTORY_LIMIT) })
|
||||
setHasUnsavedChanges(true)
|
||||
setStatus('dirty')
|
||||
setError(null)
|
||||
}, [history, invalidateDetailRequest, interruptRouting])
|
||||
}, [history, invalidateDetailRequest, interruptRouting, snapshot])
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (!editPermissionRef.current) return
|
||||
@@ -399,68 +447,76 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
invalidateDetailRequest()
|
||||
interruptRouting()
|
||||
draftRevisionRef.current += 1
|
||||
const present = { waypoints: waypointsRef.current, difficulty: difficultyRef.current }
|
||||
const present = snapshot()
|
||||
waypointsRef.current = next.waypoints
|
||||
difficultyRef.current = next.difficulty
|
||||
tourTypeRef.current = next.tourType
|
||||
setWaypoints(next.waypoints)
|
||||
setMaxHikingDifficultyState(next.difficulty)
|
||||
setTourTypeState(next.tourType)
|
||||
setSelectedWaypointId(id => next.waypoints.some(point => point.id === id) ? id : next.waypoints[0]?.id ?? null)
|
||||
setHistory({ past: [...history.past, present].slice(-HISTORY_LIMIT), future: history.future.slice(1) })
|
||||
setHasUnsavedChanges(true)
|
||||
setStatus('dirty')
|
||||
setError(null)
|
||||
}, [history, invalidateDetailRequest, interruptRouting])
|
||||
}, [history, invalidateDetailRequest, interruptRouting, snapshot])
|
||||
|
||||
/** A field other than the route changed: the draft is unsaved again, and a finished save is no longer current. */
|
||||
const markEdited = useCallback(() => {
|
||||
if (!editPermissionRef.current) return false
|
||||
invalidateDetailRequest()
|
||||
draftRevisionRef.current += 1
|
||||
setHasUnsavedChanges(true)
|
||||
setSaveOutcome(null)
|
||||
setStatus(current => current === 'saved' || current === 'saving' ? (waypointsRef.current.length < 2 ? 'dirty' : 'ready') : current)
|
||||
return true
|
||||
}, [invalidateDetailRequest])
|
||||
|
||||
const setName = useCallback((value: string) => {
|
||||
if (!editPermissionRef.current) return
|
||||
if (value === name) return
|
||||
invalidateDetailRequest()
|
||||
draftRevisionRef.current += 1
|
||||
setNameState(value)
|
||||
setHasUnsavedChanges(true)
|
||||
setSaveOutcome(null)
|
||||
if (status === 'saved' || status === 'saving') setStatus(waypointsRef.current.length < 2 ? 'dirty' : 'ready')
|
||||
}, [invalidateDetailRequest, name, status])
|
||||
if (value !== name && markEdited()) setNameState(value)
|
||||
}, [markEdited, name])
|
||||
|
||||
const setDescription = useCallback((value: string) => {
|
||||
if (!editPermissionRef.current || value === description) return
|
||||
invalidateDetailRequest()
|
||||
draftRevisionRef.current += 1
|
||||
setDescriptionState(value)
|
||||
setHasUnsavedChanges(true)
|
||||
setSaveOutcome(null)
|
||||
if (status === 'saved' || status === 'saving') setStatus(waypointsRef.current.length < 2 ? 'dirty' : 'ready')
|
||||
}, [description, invalidateDetailRequest, status])
|
||||
if (value !== description && markEdited()) setDescriptionState(value)
|
||||
}, [description, markEdited])
|
||||
|
||||
const setWebsite = useCallback((value: string) => {
|
||||
if (!editPermissionRef.current || value === website) return
|
||||
invalidateDetailRequest()
|
||||
draftRevisionRef.current += 1
|
||||
setWebsiteState(value)
|
||||
setHasUnsavedChanges(true)
|
||||
setSaveOutcome(null)
|
||||
if (status === 'saved' || status === 'saving') setStatus(waypointsRef.current.length < 2 ? 'dirty' : 'ready')
|
||||
}, [invalidateDetailRequest, status, website])
|
||||
if (value !== website && markEdited()) setWebsiteState(value)
|
||||
}, [markEdited, website])
|
||||
|
||||
const setPlannedDurationMinutes = useCallback((value: number | null) => {
|
||||
if (!editPermissionRef.current || value === plannedDurationMinutes) return
|
||||
invalidateDetailRequest()
|
||||
draftRevisionRef.current += 1
|
||||
setPlannedDurationMinutesState(value)
|
||||
setHasUnsavedChanges(true)
|
||||
setSaveOutcome(null)
|
||||
if (status === 'saved' || status === 'saving') setStatus(waypointsRef.current.length < 2 ? 'dirty' : 'ready')
|
||||
}, [invalidateDetailRequest, plannedDurationMinutes, status])
|
||||
if (value !== plannedDurationMinutes && markEdited()) setPlannedDurationMinutesState(value)
|
||||
}, [markEdited, plannedDurationMinutes])
|
||||
|
||||
const setBreakAdditionalMinutes = useCallback((value: number | null) => {
|
||||
if (!editPermissionRef.current || value === breakAdditionalMinutes) return
|
||||
if (value !== breakAdditionalMinutes && markEdited()) setBreakAdditionalMinutesState(value)
|
||||
}, [breakAdditionalMinutes, markEdited])
|
||||
|
||||
const paddle = usePaddleFields(restored?.paddle ?? NO_PADDLE_FIELDS, markEdited)
|
||||
const loadPaddle = paddle.load
|
||||
|
||||
/**
|
||||
* PAD-01: hike or paddle tour, as one step of the history with the route,
|
||||
* since a paddle tour draws every leg by hand. Nothing is routed or put on
|
||||
* a day by the choice itself.
|
||||
*/
|
||||
const setTourType = useCallback((value: PlannableTourType) => {
|
||||
if (!editPermissionRef.current || saveInFlightRef.current || value === tourTypeRef.current) return
|
||||
invalidateDetailRequest()
|
||||
interruptRouting()
|
||||
draftRevisionRef.current += 1
|
||||
setBreakAdditionalMinutesState(value)
|
||||
const entry = snapshot()
|
||||
setHistory(current => ({ past: [...current.past, entry].slice(-HISTORY_LIMIT), future: [] }))
|
||||
tourTypeRef.current = value
|
||||
setTourTypeState(value)
|
||||
const next = normalizePlannerWaypoints(waypointsRef.current, value === 'kayak')
|
||||
waypointsRef.current = next
|
||||
setWaypoints(next)
|
||||
setHasUnsavedChanges(true)
|
||||
setSaveOutcome(null)
|
||||
if (status === 'saved' || status === 'saving') setStatus(waypointsRef.current.length < 2 ? 'dirty' : 'ready')
|
||||
}, [breakAdditionalMinutes, invalidateDetailRequest, status])
|
||||
setStatus('dirty')
|
||||
setError(null)
|
||||
}, [invalidateDetailRequest, interruptRouting, snapshot])
|
||||
|
||||
const websiteInvalid = website.trim().length > 0 && !tourWebsiteSchema.safeParse(website).success
|
||||
const plannedDurationInvalid = plannedDurationMinutes !== null
|
||||
@@ -471,15 +527,29 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
)
|
||||
// The router's time only stands in until the route has its heights; from then on the
|
||||
// walking time is the signposts' rule over distance, climb and descent.
|
||||
const walkingDurationMinutes = routeAnalysis?.gain != null && routeAnalysis.loss != null
|
||||
? hikingTimeMinutes(routeAnalysis.distanceKm, routeAnalysis.gain, routeAnalysis.loss)
|
||||
: durationSeconds === null ? null : Math.round(durationSeconds / 60)
|
||||
const isPaddle = tourType === 'kayak'
|
||||
const paddleSpeedInvalid = paddle.speedKmh !== null && !paddleSpeedKmhSchema.safeParse(paddle.speedKmh).success
|
||||
const paddleIncomplete = isPaddle && (paddle.boatType === null || paddle.waterContext === null)
|
||||
// A paddle tour is timed on the water at its speed; a hike by the signposts' rule once the heights are in.
|
||||
const walkingDurationMinutes = isPaddle
|
||||
? paddleMinutes(routeAnalysis?.distanceKm ?? (distanceMeters === null ? null : distanceMeters / 1000), paddleSpeedInvalid ? null : paddle.effectiveSpeedKmh)
|
||||
: routeAnalysis?.gain != null && routeAnalysis.loss != null
|
||||
? hikingTimeMinutes(routeAnalysis.distanceKm, routeAnalysis.gain, routeAnalysis.loss)
|
||||
: durationSeconds === null ? null : Math.round(durationSeconds / 60)
|
||||
const automaticPlannedTotalMinutes = walkingDurationMinutes === null
|
||||
? null
|
||||
: walkingDurationMinutes + (breakAdditionalMinutes ?? 0)
|
||||
const breakAdditionalInvalid = breakAdditionalMinutes !== null
|
||||
&& (!Number.isInteger(breakAdditionalMinutes) || breakAdditionalMinutes < 0 || breakAdditionalMinutes > MAX_PLANNED_TOUR_DURATION_MINUTES)
|
||||
const plannedTotalMinutes = plannedDurationMinutes ?? automaticPlannedTotalMinutes
|
||||
// What the type adds to a save: a hike its routing limit, a paddle tour its boat, water and speed.
|
||||
const { boatType, waterContext, speedKmh } = paddle
|
||||
const typeFields = useMemo(() => tourType === 'kayak'
|
||||
? { tour_type: tourType, max_hiking_difficulty: null, boat_type: boatType, water_context: waterContext, paddle_speed_kmh: speedKmh }
|
||||
: { tour_type: tourType, max_hiking_difficulty: maxHikingDifficulty, boat_type: null, water_context: null, paddle_speed_kmh: null },
|
||||
[boatType, maxHikingDifficulty, speedKmh, tourType, waterContext])
|
||||
// The router's own time stands in for a hike without heights; a paddle tour has only its speed.
|
||||
const savedDurationSeconds = walkingDurationMinutes === null ? (isPaddle ? null : durationSeconds) : walkingDurationMinutes * 60
|
||||
const plannedTotalInvalid = plannedDurationMinutes === null
|
||||
&& automaticPlannedTotalMinutes !== null
|
||||
&& automaticPlannedTotalMinutes > MAX_PLANNED_TOUR_DURATION_MINUTES
|
||||
@@ -499,12 +569,16 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setBreakAdditionalMinutesState(null)
|
||||
setWaypoints([])
|
||||
setMaxHikingDifficultyState(2)
|
||||
setTourTypeState('hike')
|
||||
loadPaddle(NO_PADDLE_FIELDS)
|
||||
waypointsRef.current = []
|
||||
difficultyRef.current = 2
|
||||
tourTypeRef.current = 'hike'
|
||||
setSelectedWaypointId(null)
|
||||
setHistory({ past: [], future: [] })
|
||||
setStatus('empty')
|
||||
setRoute(null)
|
||||
setRouteParts(null)
|
||||
setEnrichedGeometry(null)
|
||||
setRouteForKey(null)
|
||||
setDistanceMeters(null)
|
||||
@@ -522,7 +596,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setElevationProfileExpanded(true)
|
||||
setNewDraftGeneration(generation => generation + 1)
|
||||
setMapFocusKey(key => key + 1)
|
||||
}, [invalidateDetailRequest, interruptRouting, storageKey])
|
||||
}, [invalidateDetailRequest, interruptRouting, loadPaddle, storageKey])
|
||||
|
||||
const forgetDeletedTour = useCallback((placeId: number) => {
|
||||
deletedTourPlaceIdsRef.current.add(placeId)
|
||||
@@ -559,9 +633,14 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
(draft: DerivedTourDraft) => {
|
||||
if (!editPermissionRef.current || saveInFlightRef.current || draft.points.length < 2) return false
|
||||
returnToNeutral()
|
||||
const type = draft.tourType ?? 'hike'
|
||||
const points = normalizePlannerWaypoints(
|
||||
draft.points.map(({ lat, lng }) => ({ id: randomId(), lat, lng, role: 'via' as const }))
|
||||
draft.points.map(({ lat, lng }) => ({ id: randomId(), lat, lng, role: 'via' as const })),
|
||||
type === 'kayak',
|
||||
)
|
||||
tourTypeRef.current = type
|
||||
setTourTypeState(type)
|
||||
if (draft.paddle) loadPaddle(draft.paddle)
|
||||
setNameState(draft.name)
|
||||
setDescriptionState(draft.description ?? '')
|
||||
setWebsiteState(draft.website ?? '')
|
||||
@@ -580,7 +659,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setHasUnsavedChanges(true)
|
||||
return true
|
||||
},
|
||||
[returnToNeutral])
|
||||
[loadPaddle, returnToNeutral])
|
||||
|
||||
const cancelNewTour = useCallback(() => setNewTourConfirmationOpen(false), [])
|
||||
const toggleElevationProfile = useCallback(() => {
|
||||
@@ -638,13 +717,14 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
}
|
||||
|
||||
interruptRouting()
|
||||
const openedType: PlannableTourType = result.tour.tour_type === 'kayak' ? 'kayak' : 'hike'
|
||||
const nextWaypoints = normalizePlannerWaypoints(result.waypoints.map(point => ({
|
||||
id: randomId(),
|
||||
lat: point.lat,
|
||||
lng: point.lng,
|
||||
role: point.role,
|
||||
...(point.fixed_leg ? { fixedLeg: point.fixed_leg } : {}),
|
||||
})))
|
||||
})), openedType === 'kayak')
|
||||
localStorage.removeItem(storageKey)
|
||||
contextGenerationRef.current += 1
|
||||
draftRevisionRef.current += 1
|
||||
@@ -655,12 +735,16 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setBreakAdditionalMinutesState(result.tour.break_additional_minutes ?? null)
|
||||
setWaypoints(nextWaypoints)
|
||||
setMaxHikingDifficultyState(result.tour.max_hiking_difficulty ?? 2)
|
||||
setTourTypeState(openedType)
|
||||
loadPaddle(paddleFieldsOf(result.tour))
|
||||
waypointsRef.current = nextWaypoints
|
||||
difficultyRef.current = result.tour.max_hiking_difficulty ?? 2
|
||||
tourTypeRef.current = openedType
|
||||
setSelectedWaypointId(nextWaypoints[0]?.id ?? null)
|
||||
setHistory({ past: [], future: [] })
|
||||
setStatus('dirty')
|
||||
setRoute(null)
|
||||
setRouteParts(null)
|
||||
setEnrichedGeometry(null)
|
||||
setRouteForKey(null)
|
||||
setDistanceMeters(null)
|
||||
@@ -683,7 +767,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setOpeningTourId(null)
|
||||
}
|
||||
}
|
||||
}, [interruptRouting, storageKey, tripId])
|
||||
}, [interruptRouting, loadPaddle, storageKey, tripId])
|
||||
|
||||
const retry = useCallback(() => {
|
||||
if (waypointsRef.current.length < 2) return
|
||||
@@ -707,6 +791,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
interruptRouting()
|
||||
setStatus(waypoints.length === 0 ? 'empty' : 'dirty')
|
||||
setRoute(null)
|
||||
setRouteParts(null)
|
||||
setRouteForKey(null)
|
||||
setEnrichedGeometry(null)
|
||||
setDistanceMeters(null)
|
||||
@@ -731,6 +816,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
}
|
||||
|
||||
setRoute(routed.coordinates)
|
||||
setRouteParts(routed.parts)
|
||||
setDistanceMeters(routed.distanceMeters)
|
||||
setDurationSeconds(routed.durationSeconds)
|
||||
setRouteForKey(waypointKey)
|
||||
@@ -762,12 +848,14 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedChanges) return
|
||||
const draft: DraftPayload = { version: 6, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId,
|
||||
derivedFromPlaceId,
|
||||
const draft: DraftPayload = { version: 7, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId,
|
||||
derivedFromPlaceId, tourType,
|
||||
paddle: { boatType: paddle.boatType, waterContext: paddle.waterContext, speedKmh: paddle.speedKmh },
|
||||
}
|
||||
localStorage.setItem(storageKey, JSON.stringify(draft))
|
||||
}, [breakAdditionalMinutes,
|
||||
derivedFromPlaceId, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, storageKey, waypoints, website])
|
||||
derivedFromPlaceId, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, storageKey, waypoints, website,
|
||||
tourType, paddle.boatType, paddle.waterContext, paddle.speedKmh])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedChanges) return
|
||||
@@ -779,6 +867,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const canSave = canEdit && !isSaving && hasUnsavedChanges && name.trim().length > 0 && status === 'ready'
|
||||
&& routeForKey === waypointKey && hasCompleteElevation(enrichedGeometry)
|
||||
&& description.length <= 2000 && !websiteInvalid && !plannedDurationInvalid && !breakAdditionalInvalid && !plannedTotalInvalid
|
||||
&& !paddleIncomplete && !(isPaddle && paddleSpeedInvalid)
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!mountedRef.current || saveInFlightRef.current || !editPermissionRef.current || !canSave || !hasCompleteElevation(enrichedGeometry)) return null
|
||||
@@ -794,7 +883,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
name: name.trim(),
|
||||
description: trimmedOrNull(description),
|
||||
website: parsedWebsiteOrNull(website),
|
||||
tour_type: 'hike',
|
||||
...typeFields,
|
||||
route_geometry: enrichedGeometry.map(point => [point[0], point[1], point[2]]),
|
||||
waypoints: waypoints.map((point, sequence) => ({
|
||||
lat: point.lat,
|
||||
@@ -803,8 +892,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
sequence,
|
||||
...(point.fixedLeg ? { fixed_leg: point.fixedLeg } : {}),
|
||||
})),
|
||||
max_hiking_difficulty: maxHikingDifficulty,
|
||||
duration_seconds: walkingDurationMinutes === null ? durationSeconds : walkingDurationMinutes * 60,
|
||||
duration_seconds: savedDurationSeconds,
|
||||
planned_duration_minutes: plannedDurationMinutes,
|
||||
break_additional_minutes: breakAdditionalMinutes,
|
||||
...derivedFromField(submittedPlaceId, derivedFromPlaceId),
|
||||
@@ -849,22 +937,24 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
}
|
||||
}, [breakAdditionalMinutes, canSave,
|
||||
derivedFromPlaceId,
|
||||
description, durationSeconds, walkingDurationMinutes, editingPlaceId, enrichedGeometry, invalidateDetailRequest, maxHikingDifficulty, name, onSaved, plannedDurationMinutes, storageKey, tripId, waypoints, website])
|
||||
description, savedDurationSeconds, editingPlaceId, enrichedGeometry, invalidateDetailRequest, name, onSaved, plannedDurationMinutes, storageKey, tripId, typeFields, waypoints, website])
|
||||
|
||||
return {
|
||||
canEdit, canAssign,
|
||||
name, setName, maxHikingDifficulty, setMaxHikingDifficulty,
|
||||
tourType, setTourType, isPaddle, paddle, paddleSpeedInvalid, paddleIncomplete,
|
||||
description, setDescription, website, setWebsite, websiteInvalid,
|
||||
plannedDurationMinutes, setPlannedDurationMinutes, plannedDurationInvalid,
|
||||
breakAdditionalMinutes, setBreakAdditionalMinutes, breakAdditionalInvalid,
|
||||
walkingDurationMinutes, plannedTotalMinutes, plannedTotalInvalid,
|
||||
waypoints, selectedWaypointId, setSelectedWaypointId,
|
||||
addWaypoint, appendFixedLeg, setWaypointPosition, removeWaypoint, moveWaypoint, reorderWaypoint,
|
||||
reverse, returnToStart, isLoop: isClosedLoop(waypoints), toggleWaypointFreeLeg,
|
||||
undo, redo, canUndo: history.past.length > 0, canRedo: history.future.length > 0,
|
||||
status, error, retry, isSaving,
|
||||
route, enrichedGeometry, routeAnalysis, distanceMeters, durationSeconds,
|
||||
route, routeParts, enrichedGeometry, routeAnalysis, distanceMeters, durationSeconds,
|
||||
routeProfileFocus, setRouteProfileFocus,
|
||||
mapBaseLayer, setMapBaseLayer,
|
||||
...mapView,
|
||||
mode, readOnlyGpxTour, readOnlyGpxAnalysis, viewGpxTour, closeGpxTour,
|
||||
startNewTour,
|
||||
startDerivedTour,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TourMapLayerSwitcher,
|
||||
type MapPlaceTool,
|
||||
type TourBaseLayer,
|
||||
type TourLayerOffer,
|
||||
} from '../../Map/MapLayerSwitcher';
|
||||
import { MAP_CONTROL_SHADOW } from '../../Map/mapControlShadow';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
@@ -38,6 +39,8 @@ export interface TourTerrainMapProps {
|
||||
framePoints: [number, number][];
|
||||
/** The planner map's via ferrata layer: its switch joins the layer pill, its lines the terrain. */
|
||||
ferrataLayer?: FerrataMapLayer;
|
||||
/** What the tour's type offers: its base layers, and the overlays drawn on the terrain too. */
|
||||
tourLayers?: TourLayerOffer;
|
||||
/** The visible area, for the via ferrata layer to ask for. */
|
||||
onViewportChange?: (bbox: FerrataBbox) => void;
|
||||
/** How high the controls along the bottom edge reach, every row of them, for what floats above them. */
|
||||
@@ -110,6 +113,7 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
|
||||
onToggleTerrain={onExit}
|
||||
ferrataLayer={props.ferrataLayer}
|
||||
placeTool={props.placeTool}
|
||||
layers={props.tourLayers}
|
||||
compact={fit !== 'full'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { FerrataBbox } from '@trek/shared';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry';
|
||||
import type { FerrataMapLayer } from '../../Ferratas/useFerrataMapLayer';
|
||||
import type { TourBaseLayer } from '../../Map/MapLayerSwitcher';
|
||||
import type { TourBaseLayer, TourLayerOffer } from '../../Map/MapLayerSwitcher';
|
||||
import { useRasterOverlaysGL, type RasterOverlayGlMap } from '../../Map/mapRasterOverlay';
|
||||
import { trackData, type PanelWidths, type TrackColorMode } from './terrainLayers';
|
||||
import { useTerrainFerratas } from './useTerrainFerratas';
|
||||
import { frameCamera, useTerrainMap, type TerrainScene } from './useTerrainMap';
|
||||
@@ -25,6 +26,7 @@ interface Options {
|
||||
framePoints: [number, number][];
|
||||
/** The via ferrata layer of the planner map, drawn here too while it is on. */
|
||||
ferrataLayer?: FerrataMapLayer;
|
||||
tourLayers?: TourLayerOffer;
|
||||
/** The visible area, for the via ferrata layer to ask for. */
|
||||
onViewportChange?: (bbox: FerrataBbox) => void;
|
||||
}
|
||||
@@ -61,6 +63,8 @@ export function useTourTerrain(options: Options) {
|
||||
const preloaded = useTerrainPrefetch(gl?.map ?? null, styleGeneration, samples);
|
||||
useTerrainPins(gl, ready, waypoints, handlers);
|
||||
useTerrainFerratas(gl, options.ferrataLayer, options.onViewportChange);
|
||||
// MapLibre answers every call the shared overlay hook makes, as it does the ferrata hook's.
|
||||
useRasterOverlaysGL(gl ? (gl.map as unknown as RasterOverlayGlMap) : null, options.tourLayers?.overlays);
|
||||
const replay = useTerrainReplay({ gl, ready, samples, focus, onFocusChange, scene, preloaded });
|
||||
const { stop } = replay;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bike, Footprints, Mountain, Route, Sailboat, type LucideIcon } from 'lucide-react';
|
||||
import { Bike, Footprints, Mountain, Route, Waves, type LucideIcon } from 'lucide-react';
|
||||
|
||||
export function tourDayRowIcon(type: string): LucideIcon {
|
||||
switch (type) {
|
||||
@@ -9,7 +9,7 @@ export function tourDayRowIcon(type: string): LucideIcon {
|
||||
case 'walk':
|
||||
return Footprints;
|
||||
case 'kayak':
|
||||
return Sailboat;
|
||||
return Waves;
|
||||
default:
|
||||
return Route;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { heightsUnknown, movingTimeLabel, tourMetricFacts, withoutHeights } from './tourFacts';
|
||||
|
||||
const t = (key: string) => key;
|
||||
|
||||
describe('heights of a paddle tour', () => {
|
||||
it('leaves the heights of a drawn paddle tour unknown, and keeps those a paddle file recorded', () => {
|
||||
expect(heightsUnknown({ tour_type: 'kayak', has_waypoints: true })).toBe(true);
|
||||
expect(heightsUnknown({ tour_type: 'kayak' })).toBe(true);
|
||||
expect(heightsUnknown({ tour_type: 'kayak', has_waypoints: false })).toBe(false);
|
||||
expect(heightsUnknown({ tour_type: 'hike', has_waypoints: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the distance alone once the heights are taken out', () => {
|
||||
const analysis = {
|
||||
distanceKm: 4.8,
|
||||
minEle: 970,
|
||||
maxEle: 1133,
|
||||
gain: 195,
|
||||
loss: 195,
|
||||
distanceIndexedProfileSamples: [],
|
||||
};
|
||||
const facts = tourMetricFacts(withoutHeights(analysis), 'metric', t);
|
||||
expect(facts.map((fact) => fact.label)).toEqual(['tours.detail.distance']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('movingTimeLabel', () => {
|
||||
it('calls the time on the move paddling for a paddle tour and keeps the hike wording otherwise', () => {
|
||||
expect(movingTimeLabel({ tour_type: 'kayak' }, 'walking', t)).toBe('tours.paddle.timeEstimatedShort');
|
||||
expect(movingTimeLabel({ tour_type: 'hike' }, 'walking', t)).toBe('walking');
|
||||
expect(movingTimeLabel(undefined, 'walking', t)).toBe('walking');
|
||||
});
|
||||
});
|
||||
@@ -26,3 +26,32 @@ export function tourMetricFacts(
|
||||
fields.push({ label: t('tours.detail.descent'), value: formatElevation(analysis.loss, unit) });
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a tour's heights say nothing true: a paddle tour drawn in the
|
||||
* planner has its heights read from the terrain at the points the paddler set,
|
||||
* on the banks, never from the water, so its ascent, descent and profile stay
|
||||
* unknown. A paddle import keeps the heights its file recorded.
|
||||
*/
|
||||
export function heightsUnknown(tour: { tour_type?: string; has_waypoints?: boolean }): boolean {
|
||||
return tour.tour_type === 'kayak' && tour.has_waypoints !== false;
|
||||
}
|
||||
|
||||
/** The same analysis with every height left unknown, the profile included. */
|
||||
export function withoutHeights<
|
||||
T extends Pick<RouteGeometryAnalysis, 'minEle' | 'maxEle' | 'gain' | 'loss' | 'distanceIndexedProfileSamples'>,
|
||||
>(analysis: T): T {
|
||||
return { ...analysis, minEle: null, maxEle: null, gain: null, loss: null, distanceIndexedProfileSamples: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* What the time on the move is called: walking for a hike (in the wording the
|
||||
* place asks for), paddling for a paddle tour.
|
||||
*/
|
||||
export function movingTimeLabel(
|
||||
tour: { tour_type?: string } | undefined,
|
||||
hikeLabel: string,
|
||||
t: (key: string) => string
|
||||
): string {
|
||||
return tour?.tour_type === 'kayak' ? t('tours.paddle.timeEstimatedShort') : hikeLabel;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { TourTypeKey } from '@trek/shared';
|
||||
import type { TourBaseLayer } from '../Map/MapLayerSwitcher';
|
||||
|
||||
/**
|
||||
* P2B-06: which maps the tour planner offers for each tour type. The planner
|
||||
* reads this one table; the global map setting is never touched, and a choice
|
||||
* made here lasts only as long as the planner keeps it.
|
||||
*
|
||||
* A base layer is the map itself (one at a time). An overlay is information
|
||||
* laid over it and switched on its own; it is off until the user turns it on.
|
||||
* Only sources TREK already ships or that a card cleared are listed: OpenTopoMap
|
||||
* and the Esri imagery the planner had, the via ferrata layer (RC-10) and
|
||||
* OpenSeaMap's seamarks (PAD-10).
|
||||
*
|
||||
* - Hike: the standard map first, as before; topo and satellite beside it; via ferratas.
|
||||
* - Paddle: the standard map, whose water reads clearly, and satellite. No topo and
|
||||
* no ferratas, those are hiking maps. Seamarks as an overlay.
|
||||
* - Any other type (switched off, or one this build does not plan): the standard map
|
||||
* and satellite, no overlays.
|
||||
*
|
||||
* If an overlay fails, the base map and the planning go on; the switch says so.
|
||||
*/
|
||||
type TourOverlayId = 'ferratas' | 'seamarks';
|
||||
|
||||
export interface TourMapLayerSet {
|
||||
bases: readonly TourBaseLayer[];
|
||||
defaultBase: TourBaseLayer;
|
||||
overlays: readonly TourOverlayId[];
|
||||
}
|
||||
|
||||
const HIKE: TourMapLayerSet = {
|
||||
bases: ['default', 'topo', 'satellite'],
|
||||
defaultBase: 'default',
|
||||
overlays: ['ferratas'],
|
||||
};
|
||||
const PADDLE: TourMapLayerSet = { bases: ['default', 'satellite'], defaultBase: 'default', overlays: ['seamarks'] };
|
||||
const OTHER: TourMapLayerSet = { bases: ['default', 'satellite'], defaultBase: 'default', overlays: [] };
|
||||
|
||||
export function tourMapLayersFor(type: TourTypeKey | null | undefined): TourMapLayerSet {
|
||||
if (type === 'hike') return HIKE;
|
||||
if (type === 'kayak') return PADDLE;
|
||||
return OTHER;
|
||||
}
|
||||
|
||||
/** The base layer to show: the one chosen while this type offers it, else the type's own default. */
|
||||
export function baseLayerFor(set: TourMapLayerSet, chosen: TourBaseLayer): TourBaseLayer {
|
||||
return set.bases.includes(chosen) ? chosen : set.defaultBase;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenSeaMap's transparent seamark layer, as its embedding guide names it:
|
||||
* HTTPS, the seamark path only, no basemap of theirs. Tiles CC-BY-SA 2.0, data
|
||||
* ODbL. Checked on 16.10.2026: from zoom 6 to 18 every tile answers 200 with a
|
||||
* PNG (an empty one is transparent), CORS open, no Cache-Control; so a failed
|
||||
* tile means the service is unreachable, not that the water has no seamarks.
|
||||
*/
|
||||
export const SEAMARK_OVERLAY = {
|
||||
id: 'seamarks',
|
||||
url: 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
|
||||
attribution:
|
||||
'Seamarks © <a href="https://www.openseamap.org">OpenSeaMap</a> contributors (<a href="https://creativecommons.org/licenses/by-sa/2.0/">CC BY-SA 2.0</a>)',
|
||||
minZoom: 6,
|
||||
maxZoom: 18,
|
||||
} as const;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { AlertTriangle, CalendarDays, ChevronDown, Info, Mountain, Plus } from 'lucide-react'
|
||||
import { AlertTriangle, CalendarDays, ChevronDown, Info, Mountain, Plus, Waves } from 'lucide-react'
|
||||
import type { Day, DistanceUnit } from '../../types'
|
||||
import type { RouteGeometryAnalysis } from '../../utils/routeGeometry'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -19,16 +19,20 @@ import type { TourDayAssignment } from './useTourDayAssignment'
|
||||
* 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'> }) {
|
||||
export function TourGradeTile({ tour }: { tour: Pick<TourListItem, 'max_hiking_difficulty' | 'wanderer_ref' | 'has_waypoints'> & Partial<Pick<TourListItem, 'tour_type'>> }) {
|
||||
const { t } = useTranslation()
|
||||
const level = tour.max_hiking_difficulty
|
||||
const source = tourSource(tour)
|
||||
const label = level == null ? null : t(`tours.planner.difficulty.t${level}`)
|
||||
// A paddle tour has no grade; its tile names the type instead.
|
||||
const paddle = tour.tour_type === 'kayak'
|
||||
const label = level != null ? t(`tours.planner.difficulty.t${level}`) : paddle ? t('tourTypes.kayak') : null
|
||||
return (
|
||||
<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}`}
|
||||
{level != null ? `T${level}` : paddle
|
||||
? <Waves size={15} strokeWidth={1.9} className="text-content-secondary" />
|
||||
: <Mountain size={15} strokeWidth={1.9} className="text-content-secondary" />}
|
||||
</span>
|
||||
</Tooltip>
|
||||
{source !== 'trek' && (
|
||||
|
||||
@@ -11,6 +11,7 @@ import { analyzeRouteGeometry } from '../../utils/routeGeometry'
|
||||
import TourListRow from './TourListRow'
|
||||
import { TourPlannerRail, TourPlannerToursRail } from './planner/TourPlannerPanels'
|
||||
import type { TourPlannerController } from './planner/useTourPlanner'
|
||||
import { hikePlannerDefaults } from '../../../tests/helpers/tourPlannerStub'
|
||||
|
||||
vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
|
||||
vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') })
|
||||
@@ -31,6 +32,7 @@ function toursText(strings: typeof en, key: string): string {
|
||||
|
||||
function planner(overrides: Partial<TourPlannerController>): TourPlannerController {
|
||||
return {
|
||||
...hikePlannerDefaults(),
|
||||
canEdit: true, canAssign: true,
|
||||
mode: { type: 'neutral' }, status: 'empty', error: null, name: '',
|
||||
maxHikingDifficulty: 2, waypoints: [], hasUnsavedChanges: false,
|
||||
|
||||
@@ -10,6 +10,7 @@ const state = vi.hoisted(() => ({
|
||||
trip: { id: 7, user_id: 1 } as { id: number; user_id: number } | null,
|
||||
user: { id: 2, role: 'user' } as { id: number; role: string } | null,
|
||||
importGpx: vi.fn(),
|
||||
previewGpx: vi.fn(),
|
||||
loadTrip: vi.fn(),
|
||||
refreshPlaces: vi.fn(),
|
||||
create: vi.fn(),
|
||||
@@ -22,7 +23,8 @@ const state = vi.hoisted(() => ({
|
||||
vi.mock('../../store/tripStore', () => ({ useTripStore: (selector: (store: unknown) => unknown) => selector({ trip: state.trip, loadTrip: state.loadTrip, refreshPlaces: state.refreshPlaces, places: [] }) }))
|
||||
vi.mock('../../store/authStore', () => ({ useAuthStore: (selector: (store: unknown) => unknown) => selector({ user: state.user }) }))
|
||||
vi.mock('../../store/settingsStore', () => ({ useSettingsStore: (selector: (store: unknown) => unknown) => selector({ settings: { distance_unit: 'metric' } }) }))
|
||||
vi.mock('../../repo/tourRepo', () => ({ tourRepo: { importGpx: state.importGpx, create: state.create, update: state.update, detail: state.detail } }))
|
||||
vi.mock('../Map/MapViewAuto', () => ({ MapViewAuto: () => null }))
|
||||
vi.mock('../../repo/tourRepo', () => ({ tourRepo: { importGpx: state.importGpx, previewGpx: state.previewGpx, create: state.create, update: state.update, detail: state.detail } }))
|
||||
vi.mock('./planner/tourRouting', () => ({ routeWalkingTour: state.route, enrichTourElevations: state.elevation }))
|
||||
vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (key: string) => key, locale: 'en' }), translateApiError: () => 'error' }))
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }) }))
|
||||
@@ -48,6 +50,7 @@ import TourDetailDialog from './TourDetailDialog'
|
||||
import MToursSelectionList from '../../mobile/screens/trip/places/MToursSelectionList'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { useTourListFilters } from './useTourListFilters'
|
||||
import { hikePlannerDefaults } from '../../../tests/helpers/tourPlannerStub'
|
||||
|
||||
/** The list as the places browser mounts it: its filters from the shared hook (PR1-13). */
|
||||
function List(props: Omit<ComponentProps<typeof MToursSelectionList>, 'filters'>) {
|
||||
@@ -63,6 +66,7 @@ beforeEach(() => {
|
||||
state.trip = { id: 7, user_id: 1 }
|
||||
state.user = { id: 2, role: 'user' }
|
||||
state.importGpx.mockResolvedValue({ tours: [tour] })
|
||||
state.previewGpx.mockResolvedValue({ tracks: [{ name: 'Ridge', geometry: [[46, 7], [46.01, 7.01]], point_count: 2, distance_km: 1.4, elevation_gain_m: null, elevation_loss_m: null, duplicate: false }] })
|
||||
usePermissionsStore.setState({ permissions: { place_edit: 'admin', day_edit: 'admin' } })
|
||||
})
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); localStorage.clear() })
|
||||
@@ -151,6 +155,7 @@ describe('Tours permissions without database setup', () => {
|
||||
})
|
||||
it.each([[false, false], [true, false], [false, true], [true, true]])('rails edit=%s assign=%s separate save and existing-tour attach', (canEdit, canAssign) => {
|
||||
const planner = {
|
||||
...hikePlannerDefaults(),
|
||||
mode: { type: 'new-draft' }, name: 'Ridge', status: 'ready', maxHikingDifficulty: 2,
|
||||
waypoints: [], canSave: true, save: vi.fn(), route: null, saveOutcome: tour,
|
||||
startNewTour: vi.fn(), openTour: vi.fn().mockResolvedValue(true),
|
||||
@@ -211,6 +216,10 @@ describe('Tours permissions without database setup', () => {
|
||||
expect(assign).toHaveBeenCalledTimes(canAssign ? 1 : 0)
|
||||
await act(async () => {
|
||||
pickFiles(importButton, [new File(['gpx'], 'ridge.gpx')]) })
|
||||
// Choosing a file only reads it; the write is the preview's confirmation.
|
||||
expect(state.previewGpx).toHaveBeenCalledTimes(canEdit ? 1 : 0)
|
||||
expect(state.importGpx).not.toHaveBeenCalled()
|
||||
if (canEdit) await act(async () => { fireEvent.click(screen.getByTestId('gpx-preview-import')) })
|
||||
expect(state.importGpx).toHaveBeenCalledTimes(canEdit ? 1 : 0)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import type { TourGpxImportFields, TourGpxPreviewTrack, TourListItem } from '@trek/shared';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { tourRepo } from '../../repo/tourRepo';
|
||||
@@ -8,9 +8,16 @@ import { useToast } from '../shared/Toast';
|
||||
|
||||
/** The server refuses anything larger; saying so first spares the upload. */
|
||||
const MAX_TOUR_GPX_BYTES = 10 * 1024 * 1024;
|
||||
const NO_TRACK = 'No track or route found in GPX file';
|
||||
|
||||
const isGpxFile = (file: Pick<File, 'name'>) => file.name.toLowerCase().endsWith('.gpx');
|
||||
|
||||
/** One file as its preview read it: every track or route it holds, each as the import would store it. */
|
||||
export interface GpxPreviewFile {
|
||||
file: File;
|
||||
tracks: TourGpxPreviewTrack[];
|
||||
}
|
||||
|
||||
interface TourGpxImportOptions {
|
||||
tripId: number;
|
||||
canEdit: boolean;
|
||||
@@ -23,19 +30,33 @@ interface TourGpxImportOptions {
|
||||
/**
|
||||
* The one way a GPX file becomes a tour, whichever control it came through:
|
||||
* the import button's file picker or a file dropped onto the tours workspace.
|
||||
* Files are checked, uploaded one after the other and summed up in one set of
|
||||
* toasts: how many came in, how many were already in the trip, which file had
|
||||
* no track and which one failed.
|
||||
*
|
||||
* PAD-02: nothing is stored on choosing a file. Each file is read first and
|
||||
* shown (its whole course, what it measures, which tracks the trip already
|
||||
* holds); only confirming the preview imports, as the type chosen there.
|
||||
* Cancelling leaves the trip as it was. A file that cannot be read is named
|
||||
* in a toast and left out of the preview.
|
||||
*/
|
||||
export function useTourGpxImport({ tripId, canEdit, onToursChanged, onImported }: TourGpxImportOptions) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const refreshPlaces = useTripStore((state) => state.refreshPlaces);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [preview, setPreview] = useState<GpxPreviewFile[] | null>(null);
|
||||
|
||||
const fileError = useCallback(
|
||||
(error: unknown, file: File) =>
|
||||
toast.error(
|
||||
getApiErrorMessage(error, '') === NO_TRACK
|
||||
? t('tours.import.noTrackIn', { name: file.name })
|
||||
: t('tours.import.errorIn', { name: file.name })
|
||||
),
|
||||
[t, toast]
|
||||
);
|
||||
|
||||
const importFiles = useCallback(
|
||||
async (files: readonly File[]) => {
|
||||
if (!canEdit || files.length === 0 || importing) return;
|
||||
if (!canEdit || files.length === 0 || importing || preview) return;
|
||||
const gpx = files.filter(isGpxFile);
|
||||
if (gpx.length < files.length) toast.error(t('tours.import.unsupported'));
|
||||
const fitting = gpx.filter((file) => file.size <= MAX_TOUR_GPX_BYTES);
|
||||
@@ -43,25 +64,44 @@ export function useTourGpxImport({ tripId, canEdit, onToursChanged, onImported }
|
||||
toast.error(t('places.importFileTooLarge', { maxMb: MAX_TOUR_GPX_BYTES / 1024 / 1024 }));
|
||||
if (fitting.length === 0) return;
|
||||
|
||||
setImporting(true);
|
||||
const read: GpxPreviewFile[] = [];
|
||||
try {
|
||||
for (const file of fitting) {
|
||||
try {
|
||||
read.push({ file, tracks: (await tourRepo.previewGpx(tripId, file)).tracks });
|
||||
} catch (error: unknown) {
|
||||
fileError(error, file);
|
||||
}
|
||||
}
|
||||
if (read.length > 0) setPreview(read);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
},
|
||||
[canEdit, fileError, importing, preview, t, toast, tripId]
|
||||
);
|
||||
|
||||
/** The previewed files imported as the chosen type, summed up in one set of toasts. */
|
||||
const confirm = useCallback(
|
||||
async (fields: TourGpxImportFields) => {
|
||||
if (!canEdit || !preview || importing) return;
|
||||
setImporting(true);
|
||||
const imported: TourListItem[] = [];
|
||||
let skipped = 0;
|
||||
let caution = false;
|
||||
try {
|
||||
for (const file of fitting) {
|
||||
for (const { file } of preview) {
|
||||
try {
|
||||
const result = await tourRepo.importGpx(tripId, file);
|
||||
const result = await tourRepo.importGpx(tripId, file, fields);
|
||||
imported.push(...result.tours);
|
||||
skipped += result.skipped;
|
||||
caution ||= result.caution;
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
getApiErrorMessage(error, '') === 'No track or route found in GPX file'
|
||||
? t('tours.import.noTrackIn', { name: file.name })
|
||||
: t('tours.import.errorIn', { name: file.name })
|
||||
);
|
||||
fileError(error, file);
|
||||
}
|
||||
}
|
||||
setPreview(null);
|
||||
if (imported.length > 0) {
|
||||
// The server's place:created skips this socket, so the places are read
|
||||
// again here for the new ones and their lines. Only the places: a whole
|
||||
@@ -81,21 +121,26 @@ export function useTourGpxImport({ tripId, canEdit, onToursChanged, onImported }
|
||||
setImporting(false);
|
||||
}
|
||||
},
|
||||
[canEdit, importing, onImported, onToursChanged, refreshPlaces, t, toast, tripId]
|
||||
[canEdit, fileError, importing, onImported, onToursChanged, preview, refreshPlaces, t, toast, tripId]
|
||||
);
|
||||
|
||||
/** The preview closed: nothing was stored. */
|
||||
const cancel = useCallback(() => {
|
||||
if (!importing) setPreview(null);
|
||||
}, [importing]);
|
||||
|
||||
/** Opens the file picker; built per click, so no view carries a hidden input for it. */
|
||||
const pick = useCallback(() => {
|
||||
if (!canEdit || importing) return;
|
||||
if (!canEdit || importing || preview) return;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.gpx';
|
||||
input.multiple = true;
|
||||
input.addEventListener('change', () => void importFiles(Array.from(input.files ?? [])), { once: true });
|
||||
input.click();
|
||||
}, [canEdit, importFiles, importing]);
|
||||
}, [canEdit, importFiles, importing, preview]);
|
||||
|
||||
return { importing, importFiles, pick };
|
||||
return { importing, importFiles, pick, preview, confirm, cancel };
|
||||
}
|
||||
|
||||
export type TourGpxImport = ReturnType<typeof useTourGpxImport>;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import type { MapRasterOverlay, ToggleableRasterOverlay } from '../Map/mapRasterOverlay';
|
||||
|
||||
/** Failed tiles in a row, with none arriving between them, before the source counts as down. */
|
||||
const FAILURES_UNTIL_DOWN = 4;
|
||||
|
||||
/**
|
||||
* An overlay the planner offers, off until switched on; whether it is on is
|
||||
* the planner's (`active`, `onToggle`), so it outlasts the map. A run of failed
|
||||
* tiles marks it down, which the switch shows; one tile arriving clears that,
|
||||
* and switching it off and on again tries afresh. One stray failure alone
|
||||
* never marks it down.
|
||||
*/
|
||||
export function useTourRasterOverlay(
|
||||
definition: Pick<MapRasterOverlay, 'id' | 'url' | 'attribution' | 'minZoom' | 'maxZoom'>,
|
||||
offered: boolean,
|
||||
active: boolean,
|
||||
toggle: () => void
|
||||
): ToggleableRasterOverlay {
|
||||
const [down, setDown] = useState(false);
|
||||
const failures = useRef(0);
|
||||
const on = active && offered;
|
||||
|
||||
const onToggle = useCallback(() => {
|
||||
failures.current = 0;
|
||||
setDown(false);
|
||||
toggle();
|
||||
}, [toggle]);
|
||||
const onTileError = useCallback(() => {
|
||||
failures.current += 1;
|
||||
if (failures.current >= FAILURES_UNTIL_DOWN) setDown(true);
|
||||
}, []);
|
||||
const onTileLoad = useCallback(() => {
|
||||
failures.current = 0;
|
||||
setDown(false);
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
...definition,
|
||||
active: on,
|
||||
status: on ? (down ? 'down' : 'on') : 'off',
|
||||
onToggle,
|
||||
onTileError,
|
||||
onTileLoad,
|
||||
}),
|
||||
[definition, down, on, onTileError, onTileLoad, onToggle]
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type { TourPlannerController } from '../Tours/planner/useTourPlanner'
|
||||
import ToursSidebar from '../Tours/ToursSidebar'
|
||||
import type { Day } from '../../types'
|
||||
import CustomSelect from './CustomSelect'
|
||||
import { hikePlannerDefaults } from '../../../tests/helpers/tourPlannerStub'
|
||||
|
||||
vi.mock('../../repo/tourRepo', () => ({ tourRepo: {} }))
|
||||
vi.mock('../../store/settingsStore', () => ({
|
||||
@@ -29,6 +30,7 @@ const days = [{ id: 7, trip_id: 1, day_number: 1, title: 'Summit day', date: '20
|
||||
|
||||
function planner(overrides: Partial<TourPlannerController> = {}): TourPlannerController {
|
||||
return {
|
||||
...hikePlannerDefaults(),
|
||||
mode: { type: 'new-draft' }, maxHikingDifficulty: 2,
|
||||
setMaxHikingDifficulty: vi.fn(), name: '', setName: vi.fn(),
|
||||
status: 'empty', waypoints: [], route: [], routeAnalysis: null,
|
||||
|
||||
@@ -98,39 +98,44 @@ vi.mock('../components/Planner/ReservationsPanel', () => ({
|
||||
}));
|
||||
vi.mock('../components/Planner/TripWarningsBanner', () => ({ default: stub('warnings', 'trip-warnings') }));
|
||||
vi.mock('../components/Tours/TourDetailDialog', () => ({ default: stub('tourDetail', 'tour-detail-dialog') }));
|
||||
vi.mock('../components/Tours/planner/useTourPlanner', () => ({
|
||||
useTourPlanner: () => ({
|
||||
route: null,
|
||||
waypoints: plannerActions.waypoints,
|
||||
routeProfileFocus: plannerActions.routeProfileFocus,
|
||||
name: '',
|
||||
startNewTour: plannerActions.startNewTour,
|
||||
newTourConfirmationOpen: false,
|
||||
cancelNewTour: vi.fn(),
|
||||
elevationProfileExpanded: true,
|
||||
toggleElevationProfile: vi.fn(),
|
||||
newDraftGeneration: plannerActions.newDraftGeneration,
|
||||
mapFocusKey: plannerActions.mapFocusKey,
|
||||
mode: plannerActions.mode,
|
||||
isSaving: plannerActions.isSaving,
|
||||
selectedWaypointId: null,
|
||||
editingPlaceId: null,
|
||||
saveOutcome: plannerActions.saveOutcome,
|
||||
hasUnsavedChanges: false,
|
||||
setSelectedWaypointId: vi.fn(),
|
||||
mapBaseLayer: 'map',
|
||||
setMapBaseLayer: vi.fn(),
|
||||
openTour: plannerActions.openTour,
|
||||
viewGpxTour: plannerActions.viewGpxTour,
|
||||
addWaypoint: plannerActions.addWaypoint,
|
||||
setWaypointPosition: plannerActions.setWaypointPosition,
|
||||
closeGpxTour: vi.fn(),
|
||||
readOnlyGpxTour: plannerActions.readOnlyGpxTour,
|
||||
readOnlyGpxAnalysis: plannerActions.readOnlyGpxAnalysis,
|
||||
discard: vi.fn(),
|
||||
forgetDeletedTour: plannerActions.forgetDeletedTour,
|
||||
}),
|
||||
}));
|
||||
vi.mock('../components/Tours/planner/useTourPlanner', async () => {
|
||||
const { hikePlannerDefaults } = await import('../../tests/helpers/tourPlannerStub');
|
||||
const defaults = hikePlannerDefaults();
|
||||
return {
|
||||
useTourPlanner: () => ({
|
||||
...defaults,
|
||||
route: null,
|
||||
waypoints: plannerActions.waypoints,
|
||||
routeProfileFocus: plannerActions.routeProfileFocus,
|
||||
name: '',
|
||||
startNewTour: plannerActions.startNewTour,
|
||||
newTourConfirmationOpen: false,
|
||||
cancelNewTour: vi.fn(),
|
||||
elevationProfileExpanded: true,
|
||||
toggleElevationProfile: vi.fn(),
|
||||
newDraftGeneration: plannerActions.newDraftGeneration,
|
||||
mapFocusKey: plannerActions.mapFocusKey,
|
||||
mode: plannerActions.mode,
|
||||
isSaving: plannerActions.isSaving,
|
||||
selectedWaypointId: null,
|
||||
editingPlaceId: null,
|
||||
saveOutcome: plannerActions.saveOutcome,
|
||||
hasUnsavedChanges: false,
|
||||
setSelectedWaypointId: vi.fn(),
|
||||
mapBaseLayer: 'map',
|
||||
setMapBaseLayer: vi.fn(),
|
||||
openTour: plannerActions.openTour,
|
||||
viewGpxTour: plannerActions.viewGpxTour,
|
||||
addWaypoint: plannerActions.addWaypoint,
|
||||
setWaypointPosition: plannerActions.setWaypointPosition,
|
||||
closeGpxTour: vi.fn(),
|
||||
readOnlyGpxTour: plannerActions.readOnlyGpxTour,
|
||||
readOnlyGpxAnalysis: plannerActions.readOnlyGpxAnalysis,
|
||||
discard: vi.fn(),
|
||||
forgetDeletedTour: plannerActions.forgetDeletedTour,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../components/Trips/TripFormModal', () => ({ default: stub('tripForm') }));
|
||||
vi.mock('../components/Trips/TripMembersModal', () => ({ default: stub('membersModal') }));
|
||||
|
||||
@@ -114,8 +114,12 @@ vi.mock('../components/Planner/AirTrailImportModal', () => ({ default: stub('air
|
||||
vi.mock('../components/Planner/ReservationsPanel', () => ({ default: stub('reservationsPanel', 'reservations-panel') }))
|
||||
vi.mock('../components/Planner/TripWarningsBanner', () => ({ default: stub('warnings', 'trip-warnings') }))
|
||||
vi.mock('../components/Tours/TourDetailDialog', () => ({ default: stub('tourDetail', 'tour-detail-dialog') }))
|
||||
vi.mock('../components/Tours/planner/useTourPlanner', () => ({
|
||||
vi.mock('../components/Tours/planner/useTourPlanner', async () => {
|
||||
const { hikePlannerDefaults } = await import('../../tests/helpers/tourPlannerStub')
|
||||
const defaults = hikePlannerDefaults()
|
||||
return {
|
||||
useTourPlanner: () => ({
|
||||
...defaults,
|
||||
route: null,
|
||||
waypoints: plannerActions.waypoints,
|
||||
routeProfileFocus: plannerActions.routeProfileFocus,
|
||||
@@ -146,7 +150,8 @@ vi.mock('../components/Tours/planner/useTourPlanner', () => ({
|
||||
discard: vi.fn(),
|
||||
forgetDeletedTour: plannerActions.forgetDeletedTour,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../components/Trips/TripFormModal', () => ({ default: stub('tripForm') }))
|
||||
vi.mock('../components/Trips/TripMembersModal', () => ({ default: stub('membersModal') }))
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { useTripFerratas } from '../../components/Ferratas/useTripFerratas';
|
||||
import type { MapViewProps } from '../../components/Map/mapViewContract';
|
||||
import { useTourMapView } from '../../components/Tours/planner/useTourMapView';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { initialTourPlaceToolState, useTourPlaceToolStore } from '../../store/tourPlaceToolStore';
|
||||
import { TourPlannerMapStage, type TourPlannerMapStageProps } from './TourPlannerMapStage';
|
||||
@@ -104,11 +105,12 @@ const FERRATA: Ferrata = {
|
||||
type Planner = TourPlannerMapStageProps['tourPlanner'];
|
||||
|
||||
function planner(
|
||||
mode: 'new-draft' | 'edit-saved' | 'view-saved' | 'view-gpx',
|
||||
mode: 'neutral' | 'new-draft' | 'edit-saved' | 'view-saved' | 'view-gpx',
|
||||
overrides: Record<string, unknown> = {}
|
||||
) {
|
||||
return {
|
||||
mode: { type: mode },
|
||||
mode: mode === 'view-gpx' ? { type: mode, placeId: 9, tour: { place_id: 9, tour_type: 'hike' } } : { type: mode },
|
||||
tourType: 'hike',
|
||||
isSaving: false,
|
||||
waypoints: [{ id: 'w1', lat: 46, lng: 7 }],
|
||||
selectedWaypointId: 'w1',
|
||||
@@ -121,6 +123,8 @@ function planner(
|
||||
readOnlyGpxAnalysis: null,
|
||||
mapBaseLayer: 'default',
|
||||
setMapBaseLayer: vi.fn(),
|
||||
mapOverlays: new Set(),
|
||||
toggleMapOverlay: vi.fn(),
|
||||
routeProfileFocus: null,
|
||||
setRouteProfileFocus: vi.fn(),
|
||||
...overrides,
|
||||
@@ -423,4 +427,72 @@ describe('TourPlannerMapStage', () => {
|
||||
expect(stage.flatMap.props?.placeTool).toBeUndefined();
|
||||
expect(screen.getByTestId('tour-map-surface')).not.toHaveAttribute('data-placing');
|
||||
});
|
||||
|
||||
describe('map layers per tour type (P2B-06/07, PAD-04, PAD-10)', () => {
|
||||
const flat = () => stage.flatMap.props as MapViewProps;
|
||||
|
||||
it('a hike keeps the standard map, topo and satellite and the via ferrata layer, and no seamarks', () => {
|
||||
renderStage(planner('new-draft'));
|
||||
expect(flat().tourLayers?.bases).toEqual(['default', 'topo', 'satellite']);
|
||||
expect(flat().ferrataLayer).toBeDefined();
|
||||
expect(flat().tourLayers?.overlays).toEqual([]);
|
||||
});
|
||||
|
||||
it('with no tour open there is no via ferrata layer, as there are no seamarks', () => {
|
||||
renderStage(planner('neutral'));
|
||||
expect(flat().ferrataLayer).toBeUndefined();
|
||||
expect(flat().tourLayers?.overlays).toEqual([]);
|
||||
});
|
||||
|
||||
it('a paddle tour offers the standard map and satellite, seamarks off, and neither topo nor ferratas', () => {
|
||||
renderStage(planner('new-draft', { tourType: 'kayak', mapBaseLayer: 'topo' }));
|
||||
expect(flat().tourLayers?.bases).toEqual(['default', 'satellite']);
|
||||
// Topo chosen for a hike falls back to the paddle tour's own default, without touching the choice.
|
||||
expect(flat().viewBaseLayer).toBe('default');
|
||||
expect(flat().ferrataLayer).toBeUndefined();
|
||||
expect(flat().tourLayers?.overlays?.map((overlay) => [overlay.id, overlay.status])).toEqual([
|
||||
['seamarks', 'off'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('an imported paddle tour is shown with the paddle maps, a type nobody plans with the standard set', () => {
|
||||
const { unmount } = renderStage(
|
||||
planner('view-gpx', { mode: { type: 'view-gpx', placeId: 9, tour: { place_id: 9, tour_type: 'kayak' } } })
|
||||
);
|
||||
expect(flat().tourLayers?.bases).toEqual(['default', 'satellite']);
|
||||
unmount();
|
||||
renderStage(
|
||||
planner('view-gpx', { mode: { type: 'view-gpx', placeId: 9, tour: { place_id: 9, tour_type: 'bike' } } })
|
||||
);
|
||||
expect(flat().tourLayers?.bases).toEqual(['default', 'satellite']);
|
||||
expect(flat().tourLayers?.overlays).toEqual([]);
|
||||
expect(flat().ferrataLayer).toBeUndefined();
|
||||
});
|
||||
|
||||
it('seamarks switch on, report a source that stopped answering, and recover on the next tile', () => {
|
||||
// The switch lives with the planner's map view, so it outlasts the map.
|
||||
function PaddleStage() {
|
||||
const view = useTourMapView();
|
||||
return <TourPlannerMapStage {...stageProps(planner('new-draft', { tourType: 'kayak', ...view }))} />;
|
||||
}
|
||||
render(<PaddleStage />);
|
||||
const overlay = () => flat().tourLayers?.overlays?.[0];
|
||||
act(() => overlay()?.onToggle());
|
||||
expect(overlay()).toMatchObject({
|
||||
status: 'on',
|
||||
active: true,
|
||||
url: 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
|
||||
});
|
||||
act(() => {
|
||||
for (let i = 0; i < 3; i += 1) overlay()?.onTileError();
|
||||
});
|
||||
expect(overlay()?.status).toBe('on');
|
||||
act(() => overlay()?.onTileError());
|
||||
expect(overlay()?.status).toBe('down');
|
||||
act(() => overlay()?.onTileLoad());
|
||||
expect(overlay()?.status).toBe('on');
|
||||
act(() => overlay()?.onToggle());
|
||||
expect(overlay()).toMatchObject({ status: 'off', active: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { publishTourFerrataCrossings } from '../../store/tourFerrataCrossingStore';
|
||||
import { useFerrataAdopt } from './useFerrataAdopt';
|
||||
import { useTourMapPoints } from './useTourMapPoints';
|
||||
import { useTourStageLayers } from './useTourStageLayers';
|
||||
import type { useTripPlanner } from './useTripPlanner';
|
||||
import type { useTripPlannerPage } from './useTripPlannerPage';
|
||||
|
||||
@@ -91,6 +92,11 @@ export function TourPlannerMapStage({
|
||||
);
|
||||
const analysis = tourPlanner.mode.type === 'view-gpx' ? tourPlanner.readOnlyGpxAnalysis : tourPlanner.routeAnalysis;
|
||||
const ferrata = useFerrataMapLayer(tripId, ferrataArea, analysis?.routeCoordinates ?? null);
|
||||
// The maps this tour's type offers: its base layers, its overlays, and the ferratas only where they belong.
|
||||
const { baseLayer, tourLayers, ferrataLayer, ferrataCrossings, ferrataSelected } = useTourStageLayers(
|
||||
tourPlanner,
|
||||
ferrata
|
||||
);
|
||||
const ferrataViewportChange = ferrata.onViewportChange;
|
||||
const viewportChanged = useCallback(
|
||||
(bounds: FerrataBbox) => {
|
||||
@@ -107,30 +113,30 @@ export function TourPlannerMapStage({
|
||||
const label = ferrataLabel(group, translate);
|
||||
return { name: label.name, grade: label.grade };
|
||||
};
|
||||
const legs = crossingLegs(tourWaypoints, analysis?.distanceIndexedProfileSamples ?? [], ferrata.crossings);
|
||||
const legs = crossingLegs(tourWaypoints, analysis?.distanceIndexedProfileSamples ?? [], ferrataCrossings);
|
||||
publishTourFerrataCrossings(
|
||||
ferrata.crossings.map(({ group, fromM, toM }, i) => ({
|
||||
ferrataCrossings.map(({ group, fromM, toM }, i) => ({
|
||||
id: group.id,
|
||||
...describe(group),
|
||||
fromM,
|
||||
toM,
|
||||
afterWaypoint: legs[i] ?? null,
|
||||
})),
|
||||
waypointsAtFerrataEnds(tourWaypoints, ferrata.crossings, describe)
|
||||
waypointsAtFerrataEnds(tourWaypoints, ferrataCrossings, describe)
|
||||
);
|
||||
}, [analysis, ferrata.crossings, tourWaypoints, translate]);
|
||||
}, [analysis, ferrataCrossings, tourWaypoints, translate]);
|
||||
useEffect(() => () => publishTourFerrataCrossings([]), []);
|
||||
// The click that closes a ferrata's card must not also add a waypoint under it.
|
||||
const closingPress = useOutsidePressGuard();
|
||||
const adoptFerrata = useFerrataAdopt(tourPlanner, canPlaceEdit, ferrata.close);
|
||||
const ferrataOnTour = useMemo(() => new Set(ferrata.crossings.map(({ group }) => group.id)), [ferrata.crossings]);
|
||||
const ferrataOnTour = useMemo(() => new Set(ferrataCrossings.map(({ group }) => group.id)), [ferrataCrossings]);
|
||||
const distanceUnit = useSettingsStore((s) => s.settings.distance_unit);
|
||||
// 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,
|
||||
ferrataOpen: !!ferrataSelected,
|
||||
guard: closingPress.guard,
|
||||
previewPoints: tourMap.previewPoints,
|
||||
});
|
||||
@@ -155,8 +161,9 @@ export function TourPlannerMapStage({
|
||||
routes={tourMap.route}
|
||||
trackColor={TOUR_TRACK_COLOR}
|
||||
unit={distanceUnit}
|
||||
baseLayer={tourPlanner.mapBaseLayer}
|
||||
baseLayer={baseLayer}
|
||||
onBaseLayerChange={tourPlanner.setMapBaseLayer}
|
||||
tourLayers={tourLayers}
|
||||
onExit={() => setTerrain3d(false)}
|
||||
focus={tourPlanner.routeProfileFocus}
|
||||
onFocusChange={tourPlanner.setRouteProfileFocus}
|
||||
@@ -164,7 +171,7 @@ export function TourPlannerMapStage({
|
||||
rightWidth={rightInset}
|
||||
waypoints={waypoints}
|
||||
framePoints={tourMap.focusPoints}
|
||||
ferrataLayer={ferrata.layer}
|
||||
ferrataLayer={ferrataLayer}
|
||||
onViewportChange={viewportChanged}
|
||||
onControlsHeightChange={setTerrainControlsPx}
|
||||
onLegendLayout={setTerrainLegend}
|
||||
@@ -193,10 +200,11 @@ export function TourPlannerMapStage({
|
||||
onPlannerWaypointClick={tourPlanner.setSelectedWaypointId}
|
||||
onPlannerWaypointMove={waypoints.onMove}
|
||||
routeProfileFocus={tourPlanner.routeProfileFocus}
|
||||
viewBaseLayer={tourPlanner.mapBaseLayer}
|
||||
viewBaseLayer={baseLayer}
|
||||
onViewBaseLayerChange={tourPlanner.setMapBaseLayer}
|
||||
tourLayers={tourLayers}
|
||||
onToggleTerrain3d={() => setTerrain3d(true)}
|
||||
ferrataLayer={ferrata.layer}
|
||||
ferrataLayer={ferrataLayer}
|
||||
onViewportChange={viewportChanged}
|
||||
placeTool={placeTool}
|
||||
/>
|
||||
@@ -205,7 +213,7 @@ export function TourPlannerMapStage({
|
||||
{/* 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
|
||||
selection={ferrata.selected}
|
||||
selection={ferrataSelected}
|
||||
onClose={ferrata.close}
|
||||
onOutsidePress={closingPress.arm}
|
||||
insetEnd={(isRtlLanguage(language) ? leftInset : rightInset) + MAP_LAYER_SWITCHER_INSET}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { useOutsidePressGuard } from '../../components/Ferratas/useOutsidePressGuard';
|
||||
import { isClosedLoop } from '../../components/Tours/planner/tourRouteActions';
|
||||
import type { TourPlannerController } from '../../components/Tours/planner/useTourPlanner';
|
||||
import { useWaypointPlaceNames } from '../../components/Tours/planner/useWaypointPlaceNames';
|
||||
import { useTranslation } from '../../i18n';
|
||||
@@ -69,7 +70,9 @@ export function useTourMapPoints({ tourPlanner, canPlaceEdit, mode, ferrataOpen,
|
||||
const { waypoints } = tourPlanner;
|
||||
const points = useMemo(() => {
|
||||
if (editing) {
|
||||
return waypoints.map((point, index) => ({
|
||||
// The way back of a loop lies on the start; one pin stands for both, and moving it moves both.
|
||||
const shown = isClosedLoop(waypoints) ? waypoints.slice(0, -1) : waypoints;
|
||||
return shown.map((point, index) => ({
|
||||
...point,
|
||||
name: placeNames[point.id] ?? t('tours.planner.waypointLabel', { n: index + 1 }),
|
||||
detail: t(ROLE_KEY[point.role]),
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { useFerrataMapLayer } from '../../components/Ferratas/useFerrataMapLayer';
|
||||
import type { TourPlannerController } from '../../components/Tours/planner/useTourPlanner';
|
||||
import { baseLayerFor, SEAMARK_OVERLAY, tourMapLayersFor } from '../../components/Tours/tourMapLayers';
|
||||
import { useTourRasterOverlay } from '../../components/Tours/useTourRasterOverlay';
|
||||
|
||||
const NO_CROSSINGS: never[] = [];
|
||||
|
||||
type FerrataLayerState = ReturnType<typeof useFerrataMapLayer>;
|
||||
|
||||
/**
|
||||
* What the planner map shows for the tour in it (P2B-06/07): the base layers
|
||||
* and overlays of its type, an import with its own type's and a draft with the
|
||||
* one chosen. A type without the via ferrata layer, or no tour open at all,
|
||||
* neither draws nor warns about ferratas, even if the layer was switched on before; a base layer it
|
||||
* does not offer falls back to its default without changing the choice kept.
|
||||
*/
|
||||
export function useTourStageLayers(
|
||||
tourPlanner: Pick<TourPlannerController, 'mode' | 'tourType' | 'mapBaseLayer' | 'mapOverlays' | 'toggleMapOverlay'>,
|
||||
ferrata: FerrataLayerState
|
||||
) {
|
||||
const { mode } = tourPlanner;
|
||||
const layerSet = tourMapLayersFor(mode.type === 'view-gpx' ? mode.tour.tour_type : tourPlanner.tourType);
|
||||
// With no tour open nothing is a hike yet: the ferratas wait for one, as the seamarks wait for a paddle tour.
|
||||
const offersFerratas = mode.type !== 'neutral' && layerSet.overlays.includes('ferratas');
|
||||
const { toggleMapOverlay } = tourPlanner;
|
||||
const toggleSeamarks = useCallback(() => toggleMapOverlay(SEAMARK_OVERLAY.id), [toggleMapOverlay]);
|
||||
const seamarks = useTourRasterOverlay(
|
||||
SEAMARK_OVERLAY,
|
||||
layerSet.overlays.includes('seamarks'),
|
||||
tourPlanner.mapOverlays.has(SEAMARK_OVERLAY.id),
|
||||
toggleSeamarks
|
||||
);
|
||||
const tourLayers = useMemo(
|
||||
() => ({ bases: layerSet.bases, overlays: layerSet.overlays.includes('seamarks') ? [seamarks] : [] }),
|
||||
[layerSet, seamarks]
|
||||
);
|
||||
return {
|
||||
baseLayer: baseLayerFor(layerSet, tourPlanner.mapBaseLayer),
|
||||
tourLayers,
|
||||
ferrataLayer: offersFerratas ? ferrata.layer : undefined,
|
||||
ferrataCrossings: offersFerratas ? ferrata.crossings : NO_CROSSINGS,
|
||||
ferrataSelected: offersFerratas ? ferrata.selected : null,
|
||||
};
|
||||
}
|
||||
@@ -72,15 +72,22 @@ export function useTripPlannerPage() {
|
||||
return tourConversion.line ? [coordinates, tourConversion.line] : [coordinates]
|
||||
}
|
||||
if (tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved') {
|
||||
// Routed and hand-drawn stretches as their own lines, so the map can tell them apart.
|
||||
if (tourPlanner.routeParts?.length) return tourPlanner.routeParts.map(part => part.coordinates)
|
||||
return tourPlanner.route ? [tourPlanner.route] : null
|
||||
}
|
||||
return null
|
||||
}, [
|
||||
wandererBrowser.open,
|
||||
wandererBrowser.line,
|
||||
tourPlanner.mode.type, tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, tourPlanner.route,
|
||||
tourPlanner.mode.type, tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, tourPlanner.route, tourPlanner.routeParts,
|
||||
tourConversion.line,
|
||||
])
|
||||
const editingRoute = tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved'
|
||||
// A hand-drawn stretch is drawn dotted, the way the map draws what no router planned.
|
||||
const routeWalking = !wandererBrowser.open && editingRoute && tourPlanner.routeParts?.some(part => part.free)
|
||||
? tourPlanner.routeParts.map(part => part.free)
|
||||
: route && route.length === 2 && tourConversion.line ? [true, false] : null
|
||||
|
||||
const { closeGpxTour } = tourPlanner
|
||||
useEffect(() => {
|
||||
@@ -121,7 +128,7 @@ export function useTripPlannerPage() {
|
||||
recordedTrack,
|
||||
tourDetailActions,
|
||||
tourMap: { route,
|
||||
routeWalking: route && route.length === 2 && tourConversion.line ? [true, false] : null,
|
||||
routeWalking,
|
||||
previewPoints: tourConversion.status === 'idle' ? null : tourConversion.points,
|
||||
focusPoints: mapFocusPoints,
|
||||
// A picked wanderer trail frames itself; the rest follows the planner.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computeTourMetrics, type GeometryPoint, type TourCreateRequest, type TourCreateResponse, type TourDetailResponse, type TourImportGpxResponse, type TourListItem, type TourWaypoint } from '@trek/shared'
|
||||
import { computeTourMetrics, type GeometryPoint, type TourCreateRequest, type TourCreateResponse, type TourDetailResponse, type TourGpxImportFields, type TourGpxPreviewResponse, type TourImportGpxResponse, type TourListItem, type TourWaypoint } from '@trek/shared'
|
||||
import { toursApi } from '../api/client'
|
||||
import { tourGpxApi } from '../api/tourGpx'
|
||||
import { recordedTrackApi } from '../api/recordedTrack'
|
||||
import { tourCopiesApi } from '../api/tourCopies'
|
||||
import { offlineDb, replaceTripTours, type CachedTour } from '../db/offlineDb'
|
||||
@@ -39,12 +40,17 @@ function endpointsOf(routeGeometry: unknown): TourWaypoint[] {
|
||||
*/
|
||||
function routeFields(body: TourCreateRequest) {
|
||||
const metrics = computeTourMetrics(body.route_geometry as GeometryPoint[])
|
||||
// As the server keeps it: a drawn paddle tour's heights stay unknown.
|
||||
const heightsKnown = body.tour_type !== 'kayak'
|
||||
const fields = {
|
||||
name: body.name,
|
||||
tour_type: body.tour_type,
|
||||
distance: metrics.distanceKm,
|
||||
elevation_gain: metrics.elevationGainM,
|
||||
elevation_loss: metrics.elevationLossM,
|
||||
elevation_gain: heightsKnown ? metrics.elevationGainM : null,
|
||||
elevation_loss: heightsKnown ? metrics.elevationLossM : null,
|
||||
boat_type: body.boat_type ?? null,
|
||||
water_context: body.water_context ?? null,
|
||||
paddle_speed_kmh: body.paddle_speed_kmh ?? null,
|
||||
duration: body.duration_seconds == null ? null : Math.round(body.duration_seconds / 60),
|
||||
match_confidence: 1,
|
||||
caution: false,
|
||||
@@ -200,9 +206,14 @@ export const tourRepo = {
|
||||
return result
|
||||
},
|
||||
|
||||
async importGpx(tripId: number | string, file: File): Promise<TourImportGpxResponse> {
|
||||
const result = await toursApi.importGpx(tripId, file)
|
||||
async importGpx(tripId: number | string, file: File, fields?: TourGpxImportFields): Promise<TourImportGpxResponse> {
|
||||
const result = await tourGpxApi.import(tripId, file, fields)
|
||||
await offlineDb.tours.bulkPut(result.tours.map(tour => ({ ...tour, trip_id: Number(tripId) })))
|
||||
return result
|
||||
},
|
||||
|
||||
/** Reads a GPX file for its preview; online only, like the import, and nothing is cached. */
|
||||
async previewGpx(tripId: number | string, file: File): Promise<TourGpxPreviewResponse> {
|
||||
return tourGpxApi.preview(tripId, file)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* What a stubbed tour planner needs beyond the fields a test sets: a hike, no
|
||||
* boat, no loop and the route tools as spies. Spread first, so a test's own
|
||||
* fields win.
|
||||
*/
|
||||
export function hikePlannerDefaults() {
|
||||
return {
|
||||
tourType: 'hike' as const,
|
||||
setTourType: vi.fn(),
|
||||
isPaddle: false,
|
||||
paddle: {
|
||||
boatType: null,
|
||||
waterContext: null,
|
||||
speedKmh: null,
|
||||
recommendedSpeedKmh: null,
|
||||
effectiveSpeedKmh: null,
|
||||
setBoatType: vi.fn(),
|
||||
setWaterContext: vi.fn(),
|
||||
setSpeedKmh: vi.fn(),
|
||||
adoptRecommendedSpeed: vi.fn(),
|
||||
load: vi.fn(),
|
||||
},
|
||||
paddleSpeedInvalid: false,
|
||||
paddleIncomplete: false,
|
||||
isLoop: false,
|
||||
reverse: vi.fn(),
|
||||
returnToStart: vi.fn(),
|
||||
toggleWaypointFreeLeg: vi.fn(),
|
||||
routeParts: null,
|
||||
mapOverlays: new Set<string>(),
|
||||
toggleMapOverlay: vi.fn(),
|
||||
};
|
||||
}
|
||||
@@ -90,7 +90,6 @@
|
||||
"src/nest/storage/storage-admin.controller.ts": 8,
|
||||
"src/nest/system-notices/system-notices.controller.ts": 2,
|
||||
"src/nest/tags/tags.controller.ts": 4,
|
||||
"src/nest/tours/tours-import.controller.ts": 1,
|
||||
"src/nest/tours/tours.controller.ts": 4,
|
||||
"src/nest/transit/transit.controller.ts": 2,
|
||||
"src/nest/trip-invite/trip-invite.controller.ts": 5,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"src/nest/doc-sync/doc-sync.service.ts": 1060,
|
||||
"src/nest/doc-sync/providers/synology-drive.client.ts": 1160,
|
||||
"src/nest/journey/journey-domain.service.ts": 1799,
|
||||
"src/nest/places/places.service.ts": 1598,
|
||||
"src/nest/places/places.service.ts": 1592,
|
||||
"src/nest/plugins/plugin-runtime.service.ts": 1463,
|
||||
"src/nest/plugins/runtime/plugin-sdk.ts": 1093,
|
||||
"src/nest/reservations/reservations.service.ts": 1237,
|
||||
|
||||
@@ -27,6 +27,9 @@ export class Tours {
|
||||
recorded_route_geometry?: string | null;
|
||||
origin: TToursOrigin & Opt = ToursOrigin.TREK;
|
||||
origin_file_name?: string | null;
|
||||
boat_type?: TToursBoatType | null;
|
||||
water_context?: TToursWaterContext | null;
|
||||
paddle_speed_kmh?: number | null;
|
||||
tourTypeRef!: Ref<TourTypes>;
|
||||
tour_sources: Ref<TourSources> | null = null;
|
||||
tour_waypoints_collection = new Collection<TourWaypoints>(this);
|
||||
@@ -40,6 +43,22 @@ export const ToursOrigin = {
|
||||
|
||||
export type TToursOrigin = (typeof ToursOrigin)[keyof typeof ToursOrigin];
|
||||
|
||||
export const ToursBoatType = {
|
||||
KAYAK: 'kayak',
|
||||
CANOE: 'canoe',
|
||||
SUP: 'sup',
|
||||
OTHER: 'other',
|
||||
} as const;
|
||||
|
||||
export type TToursBoatType = (typeof ToursBoatType)[keyof typeof ToursBoatType];
|
||||
|
||||
export const ToursWaterContext = {
|
||||
RIVER: 'river',
|
||||
LAKE: 'lake',
|
||||
} as const;
|
||||
|
||||
export type TToursWaterContext = (typeof ToursWaterContext)[keyof typeof ToursWaterContext];
|
||||
|
||||
export const ToursSchema = defineEntity({
|
||||
class: Tours,
|
||||
repository: () => ToursRepository,
|
||||
@@ -70,6 +89,9 @@ export const ToursSchema = defineEntity({
|
||||
recorded_route_geometry: p.text().nullable(),
|
||||
origin: p.enum(() => ToursOrigin).default('trek'),
|
||||
origin_file_name: p.text().nullable(),
|
||||
boat_type: p.enum(() => ToursBoatType).nullable(),
|
||||
water_context: p.enum(() => ToursWaterContext).nullable(),
|
||||
paddle_speed_kmh: p.double().nullable(),
|
||||
tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'),
|
||||
tour_sources: () => p.oneToOne(TourSources).ref().mappedBy('place').hidden(),
|
||||
tour_waypoints_collection: () => p.oneToMany(TourWaypoints).mappedBy('place').hidden(),
|
||||
|
||||
@@ -20,4 +20,7 @@ export interface ToursTable {
|
||||
recorded_route_geometry: InsertOptional<string | null>;
|
||||
origin: InsertOptional<string>;
|
||||
origin_file_name: InsertOptional<string | null>;
|
||||
boat_type: InsertOptional<string | null>;
|
||||
water_context: InsertOptional<string | null>;
|
||||
paddle_speed_kmh: InsertOptional<number | null>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
/**
|
||||
* PAD-01: a tour can be a paddle tour. The type is the `kayak` key that
|
||||
* `tour_types` has carried, disabled, since every contract type was seeded;
|
||||
* it is switched on here, and the app calls it a paddle tour. Its routing
|
||||
* profile stays NULL: a paddle tour is drawn by hand, leg by leg.
|
||||
*
|
||||
* The boat and the water are columns of the tour, NULL on every other type,
|
||||
* so no existing tour changes its meaning. `paddle_speed_kmh` is the speed the
|
||||
* paddler chose; NULL means the boat's suggested speed applies.
|
||||
*/
|
||||
export class Migration20200101043900_a_tour_can_be_paddled extends Migration {
|
||||
override name = 'Migration20200101043900_a_tour_can_be_paddled';
|
||||
|
||||
override up(): void {
|
||||
this.addSql(
|
||||
"ALTER TABLE tours ADD COLUMN boat_type TEXT CHECK (boat_type IS NULL OR boat_type IN ('kayak', 'canoe', 'sup', 'other'))",
|
||||
);
|
||||
this.addSql(
|
||||
"ALTER TABLE tours ADD COLUMN water_context TEXT CHECK (water_context IS NULL OR water_context IN ('river', 'lake'))",
|
||||
);
|
||||
this.addSql('ALTER TABLE tours ADD COLUMN paddle_speed_kmh REAL');
|
||||
this.addSql("UPDATE tour_types SET enabled = 1, icon = 'Sailboat' WHERE key = 'kayak'");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { type Tours, type TToursOrigin, ToursOrigin } from '../entities/Tours.en
|
||||
import type { DB } from '../kysely/db';
|
||||
import type { AssertRowKeys } from './_shared/rows';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
import type { PaddleBoatType, PaddleWaterContext } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* A tour as the Tours list and detail read it: the `tours` facet joined with
|
||||
@@ -27,6 +28,9 @@ export interface TourListRow {
|
||||
max_hiking_difficulty: number | null;
|
||||
derived_from_place_id: number | null;
|
||||
origin: string;
|
||||
boat_type: string | null;
|
||||
water_context: string | null;
|
||||
paddle_speed_kmh: number | null;
|
||||
has_recorded_track: number;
|
||||
planned: number;
|
||||
has_waypoints: number;
|
||||
@@ -51,6 +55,9 @@ export interface TourRow {
|
||||
recorded_route_geometry: string | null;
|
||||
origin: string;
|
||||
origin_file_name: string | null;
|
||||
boat_type: string | null;
|
||||
water_context: string | null;
|
||||
paddle_speed_kmh: number | null;
|
||||
}
|
||||
const _tourRowKeys: AssertRowKeys<TourRow, Tours> = true;
|
||||
|
||||
@@ -66,12 +73,27 @@ export interface TourInsert {
|
||||
break_additional_minutes?: number | null;
|
||||
match_confidence: number | null;
|
||||
max_hiking_difficulty: number | null;
|
||||
/** A paddle tour's boat, water and chosen speed; left out, a hike's NULLs. */
|
||||
boat_type?: PaddleBoatType | null;
|
||||
water_context?: PaddleWaterContext | null;
|
||||
paddle_speed_kmh?: number | null;
|
||||
}
|
||||
|
||||
/** What a route edit replaces on an existing tour. */
|
||||
export type TourUpdate = Omit<TourInsert, 'place_id' | 'planned_duration_minutes' | 'break_additional_minutes'> & {
|
||||
/** What a route edit replaces on an existing tour: the type and everything that belongs to it. */
|
||||
export type TourUpdate = Omit<
|
||||
TourInsert,
|
||||
| 'place_id'
|
||||
| 'planned_duration_minutes'
|
||||
| 'break_additional_minutes'
|
||||
| 'boat_type'
|
||||
| 'water_context'
|
||||
| 'paddle_speed_kmh'
|
||||
> & {
|
||||
planned_duration_minutes?: number | null;
|
||||
break_additional_minutes?: number | null;
|
||||
boat_type: PaddleBoatType | null;
|
||||
water_context: PaddleWaterContext | null;
|
||||
paddle_speed_kmh: number | null;
|
||||
};
|
||||
|
||||
type ToursKyselyDB = Pick<DB, 'tours' | 'places' | 'day_assignments' | 'tour_waypoints' | 'tour_sources' | 'days'>;
|
||||
@@ -148,6 +170,9 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
't.max_hiking_difficulty as max_hiking_difficulty',
|
||||
't.derived_from_place_id as derived_from_place_id',
|
||||
't.origin as origin',
|
||||
't.boat_type as boat_type',
|
||||
't.water_context as water_context',
|
||||
't.paddle_speed_kmh as paddle_speed_kmh',
|
||||
eb('t.recorded_route_geometry', 'is not', null).as('has_recorded_track'),
|
||||
eb
|
||||
.exists(eb.selectFrom('day_assignments as da').select('da.id').whereRef('da.place_id', '=', 'p.id'))
|
||||
@@ -352,6 +377,9 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
break_additional_minutes: input.break_additional_minutes ?? null,
|
||||
match_confidence: input.match_confidence,
|
||||
max_hiking_difficulty: input.max_hiking_difficulty,
|
||||
boat_type: input.boat_type ?? null,
|
||||
water_context: input.water_context ?? null,
|
||||
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -399,6 +427,9 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
: {}),
|
||||
match_confidence: input.match_confidence,
|
||||
max_hiking_difficulty: input.max_hiking_difficulty,
|
||||
boat_type: input.boat_type,
|
||||
water_context: input.water_context,
|
||||
paddle_speed_kmh: input.paddle_speed_kmh,
|
||||
})
|
||||
.where('place_id', '=', place_id)
|
||||
.where('place_id', 'in', (eb) => eb.selectFrom('places').select('id').where('trip_id', '=', trip_id))
|
||||
|
||||
@@ -321,6 +321,10 @@ export function applyGlobalMiddleware(
|
||||
// refused here while nothing in the app could see it: the switch flipped, the
|
||||
// layer went on, and no tile ever arrived (#2307).
|
||||
'https://server.arcgisonline.com',
|
||||
// OpenSeaMap's transparent seamark layer, offered only to a paddle tour in the
|
||||
// Tours planner (PAD-10). Leaflet loads it as an <img>; the GL map reads it
|
||||
// through fetch and needs it here.
|
||||
'https://tiles.openseamap.org',
|
||||
// Amap's raster tiles, for an install whose users are in China, for the
|
||||
// same reason as the hosts above (#2180). Road (webrd01..04) and
|
||||
// satellite (webst01..04) are numbered shards of one domain, so the
|
||||
|
||||
@@ -122,6 +122,31 @@ export function isPlaceDuplicate(candidate: PlaceMatchCandidate, dedup: DedupSet
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lookup of the places already in a trip: names lowercased and trimmed,
|
||||
* coordinates only for unnamed rows, provider ids for every row. Provider ids
|
||||
* are collected named or not: they are what lets a renamed place still be
|
||||
* recognised on a re-import (#1550).
|
||||
*/
|
||||
export function dedupSetOf(
|
||||
rows: ReadonlyArray<
|
||||
{ name: string | null; lat: number | null; lng: number | null } & Parameters<typeof externalIdsOf>[0]
|
||||
>,
|
||||
): DedupSet {
|
||||
const names = new Set<string>();
|
||||
const coords: Array<{ lat: number; lng: number }> = [];
|
||||
const externalIds = new Set<string>();
|
||||
for (const row of rows) {
|
||||
for (const id of externalIdsOf(row)) externalIds.add(id);
|
||||
if (row.name) {
|
||||
names.add(row.name.trim().toLowerCase());
|
||||
} else if (row.lat != null && row.lng != null) {
|
||||
coords.push({ lat: row.lat, lng: row.lng });
|
||||
}
|
||||
}
|
||||
return { names, coords, externalIds };
|
||||
}
|
||||
|
||||
/** Record a newly inserted place so subsequent candidates in the same batch are checked against it. */
|
||||
export function trackInsertedInDedupSet(place: PlaceMatchCandidate, dedup: DedupSet): void {
|
||||
for (const id of externalIdsOf(place)) dedup.externalIds.add(id);
|
||||
|
||||
@@ -57,7 +57,7 @@ import {
|
||||
ADDRESS_BACKFILL_MAX_PLACES,
|
||||
buildCategoryNameLookup,
|
||||
escapeLikePattern,
|
||||
externalIdsOf,
|
||||
dedupSetOf,
|
||||
isPlaceDuplicate,
|
||||
mapWithConcurrency,
|
||||
pickEnrichmentMatch,
|
||||
@@ -750,25 +750,19 @@ export class PlacesService {
|
||||
|
||||
/** Build a lookup of names/coords for places already in a trip. */
|
||||
private async buildDedupSet(tripId: string): Promise<DedupSet> {
|
||||
// PL24 — `PlacesRepository.listDedupInputs` replaces the raw statement
|
||||
// (Task 5 review L6 ruling). The JS dedup semantics below (name
|
||||
// lowercase+trim, coordinates only for unnamed rows, provider ids for
|
||||
// every row) are unchanged — only the read moved.
|
||||
const rows = await this.placesRepo.listDedupInputs(tripId);
|
||||
const names = new Set<string>();
|
||||
const coords: Array<{ lat: number; lng: number }> = [];
|
||||
// Provider ids are collected for every place, named or not: they are what lets a
|
||||
// renamed place still be recognised on a re-import (#1550).
|
||||
const externalIds = new Set<string>();
|
||||
for (const row of rows) {
|
||||
for (const id of externalIdsOf(row)) externalIds.add(id);
|
||||
if (row.name) {
|
||||
names.add(row.name.trim().toLowerCase());
|
||||
} else if (row.lat != null && row.lng != null) {
|
||||
coords.push({ lat: row.lat, lng: row.lng });
|
||||
}
|
||||
}
|
||||
return { names, coords, externalIds };
|
||||
// PL24 — `PlacesRepository.listDedupInputs` replaces the raw statement (Task 5 review L6 ruling).
|
||||
return dedupSetOf(await this.placesRepo.listDedupInputs(tripId));
|
||||
}
|
||||
|
||||
/** Which prepared GPX rows the import would skip as already in the trip: its own rule, read only. */
|
||||
async gpxDuplicates(tripId: string, rows: PreparedGpxPlace[]): Promise<boolean[]> {
|
||||
const dedup = await this.buildDedupSet(tripId);
|
||||
return rows.map((wp) => {
|
||||
const candidate = { name: wp.name, lat: wp.lat, lng: wp.lng };
|
||||
if (isPlaceDuplicate(candidate, dedup)) return true;
|
||||
trackInsertedInDedupSet(candidate, dedup);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { tourGpxImportFieldsSchema } from '@trek/shared';
|
||||
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
|
||||
/**
|
||||
* The fields beside the file of a Tours GPX import, validated by the global Zod
|
||||
* pipe. A refusal here depends on the fields alone, never on the trip, so it
|
||||
* tells a caller without access nothing the 404 would not.
|
||||
*/
|
||||
export class TourGpxImportFieldsDto extends createZodDto(tourGpxImportFieldsSchema) {}
|
||||
@@ -3,11 +3,15 @@ import type { User } from '../../types';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { CurrentUser } from '../auth-core/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { ResponseContract } from '../common/response-contract';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { TourGpxImportFieldsDto } from './dto/tour-gpx-import.dto';
|
||||
import { ToursService } from './tours.service';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
@@ -16,18 +20,21 @@ import {
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { tourGpxPreviewResponseSchema, tourImportGpxResponseSchema } from '@trek/shared';
|
||||
|
||||
import { memoryStorage } from 'multer';
|
||||
|
||||
const UPLOAD = { storage: memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } };
|
||||
const NO_TRACK = 'No track or route found in GPX file';
|
||||
|
||||
/**
|
||||
* POST /api/trips/:tripId/tours/import/gpx: the tours-mode GPX import.
|
||||
* POST /api/trips/:tripId/tours/import/gpx: the tours-mode GPX import, and
|
||||
* POST .../import/gpx/preview: the same file read and shown, nothing stored.
|
||||
*
|
||||
* A controller of its own because it must not sit behind ToursController's
|
||||
* AddonGuard and TripAccessGuard: a guard answers before multer has read the
|
||||
* multipart body, and the client then sees ECONNRESET instead of the 404 or
|
||||
* 403. The handler runs the same checks in the same order once the upload is
|
||||
* 403. The handlers run the same checks in the same order once the upload is
|
||||
* drained, with the guards' own bodies: addon off 404, no trip access 404,
|
||||
* no 'place_edit' 403. PlacesController.importGpx is the sibling.
|
||||
*/
|
||||
@@ -40,14 +47,8 @@ export class ToursImportController {
|
||||
private readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
@Post('import/gpx')
|
||||
@UseInterceptors(FileInterceptor('file', UPLOAD))
|
||||
async importGpx(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
/** The guards' checks, after the body is in, and the file they all need. */
|
||||
private async checkedFile(user: User, tripId: string, file: Express.Multer.File | undefined): Promise<Buffer> {
|
||||
if (!(await this.addons.isAddonEnabled(ADDON_IDS.TOURS))) {
|
||||
throw new HttpException({ error: 'Tours addon is not enabled' }, 404);
|
||||
}
|
||||
@@ -61,9 +62,42 @@ export class ToursImportController {
|
||||
if (!file) {
|
||||
throw new HttpException({ error: 'No file uploaded' }, 400);
|
||||
}
|
||||
const result = await this.tours.importGpxAsTour(tripId, file.buffer, file.originalname, socketId);
|
||||
return file.buffer;
|
||||
}
|
||||
|
||||
// Declared before `import/gpx` reads clearer; the paths do not overlap either way.
|
||||
@Post('import/gpx/preview')
|
||||
@HttpCode(200)
|
||||
@ResponseContract(tourGpxPreviewResponseSchema)
|
||||
@UseInterceptors(FileInterceptor('file', UPLOAD))
|
||||
async previewGpx(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
) {
|
||||
const buffer = await this.checkedFile(user, tripId, file);
|
||||
const preview = await this.tours.previewGpx(tripId, buffer, file?.originalname);
|
||||
if (!preview) {
|
||||
throw new HttpException({ error: NO_TRACK }, 400);
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
|
||||
@Post('import/gpx')
|
||||
@ResponseContract(tourImportGpxResponseSchema)
|
||||
@UseInterceptors(FileInterceptor('file', UPLOAD))
|
||||
async importGpx(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
// None is a hike, as every import was before the type could be chosen.
|
||||
@Body() fields: TourGpxImportFieldsDto = { tour_type: 'hike' },
|
||||
) {
|
||||
const buffer = await this.checkedFile(user, tripId, file);
|
||||
const result = await this.tours.importGpxAsTour(tripId, buffer, file?.originalname, socketId, fields);
|
||||
if (!result) {
|
||||
throw new HttpException({ error: 'No track or route found in GPX file' }, 400);
|
||||
throw new HttpException({ error: NO_TRACK }, 400);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export class ToursMcp {
|
||||
@Tool({
|
||||
name: 'list_tours',
|
||||
description:
|
||||
'List the saved tours of a trip (hikes and other tours planned or imported in the Tours addon): name, type, where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit (max_hiking_difficulty, SAC T1 to T6), and the days of the trip each tour is on.',
|
||||
'List the saved tours of a trip (hikes and paddle tours planned or imported in the Tours addon): name, type (hike, or kayak for a paddle tour), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), and the days of the trip each tour is on.',
|
||||
inputSchema: { tripId: idSchema },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
@@ -61,7 +61,7 @@ export class ToursMcp {
|
||||
@Tool({
|
||||
name: 'get_tour_plan',
|
||||
description:
|
||||
'Read one saved tour of a trip: its facts, its ordered control points (start, via and end; an import without its own shows the two ends of its track), where it came from (provenance) and the days it is on. The full track geometry is not included.',
|
||||
'Read one saved tour of a trip: its facts, its ordered control points (start, via and end; an import without its own shows the two ends of its track), where it came from (provenance) and the days it is on. A control point whose fixed_leg is set leads to the next one along a line the router did not plan: kind ferrata follows a via ferrata, kind free is drawn by hand and was checked by nothing. The full track geometry is not included.',
|
||||
inputSchema: { tripId: idSchema, placeId: idSchema.describe('The place id of the tour, as list_tours gives it') },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
|
||||
@@ -30,9 +30,13 @@ import {
|
||||
MAX_PLANNED_TOUR_DURATION_MINUTES,
|
||||
plannedTourDurationMinutesSchema,
|
||||
tourWebsiteSchema,
|
||||
type PaddleBoatType,
|
||||
type PaddleWaterContext,
|
||||
type TourCreateRequest,
|
||||
type TourCreateResponse,
|
||||
type TourDetailResponse,
|
||||
type TourGpxImportFields,
|
||||
type TourGpxPreviewResponse,
|
||||
type TourListItem,
|
||||
type TourOrigin,
|
||||
type TourProvenance,
|
||||
@@ -88,6 +92,9 @@ export class ToursService {
|
||||
max_hiking_difficulty: r.max_hiking_difficulty,
|
||||
derived_from_place_id: r.derived_from_place_id,
|
||||
origin: r.origin as TourOrigin,
|
||||
boat_type: r.boat_type as PaddleBoatType | null,
|
||||
water_context: r.water_context as PaddleWaterContext | null,
|
||||
paddle_speed_kmh: r.paddle_speed_kmh,
|
||||
has_recorded_track: Boolean(r.has_recorded_track),
|
||||
planned: Boolean(r.planned),
|
||||
caution: r.match_confidence !== null && r.match_confidence < LOW_CONFIDENCE_THRESHOLD,
|
||||
@@ -98,11 +105,14 @@ export class ToursService {
|
||||
/** The route a create or an update writes: metrics derived from the geometry, duration stored in minutes. */
|
||||
private routeFields(input: TourCreateRequest): TourUpdate {
|
||||
const metrics = computeTourMetrics(input.route_geometry as GeometryPoint[]);
|
||||
// A paddle tour drawn here has its heights read off the banks at the points set, not
|
||||
// off the water: its ascent and descent stay unknown rather than wrong.
|
||||
const heightsKnown = input.tour_type !== 'kayak';
|
||||
return {
|
||||
tour_type: input.tour_type,
|
||||
distance: metrics.distanceKm,
|
||||
elevation_gain: metrics.elevationGainM,
|
||||
elevation_loss: metrics.elevationLossM,
|
||||
elevation_gain: heightsKnown ? metrics.elevationGainM : null,
|
||||
elevation_loss: heightsKnown ? metrics.elevationLossM : null,
|
||||
duration: input.duration_seconds == null ? null : Math.round(input.duration_seconds / 60),
|
||||
...(input.planned_duration_minutes !== undefined
|
||||
? { planned_duration_minutes: input.planned_duration_minutes }
|
||||
@@ -112,6 +122,9 @@ export class ToursService {
|
||||
: {}),
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: input.max_hiking_difficulty,
|
||||
boat_type: input.boat_type ?? null,
|
||||
water_context: input.water_context ?? null,
|
||||
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -316,9 +329,13 @@ export class ToursService {
|
||||
duration: null,
|
||||
match_confidence: metrics.hasElevation ? 1 : 0.3,
|
||||
// The grade its source gave, on the one scale tours use; a GPX file gives none.
|
||||
max_hiking_difficulty: row.wanderer_ref
|
||||
? wandererDifficultyToSac(row.difficulty as WandererDifficulty | null)
|
||||
: null,
|
||||
max_hiking_difficulty:
|
||||
row.wanderer_ref && row.tour_type === 'hike'
|
||||
? wandererDifficultyToSac(row.difficulty as WandererDifficulty | null)
|
||||
: null,
|
||||
boat_type: row.boat_type as PaddleBoatType | null,
|
||||
water_context: row.water_context as PaddleWaterContext | null,
|
||||
paddle_speed_kmh: row.paddle_speed_kmh,
|
||||
});
|
||||
await this.waypointsRepo.deleteForPlace(pid);
|
||||
await this.toursRepo.clearRecordedTrack(pid);
|
||||
@@ -377,6 +394,9 @@ export class ToursService {
|
||||
break_additional_minutes: source.break_additional_minutes,
|
||||
match_confidence: source.match_confidence,
|
||||
max_hiking_difficulty: source.max_hiking_difficulty,
|
||||
boat_type: source.boat_type as PaddleBoatType | null,
|
||||
water_context: source.water_context as PaddleWaterContext | null,
|
||||
paddle_speed_kmh: source.paddle_speed_kmh,
|
||||
wanderer_ref: source.wanderer_ref,
|
||||
difficulty: source.difficulty,
|
||||
derived_from_place_id: pid,
|
||||
@@ -471,14 +491,16 @@ export class ToursService {
|
||||
fileBuffer: Buffer,
|
||||
fileName?: string,
|
||||
socketId?: string,
|
||||
fields: TourGpxImportFields = { tour_type: 'hike' },
|
||||
): Promise<ImportGpxAsTourResult | null> {
|
||||
const rows = this.placeImport.readGpx(fileBuffer, {
|
||||
importWaypoints: false,
|
||||
importRoutes: true,
|
||||
importTracks: true,
|
||||
defaultName: fileName,
|
||||
});
|
||||
const rows = this.readGpxTracks(fileBuffer, fileName);
|
||||
if (rows.length === 0) return null;
|
||||
await this.assertTourTypeEnabled(fields.tour_type);
|
||||
const paddle = {
|
||||
boat_type: fields.boat_type ?? null,
|
||||
water_context: fields.water_context ?? null,
|
||||
paddle_speed_kmh: null,
|
||||
};
|
||||
|
||||
const tours: TourListItem[] = [];
|
||||
const result = await this.uow.transactional(async () => {
|
||||
@@ -489,7 +511,8 @@ export class ToursService {
|
||||
const matchConfidence = metrics.hasElevation ? 1 : 0.3;
|
||||
await this.toursRepo.insertTour({
|
||||
place_id: place.id,
|
||||
tour_type: 'hike',
|
||||
tour_type: fields.tour_type,
|
||||
...paddle,
|
||||
distance: metrics.distanceKm,
|
||||
elevation_gain: metrics.elevationGainM,
|
||||
elevation_loss: metrics.elevationLossM,
|
||||
@@ -507,7 +530,8 @@ export class ToursService {
|
||||
name: place.name,
|
||||
description: place.description ?? null,
|
||||
website: place.website ?? null,
|
||||
tour_type: 'hike',
|
||||
tour_type: fields.tour_type,
|
||||
...paddle,
|
||||
distance: metrics.distanceKm,
|
||||
elevation_gain: metrics.elevationGainM,
|
||||
elevation_loss: metrics.elevationLossM,
|
||||
@@ -544,6 +568,42 @@ export class ToursService {
|
||||
}
|
||||
return { tours, caution: tours.some((t) => t.caution), skipped: result.skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-02: what a GPX file would import, read with the import's own parser
|
||||
* and duplicate rule and written nowhere. Null when the file holds no track
|
||||
* or route, the import's own refusal.
|
||||
*/
|
||||
async previewGpx(tripId: string, fileBuffer: Buffer, fileName?: string): Promise<TourGpxPreviewResponse | null> {
|
||||
const rows = this.readGpxTracks(fileBuffer, fileName);
|
||||
if (rows.length === 0) return null;
|
||||
const duplicates = await this.places.gpxDuplicates(tripId, rows);
|
||||
return {
|
||||
tracks: rows.map((row, index) => {
|
||||
const geometry = parseRouteGeometry(row.routeGeometry);
|
||||
const metrics = computeTourMetrics(geometry);
|
||||
return {
|
||||
name: row.name,
|
||||
geometry,
|
||||
point_count: geometry.length,
|
||||
distance_km: metrics.distanceKm,
|
||||
elevation_gain_m: metrics.elevationGainM,
|
||||
elevation_loss_m: metrics.elevationLossM,
|
||||
duplicate: duplicates[index] ?? false,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The tracks and routes of a GPX file, never its loose waypoints: the reading every Tours import shares. */
|
||||
private readGpxTracks(fileBuffer: Buffer, fileName?: string) {
|
||||
return this.placeImport.readGpx(fileBuffer, {
|
||||
importWaypoints: false,
|
||||
importRoutes: true,
|
||||
importTracks: true,
|
||||
defaultName: fileName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** The file name a GPX import keeps: the name as uploaded, without a path, cut to what the column shows. */
|
||||
|
||||
@@ -16,6 +16,7 @@ import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
|
||||
import { Days } from '../../src/db/entities/Days.entity';
|
||||
import { Places } from '../../src/db/entities/Places.entity';
|
||||
import { TourSources } from '../../src/db/entities/TourSources.entity';
|
||||
import { TourTypes } from '../../src/db/entities/TourTypes.entity';
|
||||
import { TourWaypoints } from '../../src/db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../src/db/entities/Tours.entity';
|
||||
import { Trips } from '../../src/db/entities/Trips.entity';
|
||||
@@ -155,12 +156,21 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
|
||||
return res.body.tour.place_id as number;
|
||||
};
|
||||
|
||||
const importGpx = (userId: number, xml: string | null, tripId = 5) => {
|
||||
const req = request(server).post(`/api/trips/${tripId}/tours/import/gpx`).set('Cookie', sessionCookie(userId));
|
||||
const importGpx = (
|
||||
userId: number,
|
||||
xml: string | null,
|
||||
tripId = 5,
|
||||
fields: Record<string, string> = {},
|
||||
path = 'import/gpx',
|
||||
) => {
|
||||
let req = request(server).post(`/api/trips/${tripId}/tours/${path}`).set('Cookie', sessionCookie(userId));
|
||||
for (const [key, value] of Object.entries(fields)) req = req.field(key, value);
|
||||
return xml === null
|
||||
? req.field('note', 'none')
|
||||
: req.attach('file', Buffer.from(xml), { filename: 'track.gpx', contentType: 'application/gpx+xml' });
|
||||
};
|
||||
const previewGpx = (userId: number, xml: string | null, tripId = 5) =>
|
||||
importGpx(userId, xml, tripId, {}, 'import/gpx/preview');
|
||||
|
||||
beforeAll(async () => {
|
||||
orm = await createTestOrm(db);
|
||||
@@ -227,6 +237,7 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
|
||||
.set('Cookie', cookie)
|
||||
.send(tourBody({ name: 'Renamed' })),
|
||||
await importGpx(OWNER, BIG_GPX),
|
||||
await previewGpx(OWNER, BIG_GPX),
|
||||
];
|
||||
for (const res of responses) {
|
||||
expect(res.status).toBe(404);
|
||||
@@ -245,6 +256,7 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
|
||||
await request(server).post('/api/trips/5/tours').set('Cookie', cookie).send(tourBody()),
|
||||
await request(server).put(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie).send(tourBody()),
|
||||
await importGpx(OUTSIDER, BIG_GPX),
|
||||
await previewGpx(OUTSIDER, BIG_GPX),
|
||||
await request(server).get('/api/trips/999/tours').set('Cookie', sessionCookie(OWNER)),
|
||||
];
|
||||
for (const res of responses) {
|
||||
@@ -270,6 +282,7 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
|
||||
.set('Cookie', cookie)
|
||||
.send(tourBody({ name: 'Hijacked' })),
|
||||
await importGpx(VIEWER, BIG_GPX),
|
||||
await previewGpx(VIEWER, BIG_GPX),
|
||||
];
|
||||
for (const res of writes) {
|
||||
expect(res.status).toBe(403);
|
||||
@@ -503,6 +516,204 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('paddle tours (PAD-01) and free legs (P2A-04)', () => {
|
||||
const paddleBody = (overrides: Record<string, unknown> = {}) =>
|
||||
tourBody({
|
||||
tour_type: 'kayak',
|
||||
max_hiking_difficulty: null,
|
||||
boat_type: 'canoe',
|
||||
water_context: 'lake',
|
||||
waypoints: [
|
||||
{
|
||||
lat: 47,
|
||||
lng: 11,
|
||||
role: 'start',
|
||||
sequence: 0,
|
||||
fixed_leg: {
|
||||
kind: 'free',
|
||||
geometry: [
|
||||
[47, 11],
|
||||
[47.02, 11.02],
|
||||
],
|
||||
},
|
||||
},
|
||||
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 1 },
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('saves a paddle tour with its boat, water, own speed and hand-drawn leg, and reads them back', async () => {
|
||||
const res = await request(server)
|
||||
.post('/api/trips/5/tours')
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send(paddleBody({ paddle_speed_kmh: 5 }));
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.tour).toMatchObject({
|
||||
tour_type: 'kayak',
|
||||
boat_type: 'canoe',
|
||||
water_context: 'lake',
|
||||
paddle_speed_kmh: 5,
|
||||
max_hiking_difficulty: null,
|
||||
// Read off the banks at the points set, not off the water: unknown, never a made-up climb.
|
||||
elevation_gain: null,
|
||||
elevation_loss: null,
|
||||
});
|
||||
expect(res.body.waypoints[0].fixed_leg).toEqual({
|
||||
kind: 'free',
|
||||
geometry: [
|
||||
[47, 11],
|
||||
[47.02, 11.02],
|
||||
],
|
||||
});
|
||||
const detail = await request(server)
|
||||
.get(`/api/trips/5/tours/${res.body.tour.place_id}`)
|
||||
.set('Cookie', sessionCookie(OWNER));
|
||||
expect(detail.body).toEqual(res.body);
|
||||
});
|
||||
|
||||
it('a change of boat keeps the chosen speed, and a paddle tour can become a hike again', async () => {
|
||||
const placeId = await createViaApi(5, paddleBody({ paddle_speed_kmh: 3.5 }));
|
||||
const reboated = await request(server)
|
||||
.put(`/api/trips/5/tours/${placeId}`)
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send(paddleBody({ boat_type: 'sup', paddle_speed_kmh: 3.5 }));
|
||||
expect(reboated.status).toBe(200);
|
||||
expect(reboated.body.tour).toMatchObject({ boat_type: 'sup', paddle_speed_kmh: 3.5 });
|
||||
|
||||
const hike = await request(server)
|
||||
.put(`/api/trips/5/tours/${placeId}`)
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send(tourBody());
|
||||
expect(hike.status).toBe(200);
|
||||
expect(hike.body.tour).toMatchObject({
|
||||
tour_type: 'hike',
|
||||
boat_type: null,
|
||||
water_context: null,
|
||||
paddle_speed_kmh: null,
|
||||
max_hiking_difficulty: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('400 for a paddle tour without a boat, a hike with one, and a free leg off its waypoints', async () => {
|
||||
const offTrack = paddleBody({
|
||||
waypoints: [
|
||||
{
|
||||
lat: 47,
|
||||
lng: 11,
|
||||
role: 'start',
|
||||
sequence: 0,
|
||||
fixed_leg: {
|
||||
kind: 'free',
|
||||
geometry: [
|
||||
[46, 10],
|
||||
[47.02, 11.02],
|
||||
],
|
||||
},
|
||||
},
|
||||
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 1 },
|
||||
],
|
||||
});
|
||||
const refused = await Promise.all(
|
||||
[paddleBody({ boat_type: null }), tourBody({ boat_type: 'kayak' }), offTrack].map((body) =>
|
||||
request(server).post('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER)).send(body),
|
||||
),
|
||||
);
|
||||
expect(refused.map((res) => res.status)).toEqual([400, 400, 400]);
|
||||
expect(await countRows(orm, Tours)).toBe(0);
|
||||
});
|
||||
|
||||
it('400 for a paddle tour while its type is switched off', async () => {
|
||||
await updateRows(orm, TourTypes, { key: 'kayak' }, { enabled: 0 });
|
||||
try {
|
||||
const res = await request(server)
|
||||
.post('/api/trips/5/tours')
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send(paddleBody());
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Tour type is not available' });
|
||||
expect(await countRows(orm, Tours)).toBe(0);
|
||||
} finally {
|
||||
await updateRows(orm, TourTypes, { key: 'kayak' }, { enabled: 1 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GPX preview and typed import (PAD-02)', () => {
|
||||
it('shows every track with its whole line and measures, and stores nothing', async () => {
|
||||
const res = await previewGpx(OWNER, TRACK_GPX);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tracks).toHaveLength(1);
|
||||
expect(res.body.tracks[0]).toMatchObject({
|
||||
name: 'Alpine loop',
|
||||
point_count: 3,
|
||||
elevation_gain_m: 50,
|
||||
elevation_loss_m: 30,
|
||||
duplicate: false,
|
||||
});
|
||||
expect(res.body.tracks[0].geometry).toEqual([
|
||||
[49, 12, 100],
|
||||
[49.01, 12.01, 150],
|
||||
[49.02, 12.02, 120],
|
||||
]);
|
||||
expect(res.body.tracks[0].distance_km).toBeGreaterThan(0);
|
||||
expect(await countRows(orm, Places)).toBe(0);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves heights it does not have unknown, and names a track the trip already holds', async () => {
|
||||
const flat = await previewGpx(OWNER, FLAT_GPX);
|
||||
expect(flat.body.tracks[0]).toMatchObject({ elevation_gain_m: null, elevation_loss_m: null });
|
||||
expect((await importGpx(OWNER, TRACK_GPX)).status).toBe(201);
|
||||
const again = await previewGpx(OWNER, TRACK_GPX);
|
||||
expect(again.body.tracks[0].duplicate).toBe(true);
|
||||
});
|
||||
|
||||
it('400 without a file, and for a file with no track, as the import says', async () => {
|
||||
const missing = await previewGpx(OWNER, null);
|
||||
expect(missing.status).toBe(400);
|
||||
expect(missing.body).toEqual({ error: 'No file uploaded' });
|
||||
const empty = await previewGpx(OWNER, '<gpx/>');
|
||||
expect(empty.status).toBe(400);
|
||||
expect(empty.body).toEqual({ error: 'No track or route found in GPX file' });
|
||||
});
|
||||
|
||||
it('imports as a paddle tour with the chosen boat and water', async () => {
|
||||
const res = await importGpx(OWNER, TRACK_GPX, 5, {
|
||||
tour_type: 'kayak',
|
||||
boat_type: 'sup',
|
||||
water_context: 'river',
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.tours[0]).toMatchObject({
|
||||
tour_type: 'kayak',
|
||||
boat_type: 'sup',
|
||||
water_context: 'river',
|
||||
paddle_speed_kmh: null,
|
||||
max_hiking_difficulty: null,
|
||||
origin: 'gpx',
|
||||
});
|
||||
expect(await findRow(orm, Tours, { place: res.body.tours[0].place_id })).toMatchObject({
|
||||
tour_type: 'kayak',
|
||||
boat_type: 'sup',
|
||||
});
|
||||
});
|
||||
|
||||
it('400 for a paddle import without its boat, or a hike import that names one; nothing written', async () => {
|
||||
const refused = await Promise.all(
|
||||
[
|
||||
{ tour_type: 'kayak', water_context: 'lake' },
|
||||
{ tour_type: 'hike', boat_type: 'sup' },
|
||||
{ tour_type: 'ski' },
|
||||
].map((fields) => importGpx(OWNER, TRACK_GPX, 5, fields)),
|
||||
);
|
||||
for (const res of refused) {
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toEqual(expect.any(String));
|
||||
}
|
||||
expect(await countRows(orm, Places)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('provenance (P2C-02)', () => {
|
||||
const detail = async (placeId: number) =>
|
||||
(await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER))).body;
|
||||
|
||||
@@ -21,6 +21,7 @@ describe('known migrations', () => {
|
||||
expect(known.has('Migration20200101043600_a_tour_remembers_the_import_it_was_derived_from')).toBe(true);
|
||||
expect(known.has('Migration20200101043700_an_import_planned_over_keeps_its_recorded_track')).toBe(true);
|
||||
expect(known.has('Migration20200101043800_a_tour_records_where_it_came_from')).toBe(true);
|
||||
expect(known.has('Migration20200101043900_a_tour_can_be_paddled')).toBe(true);
|
||||
expect(known.size).toBe(fs.readdirSync(path.join(__dirname, '../../../src/db/migrations')).length);
|
||||
});
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ const UNNUMBERED = [
|
||||
'Migration20200101043600_a_tour_remembers_the_import_it_was_derived_from',
|
||||
'Migration20200101043700_an_import_planned_over_keeps_its_recorded_track',
|
||||
'Migration20200101043800_a_tour_records_where_it_came_from',
|
||||
'Migration20200101043900_a_tour_can_be_paddled',
|
||||
];
|
||||
// The last step the positional runner ever had; planLegacyBaseline refuses a
|
||||
// schema_version past it.
|
||||
|
||||
@@ -92,6 +92,9 @@ describe('ToursRepository reads', () => {
|
||||
has_waypoints: 1,
|
||||
derived_from_place_id: null,
|
||||
origin: 'trek',
|
||||
boat_type: null,
|
||||
water_context: null,
|
||||
paddle_speed_kmh: null,
|
||||
has_recorded_track: 0,
|
||||
},
|
||||
{
|
||||
@@ -114,6 +117,9 @@ describe('ToursRepository reads', () => {
|
||||
has_waypoints: 0,
|
||||
derived_from_place_id: null,
|
||||
origin: 'trek',
|
||||
boat_type: null,
|
||||
water_context: null,
|
||||
paddle_speed_kmh: null,
|
||||
has_recorded_track: 0,
|
||||
},
|
||||
]);
|
||||
@@ -192,25 +198,31 @@ describe('ToursRepository writes', () => {
|
||||
has_waypoints: 0,
|
||||
derived_from_place_id: null,
|
||||
origin: 'trek',
|
||||
boat_type: null,
|
||||
water_context: null,
|
||||
paddle_speed_kmh: null,
|
||||
has_recorded_track: 0,
|
||||
});
|
||||
});
|
||||
|
||||
// A route edit always writes the type's own fields; a hike's are empty.
|
||||
const HIKE_UPDATE = { ...METRICS, boat_type: null, water_context: null, paddle_speed_kmh: null };
|
||||
|
||||
it('TOURREPO-007: updateInTrip replaces the metrics of a tour of that trip and refuses one of another trip', async () => {
|
||||
const { trip, other } = fixture();
|
||||
const place = createPlace(testDb, trip.id);
|
||||
createTour(testDb, place.id);
|
||||
|
||||
expect(await repo.updateInTrip(other.id, place.id, METRICS)).toBe(false);
|
||||
expect(await repo.updateInTrip(other.id, place.id, HIKE_UPDATE)).toBe(false);
|
||||
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ distance: null, max_hiking_difficulty: 2 });
|
||||
|
||||
expect(await repo.updateInTrip(trip.id, place.id, METRICS)).toBe(true);
|
||||
expect(await repo.updateInTrip(trip.id, place.id, HIKE_UPDATE)).toBe(true);
|
||||
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject(METRICS);
|
||||
const {
|
||||
planned_duration_minutes: _omitted,
|
||||
break_additional_minutes: _omittedBreaks,
|
||||
...routeOnlyUpdate
|
||||
} = METRICS;
|
||||
} = HIKE_UPDATE;
|
||||
expect(await repo.updateInTrip(trip.id, place.id, routeOnlyUpdate)).toBe(true);
|
||||
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({
|
||||
duration: 150,
|
||||
@@ -255,6 +267,9 @@ describe('ToursRepository writes', () => {
|
||||
recorded_route_geometry: null,
|
||||
origin: 'trek',
|
||||
origin_file_name: null,
|
||||
boat_type: null,
|
||||
water_context: null,
|
||||
paddle_speed_kmh: null,
|
||||
},
|
||||
]);
|
||||
expect(await repo.listRowsForTrip(other.id)).toEqual([]);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ToursBoatType, ToursWaterContext } from '../../../src/db/entities/Tours.entity';
|
||||
import { paddleBoatTypeSchema, paddleWaterContextSchema } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* The boat and water columns hold a CHECK list (migration 043900) that the
|
||||
* entity carries as its enum, and the contract holds its own list. A value
|
||||
* one side knows and the other refuses would be a tour the planner can save
|
||||
* and the database cannot store, or the other way round.
|
||||
*/
|
||||
describe('paddle columns match the contract', () => {
|
||||
it('knows the same boats as the paddle contract', () => {
|
||||
expect(Object.values(ToursBoatType).sort()).toEqual([...paddleBoatTypeSchema.options].sort());
|
||||
});
|
||||
|
||||
it('knows the same waters as the paddle contract', () => {
|
||||
expect(Object.values(ToursWaterContext).sort()).toEqual([...paddleWaterContextSchema.options].sort());
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,10 @@ describe('global CSP: the other shipped raster presets (#2180)', () => {
|
||||
expect(sources).toContain('https://*.tile.opentopomap.org');
|
||||
});
|
||||
|
||||
it('allows the OpenSeaMap seamark host a paddle tour can lay over its map', async () => {
|
||||
expect(await connectSrcSources()).toContain('https://tiles.openseamap.org');
|
||||
});
|
||||
|
||||
it('keeps the routing host, which is a different host and covers nothing here', async () => {
|
||||
// routing.openstreetmap.de was on the list all along and looks close enough
|
||||
// to hide the gap: a CSP source matches a host, not a suffix of one.
|
||||
|
||||
@@ -172,7 +172,12 @@ describe('ToursImportController (POST /api/trips/:tripId/tours/import/gpx)', ()
|
||||
expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe('import/gpx');
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, handler)).toBeUndefined();
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handler)).toBeUndefined();
|
||||
expect(Reflect.getMetadata(INTERCEPTORS_METADATA, handler)).toHaveLength(1);
|
||||
// The multipart reader, and the response contract check the test environment adds.
|
||||
expect(Reflect.getMetadata(INTERCEPTORS_METADATA, handler)).toHaveLength(2);
|
||||
const preview = ToursImportController.prototype.previewGpx;
|
||||
expect(Reflect.getMetadata(METHOD_METADATA, preview)).toBe(RequestMethod.POST);
|
||||
expect(Reflect.getMetadata(PATH_METADATA, preview)).toBe('import/gpx/preview');
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, preview)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('TOURS-IMP-002: delegates the route trip, file and socket id and returns the service result', async () => {
|
||||
@@ -181,7 +186,7 @@ describe('ToursImportController (POST /api/trips/:tripId/tours/import/gpx)', ()
|
||||
expect(await controller.importGpx(user, '7', file, 'socket')).toBe(result);
|
||||
expect(places.verifyTripAccess).toHaveBeenCalledWith('7', user.id);
|
||||
expect(places.canEdit).toHaveBeenCalledWith(trip, user);
|
||||
expect(tours.importGpxAsTour).toHaveBeenCalledWith('7', file.buffer, 'ridge.gpx', 'socket');
|
||||
expect(tours.importGpxAsTour).toHaveBeenCalledWith('7', file.buffer, 'ridge.gpx', 'socket', { tour_type: 'hike' });
|
||||
});
|
||||
|
||||
it('TOURS-IMP-003: a duplicate-only import is a successful skipped result', async () => {
|
||||
|
||||
@@ -147,7 +147,7 @@ describe('Tours permission contract (mock-only, no DB)', () => {
|
||||
const test = importFixture(canEdit, canAssign);
|
||||
if (canEdit) {
|
||||
expect(await test.controller.importGpx(user, '7', file, 'socket')).toMatchObject({ tours: [{ place_id: 42 }] });
|
||||
expect(test.importGpxAsTour).toHaveBeenCalledWith('7', file.buffer, 'ridge.gpx', 'socket');
|
||||
expect(test.importGpxAsTour).toHaveBeenCalledWith('7', file.buffer, 'ridge.gpx', 'socket', { tour_type: 'hike' });
|
||||
} else {
|
||||
await expectStatus(() => test.controller.importGpx(user, '7', file), 403);
|
||||
expect(test.importGpxAsTour).not.toHaveBeenCalled();
|
||||
|
||||
@@ -177,6 +177,9 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
|
||||
break_additional_minutes: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 2,
|
||||
boat_type: null,
|
||||
water_context: null,
|
||||
paddle_speed_kmh: null,
|
||||
});
|
||||
expect((setup.toursRepo.insertTour.mock.calls[0][0] as { distance: number }).distance).toBeGreaterThan(0);
|
||||
expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, request.waypoints);
|
||||
@@ -287,6 +290,9 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
|
||||
duration: 45,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 2,
|
||||
boat_type: null,
|
||||
water_context: null,
|
||||
paddle_speed_kmh: null,
|
||||
});
|
||||
expect(setup.waypointsRepo.deleteForPlace).toHaveBeenCalledExactlyOnceWith(42);
|
||||
expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, update.waypoints);
|
||||
|
||||
@@ -205,7 +205,7 @@ const tours: TranslationStrings = {
|
||||
'tourTypes.bike': 'جولة بالدراجة',
|
||||
'tourTypes.run': 'جري',
|
||||
'tourTypes.ski': 'جولة تزلج',
|
||||
'tourTypes.kayak': 'تجديف بالكاياك',
|
||||
'tourTypes.kayak': 'جولة تجديف',
|
||||
'tourTypes.walk': 'مشي في المدينة / مشاهدة المعالم',
|
||||
'tourTypes.food': 'جولة طعام / نبيذ',
|
||||
'tours.terrain.play': 'تشغيل التحليق',
|
||||
@@ -282,5 +282,73 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (نسخة)',
|
||||
'tours.copy.saved': 'تم الحفظ كجولة جديدة',
|
||||
'tours.copy.failed': 'تعذّر نسخ الجولة.',
|
||||
'tours.planner.tourType': 'نوع الجولة',
|
||||
'tours.paddle.boat': 'القارب',
|
||||
'tours.paddle.boat.kayak': 'كاياك',
|
||||
'tours.paddle.boat.canoe': 'كانو',
|
||||
'tours.paddle.boat.sup': 'SUP',
|
||||
'tours.paddle.boat.other': 'آخر',
|
||||
'tours.paddle.water': 'المسطح المائي',
|
||||
'tours.paddle.water.river': 'نهر',
|
||||
'tours.paddle.water.lake': 'بحيرة',
|
||||
'tours.paddle.speed': 'سرعة التجديف',
|
||||
'tours.paddle.speedOwn': 'سرعتك',
|
||||
'tours.paddle.speedUseSuggestion': 'استخدام السرعة المقترحة {speed} كم/س',
|
||||
'tours.paddle.speedNeeded': 'أدخل سرعة لتقدير الوقت على الماء.',
|
||||
'tours.paddle.speedInvalid': 'أدخل سرعة بين 0.5 و20 كم/س.',
|
||||
'tours.paddle.chooseBoat': 'اختر القارب والمسطح المائي لحفظ جولة التجديف هذه.',
|
||||
'tours.paddle.chooseBoatImport': 'اختر القارب والمسطح المائي للاستيراد كجولة تجديف.',
|
||||
'tours.paddle.noWaterData':
|
||||
'لا يعرض TREK بعد السدود الصغيرة والأهوسة ونقاط حمل القارب. تحقق بنفسك من الماء وقواعده قبل الانطلاق.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'تُرسم جولة التجديف يدويًا: يصل TREK نقاطك بخطوط مستقيمة. لا يُحسب أي مسار، ولا شيء يقول إن الماء صالح للتجديف.',
|
||||
'tours.paddle.timeEstimated': 'وقت التجديف (تقديري)',
|
||||
'tours.paddle.timeEstimatedShort': 'تجديف',
|
||||
'tours.paddle.timeEstimatedInfoLabel': 'كيف يُقدَّر وقت التجديف',
|
||||
'tours.paddle.timeEstimatedInfo':
|
||||
'المسافة مقسومة على السرعة المخططة، على ماء هادئ. لا يشمل التيار والرياح وحمل القارب والأهوسة.',
|
||||
'tours.planner.reverse': 'عكس الاتجاه',
|
||||
'tours.planner.returnToStart': 'العودة إلى البداية',
|
||||
'tours.planner.alreadyLoop': 'الجولة تعود إلى البداية بالفعل',
|
||||
'tours.planner.loopEnd': 'العودة إلى البداية',
|
||||
'tours.planner.freeLeg': 'رسم المقطع التالي يدويًا',
|
||||
'tours.planner.routeLeg': 'حساب مسار المقطع التالي مجددًا',
|
||||
'tours.planner.freeLegBadge': 'مرسوم يدويًا، بلا حساب مسار',
|
||||
'tours.planner.freeLegInfo': 'يصل TREK هاتين النقطتين بخط مستقيم. لم يخطط أي محرك مسارات لهذا المقطع ولم يتحقق منه.',
|
||||
'tours.map.seamarks': 'العلامات البحرية',
|
||||
'tours.map.seamarksDown': 'لا يتم تحميل العلامات البحرية. الخريطة ومسارك يعملان كالمعتاد.',
|
||||
'tours.import.previewTitle': 'معاينة GPX',
|
||||
'tours.import.previewHint': 'لم يُحفظ شيء بعد. تحقق من المسار ثم استورد.',
|
||||
'tours.import.previewFiles': '{count} ملف',
|
||||
'tours.import.previewFiles.zero': '{count} ملفات',
|
||||
'tours.import.previewFiles.one': 'ملف واحد',
|
||||
'tours.import.previewFiles.two': 'ملفان',
|
||||
'tours.import.previewFiles.few': '{count} ملفات',
|
||||
'tours.import.previewFiles.many': '{count} ملفًا',
|
||||
'tours.import.previewTracks': '{count} مسار',
|
||||
'tours.import.previewTracks.zero': '{count} مسارات',
|
||||
'tours.import.previewTracks.one': 'مسار واحد',
|
||||
'tours.import.previewTracks.two': 'مساران',
|
||||
'tours.import.previewTracks.few': '{count} مسارات',
|
||||
'tours.import.previewTracks.many': '{count} مسارًا',
|
||||
'tours.import.previewPoints': '{count} نقطة',
|
||||
'tours.import.previewPoints.zero': '{count} نقاط',
|
||||
'tours.import.previewPoints.one': 'نقطة واحدة',
|
||||
'tours.import.previewPoints.two': 'نقطتان',
|
||||
'tours.import.previewPoints.few': '{count} نقاط',
|
||||
'tours.import.previewPoints.many': '{count} نقطة',
|
||||
'tours.import.previewNoHeights': 'لا توجد ارتفاعات في الملف',
|
||||
'tours.import.previewDuplicate': 'موجود بالفعل في الرحلة',
|
||||
'tours.import.previewAllDuplicates': 'كل المسارات موجودة بالفعل في الرحلة، فلا شيء للاستيراد.',
|
||||
'tours.import.previewAs': 'الاستيراد كـ',
|
||||
'tours.import.previewConfirm': 'استيراد {count} جولة',
|
||||
'tours.import.previewConfirm.zero': 'استيراد {count} جولات',
|
||||
'tours.import.previewConfirm.one': 'استيراد جولة واحدة',
|
||||
'tours.import.previewConfirm.two': 'استيراد جولتين',
|
||||
'tours.import.previewConfirm.few': 'استيراد {count} جولات',
|
||||
'tours.import.previewConfirm.many': 'استيراد {count} جولة',
|
||||
'tours.conversion.introPaddle':
|
||||
'الاستيراد مسار مسجّل. خطّط لجولة تجديف قابلة للتعديل: يأخذ TREK منه نقاط تحكم، ويصلها بخطوط مستقيمة كما تُرسم جولة التجديف، ويعرض مدى قربها منه. لا يتغير شيء حتى تختار.',
|
||||
'tours.conversion.closePaddle': 'يتبع الاقتراح المسار عن قرب. نقاطه موصولة بخطوط مستقيمة، لذا ليس هو الخط نفسه.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -189,7 +189,7 @@ const tours: TranslationStrings = {
|
||||
'tourTypes.bike': 'Velosiped turu',
|
||||
'tourTypes.run': 'Qaçış',
|
||||
'tourTypes.ski': 'Xizək turu',
|
||||
'tourTypes.kayak': 'Kayak gəzintisi',
|
||||
'tourTypes.kayak': 'Avarlı tur',
|
||||
'tourTypes.walk': 'Şəhər / görməli yerlər gəzintisi',
|
||||
'tourTypes.food': 'Yemək/şərab turu',
|
||||
'tours.terrain.play': 'Uçuşu oynat',
|
||||
@@ -271,5 +271,59 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (surət)',
|
||||
'tours.copy.saved': 'Yeni tur kimi saxlanıldı',
|
||||
'tours.copy.failed': 'Turu kopyalamaq olmadı.',
|
||||
'tours.planner.tourType': 'Tur növü',
|
||||
'tours.paddle.boat': 'Qayıq',
|
||||
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
|
||||
'tours.paddle.boat.canoe': 'Kanoe',
|
||||
'tours.paddle.boat.sup': 'SUP', // same-as-en
|
||||
'tours.paddle.boat.other': 'Digər',
|
||||
'tours.paddle.water': 'Su',
|
||||
'tours.paddle.water.river': 'Çay',
|
||||
'tours.paddle.water.lake': 'Göl',
|
||||
'tours.paddle.speed': 'Avar sürəti',
|
||||
'tours.paddle.speedOwn': 'Öz sürətin',
|
||||
'tours.paddle.speedUseSuggestion': 'Təklif olunan {speed} km/saat istifadə et',
|
||||
'tours.paddle.speedNeeded': 'Suda keçən vaxtı təxmin etmək üçün sürət daxil et.',
|
||||
'tours.paddle.speedInvalid': '0,5 ilə 20 km/saat arasında sürət daxil et.',
|
||||
'tours.paddle.chooseBoat': 'Bu avarlı turu yadda saxlamaq üçün qayığı və suyu seç.',
|
||||
'tours.paddle.chooseBoatImport': 'Avarlı tur kimi idxal etmək üçün qayığı və suyu seç.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK hələ bəndləri, şlüzləri və daşıma yerlərini göstərmir. Yola çıxmazdan əvvəl suyu və qaydalarını özün yoxla.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Avarlı tur əllə çəkilir: TREK nöqtələrini düz xətlərlə birləşdirir. Heç nə marşrutlaşdırılmır və heç nə suyun keçilə bilən olduğunu demir.',
|
||||
'tours.paddle.timeEstimated': 'Avar vaxtı (təxmini)',
|
||||
'tours.paddle.timeEstimatedShort': 'Avarçəkmə',
|
||||
'tours.paddle.timeEstimatedInfoLabel': 'Avar vaxtı necə təxmin edilir',
|
||||
'tours.paddle.timeEstimatedInfo':
|
||||
'Məsafə sakit suda planlaşdırılan sürətə bölünür. Axın, külək, daşımalar və şlüzlər daxil deyil.',
|
||||
'tours.planner.reverse': 'İstiqaməti dəyiş',
|
||||
'tours.planner.returnToStart': 'Başlanğıca qayıt',
|
||||
'tours.planner.alreadyLoop': 'Tur artıq başlanğıca qayıdır',
|
||||
'tours.planner.loopEnd': 'Başlanğıca qayıdış',
|
||||
'tours.planner.freeLeg': 'Növbəti hissəni əllə çək',
|
||||
'tours.planner.routeLeg': 'Növbəti hissəni yenidən marşrutlaşdır',
|
||||
'tours.planner.freeLegBadge': 'Əllə çəkilib, marşrutlaşdırılmayıb',
|
||||
'tours.planner.freeLegInfo':
|
||||
'TREK bu iki nöqtəni düz xətlə birləşdirir. Heç bir marşrutlaşdırıcı bu hissəni planlaşdırmayıb və yoxlamayıb.',
|
||||
'tours.map.seamarks': 'Dəniz nişanları',
|
||||
'tours.map.seamarksDown': 'Dəniz nişanları yüklənmir. Xəritə və marşrutun işləməyə davam edir.',
|
||||
'tours.import.previewTitle': 'GPX önbaxışı',
|
||||
'tours.import.previewHint': 'Hələ heç nə yadda saxlanmayıb. Marşrutu yoxla, sonra idxal et.',
|
||||
'tours.import.previewFiles': '{count} fayl',
|
||||
'tours.import.previewFiles.one': '{count} fayl',
|
||||
'tours.import.previewTracks': '{count} trek',
|
||||
'tours.import.previewTracks.one': '{count} trek',
|
||||
'tours.import.previewPoints': '{count} nöqtə',
|
||||
'tours.import.previewPoints.one': '{count} nöqtə',
|
||||
'tours.import.previewNoHeights': 'Faylda hündürlük yoxdur',
|
||||
'tours.import.previewDuplicate': 'Artıq səyahətdədir',
|
||||
'tours.import.previewAllDuplicates': 'Bütün treklər artıq səyahətdədir, idxal ediləcək heç nə yoxdur.',
|
||||
'tours.import.previewAs': 'Bu kimi idxal et',
|
||||
'tours.import.previewConfirm': '{count} tur idxal et',
|
||||
'tours.import.previewConfirm.one': '{count} tur idxal et',
|
||||
'tours.conversion.introPaddle':
|
||||
'İdxal qeydə alınmış bir trekdir. Redaktə edilə bilən avarlı tur planla: TREK ondan nəzarət nöqtələri götürür, avarlı tur necə çəkilirsə onları düz xətlərlə birləşdirir və nə qədər yaxın olduğunu göstərir. Sən seçənə qədər heç nə dəyişmir.',
|
||||
'tours.conversion.closePaddle':
|
||||
'Təklif treki yaxından izləyir. Nöqtələri düz xətlərlə birləşdirilib, ona görə eyni xətt deyil.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -194,7 +194,7 @@ const tours: TranslationStrings = {
|
||||
'tourTypes.bike': 'Passeio de bicicleta',
|
||||
'tourTypes.run': 'Corrida',
|
||||
'tourTypes.ski': 'Passeio de esqui',
|
||||
'tourTypes.kayak': 'Passeio de caiaque',
|
||||
'tourTypes.kayak': 'Passeio de remo',
|
||||
'tourTypes.walk': 'Caminhada urbana / turística',
|
||||
'tourTypes.food': 'Passeio gastronômico / enológico',
|
||||
'tours.terrain.play': 'Reproduzir sobrevoo',
|
||||
@@ -277,5 +277,59 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (cópia)',
|
||||
'tours.copy.saved': 'Salvo como novo passeio',
|
||||
'tours.copy.failed': 'Não foi possível copiar o passeio.',
|
||||
'tours.planner.tourType': 'Tipo de passeio',
|
||||
'tours.paddle.boat': 'Embarcação',
|
||||
'tours.paddle.boat.kayak': 'Caiaque',
|
||||
'tours.paddle.boat.canoe': 'Canoa',
|
||||
'tours.paddle.boat.sup': 'SUP', // same-as-en
|
||||
'tours.paddle.boat.other': 'Outra',
|
||||
'tours.paddle.water': 'Água',
|
||||
'tours.paddle.water.river': 'Rio',
|
||||
'tours.paddle.water.lake': 'Lago',
|
||||
'tours.paddle.speed': 'Velocidade de remada',
|
||||
'tours.paddle.speedOwn': 'Velocidade própria',
|
||||
'tours.paddle.speedUseSuggestion': 'Usar a sugestão de {speed} km/h',
|
||||
'tours.paddle.speedNeeded': 'Informe uma velocidade para estimar o tempo na água.',
|
||||
'tours.paddle.speedInvalid': 'Informe uma velocidade de 0,5 a 20 km/h.',
|
||||
'tours.paddle.chooseBoat': 'Escolha a embarcação e a água para salvar este passeio.',
|
||||
'tours.paddle.chooseBoatImport': 'Escolha a embarcação e a água para importar como passeio de remo.',
|
||||
'tours.paddle.noWaterData':
|
||||
'O TREK ainda não mostra barragens, eclusas nem trechos de transbordo. Confira você mesmo a água e as regras antes de sair.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Um passeio de remo é desenhado à mão: o TREK liga seus pontos com linhas retas. Nada é roteado, e nada diz que a água é navegável.',
|
||||
'tours.paddle.timeEstimated': 'Tempo de remada (estimado)',
|
||||
'tours.paddle.timeEstimatedShort': 'Remada',
|
||||
'tours.paddle.timeEstimatedInfoLabel': 'Como o tempo de remada é estimado',
|
||||
'tours.paddle.timeEstimatedInfo':
|
||||
'Distância dividida pela velocidade prevista, em água calma. Correnteza, vento, transbordos e eclusas não estão incluídos.',
|
||||
'tours.planner.reverse': 'Inverter o sentido',
|
||||
'tours.planner.returnToStart': 'Voltar ao início',
|
||||
'tours.planner.alreadyLoop': 'O passeio já volta ao início',
|
||||
'tours.planner.loopEnd': 'Volta ao início',
|
||||
'tours.planner.freeLeg': 'Desenhar o próximo trecho à mão',
|
||||
'tours.planner.routeLeg': 'Rotear o próximo trecho de novo',
|
||||
'tours.planner.freeLegBadge': 'Desenhado à mão, sem rota',
|
||||
'tours.planner.freeLegInfo':
|
||||
'O TREK liga estes dois pontos com uma linha reta. Nenhum roteador planejou ou conferiu este trecho.',
|
||||
'tours.map.seamarks': 'Sinais náuticos',
|
||||
'tours.map.seamarksDown': 'Os sinais náuticos não estão carregando. O mapa e sua rota continuam funcionando.',
|
||||
'tours.import.previewTitle': 'Prévia do GPX',
|
||||
'tours.import.previewHint': 'Nada foi salvo ainda. Confira o percurso e depois importe.',
|
||||
'tours.import.previewFiles': '{count} arquivos',
|
||||
'tours.import.previewFiles.one': '{count} arquivo',
|
||||
'tours.import.previewTracks': '{count} trilhas',
|
||||
'tours.import.previewTracks.one': '{count} trilha',
|
||||
'tours.import.previewPoints': '{count} pontos',
|
||||
'tours.import.previewPoints.one': '{count} ponto',
|
||||
'tours.import.previewNoHeights': 'Sem altitudes no arquivo',
|
||||
'tours.import.previewDuplicate': 'Já está na viagem',
|
||||
'tours.import.previewAllDuplicates': 'Todas as trilhas já estão na viagem, não há nada para importar.',
|
||||
'tours.import.previewAs': 'Importar como',
|
||||
'tours.import.previewConfirm': 'Importar {count} passeios',
|
||||
'tours.import.previewConfirm.one': 'Importar {count} passeio',
|
||||
'tours.conversion.introPaddle':
|
||||
'Uma importação é uma trilha gravada. Planeje um passeio de remo editável: o TREK tira pontos de controle dela, liga-os com linhas retas como se desenha um passeio de remo e mostra o quanto chega perto. Nada muda até você escolher.',
|
||||
'tours.conversion.closePaddle':
|
||||
'A sugestão segue a trilha de perto. Os pontos são ligados por linhas retas, então não é a mesma linha.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -194,7 +194,7 @@ const tours: TranslationStrings = {
|
||||
'tourTypes.bike': 'Ruta en bicicleta',
|
||||
'tourTypes.run': 'Córrer',
|
||||
'tourTypes.ski': 'Esquí de muntanya',
|
||||
'tourTypes.kayak': 'Ruta en caiac',
|
||||
'tourTypes.kayak': 'Ruta en piragua',
|
||||
'tourTypes.walk': 'Passeig urbà / turístic',
|
||||
'tourTypes.food': 'Ruta gastronòmica / enològica',
|
||||
'tours.terrain.play': 'Reprodueix el sobrevol',
|
||||
@@ -277,5 +277,59 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (còpia)',
|
||||
'tours.copy.saved': 'Desada com a excursió nova',
|
||||
'tours.copy.failed': 'No s’ha pogut copiar l’excursió.',
|
||||
'tours.planner.tourType': 'Tipus de ruta',
|
||||
'tours.paddle.boat': 'Embarcació',
|
||||
'tours.paddle.boat.kayak': 'Caiac',
|
||||
'tours.paddle.boat.canoe': 'Canoa',
|
||||
'tours.paddle.boat.sup': 'SUP', // same-as-en
|
||||
'tours.paddle.boat.other': 'Altra',
|
||||
'tours.paddle.water': 'Aigua',
|
||||
'tours.paddle.water.river': 'Riu',
|
||||
'tours.paddle.water.lake': 'Llac',
|
||||
'tours.paddle.speed': 'Velocitat de rem',
|
||||
'tours.paddle.speedOwn': 'Velocitat pròpia',
|
||||
'tours.paddle.speedUseSuggestion': 'Fes servir el suggeriment de {speed} km/h',
|
||||
'tours.paddle.speedNeeded': 'Introdueix una velocitat per estimar el temps a l’aigua.',
|
||||
'tours.paddle.speedInvalid': 'Introdueix una velocitat de 0,5 a 20 km/h.',
|
||||
'tours.paddle.chooseBoat': 'Tria l’embarcació i l’aigua per desar aquesta ruta.',
|
||||
'tours.paddle.chooseBoatImport': 'Tria l’embarcació i l’aigua per importar com a ruta en piragua.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK encara no mostra rescloses, preses ni transports per terra. Comprova tu mateix l’aigua i les normes abans de sortir.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Una ruta en piragua es dibuixa a mà: TREK uneix els punts amb línies rectes. No es calcula res i res no diu que l’aigua sigui navegable.',
|
||||
'tours.paddle.timeEstimated': 'Temps de rem (estimat)',
|
||||
'tours.paddle.timeEstimatedShort': 'Rem',
|
||||
'tours.paddle.timeEstimatedInfoLabel': 'Com s’estima el temps de rem',
|
||||
'tours.paddle.timeEstimatedInfo':
|
||||
'Distància dividida per la velocitat prevista, en aigua tranquil·la. No inclou corrent, vent, transports per terra ni rescloses.',
|
||||
'tours.planner.reverse': 'Inverteix el sentit',
|
||||
'tours.planner.returnToStart': 'Torna a l’inici',
|
||||
'tours.planner.alreadyLoop': 'La ruta ja torna a l’inici',
|
||||
'tours.planner.loopEnd': 'Tornada a l’inici',
|
||||
'tours.planner.freeLeg': 'Dibuixa a mà el tram següent',
|
||||
'tours.planner.routeLeg': 'Torna a calcular el tram següent',
|
||||
'tours.planner.freeLegBadge': 'Dibuixat a mà, sense calcular',
|
||||
'tours.planner.freeLegInfo':
|
||||
'TREK uneix aquests dos punts amb una línia recta. Cap enrutador no ha planificat ni comprovat aquest tram.',
|
||||
'tours.map.seamarks': 'Senyals nàutics',
|
||||
'tours.map.seamarksDown': 'Els senyals nàutics no es carreguen. El mapa i la ruta continuen funcionant.',
|
||||
'tours.import.previewTitle': 'Previsualització del GPX',
|
||||
'tours.import.previewHint': 'Encara no s’ha desat res. Revisa el recorregut i després importa.',
|
||||
'tours.import.previewFiles': '{count} fitxers',
|
||||
'tours.import.previewFiles.one': '{count} fitxer',
|
||||
'tours.import.previewTracks': '{count} tracks', // same-as-en
|
||||
'tours.import.previewTracks.one': '{count} track', // same-as-en
|
||||
'tours.import.previewPoints': '{count} punts',
|
||||
'tours.import.previewPoints.one': '{count} punt',
|
||||
'tours.import.previewNoHeights': 'Sense altituds al fitxer',
|
||||
'tours.import.previewDuplicate': 'Ja és al viatge',
|
||||
'tours.import.previewAllDuplicates': 'Tots els tracks ja són al viatge, no hi ha res a importar.',
|
||||
'tours.import.previewAs': 'Importa com a',
|
||||
'tours.import.previewConfirm': 'Importa {count} rutes',
|
||||
'tours.import.previewConfirm.one': 'Importa {count} ruta',
|
||||
'tours.conversion.introPaddle':
|
||||
'Una importació és un recorregut enregistrat. Planifica una ruta en piragua editable: TREK n’agafa punts de control, els uneix amb línies rectes com es dibuixa una ruta en piragua i mostra com s’hi acosta. No canvia res fins que triïs.',
|
||||
'tours.conversion.closePaddle':
|
||||
'La proposta segueix el recorregut de prop. Els punts s’uneixen amb línies rectes, per tant no és la mateixa línia.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -192,7 +192,7 @@ const tours: TranslationStrings = {
|
||||
'tourTypes.bike': 'Cyklovýlet',
|
||||
'tourTypes.run': 'Běh',
|
||||
'tourTypes.ski': 'Skialpinistická túra',
|
||||
'tourTypes.kayak': 'Kajak',
|
||||
'tourTypes.kayak': 'Vodácká túra',
|
||||
'tourTypes.walk': 'Procházka městem / po památkách',
|
||||
'tourTypes.food': 'Gastronomický / vinařský výlet',
|
||||
'tours.terrain.play': 'Přehrát přelet',
|
||||
@@ -271,5 +271,62 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (kopie)',
|
||||
'tours.copy.saved': 'Uloženo jako nová túra',
|
||||
'tours.copy.failed': 'Túru se nepodařilo zkopírovat.',
|
||||
'tours.planner.tourType': 'Typ túry',
|
||||
'tours.paddle.boat': 'Loď',
|
||||
'tours.paddle.boat.kayak': 'Kajak',
|
||||
'tours.paddle.boat.canoe': 'Kánoe',
|
||||
'tours.paddle.boat.sup': 'SUP', // same-as-en
|
||||
'tours.paddle.boat.other': 'Jiná',
|
||||
'tours.paddle.water': 'Voda',
|
||||
'tours.paddle.water.river': 'Řeka',
|
||||
'tours.paddle.water.lake': 'Jezero',
|
||||
'tours.paddle.speed': 'Rychlost pádlování',
|
||||
'tours.paddle.speedOwn': 'Vlastní rychlost',
|
||||
'tours.paddle.speedUseSuggestion': 'Použít navrženou rychlost {speed} km/h',
|
||||
'tours.paddle.speedNeeded': 'Zadejte rychlost pro odhad času na vodě.',
|
||||
'tours.paddle.speedInvalid': 'Zadejte rychlost od 0,5 do 20 km/h.',
|
||||
'tours.paddle.chooseBoat': 'Vyberte loď a vodu, abyste mohli tuto túru uložit.',
|
||||
'tours.paddle.chooseBoatImport': 'Vyberte loď a vodu pro import jako vodácké túry.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK zatím nezobrazuje jezy, zdymadla ani přenášky. Před vyplutím si vodu a pravidla ověřte sami.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Vodácká túra se kreslí ručně: TREK spojí vaše body přímými čarami. Nic se nepočítá a nic neříká, že je voda sjízdná.',
|
||||
'tours.paddle.timeEstimated': 'Čas na vodě (odhad)',
|
||||
'tours.paddle.timeEstimatedShort': 'Pádlování',
|
||||
'tours.paddle.timeEstimatedInfoLabel': 'Jak se odhaduje čas na vodě',
|
||||
'tours.paddle.timeEstimatedInfo':
|
||||
'Vzdálenost dělená plánovanou rychlostí na klidné vodě. Proud, vítr, přenášky ani zdymadla nejsou započítány.',
|
||||
'tours.planner.reverse': 'Obrátit směr',
|
||||
'tours.planner.returnToStart': 'Zpět na start',
|
||||
'tours.planner.alreadyLoop': 'Túra se už vrací na start',
|
||||
'tours.planner.loopEnd': 'Zpět na start',
|
||||
'tours.planner.freeLeg': 'Nakreslit další úsek ručně',
|
||||
'tours.planner.routeLeg': 'Další úsek znovu vypočítat',
|
||||
'tours.planner.freeLegBadge': 'Nakresleno ručně, nevypočítáno',
|
||||
'tours.planner.freeLegInfo':
|
||||
'TREK spojí tyto dva body přímou čarou. Žádný plánovač trasy tento úsek nenaplánoval ani nezkontroloval.',
|
||||
'tours.map.seamarks': 'Plavební znaky',
|
||||
'tours.map.seamarksDown': 'Plavební znaky se nenačítají. Mapa i vaše trasa dál fungují.',
|
||||
'tours.import.previewTitle': 'Náhled GPX',
|
||||
'tours.import.previewHint': 'Zatím nic není uloženo. Zkontrolujte průběh a pak importujte.',
|
||||
'tours.import.previewFiles': '{count} souborů',
|
||||
'tours.import.previewFiles.one': '{count} soubor',
|
||||
'tours.import.previewFiles.few': '{count} soubory',
|
||||
'tours.import.previewTracks': '{count} tras',
|
||||
'tours.import.previewTracks.one': '{count} trasa',
|
||||
'tours.import.previewTracks.few': '{count} trasy',
|
||||
'tours.import.previewPoints': '{count} bodů',
|
||||
'tours.import.previewPoints.one': '{count} bod',
|
||||
'tours.import.previewPoints.few': '{count} body',
|
||||
'tours.import.previewNoHeights': 'V souboru nejsou výšky',
|
||||
'tours.import.previewDuplicate': 'Už je v cestě',
|
||||
'tours.import.previewAllDuplicates': 'Všechny trasy už v cestě jsou, není co importovat.',
|
||||
'tours.import.previewAs': 'Importovat jako',
|
||||
'tours.import.previewConfirm': 'Importovat {count} túr',
|
||||
'tours.import.previewConfirm.one': 'Importovat {count} túru',
|
||||
'tours.import.previewConfirm.few': 'Importovat {count} túry',
|
||||
'tours.conversion.introPaddle':
|
||||
'Import je zaznamenaná trasa. Naplánujte upravitelnou vodáckou túru: TREK z ní vezme kontrolní body, spojí je přímými čarami, jak se vodácká túra kreslí, a ukáže, jak blízko to je. Nic se nezmění, dokud se nerozhodnete.',
|
||||
'tours.conversion.closePaddle': 'Návrh trasu těsně sleduje. Jeho body spojují přímé čáry, takže to není stejná čára.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -195,7 +195,7 @@ const tours: TranslationStrings = {
|
||||
'tourTypes.bike': 'Radtour',
|
||||
'tourTypes.run': 'Lauf',
|
||||
'tourTypes.ski': 'Skitour',
|
||||
'tourTypes.kayak': 'Kajaktour',
|
||||
'tourTypes.kayak': 'Paddeltour',
|
||||
'tourTypes.walk': 'Stadt- und Spaziergang',
|
||||
'tourTypes.food': 'Genusstour',
|
||||
'tours.terrain.play': 'Flug abspielen',
|
||||
@@ -279,6 +279,60 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (Kopie)',
|
||||
'tours.copy.saved': 'Als neue Tour gespeichert',
|
||||
'tours.copy.failed': 'Die Tour konnte nicht kopiert werden.',
|
||||
'tours.planner.tourType': 'Tourtyp',
|
||||
'tours.paddle.boat': 'Boot',
|
||||
'tours.paddle.boat.kayak': 'Kajak',
|
||||
'tours.paddle.boat.canoe': 'Kanadier',
|
||||
'tours.paddle.boat.sup': 'SUP', // same-as-en
|
||||
'tours.paddle.boat.other': 'Sonstiges',
|
||||
'tours.paddle.water': 'Gewässer',
|
||||
'tours.paddle.water.river': 'Fluss',
|
||||
'tours.paddle.water.lake': 'See',
|
||||
'tours.paddle.speed': 'Paddeltempo',
|
||||
'tours.paddle.speedOwn': 'Eigenes Tempo',
|
||||
'tours.paddle.speedUseSuggestion': 'Vorschlag {speed} km/h übernehmen',
|
||||
'tours.paddle.speedNeeded': 'Gib ein Tempo ein, um die Zeit auf dem Wasser zu schätzen.',
|
||||
'tours.paddle.speedInvalid': 'Gib ein Tempo von 0,5 bis 20 km/h ein.',
|
||||
'tours.paddle.chooseBoat': 'Wähle Boot und Gewässer, um diese Paddeltour zu speichern.',
|
||||
'tours.paddle.chooseBoatImport': 'Wähle Boot und Gewässer, um als Paddeltour zu importieren.',
|
||||
'tours.paddle.noWaterData':
|
||||
'Wehre, Schleusen und Umtragestellen zeigt TREK noch nicht an. Prüfe das Gewässer und seine Regeln selbst, bevor du losfährst.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Eine Paddeltour wird von Hand gezeichnet: TREK verbindet deine Punkte mit geraden Linien. Nichts wird geroutet, und nichts sagt, dass das Gewässer befahrbar ist.',
|
||||
'tours.paddle.timeEstimated': 'Paddelzeit (geschätzt)',
|
||||
'tours.paddle.timeEstimatedShort': 'Paddeln',
|
||||
'tours.paddle.timeEstimatedInfoLabel': 'Wie die Paddelzeit geschätzt wird',
|
||||
'tours.paddle.timeEstimatedInfo':
|
||||
'Strecke geteilt durch das Planungstempo, auf ruhigem Wasser. Strömung, Wind, Umtragen und Schleusen sind nicht enthalten.',
|
||||
'tours.planner.reverse': 'Richtung umkehren',
|
||||
'tours.planner.returnToStart': 'Zurück zum Start',
|
||||
'tours.planner.alreadyLoop': 'Die Tour führt schon zum Start zurück',
|
||||
'tours.planner.loopEnd': 'Zurück zum Start',
|
||||
'tours.planner.freeLeg': 'Nächsten Abschnitt von Hand zeichnen',
|
||||
'tours.planner.routeLeg': 'Nächsten Abschnitt wieder routen',
|
||||
'tours.planner.freeLegBadge': 'Von Hand gezeichnet, nicht geroutet',
|
||||
'tours.planner.freeLegInfo':
|
||||
'TREK verbindet diese beiden Punkte mit einer geraden Linie. Kein Router hat diesen Abschnitt geplant oder geprüft.',
|
||||
'tours.map.seamarks': 'Seezeichen',
|
||||
'tours.map.seamarksDown': 'Seezeichen laden gerade nicht. Karte und Route funktionieren weiter.',
|
||||
'tours.import.previewTitle': 'GPX-Vorschau',
|
||||
'tours.import.previewHint': 'Noch ist nichts gespeichert. Prüfe den Verlauf, dann importieren.',
|
||||
'tours.import.previewFiles': '{count} Dateien',
|
||||
'tours.import.previewFiles.one': '{count} Datei',
|
||||
'tours.import.previewTracks': '{count} Tracks',
|
||||
'tours.import.previewTracks.one': '{count} Track',
|
||||
'tours.import.previewPoints': '{count} Punkte',
|
||||
'tours.import.previewPoints.one': '{count} Punkt',
|
||||
'tours.import.previewNoHeights': 'Keine Höhen in der Datei',
|
||||
'tours.import.previewDuplicate': 'Schon in der Reise',
|
||||
'tours.import.previewAllDuplicates': 'Alle Tracks sind schon in der Reise, es gibt nichts zu importieren.',
|
||||
'tours.import.previewAs': 'Importieren als',
|
||||
'tours.import.previewConfirm': '{count} Touren importieren',
|
||||
'tours.import.previewConfirm.one': '{count} Tour importieren',
|
||||
'tours.conversion.introPaddle':
|
||||
'Ein Import ist eine aufgezeichnete Strecke. Plane eine bearbeitbare Paddeltour: TREK nimmt Kontrollpunkte daraus, verbindet sie mit geraden Linien, wie eine Paddeltour gezeichnet wird, und zeigt, wie nah das herankommt. Nichts ändert sich, bis du dich entscheidest.',
|
||||
'tours.conversion.closePaddle':
|
||||
'Der Vorschlag folgt der Strecke eng. Seine Punkte sind mit geraden Linien verbunden, daher ist es nicht dieselbe Linie.',
|
||||
};
|
||||
|
||||
export default tours;
|
||||
|
||||
@@ -188,7 +188,7 @@ const tours: TranslationStrings = {
|
||||
'tourTypes.bike': 'Bike tour',
|
||||
'tourTypes.run': 'Run',
|
||||
'tourTypes.ski': 'Ski tour',
|
||||
'tourTypes.kayak': 'Kayak',
|
||||
'tourTypes.kayak': 'Paddle tour',
|
||||
'tourTypes.walk': 'City / sightseeing walk',
|
||||
'tourTypes.food': 'Food/wine tour',
|
||||
'tours.terrain.play': 'Play flyover',
|
||||
@@ -270,5 +270,59 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (copy)',
|
||||
'tours.copy.saved': 'Saved as a new tour',
|
||||
'tours.copy.failed': 'The tour could not be copied.',
|
||||
'tours.planner.tourType': 'Tour type',
|
||||
'tours.paddle.boat': 'Boat',
|
||||
'tours.paddle.boat.kayak': 'Kayak',
|
||||
'tours.paddle.boat.canoe': 'Canoe',
|
||||
'tours.paddle.boat.sup': 'SUP',
|
||||
'tours.paddle.boat.other': 'Other',
|
||||
'tours.paddle.water': 'Water',
|
||||
'tours.paddle.water.river': 'River',
|
||||
'tours.paddle.water.lake': 'Lake',
|
||||
'tours.paddle.speed': 'Paddling speed',
|
||||
'tours.paddle.speedOwn': 'Own speed',
|
||||
'tours.paddle.speedUseSuggestion': 'Use the suggested {speed} km/h',
|
||||
'tours.paddle.speedNeeded': 'Enter a speed to estimate the time on the water.',
|
||||
'tours.paddle.speedInvalid': 'Enter a speed from 0.5 to 20 km/h.',
|
||||
'tours.paddle.chooseBoat': 'Choose the boat and the water to save this paddle tour.',
|
||||
'tours.paddle.chooseBoatImport': 'Choose the boat and the water to import as a paddle tour.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK does not show weirs, locks or portages yet. Check the water and its rules yourself before you set off.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'A paddle tour is drawn by hand: TREK joins your points with straight lines. Nothing is routed, and nothing says the water can be paddled.',
|
||||
'tours.paddle.timeEstimated': 'Paddling time (estimated)',
|
||||
'tours.paddle.timeEstimatedShort': 'Paddling',
|
||||
'tours.paddle.timeEstimatedInfoLabel': 'How the paddling time is estimated',
|
||||
'tours.paddle.timeEstimatedInfo':
|
||||
'Distance divided by the planning speed, on calm water. Current, wind, portages and locks are not included.',
|
||||
'tours.planner.reverse': 'Reverse direction',
|
||||
'tours.planner.returnToStart': 'Return to start',
|
||||
'tours.planner.alreadyLoop': 'The tour already returns to its start',
|
||||
'tours.planner.loopEnd': 'Back to start',
|
||||
'tours.planner.freeLeg': 'Draw the next leg by hand',
|
||||
'tours.planner.routeLeg': 'Route the next leg again',
|
||||
'tours.planner.freeLegBadge': 'Drawn by hand, not routed',
|
||||
'tours.planner.freeLegInfo':
|
||||
'TREK joins these two points with a straight line. No router planned or checked this leg.',
|
||||
'tours.map.seamarks': 'Seamarks',
|
||||
'tours.map.seamarksDown': 'Seamarks are not loading. The map and your route still work.',
|
||||
'tours.import.previewTitle': 'GPX preview',
|
||||
'tours.import.previewHint': 'Nothing is saved yet. Check the course, then import.',
|
||||
'tours.import.previewFiles': '{count} files',
|
||||
'tours.import.previewFiles.one': '{count} file',
|
||||
'tours.import.previewTracks': '{count} tracks',
|
||||
'tours.import.previewTracks.one': '{count} track',
|
||||
'tours.import.previewPoints': '{count} points',
|
||||
'tours.import.previewPoints.one': '{count} point',
|
||||
'tours.import.previewNoHeights': 'No heights in the file',
|
||||
'tours.import.previewDuplicate': 'Already in the trip',
|
||||
'tours.import.previewAllDuplicates': 'Every track is already in the trip, so there is nothing to import.',
|
||||
'tours.import.previewAs': 'Import as',
|
||||
'tours.import.previewConfirm': 'Import {count} tours',
|
||||
'tours.import.previewConfirm.one': 'Import {count} tour',
|
||||
'tours.conversion.introPaddle':
|
||||
'An import is a recorded track. Plan an editable paddle tour: TREK takes control points from it, joins them with straight lines as a paddle tour is drawn, and shows how close that comes. Nothing changes until you choose.',
|
||||
'tours.conversion.closePaddle':
|
||||
'The suggestion follows the track closely. Its points are joined by straight lines, so it is not the same line.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -194,7 +194,7 @@ const tours: TranslationStrings = {
|
||||
'tourTypes.bike': 'Ruta en bicicleta',
|
||||
'tourTypes.run': 'Carrera a pie',
|
||||
'tourTypes.ski': 'Esquí de travesía',
|
||||
'tourTypes.kayak': 'Ruta en kayak',
|
||||
'tourTypes.kayak': 'Ruta en piragua',
|
||||
'tourTypes.walk': 'Paseo urbano / turístico',
|
||||
'tourTypes.food': 'Ruta gastronómica / enológica',
|
||||
'tours.terrain.play': 'Reproducir sobrevuelo',
|
||||
@@ -276,5 +276,59 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (copia)',
|
||||
'tours.copy.saved': 'Guardada como nueva excursión',
|
||||
'tours.copy.failed': 'No se pudo copiar la excursión.',
|
||||
'tours.planner.tourType': 'Tipo de ruta',
|
||||
'tours.paddle.boat': 'Embarcación',
|
||||
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
|
||||
'tours.paddle.boat.canoe': 'Canoa',
|
||||
'tours.paddle.boat.sup': 'SUP', // same-as-en
|
||||
'tours.paddle.boat.other': 'Otra',
|
||||
'tours.paddle.water': 'Agua',
|
||||
'tours.paddle.water.river': 'Río',
|
||||
'tours.paddle.water.lake': 'Lago',
|
||||
'tours.paddle.speed': 'Velocidad de remo',
|
||||
'tours.paddle.speedOwn': 'Velocidad propia',
|
||||
'tours.paddle.speedUseSuggestion': 'Usar la sugerencia de {speed} km/h',
|
||||
'tours.paddle.speedNeeded': 'Introduce una velocidad para estimar el tiempo en el agua.',
|
||||
'tours.paddle.speedInvalid': 'Introduce una velocidad de 0,5 a 20 km/h.',
|
||||
'tours.paddle.chooseBoat': 'Elige la embarcación y el agua para guardar esta ruta.',
|
||||
'tours.paddle.chooseBoatImport': 'Elige la embarcación y el agua para importar como ruta en piragua.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK aún no muestra presas, esclusas ni porteos. Comprueba tú mismo el agua y sus normas antes de salir.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Una ruta en piragua se dibuja a mano: TREK une tus puntos con líneas rectas. Nada se calcula y nada indica que el agua sea navegable.',
|
||||
'tours.paddle.timeEstimated': 'Tiempo de remo (estimado)',
|
||||
'tours.paddle.timeEstimatedShort': 'Remo',
|
||||
'tours.paddle.timeEstimatedInfoLabel': 'Cómo se estima el tiempo de remo',
|
||||
'tours.paddle.timeEstimatedInfo':
|
||||
'Distancia dividida entre la velocidad prevista, en agua tranquila. No incluye corriente, viento, porteos ni esclusas.',
|
||||
'tours.planner.reverse': 'Invertir el sentido',
|
||||
'tours.planner.returnToStart': 'Volver al inicio',
|
||||
'tours.planner.alreadyLoop': 'La ruta ya vuelve al inicio',
|
||||
'tours.planner.loopEnd': 'Vuelta al inicio',
|
||||
'tours.planner.freeLeg': 'Dibujar a mano el siguiente tramo',
|
||||
'tours.planner.routeLeg': 'Volver a calcular el siguiente tramo',
|
||||
'tours.planner.freeLegBadge': 'Dibujado a mano, sin calcular',
|
||||
'tours.planner.freeLegInfo':
|
||||
'TREK une estos dos puntos con una línea recta. Ningún enrutador ha planificado ni comprobado este tramo.',
|
||||
'tours.map.seamarks': 'Señales náuticas',
|
||||
'tours.map.seamarksDown': 'Las señales náuticas no cargan. El mapa y tu ruta siguen funcionando.',
|
||||
'tours.import.previewTitle': 'Vista previa del GPX',
|
||||
'tours.import.previewHint': 'Aún no se ha guardado nada. Revisa el recorrido y luego importa.',
|
||||
'tours.import.previewFiles': '{count} archivos',
|
||||
'tours.import.previewFiles.one': '{count} archivo',
|
||||
'tours.import.previewTracks': '{count} tracks', // same-as-en
|
||||
'tours.import.previewTracks.one': '{count} track', // same-as-en
|
||||
'tours.import.previewPoints': '{count} puntos',
|
||||
'tours.import.previewPoints.one': '{count} punto',
|
||||
'tours.import.previewNoHeights': 'Sin altitudes en el archivo',
|
||||
'tours.import.previewDuplicate': 'Ya está en el viaje',
|
||||
'tours.import.previewAllDuplicates': 'Todos los tracks ya están en el viaje, no hay nada que importar.',
|
||||
'tours.import.previewAs': 'Importar como',
|
||||
'tours.import.previewConfirm': 'Importar {count} rutas',
|
||||
'tours.import.previewConfirm.one': 'Importar {count} ruta',
|
||||
'tours.conversion.introPaddle':
|
||||
'Una importación es un recorrido grabado. Planifica una ruta en piragua editable: TREK toma puntos de control, los une con líneas rectas como se dibuja una ruta en piragua y muestra cuánto se acerca. Nada cambia hasta que elijas.',
|
||||
'tours.conversion.closePaddle':
|
||||
'La propuesta sigue el recorrido de cerca. Sus puntos se unen con líneas rectas, así que no es la misma línea.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -191,7 +191,7 @@ const tours: TranslationStrings = {
|
||||
'tourTypes.bike': 'Rattaretk',
|
||||
'tourTypes.run': 'Jooks',
|
||||
'tourTypes.ski': 'Suusaretk',
|
||||
'tourTypes.kayak': 'Süstaretk',
|
||||
'tourTypes.kayak': 'Aerutamismatk',
|
||||
'tourTypes.walk': 'Linna- / vaatamisväärsuste jalutuskäik',
|
||||
'tourTypes.food': 'Toidu- / veiniretk',
|
||||
'tours.terrain.play': 'Esita ülelend',
|
||||
@@ -273,5 +273,59 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (koopia)',
|
||||
'tours.copy.saved': 'Salvestatud uue matkana',
|
||||
'tours.copy.failed': 'Matka ei õnnestunud kopeerida.',
|
||||
'tours.planner.tourType': 'Matka tüüp',
|
||||
'tours.paddle.boat': 'Paat',
|
||||
'tours.paddle.boat.kayak': 'Süst',
|
||||
'tours.paddle.boat.canoe': 'Kanuu',
|
||||
'tours.paddle.boat.sup': 'SUP', // same-as-en
|
||||
'tours.paddle.boat.other': 'Muu',
|
||||
'tours.paddle.water': 'Veekogu',
|
||||
'tours.paddle.water.river': 'Jõgi',
|
||||
'tours.paddle.water.lake': 'Järv',
|
||||
'tours.paddle.speed': 'Aerutamiskiirus',
|
||||
'tours.paddle.speedOwn': 'Oma kiirus',
|
||||
'tours.paddle.speedUseSuggestion': 'Kasuta soovitatud {speed} km/h',
|
||||
'tours.paddle.speedNeeded': 'Sisesta kiirus, et hinnata aega vee peal.',
|
||||
'tours.paddle.speedInvalid': 'Sisesta kiirus vahemikus 0,5 kuni 20 km/h.',
|
||||
'tours.paddle.chooseBoat': 'Vali paat ja veekogu, et see matk salvestada.',
|
||||
'tours.paddle.chooseBoatImport': 'Vali paat ja veekogu, et importida aerutamismatkana.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK ei näita veel paise, lüüse ega ülekandekohti. Kontrolli enne teele asumist veekogu ja selle reegleid ise.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Aerutamismatk joonistatakse käsitsi: TREK ühendab sinu punktid sirgjoontega. Midagi ei marsruudita ja miski ei ütle, et vesi on läbitav.',
|
||||
'tours.paddle.timeEstimated': 'Aerutamisaeg (hinnanguline)',
|
||||
'tours.paddle.timeEstimatedShort': 'Aerutamine',
|
||||
'tours.paddle.timeEstimatedInfoLabel': 'Kuidas aerutamisaega hinnatakse',
|
||||
'tours.paddle.timeEstimatedInfo':
|
||||
'Vahemaa jagatud planeeritud kiirusega, vaiksel veel. Hoovus, tuul, ülekanded ja lüüsid pole arvestatud.',
|
||||
'tours.planner.reverse': 'Pööra suund ümber',
|
||||
'tours.planner.returnToStart': 'Tagasi algusesse',
|
||||
'tours.planner.alreadyLoop': 'Matk naaseb juba algusesse',
|
||||
'tours.planner.loopEnd': 'Tagasi algusesse',
|
||||
'tours.planner.freeLeg': 'Joonista järgmine lõik käsitsi',
|
||||
'tours.planner.routeLeg': 'Marsruudi järgmine lõik uuesti',
|
||||
'tours.planner.freeLegBadge': 'Käsitsi joonistatud, marsruutimata',
|
||||
'tours.planner.freeLegInfo':
|
||||
'TREK ühendab need kaks punkti sirgjoonega. Ükski marsruudiplaneerija ei ole seda lõiku planeerinud ega kontrollinud.',
|
||||
'tours.map.seamarks': 'Navigatsioonimärgid',
|
||||
'tours.map.seamarksDown': 'Navigatsioonimärgid ei laadi. Kaart ja sinu marsruut töötavad edasi.',
|
||||
'tours.import.previewTitle': 'GPX eelvaade',
|
||||
'tours.import.previewHint': 'Midagi pole veel salvestatud. Kontrolli marsruuti ja impordi siis.',
|
||||
'tours.import.previewFiles': '{count} faili',
|
||||
'tours.import.previewFiles.one': '{count} fail',
|
||||
'tours.import.previewTracks': '{count} rada',
|
||||
'tours.import.previewTracks.one': '{count} rada',
|
||||
'tours.import.previewPoints': '{count} punkti',
|
||||
'tours.import.previewPoints.one': '{count} punkt',
|
||||
'tours.import.previewNoHeights': 'Failis pole kõrgusi',
|
||||
'tours.import.previewDuplicate': 'Juba reisil',
|
||||
'tours.import.previewAllDuplicates': 'Kõik rajad on juba reisil, pole midagi importida.',
|
||||
'tours.import.previewAs': 'Impordi kui',
|
||||
'tours.import.previewConfirm': 'Impordi {count} matka',
|
||||
'tours.import.previewConfirm.one': 'Impordi {count} matk',
|
||||
'tours.conversion.introPaddle':
|
||||
'Import on salvestatud rada. Planeeri muudetav aerutamismatk: TREK võtab sellest kontrollpunktid, ühendab need sirgjoontega, nagu aerutamismatk joonistatakse, ja näitab, kui lähedale see jõuab. Midagi ei muutu enne, kui sa valid.',
|
||||
'tours.conversion.closePaddle':
|
||||
'Ettepanek järgib rada täpselt. Selle punktid on ühendatud sirgjoontega, seega pole see sama joon.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -197,7 +197,7 @@ const tours: TranslationStrings = {
|
||||
'tourTypes.bike': 'Sortie à vélo',
|
||||
'tourTypes.run': 'Course à pied',
|
||||
'tourTypes.ski': 'Ski de randonnée',
|
||||
'tourTypes.kayak': 'Sortie en kayak',
|
||||
'tourTypes.kayak': 'Sortie en pagaie',
|
||||
'tourTypes.walk': 'Balade urbaine / touristique',
|
||||
'tourTypes.food': 'Circuit gastronomique / œnologique',
|
||||
'tours.terrain.play': 'Lancer le survol',
|
||||
@@ -282,5 +282,60 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (copie)',
|
||||
'tours.copy.saved': 'Enregistrée comme nouvelle sortie',
|
||||
'tours.copy.failed': 'La sortie n’a pas pu être copiée.',
|
||||
'tours.planner.tourType': 'Type de sortie',
|
||||
'tours.paddle.boat': 'Embarcation',
|
||||
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
|
||||
'tours.paddle.boat.canoe': 'Canoë',
|
||||
'tours.paddle.boat.sup': 'SUP', // same-as-en
|
||||
'tours.paddle.boat.other': 'Autre',
|
||||
'tours.paddle.water': 'Plan d’eau',
|
||||
'tours.paddle.water.river': 'Rivière',
|
||||
'tours.paddle.water.lake': 'Lac',
|
||||
'tours.paddle.speed': 'Vitesse de pagaie',
|
||||
'tours.paddle.speedOwn': 'Vitesse choisie',
|
||||
'tours.paddle.speedUseSuggestion': 'Prendre la suggestion de {speed} km/h',
|
||||
'tours.paddle.speedNeeded': 'Saisissez une vitesse pour estimer le temps sur l’eau.',
|
||||
'tours.paddle.speedInvalid': 'Saisissez une vitesse de 0,5 à 20 km/h.',
|
||||
'tours.paddle.chooseBoat': 'Choisissez l’embarcation et le plan d’eau pour enregistrer cette sortie.',
|
||||
'tours.paddle.chooseBoatImport': 'Choisissez l’embarcation et le plan d’eau pour importer en sortie en pagaie.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK n’affiche pas encore les barrages, écluses ni portages. Vérifiez vous-même le plan d’eau et ses règles avant de partir.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Une sortie en pagaie se trace à la main : TREK relie vos points en lignes droites. Rien n’est calculé, et rien ne dit que l’eau est navigable.',
|
||||
'tours.paddle.timeEstimated': 'Temps de pagaie (estimé)',
|
||||
'tours.paddle.timeEstimatedShort': 'Pagaie',
|
||||
'tours.paddle.timeEstimatedInfoLabel': 'Comment le temps de pagaie est estimé',
|
||||
'tours.paddle.timeEstimatedInfo':
|
||||
'Distance divisée par la vitesse prévue, sur eau calme. Courant, vent, portages et écluses ne sont pas compris.',
|
||||
'tours.planner.reverse': 'Inverser le sens',
|
||||
'tours.planner.returnToStart': 'Retour au départ',
|
||||
'tours.planner.alreadyLoop': 'La sortie revient déjà au départ',
|
||||
'tours.planner.loopEnd': 'Retour au départ',
|
||||
'tours.planner.freeLeg': 'Tracer le tronçon suivant à la main',
|
||||
'tours.planner.routeLeg': 'Calculer à nouveau le tronçon suivant',
|
||||
'tours.planner.freeLegBadge': 'Tracé à la main, non calculé',
|
||||
'tours.planner.freeLegInfo':
|
||||
'TREK relie ces deux points par une ligne droite. Aucun calcul d’itinéraire n’a planifié ni vérifié ce tronçon.',
|
||||
'tours.map.seamarks': 'Balisage maritime',
|
||||
'tours.map.seamarksDown':
|
||||
'Le balisage maritime ne se charge pas. La carte et votre itinéraire fonctionnent toujours.',
|
||||
'tours.import.previewTitle': 'Aperçu GPX',
|
||||
'tours.import.previewHint': 'Rien n’est encore enregistré. Vérifiez le tracé, puis importez.',
|
||||
'tours.import.previewFiles': '{count} fichiers',
|
||||
'tours.import.previewFiles.one': '{count} fichier',
|
||||
'tours.import.previewTracks': '{count} traces',
|
||||
'tours.import.previewTracks.one': '{count} trace',
|
||||
'tours.import.previewPoints': '{count} points', // same-as-en
|
||||
'tours.import.previewPoints.one': '{count} point', // same-as-en
|
||||
'tours.import.previewNoHeights': 'Aucune altitude dans le fichier',
|
||||
'tours.import.previewDuplicate': 'Déjà dans le voyage',
|
||||
'tours.import.previewAllDuplicates': 'Toutes les traces sont déjà dans le voyage : il n’y a rien à importer.',
|
||||
'tours.import.previewAs': 'Importer comme',
|
||||
'tours.import.previewConfirm': 'Importer {count} sorties',
|
||||
'tours.import.previewConfirm.one': 'Importer {count} sortie',
|
||||
'tours.conversion.introPaddle':
|
||||
'Un import est une trace enregistrée. Planifiez une sortie en pagaie modifiable : TREK en tire des points de contrôle, les relie en lignes droites comme se trace une sortie en pagaie et montre à quel point c’est proche. Rien ne change tant que vous ne choisissez pas.',
|
||||
'tours.conversion.closePaddle':
|
||||
'La proposition suit la trace de près. Ses points sont reliés par des lignes droites, ce n’est donc pas la même ligne.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -198,7 +198,7 @@ const tours: TranslationStrings = {
|
||||
'tourTypes.bike': 'Ποδηλατική εκδρομή',
|
||||
'tourTypes.run': 'Τρέξιμο',
|
||||
'tourTypes.ski': 'Ορειβατικό σκι',
|
||||
'tourTypes.kayak': 'Καγιάκ',
|
||||
'tourTypes.kayak': 'Διαδρομή με κουπί',
|
||||
'tourTypes.walk': 'Περίπατος στην πόλη / στα αξιοθέατα',
|
||||
'tourTypes.food': 'Γαστρονομική / οινική εκδρομή',
|
||||
'tours.terrain.play': 'Αναπαραγωγή πτήσης',
|
||||
@@ -283,5 +283,59 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (αντίγραφο)',
|
||||
'tours.copy.saved': 'Αποθηκεύτηκε ως νέα εκδρομή',
|
||||
'tours.copy.failed': 'Η εκδρομή δεν αντιγράφηκε.',
|
||||
'tours.planner.tourType': 'Τύπος διαδρομής',
|
||||
'tours.paddle.boat': 'Σκάφος',
|
||||
'tours.paddle.boat.kayak': 'Καγιάκ',
|
||||
'tours.paddle.boat.canoe': 'Κανό',
|
||||
'tours.paddle.boat.sup': 'SUP',
|
||||
'tours.paddle.boat.other': 'Άλλο',
|
||||
'tours.paddle.water': 'Νερό',
|
||||
'tours.paddle.water.river': 'Ποτάμι',
|
||||
'tours.paddle.water.lake': 'Λίμνη',
|
||||
'tours.paddle.speed': 'Ταχύτητα κωπηλασίας',
|
||||
'tours.paddle.speedOwn': 'Δική σας ταχύτητα',
|
||||
'tours.paddle.speedUseSuggestion': 'Χρήση της πρότασης {speed} km/h',
|
||||
'tours.paddle.speedNeeded': 'Δώστε ταχύτητα για να εκτιμηθεί ο χρόνος στο νερό.',
|
||||
'tours.paddle.speedInvalid': 'Δώστε ταχύτητα από 0,5 έως 20 km/h.',
|
||||
'tours.paddle.chooseBoat': 'Επιλέξτε σκάφος και νερό για να αποθηκεύσετε τη διαδρομή.',
|
||||
'tours.paddle.chooseBoatImport': 'Επιλέξτε σκάφος και νερό για εισαγωγή ως διαδρομή με κουπί.',
|
||||
'tours.paddle.noWaterData':
|
||||
'Το TREK δεν δείχνει ακόμη φράγματα, υδατοφράκτες ή σημεία μεταφοράς. Ελέγξτε μόνοι σας το νερό και τους κανόνες πριν ξεκινήσετε.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Μια διαδρομή με κουπί σχεδιάζεται με το χέρι: το TREK ενώνει τα σημεία σας με ευθείες. Τίποτα δεν δρομολογείται και τίποτα δεν λέει ότι το νερό είναι πλωτό.',
|
||||
'tours.paddle.timeEstimated': 'Χρόνος κωπηλασίας (εκτίμηση)',
|
||||
'tours.paddle.timeEstimatedShort': 'Κωπηλασία',
|
||||
'tours.paddle.timeEstimatedInfoLabel': 'Πώς εκτιμάται ο χρόνος κωπηλασίας',
|
||||
'tours.paddle.timeEstimatedInfo':
|
||||
'Απόσταση διά την προγραμματισμένη ταχύτητα, σε ήρεμο νερό. Ρεύμα, άνεμος, μεταφορές και υδατοφράκτες δεν περιλαμβάνονται.',
|
||||
'tours.planner.reverse': 'Αντιστροφή κατεύθυνσης',
|
||||
'tours.planner.returnToStart': 'Επιστροφή στην αφετηρία',
|
||||
'tours.planner.alreadyLoop': 'Η διαδρομή επιστρέφει ήδη στην αφετηρία',
|
||||
'tours.planner.loopEnd': 'Επιστροφή στην αφετηρία',
|
||||
'tours.planner.freeLeg': 'Σχεδίαση του επόμενου τμήματος με το χέρι',
|
||||
'tours.planner.routeLeg': 'Νέα δρομολόγηση του επόμενου τμήματος',
|
||||
'tours.planner.freeLegBadge': 'Με το χέρι, χωρίς δρομολόγηση',
|
||||
'tours.planner.freeLegInfo':
|
||||
'Το TREK ενώνει αυτά τα δύο σημεία με ευθεία. Κανένας δρομολογητής δεν σχεδίασε ούτε έλεγξε αυτό το τμήμα.',
|
||||
'tours.map.seamarks': 'Ναυτικά σήματα',
|
||||
'tours.map.seamarksDown': 'Τα ναυτικά σήματα δεν φορτώνουν. Ο χάρτης και η διαδρομή σας λειτουργούν κανονικά.',
|
||||
'tours.import.previewTitle': 'Προεπισκόπηση GPX',
|
||||
'tours.import.previewHint': 'Δεν έχει αποθηκευτεί τίποτα ακόμη. Ελέγξτε τη διαδρομή και μετά κάντε εισαγωγή.',
|
||||
'tours.import.previewFiles': '{count} αρχεία',
|
||||
'tours.import.previewFiles.one': '{count} αρχείο',
|
||||
'tours.import.previewTracks': '{count} ίχνη',
|
||||
'tours.import.previewTracks.one': '{count} ίχνος',
|
||||
'tours.import.previewPoints': '{count} σημεία',
|
||||
'tours.import.previewPoints.one': '{count} σημείο',
|
||||
'tours.import.previewNoHeights': 'Χωρίς υψόμετρα στο αρχείο',
|
||||
'tours.import.previewDuplicate': 'Υπάρχει ήδη στο ταξίδι',
|
||||
'tours.import.previewAllDuplicates': 'Όλα τα ίχνη υπάρχουν ήδη στο ταξίδι, δεν υπάρχει τίποτα για εισαγωγή.',
|
||||
'tours.import.previewAs': 'Εισαγωγή ως',
|
||||
'tours.import.previewConfirm': 'Εισαγωγή {count} διαδρομών',
|
||||
'tours.import.previewConfirm.one': 'Εισαγωγή {count} διαδρομής',
|
||||
'tours.conversion.introPaddle':
|
||||
'Μια εισαγωγή είναι ένα καταγεγραμμένο ίχνος. Σχεδιάστε μια επεξεργάσιμη διαδρομή με κουπί: το TREK παίρνει σημεία ελέγχου, τα ενώνει με ευθείες, όπως σχεδιάζεται μια διαδρομή με κουπί, και δείχνει πόσο κοντά φτάνει. Τίποτα δεν αλλάζει μέχρι να επιλέξετε.',
|
||||
'tours.conversion.closePaddle':
|
||||
'Η πρόταση ακολουθεί στενά το ίχνος. Τα σημεία της ενώνονται με ευθείες, άρα δεν είναι η ίδια γραμμή.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -193,7 +193,7 @@ const tours: TranslationStrings = {
|
||||
'tourTypes.bike': 'Kerékpártúra',
|
||||
'tourTypes.run': 'Futás',
|
||||
'tourTypes.ski': 'Sítúra',
|
||||
'tourTypes.kayak': 'Kajakozás',
|
||||
'tourTypes.kayak': 'Evezős túra',
|
||||
'tourTypes.walk': 'Városi / városnéző séta',
|
||||
'tourTypes.food': 'Gasztro- / bortúra',
|
||||
'tours.terrain.play': 'Átrepülés lejátszása',
|
||||
@@ -277,5 +277,59 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (másolat)',
|
||||
'tours.copy.saved': 'Új túraként mentve',
|
||||
'tours.copy.failed': 'A túrát nem sikerült másolni.',
|
||||
'tours.planner.tourType': 'Túratípus',
|
||||
'tours.paddle.boat': 'Hajó',
|
||||
'tours.paddle.boat.kayak': 'Kajak',
|
||||
'tours.paddle.boat.canoe': 'Kenu',
|
||||
'tours.paddle.boat.sup': 'SUP', // same-as-en
|
||||
'tours.paddle.boat.other': 'Egyéb',
|
||||
'tours.paddle.water': 'Víz',
|
||||
'tours.paddle.water.river': 'Folyó',
|
||||
'tours.paddle.water.lake': 'Tó',
|
||||
'tours.paddle.speed': 'Evezési tempó',
|
||||
'tours.paddle.speedOwn': 'Saját tempó',
|
||||
'tours.paddle.speedUseSuggestion': 'A javasolt {speed} km/h használata',
|
||||
'tours.paddle.speedNeeded': 'Adj meg egy tempót a vízen töltött idő becsléséhez.',
|
||||
'tours.paddle.speedInvalid': 'Adj meg egy tempót 0,5 és 20 km/h között.',
|
||||
'tours.paddle.chooseBoat': 'Válaszd ki a hajót és a vizet a túra mentéséhez.',
|
||||
'tours.paddle.chooseBoatImport': 'Válaszd ki a hajót és a vizet az evezős túraként való importáláshoz.',
|
||||
'tours.paddle.noWaterData':
|
||||
'A TREK még nem mutatja a gátakat, zsilipeket és átemelőket. Indulás előtt magad ellenőrizd a vizet és a szabályokat.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Az evezős túrát kézzel rajzolod: a TREK egyenes vonalakkal köti össze a pontjaidat. Semmi sincs útvonalra tervezve, és semmi sem mondja, hogy a víz járható.',
|
||||
'tours.paddle.timeEstimated': 'Evezési idő (becslés)',
|
||||
'tours.paddle.timeEstimatedShort': 'Evezés',
|
||||
'tours.paddle.timeEstimatedInfoLabel': 'Így becsüljük az evezési időt',
|
||||
'tours.paddle.timeEstimatedInfo':
|
||||
'A táv osztva a tervezett tempóval, nyugodt vízen. Az áramlás, a szél, az átemelés és a zsilipek nincsenek benne.',
|
||||
'tours.planner.reverse': 'Irány megfordítása',
|
||||
'tours.planner.returnToStart': 'Vissza a kiindulópontra',
|
||||
'tours.planner.alreadyLoop': 'A túra már visszatér a kiindulópontra',
|
||||
'tours.planner.loopEnd': 'Vissza a kiindulópontra',
|
||||
'tours.planner.freeLeg': 'A következő szakasz kézi rajzolása',
|
||||
'tours.planner.routeLeg': 'A következő szakasz újratervezése',
|
||||
'tours.planner.freeLegBadge': 'Kézzel rajzolva, nem tervezett',
|
||||
'tours.planner.freeLegInfo':
|
||||
'A TREK egyenes vonallal köti össze ezt a két pontot. Útvonaltervező nem tervezte és nem ellenőrizte ezt a szakaszt.',
|
||||
'tours.map.seamarks': 'Hajózási jelek',
|
||||
'tours.map.seamarksDown': 'A hajózási jelek nem töltődnek be. A térkép és az útvonalad továbbra is működik.',
|
||||
'tours.import.previewTitle': 'GPX-előnézet',
|
||||
'tours.import.previewHint': 'Még semmi sincs mentve. Ellenőrizd a nyomvonalat, aztán importáld.',
|
||||
'tours.import.previewFiles': '{count} fájl',
|
||||
'tours.import.previewFiles.one': '{count} fájl',
|
||||
'tours.import.previewTracks': '{count} nyomvonal',
|
||||
'tours.import.previewTracks.one': '{count} nyomvonal',
|
||||
'tours.import.previewPoints': '{count} pont',
|
||||
'tours.import.previewPoints.one': '{count} pont',
|
||||
'tours.import.previewNoHeights': 'Nincsenek magasságok a fájlban',
|
||||
'tours.import.previewDuplicate': 'Már az utazásban van',
|
||||
'tours.import.previewAllDuplicates': 'Minden nyomvonal már az utazásban van, nincs mit importálni.',
|
||||
'tours.import.previewAs': 'Importálás mint',
|
||||
'tours.import.previewConfirm': '{count} túra importálása',
|
||||
'tours.import.previewConfirm.one': '{count} túra importálása',
|
||||
'tours.conversion.introPaddle':
|
||||
'Az import egy rögzített nyomvonal. Tervezz belőle szerkeszthető evezős túrát: a TREK kontrollpontokat vesz ki belőle, egyenes vonalakkal köti össze őket, ahogy az evezős túrát rajzolod, és megmutatja, mennyire közelíti meg. Semmi sem változik, amíg nem döntesz.',
|
||||
'tours.conversion.closePaddle':
|
||||
'A javaslat szorosan követi a nyomvonalat. Pontjait egyenes vonalak kötik össze, így nem ugyanaz a vonal.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -187,7 +187,7 @@ const tours: TranslationStrings = {
|
||||
'tourTypes.bike': 'Tur sepeda',
|
||||
'tourTypes.run': 'Lari',
|
||||
'tourTypes.ski': 'Tur ski',
|
||||
'tourTypes.kayak': 'Berkayak',
|
||||
'tourTypes.kayak': 'Tur dayung',
|
||||
'tourTypes.walk': 'Jalan-jalan kota / wisata',
|
||||
'tourTypes.food': 'Tur kuliner / anggur',
|
||||
'tours.terrain.play': 'Putar penerbangan',
|
||||
@@ -270,5 +270,55 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (salinan)',
|
||||
'tours.copy.saved': 'Disimpan sebagai tur baru',
|
||||
'tours.copy.failed': 'Tur tidak dapat disalin.',
|
||||
'tours.planner.tourType': 'Jenis tur',
|
||||
'tours.paddle.boat': 'Perahu',
|
||||
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
|
||||
'tours.paddle.boat.canoe': 'Kano',
|
||||
'tours.paddle.boat.sup': 'SUP', // same-as-en
|
||||
'tours.paddle.boat.other': 'Lainnya',
|
||||
'tours.paddle.water': 'Perairan',
|
||||
'tours.paddle.water.river': 'Sungai',
|
||||
'tours.paddle.water.lake': 'Danau',
|
||||
'tours.paddle.speed': 'Kecepatan mendayung',
|
||||
'tours.paddle.speedOwn': 'Kecepatan sendiri',
|
||||
'tours.paddle.speedUseSuggestion': 'Gunakan saran {speed} km/jam',
|
||||
'tours.paddle.speedNeeded': 'Masukkan kecepatan untuk memperkirakan waktu di air.',
|
||||
'tours.paddle.speedInvalid': 'Masukkan kecepatan dari 0,5 hingga 20 km/jam.',
|
||||
'tours.paddle.chooseBoat': 'Pilih perahu dan perairan untuk menyimpan tur dayung ini.',
|
||||
'tours.paddle.chooseBoatImport': 'Pilih perahu dan perairan untuk mengimpor sebagai tur dayung.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK belum menampilkan bendung, pintu air, atau titik angkut. Periksa sendiri perairan dan aturannya sebelum berangkat.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Tur dayung digambar dengan tangan: TREK menghubungkan titik-titikmu dengan garis lurus. Tidak ada yang dirutekan, dan tidak ada yang menyatakan perairan bisa didayung.',
|
||||
'tours.paddle.timeEstimated': 'Waktu mendayung (perkiraan)',
|
||||
'tours.paddle.timeEstimatedShort': 'Mendayung',
|
||||
'tours.paddle.timeEstimatedInfoLabel': 'Cara memperkirakan waktu mendayung',
|
||||
'tours.paddle.timeEstimatedInfo':
|
||||
'Jarak dibagi kecepatan rencana, di air tenang. Arus, angin, angkut perahu, dan pintu air tidak dihitung.',
|
||||
'tours.planner.reverse': 'Balik arah',
|
||||
'tours.planner.returnToStart': 'Kembali ke awal',
|
||||
'tours.planner.alreadyLoop': 'Tur sudah kembali ke titik awal',
|
||||
'tours.planner.loopEnd': 'Kembali ke awal',
|
||||
'tours.planner.freeLeg': 'Gambar ruas berikutnya dengan tangan',
|
||||
'tours.planner.routeLeg': 'Rutekan lagi ruas berikutnya',
|
||||
'tours.planner.freeLegBadge': 'Digambar tangan, tidak dirutekan',
|
||||
'tours.planner.freeLegInfo':
|
||||
'TREK menghubungkan dua titik ini dengan garis lurus. Tidak ada perute yang merencanakan atau memeriksa ruas ini.',
|
||||
'tours.map.seamarks': 'Rambu laut',
|
||||
'tours.map.seamarksDown': 'Rambu laut tidak termuat. Peta dan rutemu tetap berfungsi.',
|
||||
'tours.import.previewTitle': 'Pratinjau GPX',
|
||||
'tours.import.previewHint': 'Belum ada yang disimpan. Periksa jalurnya, lalu impor.',
|
||||
'tours.import.previewFiles': '{count} berkas',
|
||||
'tours.import.previewTracks': '{count} jejak',
|
||||
'tours.import.previewPoints': '{count} titik',
|
||||
'tours.import.previewNoHeights': 'Tidak ada ketinggian di berkas',
|
||||
'tours.import.previewDuplicate': 'Sudah ada di perjalanan',
|
||||
'tours.import.previewAllDuplicates': 'Semua jejak sudah ada di perjalanan, tidak ada yang perlu diimpor.',
|
||||
'tours.import.previewAs': 'Impor sebagai',
|
||||
'tours.import.previewConfirm': 'Impor {count} tur',
|
||||
'tours.conversion.introPaddle':
|
||||
'Impor adalah jejak yang direkam. Rencanakan tur dayung yang bisa diedit: TREK mengambil titik kontrol darinya, menghubungkannya dengan garis lurus seperti tur dayung digambar, dan menunjukkan seberapa dekat hasilnya. Tidak ada yang berubah sampai kamu memilih.',
|
||||
'tours.conversion.closePaddle':
|
||||
'Usulan mengikuti jejak dengan dekat. Titik-titiknya dihubungkan garis lurus, jadi bukan garis yang sama.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -193,7 +193,7 @@ const tours: TranslationStrings = {
|
||||
'tourTypes.bike': 'Escursione in bici',
|
||||
'tourTypes.run': 'Corsa',
|
||||
'tourTypes.ski': 'Escursione con gli sci',
|
||||
'tourTypes.kayak': 'Escursione in kayak',
|
||||
'tourTypes.kayak': 'Uscita in pagaia',
|
||||
'tourTypes.walk': 'Passeggiata urbana / turistica',
|
||||
'tourTypes.food': 'Tour enogastronomico',
|
||||
'tours.terrain.play': 'Avvia il sorvolo',
|
||||
@@ -277,5 +277,59 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (copia)',
|
||||
'tours.copy.saved': 'Salvato come nuovo tour',
|
||||
'tours.copy.failed': 'Non è stato possibile copiare il tour.',
|
||||
'tours.planner.tourType': 'Tipo di tour',
|
||||
'tours.paddle.boat': 'Imbarcazione',
|
||||
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
|
||||
'tours.paddle.boat.canoe': 'Canoa',
|
||||
'tours.paddle.boat.sup': 'SUP', // same-as-en
|
||||
'tours.paddle.boat.other': 'Altro',
|
||||
'tours.paddle.water': 'Acqua',
|
||||
'tours.paddle.water.river': 'Fiume',
|
||||
'tours.paddle.water.lake': 'Lago',
|
||||
'tours.paddle.speed': 'Velocità di pagaiata',
|
||||
'tours.paddle.speedOwn': 'Velocità propria',
|
||||
'tours.paddle.speedUseSuggestion': 'Usa il suggerimento di {speed} km/h',
|
||||
'tours.paddle.speedNeeded': 'Inserisci una velocità per stimare il tempo in acqua.',
|
||||
'tours.paddle.speedInvalid': 'Inserisci una velocità da 0,5 a 20 km/h.',
|
||||
'tours.paddle.chooseBoat': 'Scegli imbarcazione e acqua per salvare questa uscita.',
|
||||
'tours.paddle.chooseBoatImport': 'Scegli imbarcazione e acqua per importare come uscita in pagaia.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK non mostra ancora sbarramenti, chiuse e trasbordi. Controlla tu stesso l’acqua e le sue regole prima di partire.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Un’uscita in pagaia si disegna a mano: TREK collega i tuoi punti con linee rette. Niente viene calcolato e niente dice che l’acqua sia navigabile.',
|
||||
'tours.paddle.timeEstimated': 'Tempo di pagaiata (stimato)',
|
||||
'tours.paddle.timeEstimatedShort': 'Pagaiata',
|
||||
'tours.paddle.timeEstimatedInfoLabel': 'Come si stima il tempo di pagaiata',
|
||||
'tours.paddle.timeEstimatedInfo':
|
||||
'Distanza divisa per la velocità prevista, su acqua calma. Corrente, vento, trasbordi e chiuse non sono inclusi.',
|
||||
'tours.planner.reverse': 'Inverti direzione',
|
||||
'tours.planner.returnToStart': 'Ritorno al punto di partenza',
|
||||
'tours.planner.alreadyLoop': 'Il tour torna già alla partenza',
|
||||
'tours.planner.loopEnd': 'Ritorno alla partenza',
|
||||
'tours.planner.freeLeg': 'Disegna a mano il tratto successivo',
|
||||
'tours.planner.routeLeg': 'Calcola di nuovo il tratto successivo',
|
||||
'tours.planner.freeLegBadge': 'Disegnato a mano, non calcolato',
|
||||
'tours.planner.freeLegInfo':
|
||||
'TREK collega questi due punti con una linea retta. Nessun router ha pianificato o verificato questo tratto.',
|
||||
'tours.map.seamarks': 'Segnali nautici',
|
||||
'tours.map.seamarksDown': 'I segnali nautici non si caricano. La mappa e il percorso funzionano ancora.',
|
||||
'tours.import.previewTitle': 'Anteprima GPX',
|
||||
'tours.import.previewHint': 'Non è ancora stato salvato nulla. Controlla il percorso, poi importa.',
|
||||
'tours.import.previewFiles': '{count} file',
|
||||
'tours.import.previewFiles.one': '{count} file', // same-as-en
|
||||
'tours.import.previewTracks': '{count} tracce',
|
||||
'tours.import.previewTracks.one': '{count} traccia',
|
||||
'tours.import.previewPoints': '{count} punti',
|
||||
'tours.import.previewPoints.one': '{count} punto',
|
||||
'tours.import.previewNoHeights': 'Nessuna quota nel file',
|
||||
'tours.import.previewDuplicate': 'Già nel viaggio',
|
||||
'tours.import.previewAllDuplicates': 'Tutte le tracce sono già nel viaggio, non c’è nulla da importare.',
|
||||
'tours.import.previewAs': 'Importa come',
|
||||
'tours.import.previewConfirm': 'Importa {count} tour',
|
||||
'tours.import.previewConfirm.one': 'Importa {count} tour',
|
||||
'tours.conversion.introPaddle':
|
||||
'Un import è una traccia registrata. Pianifica un’uscita in pagaia modificabile: TREK ne ricava punti di controllo, li collega con linee rette come si disegna un’uscita in pagaia e mostra quanto si avvicina. Nulla cambia finché non scegli.',
|
||||
'tours.conversion.closePaddle':
|
||||
'La proposta segue da vicino la traccia. I suoi punti sono collegati da linee rette, quindi non è la stessa linea.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -184,7 +184,7 @@ const tours: TranslationStrings = {
|
||||
'tourTypes.bike': 'サイクリング',
|
||||
'tourTypes.run': 'ランニング',
|
||||
'tourTypes.ski': 'スキーツアー',
|
||||
'tourTypes.kayak': 'カヤック',
|
||||
'tourTypes.kayak': 'パドルツアー',
|
||||
'tourTypes.walk': '街歩き・観光',
|
||||
'tourTypes.food': 'グルメ・ワインツアー',
|
||||
'tours.terrain.play': 'フライオーバーを再生',
|
||||
@@ -264,5 +264,55 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name}(コピー)',
|
||||
'tours.copy.saved': '新しいツアーとして保存しました',
|
||||
'tours.copy.failed': 'ツアーをコピーできませんでした。',
|
||||
'tours.planner.tourType': 'ツアーの種類',
|
||||
'tours.paddle.boat': 'ボート',
|
||||
'tours.paddle.boat.kayak': 'カヤック',
|
||||
'tours.paddle.boat.canoe': 'カナディアンカヌー',
|
||||
'tours.paddle.boat.sup': 'SUP',
|
||||
'tours.paddle.boat.other': 'その他',
|
||||
'tours.paddle.water': '水域',
|
||||
'tours.paddle.water.river': '川',
|
||||
'tours.paddle.water.lake': '湖',
|
||||
'tours.paddle.speed': 'パドル速度',
|
||||
'tours.paddle.speedOwn': '自分の速度',
|
||||
'tours.paddle.speedUseSuggestion': '提案の {speed} km/h を使う',
|
||||
'tours.paddle.speedNeeded': '水上の時間を見積もるには速度を入力してください。',
|
||||
'tours.paddle.speedInvalid': '0.5〜20 km/h の速度を入力してください。',
|
||||
'tours.paddle.chooseBoat': 'このパドルツアーを保存するには、ボートと水域を選んでください。',
|
||||
'tours.paddle.chooseBoatImport': 'パドルツアーとして取り込むには、ボートと水域を選んでください。',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK はまだ堰、閘門、陸上運搬区間を表示しません。出発前に水域とそのルールをご自身で確認してください。',
|
||||
'tours.paddle.drawnByHand':
|
||||
'パドルツアーは手で描きます。TREK はポイントを直線で結ぶだけで、ルート計算はせず、その水域を漕げることも示しません。',
|
||||
'tours.paddle.timeEstimated': 'パドル時間(推定)',
|
||||
'tours.paddle.timeEstimatedShort': 'パドル',
|
||||
'tours.paddle.timeEstimatedInfoLabel': 'パドル時間の見積もり方',
|
||||
'tours.paddle.timeEstimatedInfo':
|
||||
'距離を計画速度で割った値で、静水が前提です。流れ、風、陸上運搬、閘門は含まれません。',
|
||||
'tours.planner.reverse': '方向を反転',
|
||||
'tours.planner.returnToStart': 'スタートに戻る',
|
||||
'tours.planner.alreadyLoop': 'このツアーはすでにスタートに戻ります',
|
||||
'tours.planner.loopEnd': 'スタートへ戻る',
|
||||
'tours.planner.freeLeg': '次の区間を手で描く',
|
||||
'tours.planner.routeLeg': '次の区間を再びルート計算する',
|
||||
'tours.planner.freeLegBadge': '手描き、ルート計算なし',
|
||||
'tours.planner.freeLegInfo':
|
||||
'TREK はこの 2 点を直線で結びます。この区間はどのルート計算でも計画・確認されていません。',
|
||||
'tours.map.seamarks': '航路標識',
|
||||
'tours.map.seamarksDown': '航路標識を読み込めません。地図とルートはそのまま使えます。',
|
||||
'tours.import.previewTitle': 'GPX プレビュー',
|
||||
'tours.import.previewHint': 'まだ何も保存されていません。コースを確認してから取り込んでください。',
|
||||
'tours.import.previewFiles': '{count} 件のファイル',
|
||||
'tours.import.previewTracks': '{count} 本のトラック',
|
||||
'tours.import.previewPoints': '{count} 個のポイント',
|
||||
'tours.import.previewNoHeights': 'ファイルに標高がありません',
|
||||
'tours.import.previewDuplicate': 'すでに旅行にあります',
|
||||
'tours.import.previewAllDuplicates': 'すべてのトラックがすでに旅行にあるため、取り込むものはありません。',
|
||||
'tours.import.previewAs': '取り込む種類',
|
||||
'tours.import.previewConfirm': '{count} 件のツアーを取り込む',
|
||||
'tours.conversion.introPaddle':
|
||||
'インポートは記録されたトラックです。編集できるパドルツアーを計画すると、TREK はそこから制御点を取り出し、パドルツアーの描き方どおり直線で結び、どれだけ近いかを示します。選ぶまで何も変わりません。',
|
||||
'tours.conversion.closePaddle':
|
||||
'提案はトラックによく沿っています。ポイントは直線で結ばれているため、同じ線ではありません。',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -182,7 +182,7 @@ const tours: TranslationStrings = {
|
||||
'tourTypes.bike': '자전거 투어',
|
||||
'tourTypes.run': '달리기',
|
||||
'tourTypes.ski': '스키 투어',
|
||||
'tourTypes.kayak': '카약',
|
||||
'tourTypes.kayak': '패들 투어',
|
||||
'tourTypes.walk': '도심 산책 / 관광',
|
||||
'tourTypes.food': '미식 / 와인 투어',
|
||||
'tours.terrain.play': '비행 재생',
|
||||
@@ -262,5 +262,54 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (사본)',
|
||||
'tours.copy.saved': '새 투어로 저장했습니다',
|
||||
'tours.copy.failed': '투어를 복사하지 못했습니다.',
|
||||
'tours.planner.tourType': '투어 유형',
|
||||
'tours.paddle.boat': '보트',
|
||||
'tours.paddle.boat.kayak': '카약',
|
||||
'tours.paddle.boat.canoe': '카누',
|
||||
'tours.paddle.boat.sup': 'SUP',
|
||||
'tours.paddle.boat.other': '기타',
|
||||
'tours.paddle.water': '수역',
|
||||
'tours.paddle.water.river': '강',
|
||||
'tours.paddle.water.lake': '호수',
|
||||
'tours.paddle.speed': '패들 속도',
|
||||
'tours.paddle.speedOwn': '내 속도',
|
||||
'tours.paddle.speedUseSuggestion': '추천 속도 {speed} km/h 사용',
|
||||
'tours.paddle.speedNeeded': '물 위에서 걸리는 시간을 추정하려면 속도를 입력하세요.',
|
||||
'tours.paddle.speedInvalid': '0.5~20 km/h 사이의 속도를 입력하세요.',
|
||||
'tours.paddle.chooseBoat': '이 패들 투어를 저장하려면 보트와 수역을 선택하세요.',
|
||||
'tours.paddle.chooseBoatImport': '패들 투어로 가져오려면 보트와 수역을 선택하세요.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK은 아직 보, 갑문, 육상 운반 구간을 표시하지 않습니다. 출발 전에 수역과 규칙을 직접 확인하세요.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'패들 투어는 손으로 그립니다. TREK은 지점들을 직선으로 이을 뿐 경로를 계산하지 않으며, 그 물길을 지날 수 있다고 알려주지도 않습니다.',
|
||||
'tours.paddle.timeEstimated': '패들 시간(추정)',
|
||||
'tours.paddle.timeEstimatedShort': '패들',
|
||||
'tours.paddle.timeEstimatedInfoLabel': '패들 시간을 추정하는 방법',
|
||||
'tours.paddle.timeEstimatedInfo':
|
||||
'잔잔한 물을 기준으로 거리를 계획 속도로 나눈 값입니다. 물살, 바람, 육상 운반, 갑문은 포함되지 않습니다.',
|
||||
'tours.planner.reverse': '방향 뒤집기',
|
||||
'tours.planner.returnToStart': '출발점으로 돌아가기',
|
||||
'tours.planner.alreadyLoop': '투어가 이미 출발점으로 돌아갑니다',
|
||||
'tours.planner.loopEnd': '출발점으로 복귀',
|
||||
'tours.planner.freeLeg': '다음 구간을 손으로 그리기',
|
||||
'tours.planner.routeLeg': '다음 구간을 다시 경로 계산',
|
||||
'tours.planner.freeLegBadge': '손으로 그림, 경로 계산 안 됨',
|
||||
'tours.planner.freeLegInfo':
|
||||
'TREK은 이 두 지점을 직선으로 잇습니다. 어떤 경로 계산도 이 구간을 계획하거나 확인하지 않았습니다.',
|
||||
'tours.map.seamarks': '항로 표지',
|
||||
'tours.map.seamarksDown': '항로 표지를 불러오지 못했습니다. 지도와 경로는 계속 사용할 수 있습니다.',
|
||||
'tours.import.previewTitle': 'GPX 미리보기',
|
||||
'tours.import.previewHint': '아직 저장된 것이 없습니다. 경로를 확인한 뒤 가져오세요.',
|
||||
'tours.import.previewFiles': '파일 {count}개',
|
||||
'tours.import.previewTracks': '트랙 {count}개',
|
||||
'tours.import.previewPoints': '지점 {count}개',
|
||||
'tours.import.previewNoHeights': '파일에 고도가 없습니다',
|
||||
'tours.import.previewDuplicate': '이미 여행에 있음',
|
||||
'tours.import.previewAllDuplicates': '모든 트랙이 이미 여행에 있어 가져올 것이 없습니다.',
|
||||
'tours.import.previewAs': '가져올 유형',
|
||||
'tours.import.previewConfirm': '투어 {count}개 가져오기',
|
||||
'tours.conversion.introPaddle':
|
||||
'가져온 항목은 기록된 트랙입니다. 편집 가능한 패들 투어를 계획하면 TREK이 트랙에서 제어 지점을 뽑아 패들 투어를 그리듯 직선으로 잇고, 얼마나 가까운지 보여 줍니다. 선택하기 전까지는 아무것도 바뀌지 않습니다.',
|
||||
'tours.conversion.closePaddle': '제안이 트랙을 가깝게 따릅니다. 지점들이 직선으로 이어져 있어 같은 선은 아닙니다.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -193,7 +193,7 @@ const tours: TranslationStrings = {
|
||||
'tourTypes.bike': 'Fietstocht',
|
||||
'tourTypes.run': 'Hardlooptocht',
|
||||
'tourTypes.ski': 'Skitocht',
|
||||
'tourTypes.kayak': 'Kajaktocht',
|
||||
'tourTypes.kayak': 'Peddeltocht',
|
||||
'tourTypes.walk': 'Stadswandeling / bezienswaardigheden',
|
||||
'tourTypes.food': 'Culinaire tocht / wijntocht',
|
||||
'tours.terrain.play': 'Overvlucht afspelen',
|
||||
@@ -278,5 +278,59 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (kopie)',
|
||||
'tours.copy.saved': 'Opgeslagen als nieuwe tocht',
|
||||
'tours.copy.failed': 'De tocht kon niet worden gekopieerd.',
|
||||
'tours.planner.tourType': 'Soort tocht',
|
||||
'tours.paddle.boat': 'Boot',
|
||||
'tours.paddle.boat.kayak': 'Kajak',
|
||||
'tours.paddle.boat.canoe': 'Canadese kano',
|
||||
'tours.paddle.boat.sup': 'SUP', // same-as-en
|
||||
'tours.paddle.boat.other': 'Overig',
|
||||
'tours.paddle.water': 'Water', // same-as-en
|
||||
'tours.paddle.water.river': 'Rivier',
|
||||
'tours.paddle.water.lake': 'Meer',
|
||||
'tours.paddle.speed': 'Peddeltempo',
|
||||
'tours.paddle.speedOwn': 'Eigen tempo',
|
||||
'tours.paddle.speedUseSuggestion': 'Voorstel van {speed} km/h gebruiken',
|
||||
'tours.paddle.speedNeeded': 'Vul een tempo in om de tijd op het water te schatten.',
|
||||
'tours.paddle.speedInvalid': 'Vul een tempo van 0,5 tot 20 km/h in.',
|
||||
'tours.paddle.chooseBoat': 'Kies boot en water om deze peddeltocht op te slaan.',
|
||||
'tours.paddle.chooseBoatImport': 'Kies boot en water om als peddeltocht te importeren.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK toont nog geen stuwen, sluizen of overdraagplaatsen. Controleer het water en de regels zelf voor je vertrekt.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Een peddeltocht teken je met de hand: TREK verbindt je punten met rechte lijnen. Er wordt niets gerouteerd en niets zegt dat het water bevaarbaar is.',
|
||||
'tours.paddle.timeEstimated': 'Peddeltijd (geschat)',
|
||||
'tours.paddle.timeEstimatedShort': 'Peddelen',
|
||||
'tours.paddle.timeEstimatedInfoLabel': 'Zo wordt de peddeltijd geschat',
|
||||
'tours.paddle.timeEstimatedInfo':
|
||||
'Afstand gedeeld door het plantempo, op kalm water. Stroming, wind, overdragen en sluizen zitten er niet in.',
|
||||
'tours.planner.reverse': 'Richting omkeren',
|
||||
'tours.planner.returnToStart': 'Terug naar start',
|
||||
'tours.planner.alreadyLoop': 'De tocht keert al terug naar de start',
|
||||
'tours.planner.loopEnd': 'Terug naar start',
|
||||
'tours.planner.freeLeg': 'Volgende stuk met de hand tekenen',
|
||||
'tours.planner.routeLeg': 'Volgende stuk weer routeren',
|
||||
'tours.planner.freeLegBadge': 'Met de hand getekend, niet gerouteerd',
|
||||
'tours.planner.freeLegInfo':
|
||||
'TREK verbindt deze twee punten met een rechte lijn. Geen router heeft dit stuk gepland of gecontroleerd.',
|
||||
'tours.map.seamarks': 'Zeemarkeringen',
|
||||
'tours.map.seamarksDown': 'Zeemarkeringen laden niet. De kaart en je route werken nog.',
|
||||
'tours.import.previewTitle': 'GPX-voorbeeld',
|
||||
'tours.import.previewHint': 'Er is nog niets opgeslagen. Controleer het verloop en importeer dan.',
|
||||
'tours.import.previewFiles': '{count} bestanden',
|
||||
'tours.import.previewFiles.one': '{count} bestand',
|
||||
'tours.import.previewTracks': '{count} tracks', // same-as-en
|
||||
'tours.import.previewTracks.one': '{count} track', // same-as-en
|
||||
'tours.import.previewPoints': '{count} punten',
|
||||
'tours.import.previewPoints.one': '{count} punt',
|
||||
'tours.import.previewNoHeights': 'Geen hoogtes in het bestand',
|
||||
'tours.import.previewDuplicate': 'Al in de reis',
|
||||
'tours.import.previewAllDuplicates': 'Alle tracks staan al in de reis, er is niets te importeren.',
|
||||
'tours.import.previewAs': 'Importeren als',
|
||||
'tours.import.previewConfirm': '{count} tochten importeren',
|
||||
'tours.import.previewConfirm.one': '{count} tocht importeren',
|
||||
'tours.conversion.introPaddle':
|
||||
'Een import is een opgenomen track. Plan een bewerkbare peddeltocht: TREK haalt er controlepunten uit, verbindt ze met rechte lijnen zoals een peddeltocht wordt getekend en laat zien hoe dicht dat in de buurt komt. Er verandert niets tot je kiest.',
|
||||
'tours.conversion.closePaddle':
|
||||
'Het voorstel volgt de track nauw. De punten zijn met rechte lijnen verbonden, dus het is niet dezelfde lijn.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -201,7 +201,7 @@ const tours: TranslationStrings = {
|
||||
'tourTypes.bike': 'Wycieczka rowerowa',
|
||||
'tourTypes.run': 'Bieg',
|
||||
'tourTypes.ski': 'Wycieczka skiturowa',
|
||||
'tourTypes.kayak': 'Kajak',
|
||||
'tourTypes.kayak': 'Spływ',
|
||||
'tourTypes.walk': 'Spacer po mieście / zwiedzanie',
|
||||
'tourTypes.food': 'Wycieczka kulinarna / winiarska',
|
||||
'tours.terrain.play': 'Odtwórz przelot',
|
||||
@@ -284,5 +284,67 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (kopia)',
|
||||
'tours.copy.saved': 'Zapisano jako nową trasę',
|
||||
'tours.copy.failed': 'Nie udało się skopiować trasy.',
|
||||
'tours.planner.tourType': 'Rodzaj wycieczki',
|
||||
'tours.paddle.boat': 'Łódź',
|
||||
'tours.paddle.boat.kayak': 'Kajak',
|
||||
'tours.paddle.boat.canoe': 'Kanadyjka',
|
||||
'tours.paddle.boat.sup': 'SUP', // same-as-en
|
||||
'tours.paddle.boat.other': 'Inna',
|
||||
'tours.paddle.water': 'Woda',
|
||||
'tours.paddle.water.river': 'Rzeka',
|
||||
'tours.paddle.water.lake': 'Jezioro',
|
||||
'tours.paddle.speed': 'Tempo wiosłowania',
|
||||
'tours.paddle.speedOwn': 'Własne tempo',
|
||||
'tours.paddle.speedUseSuggestion': 'Użyj sugerowanych {speed} km/h',
|
||||
'tours.paddle.speedNeeded': 'Podaj tempo, aby oszacować czas na wodzie.',
|
||||
'tours.paddle.speedInvalid': 'Podaj tempo od 0,5 do 20 km/h.',
|
||||
'tours.paddle.chooseBoat': 'Wybierz łódź i wodę, aby zapisać ten spływ.',
|
||||
'tours.paddle.chooseBoatImport': 'Wybierz łódź i wodę, aby zaimportować jako spływ.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK nie pokazuje jeszcze jazów, śluz ani przenosek. Przed wyruszeniem sprawdź sam wodę i obowiązujące zasady.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Spływ rysuje się ręcznie: TREK łączy Twoje punkty prostymi liniami. Nic nie jest wyznaczane i nic nie mówi, że woda jest spławna.',
|
||||
'tours.paddle.timeEstimated': 'Czas na wodzie (szacunkowo)',
|
||||
'tours.paddle.timeEstimatedShort': 'Wiosłowanie',
|
||||
'tours.paddle.timeEstimatedInfoLabel': 'Jak szacowany jest czas na wodzie',
|
||||
'tours.paddle.timeEstimatedInfo':
|
||||
'Odległość podzielona przez planowane tempo, na spokojnej wodzie. Nurt, wiatr, przenoski i śluzy nie są wliczone.',
|
||||
'tours.planner.reverse': 'Odwróć kierunek',
|
||||
'tours.planner.returnToStart': 'Powrót do startu',
|
||||
'tours.planner.alreadyLoop': 'Wycieczka już wraca do startu',
|
||||
'tours.planner.loopEnd': 'Powrót do startu',
|
||||
'tours.planner.freeLeg': 'Narysuj następny odcinek ręcznie',
|
||||
'tours.planner.routeLeg': 'Wyznacz następny odcinek ponownie',
|
||||
'tours.planner.freeLegBadge': 'Narysowane ręcznie, nie wyznaczone',
|
||||
'tours.planner.freeLegInfo':
|
||||
'TREK łączy te dwa punkty prostą linią. Żaden planer trasy nie zaplanował ani nie sprawdził tego odcinka.',
|
||||
'tours.map.seamarks': 'Znaki nawigacyjne',
|
||||
'tours.map.seamarksDown': 'Znaki nawigacyjne się nie wczytują. Mapa i Twoja trasa nadal działają.',
|
||||
'tours.import.previewTitle': 'Podgląd GPX',
|
||||
'tours.import.previewHint': 'Nic jeszcze nie zapisano. Sprawdź przebieg, a potem zaimportuj.',
|
||||
'tours.import.previewFiles': '{count} pliku',
|
||||
'tours.import.previewFiles.one': '{count} plik',
|
||||
'tours.import.previewFiles.few': '{count} pliki',
|
||||
'tours.import.previewFiles.many': '{count} plików',
|
||||
'tours.import.previewTracks': '{count} śladu',
|
||||
'tours.import.previewTracks.one': '{count} ślad',
|
||||
'tours.import.previewTracks.few': '{count} ślady',
|
||||
'tours.import.previewTracks.many': '{count} śladów',
|
||||
'tours.import.previewPoints': '{count} punktu',
|
||||
'tours.import.previewPoints.one': '{count} punkt',
|
||||
'tours.import.previewPoints.few': '{count} punkty',
|
||||
'tours.import.previewPoints.many': '{count} punktów',
|
||||
'tours.import.previewNoHeights': 'Brak wysokości w pliku',
|
||||
'tours.import.previewDuplicate': 'Już jest w podróży',
|
||||
'tours.import.previewAllDuplicates': 'Wszystkie ślady są już w podróży, nie ma czego importować.',
|
||||
'tours.import.previewAs': 'Importuj jako',
|
||||
'tours.import.previewConfirm': 'Importuj {count} wycieczki',
|
||||
'tours.import.previewConfirm.one': 'Importuj {count} wycieczkę',
|
||||
'tours.import.previewConfirm.few': 'Importuj {count} wycieczki',
|
||||
'tours.import.previewConfirm.many': 'Importuj {count} wycieczek',
|
||||
'tours.conversion.introPaddle':
|
||||
'Import to nagrany ślad. Zaplanuj edytowalny spływ: TREK pobiera z niego punkty kontrolne, łączy je prostymi liniami, tak jak rysuje się spływ, i pokazuje, jak blisko to wypada. Nic się nie zmienia, dopóki nie wybierzesz.',
|
||||
'tours.conversion.closePaddle':
|
||||
'Propozycja ściśle trzyma się śladu. Jej punkty łączą proste linie, więc to nie jest ta sama linia.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -203,7 +203,7 @@ const tours: TranslationStrings = {
|
||||
'tourTypes.bike': 'Велотур',
|
||||
'tourTypes.run': 'Пробежка',
|
||||
'tourTypes.ski': 'Лыжный тур',
|
||||
'tourTypes.kayak': 'Каякинг',
|
||||
'tourTypes.kayak': 'Сплав',
|
||||
'tourTypes.walk': 'Прогулка по городу / достопримечательностям',
|
||||
'tourTypes.food': 'Гастрономический / винный тур',
|
||||
'tours.terrain.play': 'Запустить облёт',
|
||||
@@ -287,5 +287,67 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (копия)',
|
||||
'tours.copy.saved': 'Сохранено как новый поход',
|
||||
'tours.copy.failed': 'Не удалось скопировать поход.',
|
||||
'tours.planner.tourType': 'Тип тура',
|
||||
'tours.paddle.boat': 'Лодка',
|
||||
'tours.paddle.boat.kayak': 'Каяк',
|
||||
'tours.paddle.boat.canoe': 'Каноэ',
|
||||
'tours.paddle.boat.sup': 'SUP',
|
||||
'tours.paddle.boat.other': 'Другая',
|
||||
'tours.paddle.water': 'Водоём',
|
||||
'tours.paddle.water.river': 'Река',
|
||||
'tours.paddle.water.lake': 'Озеро',
|
||||
'tours.paddle.speed': 'Скорость гребли',
|
||||
'tours.paddle.speedOwn': 'Своя скорость',
|
||||
'tours.paddle.speedUseSuggestion': 'Взять предложенные {speed} км/ч',
|
||||
'tours.paddle.speedNeeded': 'Введите скорость, чтобы оценить время на воде.',
|
||||
'tours.paddle.speedInvalid': 'Введите скорость от 0,5 до 20 км/ч.',
|
||||
'tours.paddle.chooseBoat': 'Выберите лодку и водоём, чтобы сохранить этот сплав.',
|
||||
'tours.paddle.chooseBoatImport': 'Выберите лодку и водоём, чтобы импортировать как сплав.',
|
||||
'tours.paddle.noWaterData':
|
||||
'TREK пока не показывает плотины, шлюзы и обносы. Перед выходом сами проверьте водоём и правила на нём.',
|
||||
'tours.paddle.drawnByHand':
|
||||
'Сплав рисуется вручную: TREK соединяет ваши точки прямыми линиями. Ничего не прокладывается, и ничто не говорит, что по воде можно пройти.',
|
||||
'tours.paddle.timeEstimated': 'Время на воде (оценка)',
|
||||
'tours.paddle.timeEstimatedShort': 'Гребля',
|
||||
'tours.paddle.timeEstimatedInfoLabel': 'Как оценивается время на воде',
|
||||
'tours.paddle.timeEstimatedInfo':
|
||||
'Расстояние, делённое на плановую скорость, на спокойной воде. Течение, ветер, обносы и шлюзы не учтены.',
|
||||
'tours.planner.reverse': 'Развернуть направление',
|
||||
'tours.planner.returnToStart': 'Вернуться к старту',
|
||||
'tours.planner.alreadyLoop': 'Тур уже возвращается к старту',
|
||||
'tours.planner.loopEnd': 'Возврат к старту',
|
||||
'tours.planner.freeLeg': 'Нарисовать следующий участок вручную',
|
||||
'tours.planner.routeLeg': 'Снова проложить следующий участок',
|
||||
'tours.planner.freeLegBadge': 'Нарисовано вручную, не проложено',
|
||||
'tours.planner.freeLegInfo':
|
||||
'TREK соединяет эти две точки прямой линией. Ни один маршрутизатор не прокладывал и не проверял этот участок.',
|
||||
'tours.map.seamarks': 'Навигационные знаки',
|
||||
'tours.map.seamarksDown': 'Навигационные знаки не загружаются. Карта и ваш маршрут работают дальше.',
|
||||
'tours.import.previewTitle': 'Предпросмотр GPX',
|
||||
'tours.import.previewHint': 'Пока ничего не сохранено. Проверьте маршрут и затем импортируйте.',
|
||||
'tours.import.previewFiles': '{count} файла',
|
||||
'tours.import.previewFiles.one': '{count} файл',
|
||||
'tours.import.previewFiles.few': '{count} файла',
|
||||
'tours.import.previewFiles.many': '{count} файлов',
|
||||
'tours.import.previewTracks': '{count} трека',
|
||||
'tours.import.previewTracks.one': '{count} трек',
|
||||
'tours.import.previewTracks.few': '{count} трека',
|
||||
'tours.import.previewTracks.many': '{count} треков',
|
||||
'tours.import.previewPoints': '{count} точки',
|
||||
'tours.import.previewPoints.one': '{count} точка',
|
||||
'tours.import.previewPoints.few': '{count} точки',
|
||||
'tours.import.previewPoints.many': '{count} точек',
|
||||
'tours.import.previewNoHeights': 'В файле нет высот',
|
||||
'tours.import.previewDuplicate': 'Уже в поездке',
|
||||
'tours.import.previewAllDuplicates': 'Все треки уже в поездке, импортировать нечего.',
|
||||
'tours.import.previewAs': 'Импортировать как',
|
||||
'tours.import.previewConfirm': 'Импортировать {count} тура',
|
||||
'tours.import.previewConfirm.one': 'Импортировать {count} тур',
|
||||
'tours.import.previewConfirm.few': 'Импортировать {count} тура',
|
||||
'tours.import.previewConfirm.many': 'Импортировать {count} туров',
|
||||
'tours.conversion.introPaddle':
|
||||
'Импорт — это записанный трек. Спланируйте редактируемый сплав: TREK берёт из него контрольные точки, соединяет их прямыми линиями, как рисуется сплав, и показывает, насколько это близко. Ничего не меняется, пока вы не решите.',
|
||||
'tours.conversion.closePaddle':
|
||||
'Предложение близко следует треку. Его точки соединены прямыми линиями, поэтому это не та же линия.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user