diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index b039afbe0..a69f961ca 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -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, diff --git a/client/src/api/client.ts b/client/src/api/client.ts index bff0db6b0..49dc376ae 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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 => apiClient.put(`/trips/${tripId}/tours/${placeId}`, body).then(r => r.data), - importGpx: (tripId: number | string, file: File): Promise => { - const fd = new FormData() - fd.append('file', file) - return postMultipart(`/trips/${tripId}/tours/import/gpx`, fd) - }, } export const assignmentsApi = { diff --git a/client/src/api/tourGpx.ts b/client/src/api/tourGpx.ts new file mode 100644 index 000000000..a700669cb --- /dev/null +++ b/client/src/api/tourGpx.ts @@ -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 => + postMultipart(`/trips/${tripId}/tours/import/gpx/preview`, gpxForm(file)), + import: (tripId: number | string, file: File, fields?: TourGpxImportFields): Promise => + postMultipart(`/trips/${tripId}/tours/import/gpx`, gpxForm(file, fields)), +}; diff --git a/client/src/components/Map/MapLayerSwitcher.tsx b/client/src/components/Map/MapLayerSwitcher.tsx index b6e6689f6..be5a35a93 100644 --- a/client/src/components/Map/MapLayerSwitcher.tsx +++ b/client/src/components/Map/MapLayerSwitcher.tsx @@ -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 = { + 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 ( + <> + + + + {down ? t(look.downKey) : ''} + + ) +} + /** * 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 (
- {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({ ) })} - {(ferrataLayer || onToggleTerrain || placeTool) && } + {(ferrataLayer || onToggleTerrain || placeTool || !!overlays?.length) && } {placeTool && } {ferrataLayer && } + {overlays?.map(overlay => )} {onToggleTerrain && (
diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 7d2f86c0b..590960974 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -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(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 && } {hasViewBaseLayer ? + onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} layers={tourLayers} /> : } {/* Hover tooltip — cursor-following name/category/address card, identical to diff --git a/client/src/components/Map/RasterOverlayLeaflet.tsx b/client/src/components/Map/RasterOverlayLeaflet.tsx new file mode 100644 index 000000000..6c1c9d80c --- /dev/null +++ b/client/src/components/Map/RasterOverlayLeaflet.tsx @@ -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) => ( + + ))} + + ); +} + +/** 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 ( + <> + + + + ); +} diff --git a/client/src/components/Map/glRasterLayers.ts b/client/src/components/Map/glRasterLayers.ts new file mode 100644 index 000000000..128eedbcf --- /dev/null +++ b/client/src/components/Map/glRasterLayers.ts @@ -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 */ + } +} diff --git a/client/src/components/Map/mapRasterOverlay.ts b/client/src/components/Map/mapRasterOverlay.ts new file mode 100644 index 000000000..3fec78fea --- /dev/null +++ b/client/src/components/Map/mapRasterOverlay.ts @@ -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); +} diff --git a/client/src/components/Map/mapViewContract.ts b/client/src/components/Map/mapViewContract.ts index d13c2045d..f330d81f3 100644 --- a/client/src/components/Map/mapViewContract.ts +++ b/client/src/components/Map/mapViewContract.ts @@ -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. * diff --git a/client/src/components/Planner/DayPlanPlaceSubline.tsx b/client/src/components/Planner/DayPlanPlaceSubline.tsx index 458389ec6..a7cb79505 100644 --- a/client/src/components/Planner/DayPlanPlaceSubline.tsx +++ b/client/src/components/Planner/DayPlanPlaceSubline.tsx @@ -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 }) { - {t('tours.planner.walkingShort')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)} + {movingTimeLabel(tourTimes.tour, t('tours.planner.walkingShort'), t)}:{' '} + {formatPlannedTourDuration(tourTimes.walkingMinutes)} )} diff --git a/client/src/components/Tours/TourDayRowFacts.tsx b/client/src/components/Tours/TourDayRowFacts.tsx index 74b734b35..5e0d39d98 100644 --- a/client/src/components/Tours/TourDayRowFacts.tsx +++ b/client/src/components/Tours/TourDayRowFacts.tsx @@ -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) diff --git a/client/src/components/Tours/TourDetailDialog.test.tsx b/client/src/components/Tours/TourDetailDialog.test.tsx index 09e65150d..473fade6c 100644 --- a/client/src/components/Tours/TourDetailDialog.test.tsx +++ b/client/src/components/Tours/TourDetailDialog.test.tsx @@ -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() + + 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( ) => string; /** The parts of the time a tour takes, as fields: walking, breaks, the planned total. */ -function tourTimeFields(times: ReturnType, t: Translate): TourMetricField[] { +function tourTimeFields( + times: ReturnType, + t: Translate, + tour: Pick +): 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 (
- + {createElement(tourDayRowIcon(tour.tour_type), { + size: 11, + strokeWidth: 2, + className: 'flex-none', + style: { color: trackColor }, + })} {t(`tourTypes.${tour.tour_type}`)} + {/* A paddle tour names its boat and its water, each a badge of its own. */} + {tour.boat_type && ( + + {t(`tours.paddle.boat.${tour.boat_type}`)} + + )} + {tour.water_context && ( + + {t(`tours.paddle.water.${tour.water_context}`)} + + )} {source !== 'trek' && ( @@ -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 && (
- - {timeFields.length > 0 && } + {analysis.minEle == null && analysis.gain == null ? ( + // Without heights the distance is the only measure: it joins the times in one row. + + ) : ( + <> + + {timeFields.length > 0 && } + + )} {analysis.distanceIndexedProfileSamples.length >= 2 && (
= {}): 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([''], 'ridge.gpx', { type: 'application/gpx+xml' }); diff --git a/client/src/components/Tours/TourGpxDropZone.tsx b/client/src/components/Tours/TourGpxDropZone.tsx index c9c4bff3c..6a342bd03 100644 --- a/client/src/components/Tours/TourGpxDropZone.tsx +++ b/client/src/components/Tours/TourGpxDropZone.tsx @@ -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} + {(over || gpxImport.importing) && (
state.settings.distance_unit) ?? 'metric'; + const heights = track.elevation_gain_m != null && track.elevation_loss_m != null; + return ( +
  • +
    + + {track.name || name} + + {track.duplicate && {t('tours.import.previewDuplicate')}} +
    +
    + {formatDistance(track.distance_km, unit)} + {heights ? ( + <> + ↑ {formatElevation(track.elevation_gain_m ?? 0, unit)} + ↓ {formatElevation(track.elevation_loss_m ?? 0, unit)} + + ) : ( + {t('tours.import.previewNoHeights')} + )} + {t('tours.import.previewPoints', { count: track.point_count })} +
    +
  • + ); +} + +/** + * 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 ; +} + +function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files: GpxPreviewFile[] }) { + const { t } = useTranslation(); + const labelId = useId(); + const tileUrl = useTileUrl(OFM_POSITRON); + const [type, setType] = useState('hike'); + const [boat, setBoat] = useState(null); + const [water, setWater] = useState(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 ( + + + + } + tint={NEUTRAL_TINT} + labelId={labelId} + onClose={gpxImport.cancel} + eyebrow={t('tours.import.previewTitle')} + title={title} + sub={t('tours.import.previewHint')} + subWraps + /> + } + footer={ + + + + {t('common.cancel')} + + } + disabled={!canImport} + onClick={() => void gpxImport.confirm(fields)} + > + {t('tours.import.previewConfirm', { count: fresh })} + + + } + > +
    + file.name).join('|')} + followSelection={false} + /> +
    + + +
      + {tracks.map(({ file, track }, index) => ( + + ))} +
    + {fresh === 0 && ( +
    + }> + {t('tours.import.previewAllDuplicates')} + +
    + )} +
    + + +
    + + value={type} + onChange={setType} + label={t('tours.import.previewAs')} + fill + options={[ + { value: 'hike', label: t('tourTypes.hike'), icon: }, + { value: 'kayak', label: t('tourTypes.kayak'), icon: }, + ]} + /> + {type === 'kayak' && ( +
    +
    + {t('tours.paddle.boat')} + + value={(boat ?? '') as PaddleBoatType} + onChange={setBoat} + label={t('tours.paddle.boat')} + fill + options={BOATS.map((value) => ({ value, label: t(`tours.paddle.boat.${value}`) }))} + /> +
    +
    + {t('tours.paddle.water')} + + value={(water ?? '') as PaddleWaterContext} + onChange={setWater} + label={t('tours.paddle.water')} + fill + options={WATERS.map((value) => ({ value, label: t(`tours.paddle.water.${value}`) }))} + /> +
    +
    + )} + {paddleIncomplete && ( + }> + {t('tours.paddle.chooseBoatImport')} + + )} +
    +
    +
    + ); +} diff --git a/client/src/components/Tours/ToursSidebar.test.tsx b/client/src/components/Tours/ToursSidebar.test.tsx index 5ebcc96b4..e4eebe679 100644 --- a/client/src/components/Tours/ToursSidebar.test.tsx +++ b/client/src/components/Tours/ToursSidebar.test.tsx @@ -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: () =>
    })) 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 = {}) { + 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() @@ -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([''], '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( { pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File([''], '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( @@ -269,15 +289,78 @@ describe('ToursSidebar', () => { new File([''], 'empty.gpx', { type: 'application/gpx+xml' }), new File([''], '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( + + ) + pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File([''], '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( + + ) + pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File([''], '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( + + ) + const file = new File([''], '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( ) diff --git a/client/src/components/Tours/ToursSidebar.tsx b/client/src/components/Tours/ToursSidebar.tsx index 979476763..a289d8816 100644 --- a/client/src/components/Tours/ToursSidebar.tsx +++ b/client/src/components/Tours/ToursSidebar.tsx @@ -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 )}
    +
    ) } diff --git a/client/src/components/Tours/conversion/TourConversionSection.tsx b/client/src/components/Tours/conversion/TourConversionSection.tsx index 4ba86a643..54f8ca7a4 100644 --- a/client/src/components/Tours/conversion/TourConversionSection.tsx +++ b/client/src/components/Tours/conversion/TourConversionSection.tsx @@ -77,7 +77,7 @@ export default function TourConversionSection({ conversion }: { conversion: GpxC return card(

    - {t('tours.conversion.intro')} + {t(conversion.paddle ? 'tours.conversion.introPaddle' : 'tours.conversion.intro')}

    void conversion.analyze()} icon={}> {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' + )}
    diff --git a/client/src/components/Tours/conversion/useGpxConversion.ts b/client/src/components/Tours/conversion/useGpxConversion.ts index cf9a49f64..c6662ce1a 100644 --- a/client/src/components/Tours/conversion/useGpxConversion.ts +++ b/client/src/components/Tours/conversion/useGpxConversion.ts @@ -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] ); } diff --git a/client/src/components/Tours/planner/TourEditorFields.tsx b/client/src/components/Tours/planner/TourEditorFields.tsx index f3068c2be..4da3efc28 100644 --- a/client/src/components/Tours/planner/TourEditorFields.tsx +++ b/client/src/components/Tours/planner/TourEditorFields.tsx @@ -348,7 +348,10 @@ export function TourDetailsSection({ <> - + {/* A paddle tour is not graded on the hiking scale. */} + {!planner.isPaddle && ( + + )} )} diff --git a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx index be58a6015..7ed246eee 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx @@ -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 { const state = { + ...hikePlannerDefaults(), canEdit: true, canAssign: true, isSaving: false, diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx index 1db7879d5..e9617af43 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx @@ -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, } /> - { if (canEdit && !isSaving) planner.undo() }} disabled={!canEdit || isSaving || !planner.canUndo}> + { if (canEdit && !isSaving) planner.undo() }} disabled={locked || !planner.canUndo}> - { if (canEdit && !isSaving) planner.redo() }} disabled={!canEdit || isSaving || !planner.canRedo}> + { if (canEdit && !isSaving) planner.redo() }} disabled={locked || !planner.canRedo}>
    - + + + {recordedTrack?.available && } - +
    @@ -353,7 +358,7 @@ export function TourPlannerRail({ planner, } onClick={requestClose} disabled={isSaving || (!planner.hasUnsavedChanges && !isSavedEdit)}> {t('common.discard')} - } onClick={() => { if (canEdit && !isSaving) void planner.save() }} disabled={!canEdit || isSaving || !planner.canSave}> + } onClick={() => { if (canEdit && !isSaving) void planner.save() }} disabled={locked || !planner.canSave}> {t('common.save')}
    @@ -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 (
    diff --git a/client/src/components/Tours/terrain/useTourTerrain.ts b/client/src/components/Tours/terrain/useTourTerrain.ts index d1040a489..4f1406dfa 100644 --- a/client/src/components/Tours/terrain/useTourTerrain.ts +++ b/client/src/components/Tours/terrain/useTourTerrain.ts @@ -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; diff --git a/client/src/components/Tours/tourDayRowIcon.ts b/client/src/components/Tours/tourDayRowIcon.ts index 6246392a4..c4e8c44ba 100644 --- a/client/src/components/Tours/tourDayRowIcon.ts +++ b/client/src/components/Tours/tourDayRowIcon.ts @@ -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; } diff --git a/client/src/components/Tours/tourFacts.test.ts b/client/src/components/Tours/tourFacts.test.ts new file mode 100644 index 000000000..069548405 --- /dev/null +++ b/client/src/components/Tours/tourFacts.test.ts @@ -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'); + }); +}); diff --git a/client/src/components/Tours/tourFacts.ts b/client/src/components/Tours/tourFacts.ts index 71725b2b7..68d3b618c 100644 --- a/client/src/components/Tours/tourFacts.ts +++ b/client/src/components/Tours/tourFacts.ts @@ -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, +>(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; +} diff --git a/client/src/components/Tours/tourMapLayers.ts b/client/src/components/Tours/tourMapLayers.ts new file mode 100644 index 000000000..448d0488c --- /dev/null +++ b/client/src/components/Tours/tourMapLayers.ts @@ -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 © OpenSeaMap contributors (CC BY-SA 2.0)', + minZoom: 6, + maxZoom: 18, +} as const; diff --git a/client/src/components/Tours/tourParts.tsx b/client/src/components/Tours/tourParts.tsx index 09fbc8204..61a04d2e0 100644 --- a/client/src/components/Tours/tourParts.tsx +++ b/client/src/components/Tours/tourParts.tsx @@ -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 }) { +export function TourGradeTile({ tour }: { tour: Pick & Partial> }) { 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 ( - {level == null ? : `T${level}`} + {level != null ? `T${level}` : paddle + ? + : } {source !== 'trek' && ( diff --git a/client/src/components/Tours/toursI18n.test.tsx b/client/src/components/Tours/toursI18n.test.tsx index 831cfc4cb..7f95ed13c 100644 --- a/client/src/components/Tours/toursI18n.test.tsx +++ b/client/src/components/Tours/toursI18n.test.tsx @@ -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 { return { + ...hikePlannerDefaults(), canEdit: true, canAssign: true, mode: { type: 'neutral' }, status: 'empty', error: null, name: '', maxHikingDifficulty: 2, waypoints: [], hasUnsavedChanges: false, diff --git a/client/src/components/Tours/toursPermissions.test.tsx b/client/src/components/Tours/toursPermissions.test.tsx index 9990dfe30..05853732f 100644 --- a/client/src/components/Tours/toursPermissions.test.tsx +++ b/client/src/components/Tours/toursPermissions.test.tsx @@ -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, '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) }) diff --git a/client/src/components/Tours/useTourGpxImport.ts b/client/src/components/Tours/useTourGpxImport.ts index 1cb3ee31b..620b1c343 100644 --- a/client/src/components/Tours/useTourGpxImport.ts +++ b/client/src/components/Tours/useTourGpxImport.ts @@ -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.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(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; diff --git a/client/src/components/Tours/useTourRasterOverlay.ts b/client/src/components/Tours/useTourRasterOverlay.ts new file mode 100644 index 000000000..fc14fe843 --- /dev/null +++ b/client/src/components/Tours/useTourRasterOverlay.ts @@ -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, + 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] + ); +} diff --git a/client/src/components/shared/CustomSelect.consumers.test.tsx b/client/src/components/shared/CustomSelect.consumers.test.tsx index 038b211fe..d1b7d924e 100644 --- a/client/src/components/shared/CustomSelect.consumers.test.tsx +++ b/client/src/components/shared/CustomSelect.consumers.test.tsx @@ -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 { return { + ...hikePlannerDefaults(), mode: { type: 'new-draft' }, maxHikingDifficulty: 2, setMaxHikingDifficulty: vi.fn(), name: '', setName: vi.fn(), status: 'empty', waypoints: [], route: [], routeAnalysis: null, diff --git a/client/src/pages/TripPlannerPage.tours.wiring.test.tsx b/client/src/pages/TripPlannerPage.tours.wiring.test.tsx index b21fbebe8..c9e33efa7 100644 --- a/client/src/pages/TripPlannerPage.tours.wiring.test.tsx +++ b/client/src/pages/TripPlannerPage.tours.wiring.test.tsx @@ -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') })); diff --git a/client/src/pages/TripPlannerPage.wiring.test.tsx b/client/src/pages/TripPlannerPage.wiring.test.tsx index cecbe0b0f..4be95bf13 100644 --- a/client/src/pages/TripPlannerPage.wiring.test.tsx +++ b/client/src/pages/TripPlannerPage.wiring.test.tsx @@ -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') })) diff --git a/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx b/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx index 3c023bcf1..ae64ce233 100644 --- a/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx +++ b/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx @@ -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 = {} ) { 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 ; + } + render(); + 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 }); + }); + }); }); diff --git a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx index 2a830b4f1..8738f4b27 100644 --- a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx +++ b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx @@ -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. */} { 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]), diff --git a/client/src/pages/tripPlanner/useTourStageLayers.ts b/client/src/pages/tripPlanner/useTourStageLayers.ts new file mode 100644 index 000000000..384e56f16 --- /dev/null +++ b/client/src/pages/tripPlanner/useTourStageLayers.ts @@ -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; + +/** + * 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, + 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, + }; +} diff --git a/client/src/pages/tripPlanner/useTripPlannerPage.ts b/client/src/pages/tripPlanner/useTripPlannerPage.ts index 64150a647..0e1d11579 100644 --- a/client/src/pages/tripPlanner/useTripPlannerPage.ts +++ b/client/src/pages/tripPlanner/useTripPlannerPage.ts @@ -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. diff --git a/client/src/repo/tourRepo.ts b/client/src/repo/tourRepo.ts index cfddb7518..1a0bea840 100644 --- a/client/src/repo/tourRepo.ts +++ b/client/src/repo/tourRepo.ts @@ -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 { - const result = await toursApi.importGpx(tripId, file) + async importGpx(tripId: number | string, file: File, fields?: TourGpxImportFields): Promise { + 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 { + return tourGpxApi.preview(tripId, file) + }, } diff --git a/client/tests/helpers/tourPlannerStub.ts b/client/tests/helpers/tourPlannerStub.ts new file mode 100644 index 000000000..1c1c48a7e --- /dev/null +++ b/client/tests/helpers/tourPlannerStub.ts @@ -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(), + toggleMapOverlay: vi.fn(), + }; +} diff --git a/server/scripts/response-contract-baseline.json b/server/scripts/response-contract-baseline.json index a90d780f5..329579ae4 100644 --- a/server/scripts/response-contract-baseline.json +++ b/server/scripts/response-contract-baseline.json @@ -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, diff --git a/server/scripts/size-baseline.json b/server/scripts/size-baseline.json index 40cd1c512..7a6a083e2 100644 --- a/server/scripts/size-baseline.json +++ b/server/scripts/size-baseline.json @@ -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, diff --git a/server/src/db/entities/Tours.entity.ts b/server/src/db/entities/Tours.entity.ts index 79675595b..485ca4820 100644 --- a/server/src/db/entities/Tours.entity.ts +++ b/server/src/db/entities/Tours.entity.ts @@ -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; tour_sources: Ref | null = null; tour_waypoints_collection = new Collection(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(), diff --git a/server/src/db/kysely/Tours.table.ts b/server/src/db/kysely/Tours.table.ts index 9ab0b4c8b..309bbb9f3 100644 --- a/server/src/db/kysely/Tours.table.ts +++ b/server/src/db/kysely/Tours.table.ts @@ -20,4 +20,7 @@ export interface ToursTable { recorded_route_geometry: InsertOptional; origin: InsertOptional; origin_file_name: InsertOptional; + boat_type: InsertOptional; + water_context: InsertOptional; + paddle_speed_kmh: InsertOptional; } diff --git a/server/src/db/migrations/Migration20200101043900_a_tour_can_be_paddled.ts b/server/src/db/migrations/Migration20200101043900_a_tour_can_be_paddled.ts new file mode 100644 index 000000000..b731a9a37 --- /dev/null +++ b/server/src/db/migrations/Migration20200101043900_a_tour_can_be_paddled.ts @@ -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'"); + } +} diff --git a/server/src/db/repositories/Tours.repository.ts b/server/src/db/repositories/Tours.repository.ts index 3f3642a69..8760de64a 100644 --- a/server/src/db/repositories/Tours.repository.ts +++ b/server/src/db/repositories/Tours.repository.ts @@ -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 = 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 & { +/** 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; @@ -148,6 +170,9 @@ export class ToursRepository extends TrekRepository { '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 { 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 { : {}), 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)) diff --git a/server/src/middleware/globalMiddleware.ts b/server/src/middleware/globalMiddleware.ts index 8e078af53..2fc5e598c 100644 --- a/server/src/middleware/globalMiddleware.ts +++ b/server/src/middleware/globalMiddleware.ts @@ -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 ; 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 diff --git a/server/src/nest/places/places.helpers.ts b/server/src/nest/places/places.helpers.ts index 7959bbf8e..78a143416 100644 --- a/server/src/nest/places/places.helpers.ts +++ b/server/src/nest/places/places.helpers.ts @@ -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[0] + >, +): DedupSet { + const names = new Set(); + const coords: Array<{ lat: number; lng: number }> = []; + const externalIds = new Set(); + 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); diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index 67a07c60b..49414aecb 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -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 { - // 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(); - 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(); - 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 { + 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; + }); } /** diff --git a/server/src/nest/tours/dto/tour-gpx-import.dto.ts b/server/src/nest/tours/dto/tour-gpx-import.dto.ts new file mode 100644 index 000000000..99c7c8965 --- /dev/null +++ b/server/src/nest/tours/dto/tour-gpx-import.dto.ts @@ -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) {} diff --git a/server/src/nest/tours/tours-import.controller.ts b/server/src/nest/tours/tours-import.controller.ts index 9c03f1806..6c6e6385b 100644 --- a/server/src/nest/tours/tours-import.controller.ts +++ b/server/src/nest/tours/tours-import.controller.ts @@ -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 { 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; } diff --git a/server/src/nest/tours/tours.mcp.ts b/server/src/nest/tours/tours.mcp.ts index 102f097a0..5a4336789 100644 --- a/server/src/nest/tours/tours.mcp.ts +++ b/server/src/nest/tours/tours.mcp.ts @@ -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), diff --git a/server/src/nest/tours/tours.service.ts b/server/src/nest/tours/tours.service.ts index a232453c3..7d58d6aed 100644 --- a/server/src/nest/tours/tours.service.ts +++ b/server/src/nest/tours/tours.service.ts @@ -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 { - 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 { + 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. */ diff --git a/server/tests/e2e/tours.e2e.test.ts b/server/tests/e2e/tours.e2e.test.ts index 84fb9553b..16027fcee 100644 --- a/server/tests/e2e/tours.e2e.test.ts +++ b/server/tests/e2e/tours.e2e.test.ts @@ -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 = {}, + 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 = {}) => + 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, ''); + 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; diff --git a/server/tests/unit/db/known-migrations.test.ts b/server/tests/unit/db/known-migrations.test.ts index 183afe944..4d5af6c33 100644 --- a/server/tests/unit/db/known-migrations.test.ts +++ b/server/tests/unit/db/known-migrations.test.ts @@ -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); }); diff --git a/server/tests/unit/db/legacy-baseline.test.ts b/server/tests/unit/db/legacy-baseline.test.ts index 9c7dfc2f0..6de801660 100644 --- a/server/tests/unit/db/legacy-baseline.test.ts +++ b/server/tests/unit/db/legacy-baseline.test.ts @@ -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. diff --git a/server/tests/unit/db/repositories/Tours.repository.test.ts b/server/tests/unit/db/repositories/Tours.repository.test.ts index 7af09e985..662df9837 100644 --- a/server/tests/unit/db/repositories/Tours.repository.test.ts +++ b/server/tests/unit/db/repositories/Tours.repository.test.ts @@ -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([]); diff --git a/server/tests/unit/db/tour-paddle-columns.test.ts b/server/tests/unit/db/tour-paddle-columns.test.ts new file mode 100644 index 000000000..2f74221d5 --- /dev/null +++ b/server/tests/unit/db/tour-paddle-columns.test.ts @@ -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()); + }); +}); diff --git a/server/tests/unit/middleware/globalMiddleware.csp.test.ts b/server/tests/unit/middleware/globalMiddleware.csp.test.ts index aa29d81c3..d46ab41d1 100644 --- a/server/tests/unit/middleware/globalMiddleware.csp.test.ts +++ b/server/tests/unit/middleware/globalMiddleware.csp.test.ts @@ -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. diff --git a/server/tests/unit/nest/tours.controller.test.ts b/server/tests/unit/nest/tours.controller.test.ts index 9a9ecac9a..32b85d537 100644 --- a/server/tests/unit/nest/tours.controller.test.ts +++ b/server/tests/unit/nest/tours.controller.test.ts @@ -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 () => { diff --git a/server/tests/unit/nest/tours.permissions.test.ts b/server/tests/unit/nest/tours.permissions.test.ts index 61c16da16..1430244ca 100644 --- a/server/tests/unit/nest/tours.permissions.test.ts +++ b/server/tests/unit/nest/tours.permissions.test.ts @@ -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(); diff --git a/server/tests/unit/nest/tours.service.permissions.test.ts b/server/tests/unit/nest/tours.service.permissions.test.ts index 10c75fe2e..77c2a22e2 100644 --- a/server/tests/unit/nest/tours.service.permissions.test.ts +++ b/server/tests/unit/nest/tours.service.permissions.test.ts @@ -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); diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts index a35aeb739..17762b09c 100644 --- a/shared/src/i18n/ar/tours.ts +++ b/shared/src/i18n/ar/tours.ts @@ -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; diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts index be3f360a4..787eccf48 100644 --- a/shared/src/i18n/az/tours.ts +++ b/shared/src/i18n/az/tours.ts @@ -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; diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts index e12e3d4ad..2fb9b7fed 100644 --- a/shared/src/i18n/br/tours.ts +++ b/shared/src/i18n/br/tours.ts @@ -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; diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts index b49d9471c..669037097 100644 --- a/shared/src/i18n/ca/tours.ts +++ b/shared/src/i18n/ca/tours.ts @@ -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; diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts index 7574f4196..557308b77 100644 --- a/shared/src/i18n/cs/tours.ts +++ b/shared/src/i18n/cs/tours.ts @@ -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; diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts index c49f315c7..d1cff8e4d 100644 --- a/shared/src/i18n/de/tours.ts +++ b/shared/src/i18n/de/tours.ts @@ -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; diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts index 51c566e11..f0f40fc99 100644 --- a/shared/src/i18n/en/tours.ts +++ b/shared/src/i18n/en/tours.ts @@ -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; diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts index c9d5ab3c9..55a4e4bda 100644 --- a/shared/src/i18n/es/tours.ts +++ b/shared/src/i18n/es/tours.ts @@ -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; diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts index c4ac8e1b9..e898368e7 100644 --- a/shared/src/i18n/et/tours.ts +++ b/shared/src/i18n/et/tours.ts @@ -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; diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts index 910e69c3d..d57c74498 100644 --- a/shared/src/i18n/fr/tours.ts +++ b/shared/src/i18n/fr/tours.ts @@ -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; diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts index 0dcee486f..93c0ea215 100644 --- a/shared/src/i18n/gr/tours.ts +++ b/shared/src/i18n/gr/tours.ts @@ -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; diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts index aee0996cf..eff4a6f62 100644 --- a/shared/src/i18n/hu/tours.ts +++ b/shared/src/i18n/hu/tours.ts @@ -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; diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts index 7b02a7d05..1bff0e635 100644 --- a/shared/src/i18n/id/tours.ts +++ b/shared/src/i18n/id/tours.ts @@ -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; diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts index f4eca3be5..3e41516c9 100644 --- a/shared/src/i18n/it/tours.ts +++ b/shared/src/i18n/it/tours.ts @@ -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; diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts index 82e4f6cc2..268fc6700 100644 --- a/shared/src/i18n/ja/tours.ts +++ b/shared/src/i18n/ja/tours.ts @@ -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; diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts index ca10dc37e..b720ce491 100644 --- a/shared/src/i18n/ko/tours.ts +++ b/shared/src/i18n/ko/tours.ts @@ -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; diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts index 6b3035edd..b100bf809 100644 --- a/shared/src/i18n/nl/tours.ts +++ b/shared/src/i18n/nl/tours.ts @@ -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; diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts index d9cece0b5..51bad5fae 100644 --- a/shared/src/i18n/pl/tours.ts +++ b/shared/src/i18n/pl/tours.ts @@ -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; diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts index 82e2156d0..77384663d 100644 --- a/shared/src/i18n/ru/tours.ts +++ b/shared/src/i18n/ru/tours.ts @@ -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; diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts index ed2f66a28..e8260a303 100644 --- a/shared/src/i18n/sk/tours.ts +++ b/shared/src/i18n/sk/tours.ts @@ -193,7 +193,7 @@ const tours: TranslationStrings = { 'tourTypes.bike': 'Cyklotúra', 'tourTypes.run': 'Beh', 'tourTypes.ski': 'Lyžiarska túra', - 'tourTypes.kayak': 'Kajak', + 'tourTypes.kayak': 'Vodácka túra', 'tourTypes.walk': 'Prechádzka mestom / po pamiatkach', 'tourTypes.food': 'Gastronomická / vínna túra', 'tours.terrain.play': 'Prehrať prelet', @@ -272,5 +272,63 @@ const tours: TranslationStrings = { 'tours.copy.name': '{name} (kópia)', 'tours.copy.saved': 'Uložené ako nová túra', 'tours.copy.failed': 'Túru sa nepodarilo skopírovať.', + 'tours.planner.tourType': 'Typ túry', + 'tours.paddle.boat': 'Loď', + 'tours.paddle.boat.kayak': 'Kajak', + 'tours.paddle.boat.canoe': 'Kanoe', + 'tours.paddle.boat.sup': 'SUP', // same-as-en + 'tours.paddle.boat.other': 'Iná', + 'tours.paddle.water': 'Voda', + 'tours.paddle.water.river': 'Rieka', + 'tours.paddle.water.lake': 'Jazero', + 'tours.paddle.speed': 'Rýchlosť pádlovania', + 'tours.paddle.speedOwn': 'Vlastná rýchlosť', + 'tours.paddle.speedUseSuggestion': 'Použiť navrhnutú rýchlosť {speed} km/h', + 'tours.paddle.speedNeeded': 'Zadajte rýchlosť na odhad času na vode.', + 'tours.paddle.speedInvalid': 'Zadajte rýchlosť od 0,5 do 20 km/h.', + 'tours.paddle.chooseBoat': 'Vyberte loď a vodu, aby ste mohli túto túru uložiť.', + 'tours.paddle.chooseBoatImport': 'Vyberte loď a vodu na import ako vodáckej túry.', + 'tours.paddle.noWaterData': + 'TREK zatiaľ nezobrazuje hate, plavebné komory ani prenášky. Pred vyplávaním si vodu a pravidlá overte sami.', + 'tours.paddle.drawnByHand': + 'Vodácka túra sa kreslí ručne: TREK spojí vaše body priamymi čiarami. Nič sa nepočíta a nič nehovorí, že je voda splavná.', + 'tours.paddle.timeEstimated': 'Čas na vode (odhad)', + 'tours.paddle.timeEstimatedShort': 'Pádlovanie', + 'tours.paddle.timeEstimatedInfoLabel': 'Ako sa odhaduje čas na vode', + 'tours.paddle.timeEstimatedInfo': + 'Vzdialenosť delená plánovanou rýchlosťou na pokojnej vode. Prúd, vietor, prenášky ani plavebné komory nie sú započítané.', + 'tours.planner.reverse': 'Obrátiť smer', + 'tours.planner.returnToStart': 'Späť na štart', + 'tours.planner.alreadyLoop': 'Túra sa už vracia na štart', + 'tours.planner.loopEnd': 'Späť na štart', + 'tours.planner.freeLeg': 'Nakresliť ďalší úsek ručne', + 'tours.planner.routeLeg': 'Ďalší úsek znova vypočítať', + 'tours.planner.freeLegBadge': 'Nakreslené ručne, nevypočítané', + 'tours.planner.freeLegInfo': + 'TREK spojí tieto dva body priamou čiarou. Žiadny plánovač trasy tento úsek nenaplánoval ani neskontroloval.', + 'tours.map.seamarks': 'Plavebné znaky', + 'tours.map.seamarksDown': 'Plavebné znaky sa nenačítavajú. Mapa aj vaša trasa ďalej fungujú.', + 'tours.import.previewTitle': 'Náhľad GPX', + 'tours.import.previewHint': 'Zatiaľ nie je nič uložené. Skontrolujte priebeh a potom importujte.', + 'tours.import.previewFiles': '{count} súborov', + 'tours.import.previewFiles.one': '{count} súbor', + 'tours.import.previewFiles.few': '{count} súbory', + 'tours.import.previewTracks': '{count} trás', + 'tours.import.previewTracks.one': '{count} trasa', + 'tours.import.previewTracks.few': '{count} trasy', + 'tours.import.previewPoints': '{count} bodov', + 'tours.import.previewPoints.one': '{count} bod', + 'tours.import.previewPoints.few': '{count} body', + 'tours.import.previewNoHeights': 'V súbore nie sú výšky', + 'tours.import.previewDuplicate': 'Už je v ceste', + 'tours.import.previewAllDuplicates': 'Všetky trasy už v ceste sú, nie je čo importovať.', + 'tours.import.previewAs': 'Importovať ako', + 'tours.import.previewConfirm': 'Importovať {count} túr', + 'tours.import.previewConfirm.one': 'Importovať {count} túru', + 'tours.import.previewConfirm.few': 'Importovať {count} túry', + 'tours.conversion.introPaddle': + 'Import je zaznamenaná trasa. Naplánujte upraviteľnú vodácku túru: TREK z nej vezme kontrolné body, spojí ich priamymi čiarami, ako sa vodácka túra kreslí, a ukáže, ako blízko to je. Nič sa nezmení, kým sa nerozhodnete.', + 'tours.conversion.closePaddle': + 'Návrh trasu tesne sleduje. Jeho body spájajú priame čiary, takže to nie je tá istá čiara.', }; export default tours; diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts index 677ad63f3..1dfaa4b27 100644 --- a/shared/src/i18n/sv/tours.ts +++ b/shared/src/i18n/sv/tours.ts @@ -188,7 +188,7 @@ const tours: TranslationStrings = { 'tourTypes.bike': 'Cykeltur', 'tourTypes.run': 'Löptur', 'tourTypes.ski': 'Skidtur', - 'tourTypes.kayak': 'Kajaktur', + 'tourTypes.kayak': 'Paddeltur', 'tourTypes.walk': 'Stadspromenad / sightseeing', 'tourTypes.food': 'Mat- och vintur', 'tours.terrain.play': 'Spela upp överflygning', @@ -269,5 +269,59 @@ const tours: TranslationStrings = { 'tours.copy.name': '{name} (kopia)', 'tours.copy.saved': 'Sparad som ny tur', 'tours.copy.failed': 'Turen kunde inte kopieras.', + 'tours.planner.tourType': 'Turtyp', + 'tours.paddle.boat': 'Båt', + 'tours.paddle.boat.kayak': 'Kajak', + 'tours.paddle.boat.canoe': 'Kanadensare', + 'tours.paddle.boat.sup': 'SUP', // same-as-en + 'tours.paddle.boat.other': 'Annan', + 'tours.paddle.water': 'Vatten', + 'tours.paddle.water.river': 'Älv', + 'tours.paddle.water.lake': 'Sjö', + 'tours.paddle.speed': 'Paddeltempo', + 'tours.paddle.speedOwn': 'Eget tempo', + 'tours.paddle.speedUseSuggestion': 'Använd förslaget {speed} km/h', + 'tours.paddle.speedNeeded': 'Ange ett tempo för att uppskatta tiden på vattnet.', + 'tours.paddle.speedInvalid': 'Ange ett tempo från 0,5 till 20 km/h.', + 'tours.paddle.chooseBoat': 'Välj båt och vatten för att spara paddelturen.', + 'tours.paddle.chooseBoatImport': 'Välj båt och vatten för att importera som paddeltur.', + 'tours.paddle.noWaterData': + 'TREK visar ännu inte dammar, slussar eller lyft. Kontrollera vattnet och dess regler själv innan du ger dig av.', + 'tours.paddle.drawnByHand': + 'En paddeltur ritas för hand: TREK förbinder dina punkter med raka linjer. Inget ruttas, och inget säger att vattnet går att paddla.', + 'tours.paddle.timeEstimated': 'Paddeltid (uppskattad)', + 'tours.paddle.timeEstimatedShort': 'Paddling', // same-as-en + 'tours.paddle.timeEstimatedInfoLabel': 'Så uppskattas paddeltiden', + 'tours.paddle.timeEstimatedInfo': + 'Sträckan delad med planeringstempot, på lugnt vatten. Ström, vind, lyft och slussar ingår inte.', + 'tours.planner.reverse': 'Vänd riktning', + 'tours.planner.returnToStart': 'Tillbaka till start', + 'tours.planner.alreadyLoop': 'Turen går redan tillbaka till start', + 'tours.planner.loopEnd': 'Tillbaka till start', + 'tours.planner.freeLeg': 'Rita nästa sträcka för hand', + 'tours.planner.routeLeg': 'Rutta nästa sträcka igen', + 'tours.planner.freeLegBadge': 'Ritad för hand, inte ruttad', + 'tours.planner.freeLegInfo': + 'TREK förbinder de här två punkterna med en rak linje. Ingen ruttplanerare har planerat eller kontrollerat sträckan.', + 'tours.map.seamarks': 'Sjömärken', + 'tours.map.seamarksDown': 'Sjömärkena laddas inte. Kartan och din rutt fungerar ändå.', + 'tours.import.previewTitle': 'GPX-förhandsvisning', + 'tours.import.previewHint': 'Inget är sparat ännu. Kontrollera sträckningen och importera sedan.', + 'tours.import.previewFiles': '{count} filer', + 'tours.import.previewFiles.one': '{count} fil', + 'tours.import.previewTracks': '{count} spår', + 'tours.import.previewTracks.one': '{count} spår', + 'tours.import.previewPoints': '{count} punkter', + 'tours.import.previewPoints.one': '{count} punkt', + 'tours.import.previewNoHeights': 'Inga höjder i filen', + 'tours.import.previewDuplicate': 'Finns redan i resan', + 'tours.import.previewAllDuplicates': 'Alla spår finns redan i resan, det finns inget att importera.', + 'tours.import.previewAs': 'Importera som', + 'tours.import.previewConfirm': 'Importera {count} turer', + 'tours.import.previewConfirm.one': 'Importera {count} tur', + 'tours.conversion.introPaddle': + 'En import är ett inspelat spår. Planera en redigerbar paddeltur: TREK tar kontrollpunkter ur det, förbinder dem med raka linjer som en paddeltur ritas och visar hur nära det kommer. Inget ändras förrän du väljer.', + 'tours.conversion.closePaddle': + 'Förslaget följer spåret noga. Punkterna är förbundna med raka linjer, så det är inte samma linje.', }; export default tours; diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts index bd1733ac2..128146b96 100644 --- a/shared/src/i18n/th/tours.ts +++ b/shared/src/i18n/th/tours.ts @@ -180,7 +180,7 @@ const tours: TranslationStrings = { 'tourTypes.bike': 'ทัวร์จักรยาน', 'tourTypes.run': 'วิ่ง', 'tourTypes.ski': 'ทัวร์สกี', - 'tourTypes.kayak': 'พายเรือคายัก', + 'tourTypes.kayak': 'ทริปพายเรือ', 'tourTypes.walk': 'เดินชมเมือง / สถานที่ท่องเที่ยว', 'tourTypes.food': 'ทัวร์อาหาร/ไวน์', 'tours.terrain.play': 'เล่นการบินผ่าน', @@ -258,5 +258,53 @@ 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.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; diff --git a/shared/src/i18n/tours-locales.spec.ts b/shared/src/i18n/tours-locales.spec.ts index b8eb6a14b..be623f412 100644 --- a/shared/src/i18n/tours-locales.spec.ts +++ b/shared/src/i18n/tours-locales.spec.ts @@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match) // placeholder spec; here only the general keys are. const groups = pluralGroups(Object.keys(english)); const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups)); -const GENERAL_KEY_COUNT = 243; +const GENERAL_KEY_COUNT = 287; function literalKeys(source: string): string[] { const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true); @@ -34,7 +34,7 @@ function literalKeys(source: string): string[] { } describe('Tours locale contracts', () => { - it('covers all 27 supported locales with 243 general source keys', () => { + it('covers all 27 supported locales with 287 general source keys', () => { expect(Object.keys(domains)).toHaveLength(27); expect(Object.keys(domains).sort()).toEqual(SUPPORTED_LANGUAGE_CODES.map((code) => `./${code}/tours.ts`).sort()); expect(generalKeys(Object.keys(english))).toHaveLength(GENERAL_KEY_COUNT); diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts index 9d168e242..49a9afb58 100644 --- a/shared/src/i18n/tr/tours.ts +++ b/shared/src/i18n/tr/tours.ts @@ -189,7 +189,7 @@ const tours: TranslationStrings = { 'tourTypes.bike': 'Bisiklet turu', 'tourTypes.run': 'Koşu', 'tourTypes.ski': 'Kayak turu', - 'tourTypes.kayak': 'Kayak kanosu', + 'tourTypes.kayak': 'Kürek turu', 'tourTypes.walk': 'Şehir / gezi yürüyüşü', 'tourTypes.food': 'Yemek / şarap turu', 'tours.terrain.play': 'Uçuşu oynat', @@ -271,5 +271,59 @@ const tours: TranslationStrings = { 'tours.copy.name': '{name} (kopya)', 'tours.copy.saved': 'Yeni tur olarak kaydedildi', 'tours.copy.failed': 'Tur kopyalanamadı.', + 'tours.planner.tourType': 'Tur türü', + 'tours.paddle.boat': 'Tekne', + 'tours.paddle.boat.kayak': 'Kano (kayak)', + 'tours.paddle.boat.canoe': 'Kano', + 'tours.paddle.boat.sup': 'SUP', // same-as-en + 'tours.paddle.boat.other': 'Diğer', + 'tours.paddle.water': 'Su', + 'tours.paddle.water.river': 'Nehir', + 'tours.paddle.water.lake': 'Göl', + 'tours.paddle.speed': 'Kürek hızı', + 'tours.paddle.speedOwn': 'Kendi hızın', + 'tours.paddle.speedUseSuggestion': 'Önerilen {speed} km/sa değerini kullan', + 'tours.paddle.speedNeeded': 'Sudaki süreyi tahmin etmek için bir hız gir.', + 'tours.paddle.speedInvalid': '0,5 ile 20 km/sa arasında bir hız gir.', + 'tours.paddle.chooseBoat': 'Bu kürek turunu kaydetmek için tekneyi ve suyu seç.', + 'tours.paddle.chooseBoatImport': 'Kürek turu olarak içe aktarmak için tekneyi ve suyu seç.', + 'tours.paddle.noWaterData': + 'TREK henüz savakları, kilitleri ve taşıma noktalarını göstermiyor. Yola çıkmadan önce suyu ve kurallarını kendin kontrol et.', + 'tours.paddle.drawnByHand': + 'Kürek turu elle çizilir: TREK noktalarını düz çizgilerle birleştirir. Hiçbir şey rotalanmaz ve hiçbir şey suyun geçilebilir olduğunu söylemez.', + 'tours.paddle.timeEstimated': 'Kürek süresi (tahmini)', + 'tours.paddle.timeEstimatedShort': 'Kürek', + 'tours.paddle.timeEstimatedInfoLabel': 'Kürek süresi nasıl tahmin edilir', + 'tours.paddle.timeEstimatedInfo': + 'Mesafe, sakin suda planlanan hıza bölünür. Akıntı, rüzgâr, taşımalar ve kilitler dahil değildir.', + 'tours.planner.reverse': 'Yönü ters çevir', + 'tours.planner.returnToStart': 'Başlangıca dön', + 'tours.planner.alreadyLoop': 'Tur zaten başlangıca dönüyor', + 'tours.planner.loopEnd': 'Başlangıca dönüş', + 'tours.planner.freeLeg': 'Sonraki bölümü elle çiz', + 'tours.planner.routeLeg': 'Sonraki bölümü yeniden rotala', + 'tours.planner.freeLegBadge': 'Elle çizildi, rotalanmadı', + 'tours.planner.freeLegInfo': + 'TREK bu iki noktayı düz bir çizgiyle birleştirir. Hiçbir rota planlayıcı bu bölümü planlamadı veya kontrol etmedi.', + 'tours.map.seamarks': 'Deniz işaretleri', + 'tours.map.seamarksDown': 'Deniz işaretleri yüklenmiyor. Harita ve rotan çalışmaya devam ediyor.', + 'tours.import.previewTitle': 'GPX önizlemesi', + 'tours.import.previewHint': 'Henüz hiçbir şey kaydedilmedi. Güzergâhı kontrol et, sonra içe aktar.', + 'tours.import.previewFiles': '{count} dosya', + 'tours.import.previewFiles.one': '{count} dosya', + 'tours.import.previewTracks': '{count} iz', + 'tours.import.previewTracks.one': '{count} iz', + 'tours.import.previewPoints': '{count} nokta', + 'tours.import.previewPoints.one': '{count} nokta', + 'tours.import.previewNoHeights': 'Dosyada yükseklik yok', + 'tours.import.previewDuplicate': 'Zaten seyahatte', + 'tours.import.previewAllDuplicates': 'Tüm izler zaten seyahatte, içe aktarılacak bir şey yok.', + 'tours.import.previewAs': 'Şu olarak içe aktar', + 'tours.import.previewConfirm': '{count} turu içe aktar', + 'tours.import.previewConfirm.one': '{count} turu içe aktar', + 'tours.conversion.introPaddle': + 'İçe aktarma kaydedilmiş bir izdir. Düzenlenebilir bir kürek turu planla: TREK ondan kontrol noktaları alır, kürek turu nasıl çiziliyorsa onları düz çizgilerle birleştirir ve ne kadar yaklaştığını gösterir. Sen seçene kadar hiçbir şey değişmez.', + 'tours.conversion.closePaddle': + 'Öneri izi yakından takip ediyor. Noktaları düz çizgilerle birleştirildiği için aynı çizgi değil.', }; export default tours; diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts index 5776cf74e..68afe6c3d 100644 --- a/shared/src/i18n/uk/tours.ts +++ b/shared/src/i18n/uk/tours.ts @@ -200,7 +200,7 @@ const tours: TranslationStrings = { 'tourTypes.bike': 'Велотур', 'tourTypes.run': 'Пробіжка', 'tourTypes.ski': 'Лижний тур', - 'tourTypes.kayak': 'Каякінг', + 'tourTypes.kayak': 'Сплав', 'tourTypes.walk': 'Прогулянка містом / визначними місцями', 'tourTypes.food': 'Гастрономічний / винний тур', 'tours.terrain.play': 'Запустити обліт', @@ -284,5 +284,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; diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts index 309a231a9..1861df52b 100644 --- a/shared/src/i18n/vi/tours.ts +++ b/shared/src/i18n/vi/tours.ts @@ -189,7 +189,7 @@ const tours: TranslationStrings = { 'tourTypes.bike': 'Chuyến đạp xe', 'tourTypes.run': 'Chạy bộ', 'tourTypes.ski': 'Chuyến trượt tuyết', - 'tourTypes.kayak': 'Chèo kayak', + 'tourTypes.kayak': 'Chuyến chèo thuyền', 'tourTypes.walk': 'Đi bộ trong thành phố / ngắm cảnh', 'tourTypes.food': 'Chuyến khám phá ẩm thực / rượu vang', 'tours.terrain.play': 'Phát chuyến bay', @@ -271,5 +271,55 @@ const tours: TranslationStrings = { 'tours.copy.name': '{name} (bản sao)', 'tours.copy.saved': 'Đã lưu thành chuyến đi bộ mới', 'tours.copy.failed': 'Không thể sao chép chuyến đi bộ.', + 'tours.planner.tourType': 'Loại chuyến', + 'tours.paddle.boat': 'Thuyền', + 'tours.paddle.boat.kayak': 'Thuyền kayak', + 'tours.paddle.boat.canoe': 'Xuồng canoe', + 'tours.paddle.boat.sup': 'SUP', // same-as-en + 'tours.paddle.boat.other': 'Khác', + 'tours.paddle.water': 'Mặt nước', + 'tours.paddle.water.river': 'Sông', + 'tours.paddle.water.lake': 'Hồ', + 'tours.paddle.speed': 'Tốc độ chèo', + 'tours.paddle.speedOwn': 'Tốc độ của bạn', + 'tours.paddle.speedUseSuggestion': 'Dùng gợi ý {speed} km/h', + 'tours.paddle.speedNeeded': 'Nhập tốc độ để ước tính thời gian trên nước.', + 'tours.paddle.speedInvalid': 'Nhập tốc độ từ 0,5 đến 20 km/h.', + 'tours.paddle.chooseBoat': 'Chọn thuyền và mặt nước để lưu chuyến chèo này.', + 'tours.paddle.chooseBoatImport': 'Chọn thuyền và mặt nước để nhập thành chuyến chèo thuyền.', + 'tours.paddle.noWaterData': + 'TREK chưa hiển thị đập tràn, âu thuyền hay điểm khiêng thuyền. Hãy tự kiểm tra mặt nước và quy định trước khi xuất phát.', + 'tours.paddle.drawnByHand': + 'Chuyến chèo thuyền được vẽ bằng tay: TREK nối các điểm của bạn bằng đường thẳng. Không có gì được định tuyến, và không gì cho biết mặt nước có chèo được hay không.', + 'tours.paddle.timeEstimated': 'Thời gian chèo (ước tính)', + 'tours.paddle.timeEstimatedShort': 'Chèo', + 'tours.paddle.timeEstimatedInfoLabel': 'Cách ước tính thời gian chèo', + 'tours.paddle.timeEstimatedInfo': + 'Quãng đường chia cho tốc độ dự kiến, trên mặt nước tĩnh. Chưa tính dòng chảy, gió, khiêng thuyền và âu thuyền.', + 'tours.planner.reverse': 'Đảo chiều', + 'tours.planner.returnToStart': 'Quay về điểm xuất phát', + 'tours.planner.alreadyLoop': 'Chuyến đã quay về điểm xuất phát', + 'tours.planner.loopEnd': 'Quay về điểm xuất phát', + 'tours.planner.freeLeg': 'Vẽ tay đoạn tiếp theo', + 'tours.planner.routeLeg': 'Định tuyến lại đoạn tiếp theo', + 'tours.planner.freeLegBadge': 'Vẽ tay, không định tuyến', + 'tours.planner.freeLegInfo': + 'TREK nối hai điểm này bằng một đường thẳng. Không công cụ định tuyến nào đã lập hay kiểm tra đoạn này.', + 'tours.map.seamarks': 'Báo hiệu hàng hải', + 'tours.map.seamarksDown': 'Báo hiệu hàng hải không tải được. Bản đồ và tuyến của bạn vẫn hoạt động.', + 'tours.import.previewTitle': 'Xem trước GPX', + 'tours.import.previewHint': 'Chưa có gì được lưu. Hãy kiểm tra lộ trình rồi nhập.', + 'tours.import.previewFiles': '{count} tệp', + 'tours.import.previewTracks': '{count} vết', + 'tours.import.previewPoints': '{count} điểm', + 'tours.import.previewNoHeights': 'Tệp không có độ cao', + 'tours.import.previewDuplicate': 'Đã có trong chuyến đi', + 'tours.import.previewAllDuplicates': 'Mọi vết đã có trong chuyến đi, không có gì để nhập.', + 'tours.import.previewAs': 'Nhập dưới dạng', + 'tours.import.previewConfirm': 'Nhập {count} chuyến', + 'tours.conversion.introPaddle': + 'Bản nhập là một vết đã ghi. Hãy lập một chuyến chèo thuyền có thể chỉnh sửa: TREK lấy các điểm kiểm soát từ đó, nối chúng bằng đường thẳng như cách vẽ chuyến chèo thuyền và cho thấy nó sát đến đâu. Không gì thay đổi cho đến khi bạn chọn.', + 'tours.conversion.closePaddle': + 'Đề xuất bám sát vết. Các điểm được nối bằng đường thẳng nên đây không phải cùng một đường.', }; export default tours; diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts index d09954d60..a6272abd3 100644 --- a/shared/src/i18n/zh-TW/tours.ts +++ b/shared/src/i18n/zh-TW/tours.ts @@ -174,7 +174,7 @@ const tours: TranslationStrings = { 'tourTypes.bike': '單車遊程', 'tourTypes.run': '跑步', 'tourTypes.ski': '滑雪登山', - 'tourTypes.kayak': '獨木舟', + 'tourTypes.kayak': '划槳行程', 'tourTypes.walk': '城市散步 / 觀光', 'tourTypes.food': '美食 / 葡萄酒之旅', 'tours.terrain.play': '播放飛越', @@ -248,5 +248,50 @@ const tours: TranslationStrings = { 'tours.copy.name': '{name}(副本)', 'tours.copy.saved': '已另存為新遊程', 'tours.copy.failed': '無法複製遊程。', + 'tours.planner.tourType': '路線類型', + 'tours.paddle.boat': '船隻', + 'tours.paddle.boat.kayak': '獨木舟(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; diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts index ccab1ae6b..50f06428e 100644 --- a/shared/src/i18n/zh/tours.ts +++ b/shared/src/i18n/zh/tours.ts @@ -175,7 +175,7 @@ const tours: TranslationStrings = { 'tourTypes.bike': '骑行', 'tourTypes.run': '跑步', 'tourTypes.ski': '滑雪穿越', - 'tourTypes.kayak': '皮划艇', + 'tourTypes.kayak': '划桨游', 'tourTypes.walk': '城市漫步 / 观光', 'tourTypes.food': '美食 / 葡萄酒之旅', 'tours.terrain.play': '播放飞越', @@ -249,5 +249,50 @@ 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; diff --git a/shared/src/index.ts b/shared/src/index.ts index 397d87286..aa91abf14 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -51,6 +51,7 @@ export * from './place/track-colors'; export * from './tours/tours.schema'; export * from './tours/tour-provenance.schema'; export * from './tours/tours.metrics'; +export * from './tours/tour-gpx-import.schema'; export * from './wanderer/wanderer.schema'; export * from './geo/haversine'; export * from './collection/collection.schema'; diff --git a/shared/src/tours/tour-gpx-import.schema.test.ts b/shared/src/tours/tour-gpx-import.schema.test.ts new file mode 100644 index 000000000..62d980c50 --- /dev/null +++ b/shared/src/tours/tour-gpx-import.schema.test.ts @@ -0,0 +1,57 @@ +import { tourGpxImportFieldsSchema, tourGpxPreviewResponseSchema } from './tour-gpx-import.schema'; + +import { describe, expect, it } from 'vitest'; + +describe('Tours GPX import fields', () => { + it('imports as a hike when nothing is chosen, as it always did', () => { + expect(tourGpxImportFieldsSchema.parse({})).toEqual({ tour_type: 'hike' }); + }); + + it('takes a paddle import with its boat and water', () => { + expect(tourGpxImportFieldsSchema.parse({ tour_type: 'kayak', boat_type: 'sup', water_context: 'lake' })).toEqual({ + tour_type: 'kayak', + boat_type: 'sup', + water_context: 'lake', + }); + }); + + it('refuses a paddle import without a boat, and a hike that names one', () => { + expect(() => tourGpxImportFieldsSchema.parse({ tour_type: 'kayak', water_context: 'lake' })).toThrow(/boat_type/); + expect(() => tourGpxImportFieldsSchema.parse({ tour_type: 'hike', boat_type: 'sup' })).toThrow(/paddle import/); + expect(() => tourGpxImportFieldsSchema.parse({ tour_type: 'ski' })).toThrow(); + }); +}); + +describe('Tours GPX preview', () => { + it('describes a track with and without heights', () => { + const preview = { + tracks: [ + { + name: 'Lake loop', + geometry: [ + [53, 13], + [53.01, 13.02], + ], + point_count: 2, + distance_km: 1.8, + elevation_gain_m: null, + elevation_loss_m: null, + duplicate: false, + }, + { + name: 'River', + geometry: [ + [53, 13, 50], + [53.01, 13.02, 48], + ], + point_count: 2, + distance_km: 1.8, + elevation_gain_m: 0, + elevation_loss_m: 2, + duplicate: true, + }, + ], + }; + expect(tourGpxPreviewResponseSchema.parse(preview)).toEqual(preview); + }); +}); diff --git a/shared/src/tours/tour-gpx-import.schema.ts b/shared/src/tours/tour-gpx-import.schema.ts new file mode 100644 index 000000000..a352c5106 --- /dev/null +++ b/shared/src/tours/tour-gpx-import.schema.ts @@ -0,0 +1,53 @@ +import { paddleBoatTypeSchema, paddleWaterContextSchema, plannableTourTypeSchema } from './tours.schema'; + +import { z } from 'zod'; + +/** + * PAD-02: a GPX file is read and shown before anything is stored. The preview + * runs the file through the same parser the import uses and answers what it + * found; nothing is written. The import that follows takes the type the user + * chose for it. + */ + +const previewPointSchema = z.union([z.tuple([z.number(), z.number()]), z.tuple([z.number(), z.number(), z.number()])]); + +export const tourGpxPreviewTrackSchema = z.object({ + name: z.string(), + /** The whole line as the file holds it, every point, so the preview shows the full course. */ + geometry: z.array(previewPointSchema), + point_count: z.number().int().nonnegative(), + distance_km: z.number(), + /** Null when the file carries no heights: unknown, never zero. */ + elevation_gain_m: z.number().nullable(), + elevation_loss_m: z.number().nullable(), + /** Already in the trip under the same name and start: the import skips it, as it always has. */ + duplicate: z.boolean(), +}); +export type TourGpxPreviewTrack = z.infer; + +export const tourGpxPreviewResponseSchema = z.object({ + tracks: z.array(tourGpxPreviewTrackSchema), +}); +export type TourGpxPreviewResponse = z.infer; + +/** + * The fields of the multipart import beside the file, as strings the way a + * form sends them. Left out, the import is a hike, as it always was; a paddle + * import names its boat and water like a planned paddle tour does. + */ +export const tourGpxImportFieldsSchema = z + .object({ + tour_type: plannableTourTypeSchema.default('hike'), + boat_type: paddleBoatTypeSchema.optional(), + water_context: paddleWaterContextSchema.optional(), + }) + .superRefine((value, ctx) => { + const paddle = value.tour_type === 'kayak'; + for (const field of ['boat_type', 'water_context'] as const) { + if (paddle && value[field] === undefined) + ctx.addIssue({ code: z.ZodIssueCode.custom, path: [field], message: `a paddle import needs ${field}` }); + if (!paddle && value[field] !== undefined) + ctx.addIssue({ code: z.ZodIssueCode.custom, path: [field], message: `${field} belongs to a paddle import` }); + } + }); +export type TourGpxImportFields = z.infer; diff --git a/shared/src/tours/tours.schema.test.ts b/shared/src/tours/tours.schema.test.ts index 1728f36e9..d17f62f20 100644 --- a/shared/src/tours/tours.schema.test.ts +++ b/shared/src/tours/tours.schema.test.ts @@ -1,5 +1,7 @@ import { + MAX_PADDLE_SPEED_KMH, MAX_PLANNED_TOUR_DURATION_MINUTES, + recommendedPaddleSpeedKmh, plannedTourDurationMinutesSchema, tourCreateRequestSchema, tourListItemSchema, @@ -164,7 +166,10 @@ describe('Tours fixed legs', () => { { lat: 48.01, lng: 11.02, role: 'end' as const, sequence: 1 }, ], }; - expect(tourCreateRequestSchema.parse(request).waypoints[0]?.fixed_leg?.ref).toBe('way/1'); + expect(tourCreateRequestSchema.parse(request).waypoints[0]?.fixed_leg).toMatchObject({ + kind: 'ferrata', + ref: 'way/1', + }); }); it('refuses a fixed leg that does not join its waypoints, or hangs off the last one', () => { @@ -186,3 +191,114 @@ describe('Tours fixed legs', () => { expect(() => tourCreateRequestSchema.parse(dangling)).toThrow(/fixed leg/); }); }); + +describe('Tours free legs', () => { + const free = (from: [number, number], to: [number, number]) => ({ kind: 'free' as const, geometry: [from, to] }); + + it('takes a hand-drawn leg that runs from its waypoint to the next one, with no reference', () => { + const request = { + ...baseRequest, + waypoints: [ + { lat: 48, lng: 11, role: 'start' as const, sequence: 0, fixed_leg: free([48, 11], [48.01, 11.02]) }, + { lat: 48.01, lng: 11.02, role: 'end' as const, sequence: 1 }, + ], + }; + expect(tourCreateRequestSchema.parse(request).waypoints[0]?.fixed_leg).toEqual(free([48, 11], [48.01, 11.02])); + }); + + it('holds a free leg to the same ends as a via ferrata', () => { + const request = { + ...baseRequest, + waypoints: [ + { lat: 48, lng: 11, role: 'start' as const, sequence: 0, fixed_leg: free([48.2, 11], [48.01, 11.02]) }, + { lat: 48.01, lng: 11.02, role: 'end' as const, sequence: 1 }, + ], + }; + expect(() => tourCreateRequestSchema.parse(request)).toThrow(/fixed leg/); + }); + + it('refuses a leg of a kind it does not know', () => { + const request = { + ...baseRequest, + waypoints: [ + { + lat: 48, + lng: 11, + role: 'start' as const, + sequence: 0, + fixed_leg: { + kind: 'ferry', + geometry: [ + [48, 11], + [48.01, 11.02], + ], + }, + }, + { lat: 48.01, lng: 11.02, role: 'end' as const, sequence: 1 }, + ], + }; + expect(() => tourCreateRequestSchema.parse(request)).toThrow(); + }); +}); + +describe('Tours paddle type', () => { + const paddle = { + ...baseRequest, + tour_type: 'kayak' as const, + boat_type: 'canoe' as const, + water_context: 'river' as const, + }; + + it('saves a paddle tour with one boat, its water and no hiking limit', () => { + const parsed = tourCreateRequestSchema.parse(paddle); + expect(parsed).toMatchObject({ + tour_type: 'kayak', + boat_type: 'canoe', + water_context: 'river', + max_hiking_difficulty: null, + }); + }); + + it('keeps the speed a paddler chose', () => { + expect(tourCreateRequestSchema.parse({ ...paddle, paddle_speed_kmh: 5.5 }).paddle_speed_kmh).toBe(5.5); + expect(() => tourCreateRequestSchema.parse({ ...paddle, paddle_speed_kmh: 0 })).toThrow(); + expect(() => tourCreateRequestSchema.parse({ ...paddle, paddle_speed_kmh: MAX_PADDLE_SPEED_KMH + 1 })).toThrow(); + }); + + it('refuses a paddle tour without a boat or water, or with a hiking limit', () => { + expect(() => tourCreateRequestSchema.parse({ ...paddle, boat_type: undefined })).toThrow(/boat type/); + expect(() => tourCreateRequestSchema.parse({ ...paddle, water_context: null })).toThrow(/water context/); + expect(() => tourCreateRequestSchema.parse({ ...paddle, max_hiking_difficulty: 2 })).toThrow(/hiking difficulty/); + }); + + it('refuses boat fields on a hike, and a type nobody can plan yet', () => { + expect(() => tourCreateRequestSchema.parse({ ...baseRequest, boat_type: 'sup' })).toThrow(/paddle tour/); + expect(() => tourCreateRequestSchema.parse({ ...baseRequest, paddle_speed_kmh: 4 })).toThrow(/paddle tour/); + expect(() => tourCreateRequestSchema.parse({ ...baseRequest, tour_type: 'bike' })).toThrow(); + }); + + it('suggests 4 km/h for kayak, canoe and SUP, and nothing for any other boat', () => { + expect(['kayak', 'canoe', 'sup'].map((boat) => recommendedPaddleSpeedKmh(boat as 'kayak'))).toEqual([4, 4, 4]); + expect(recommendedPaddleSpeedKmh('other')).toBeNull(); + expect(recommendedPaddleSpeedKmh(null)).toBeNull(); + }); + + it('reads a list item cached before the paddle fields existed', () => { + const cached = { + place_id: 1, + tour_type: 'hike', + distance: 1, + elevation_gain: null, + elevation_loss: null, + duration: null, + difficulty: null, + wanderer_ref: null, + match_confidence: 1, + max_hiking_difficulty: 2, + name: 'Old', + planned: false, + caution: false, + }; + expect(tourListItemSchema.parse(cached).boat_type).toBeUndefined(); + }); +}); diff --git a/shared/src/tours/tours.schema.ts b/shared/src/tours/tours.schema.ts index 589f5655d..dd77af628 100644 --- a/shared/src/tours/tours.schema.ts +++ b/shared/src/tours/tours.schema.ts @@ -17,6 +17,34 @@ import { z } from 'zod'; export const tourTypeKeySchema = z.enum(['hike', 'bike', 'run', 'ski', 'kayak', 'walk', 'food']); export type TourTypeKey = z.infer; +/** + * The types a tour can be planned, saved and imported as. `kayak` is the key + * the paddle tour has had in `tour_types` from the start; the app calls it a + * paddle tour, and the boat is a field of its own. + */ +export const plannableTourTypeSchema = z.enum(['hike', 'kayak']); +export type PlannableTourType = z.infer; + +/** PAD-01: the one boat a paddle tour is planned for. */ +export const paddleBoatTypeSchema = z.enum(['kayak', 'canoe', 'sup', 'other']); +export type PaddleBoatType = z.infer; + +/** PAD-01: the water a paddle tour is on. River and lake are the first supported. */ +export const paddleWaterContextSchema = z.enum(['river', 'lake']); +export type PaddleWaterContext = z.infer; + +/** The highest planning speed a paddler can enter, in km/h. */ +export const MAX_PADDLE_SPEED_KMH = 20; +export const paddleSpeedKmhSchema = z.number().finite().min(0.5).max(MAX_PADDLE_SPEED_KMH); + +/** + * The planning speed TREK suggests for a boat, in km/h: a starting value to + * adjust, not a measurement. "Other" has none; its paddler enters a speed. + */ +export function recommendedPaddleSpeedKmh(boat: PaddleBoatType | null | undefined): number | null { + return boat === 'kayak' || boat === 'canoe' || boat === 'sup' ? 4 : null; +} + export const tourMaxHikingDifficultySchema = z.number().int().min(1).max(6); export type TourMaxHikingDifficulty = z.infer; @@ -81,6 +109,11 @@ export const tourSchema = z.object({ max_hiking_difficulty: tourMaxHikingDifficultySchema.nullable(), /** The import (a GPX file, a wanderer snapshot) this tour was planned from, while it is still in the trip. */ derived_from_place_id: z.number().int().positive().nullable().optional(), + /** A paddle tour's boat and water; null for every other type. Absent in a list cached before they existed. */ + boat_type: paddleBoatTypeSchema.nullable().optional(), + water_context: paddleWaterContextSchema.nullable().optional(), + /** The speed the paddler chose; null while the boat's suggested speed applies. */ + paddle_speed_kmh: z.number().nullable().optional(), }); export type Tour = z.infer; @@ -126,23 +159,34 @@ export const TOUR_FIXED_LEG_MAX_POINTS = 5000; /** How far a fixed leg's ends may lie from the waypoints it joins, in metres. */ export const TOUR_FIXED_LEG_END_TOLERANCE_M = 60; +const fixedLegGeometrySchema = z + .array(z.tuple([z.number().finite().min(-90).max(90), z.number().finite().min(-180).max(180)])) + .min(2) + .max(TOUR_FIXED_LEG_MAX_POINTS); + /** * A leg the router does not plan: the line from this waypoint to the next is - * taken as it stands. Today that is a via ferrata put onto the tour from the - * map, whose own line from OpenStreetMap the tour follows, since a router - * walks around a ferrata wherever it can. + * taken as it stands. A via ferrata put onto the tour from the map follows its + * own line from OpenStreetMap, since a router walks around a ferrata wherever + * it can. A free leg (P2A-04) is drawn by hand, straight from one waypoint to + * the next: off the path network, across a lake, wherever no router goes. + * Nothing about a free leg was checked by a router. */ -export const tourFixedLegSchema = z.object({ - kind: z.literal('ferrata'), - /** The OSM way or group the line came from, `way/` style. */ - ref: z.string().trim().min(1).max(64), - name: z.string().trim().max(255).nullable(), - geometry: z - .array(z.tuple([z.number().finite().min(-90).max(90), z.number().finite().min(-180).max(180)])) - .min(2) - .max(TOUR_FIXED_LEG_MAX_POINTS), -}); +export const tourFixedLegSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('ferrata'), + /** The OSM way or group the line came from, `way/` style. */ + ref: z.string().trim().min(1).max(64), + name: z.string().trim().max(255).nullable(), + geometry: fixedLegGeometrySchema, + }), + z.object({ + kind: z.literal('free'), + geometry: fixedLegGeometrySchema, + }), +]); export type TourFixedLeg = z.infer; +export type TourFixedLegKind = TourFixedLeg['kind']; export const tourWaypointSchema = z.object({ lat: z.number().finite().min(-90).max(90), @@ -176,10 +220,14 @@ const enrichedRoutePointSchema = z.tuple([ export const tourCreateRequestSchema = z .object({ name: z.string().trim().min(1).max(255), - tour_type: z.literal('hike').default('hike'), + tour_type: plannableTourTypeSchema.default('hike'), route_geometry: z.array(enrichedRoutePointSchema).min(2), waypoints: z.array(tourWaypointSchema).min(2), - max_hiking_difficulty: tourMaxHikingDifficultySchema.default(2), + /** A hike's routing limit (2 when left out); a paddle tour has none. */ + max_hiking_difficulty: tourMaxHikingDifficultySchema.nullable().optional(), + boat_type: paddleBoatTypeSchema.nullable().optional(), + water_context: paddleWaterContextSchema.nullable().optional(), + paddle_speed_kmh: paddleSpeedKmhSchema.nullable().optional(), duration_seconds: z.number().finite().nonnegative().nullable().optional(), planned_duration_minutes: plannedTourDurationMinutesSchema.optional(), break_additional_minutes: plannedTourDurationMinutesSchema.optional(), @@ -204,6 +252,7 @@ export const tourCreateRequestSchema = z message: 'walking time plus breaks must be 1440 minutes or less unless a total is overridden', }); } + tourTypeIssues(value).forEach((issue) => ctx.addIssue({ code: z.ZodIssueCode.custom, ...issue })); value.waypoints.forEach((point, index) => { const leg = point.fixed_leg; const next = value.waypoints[index + 1]; @@ -234,8 +283,43 @@ export const tourCreateRequestSchema = z }); } }); - }); + }) + // A hike left without a limit is routed at T2, as before the field could be null. + .transform((value) => ({ + ...value, + max_hiking_difficulty: value.tour_type === 'hike' ? (value.max_hiking_difficulty ?? 2) : null, + })); export type TourCreateRequest = z.infer; +/** What a client sends: the fields the server fills in may be left out. */ +export type TourCreateInput = z.input; + +/** + * What belongs to which type: a hike carries a routing limit and no boat; a + * paddle tour carries exactly one boat and its water, and no hiking limit. + */ +function tourTypeIssues(value: { + tour_type: PlannableTourType; + max_hiking_difficulty?: number | null; + boat_type?: PaddleBoatType | null; + water_context?: PaddleWaterContext | null; + paddle_speed_kmh?: number | null; +}): Array<{ path: string[]; message: string }> { + const issues: Array<{ path: string[]; message: string }> = []; + if (value.tour_type === 'kayak') { + if (value.boat_type == null) issues.push({ path: ['boat_type'], message: 'a paddle tour needs a boat type' }); + if (value.water_context == null) + issues.push({ path: ['water_context'], message: 'a paddle tour needs a water context' }); + if (value.max_hiking_difficulty != null) + issues.push({ path: ['max_hiking_difficulty'], message: 'a paddle tour has no hiking difficulty' }); + return issues; + } + if (value.max_hiking_difficulty === null) + issues.push({ path: ['max_hiking_difficulty'], message: 'a hike needs a hiking difficulty' }); + for (const field of ['boat_type', 'water_context', 'paddle_speed_kmh'] as const) { + if (value[field] != null) issues.push({ path: [field], message: `${field} belongs to a paddle tour` }); + } + return issues; +} export const tourCreateResponseSchema = z.object({ tour: tourListItemSchema, diff --git a/wiki/MCP-Addon-Tools.md b/wiki/MCP-Addon-Tools.md index d6e79f4f5..76606d7f2 100644 --- a/wiki/MCP-Addon-Tools.md +++ b/wiki/MCP-Addon-Tools.md @@ -237,8 +237,8 @@ Read only: the saved tours of a trip, as the Tours list and the tour detail show | Tool | Description | Scope | |---|---|---| -| `list_tours` | The saved tours of a trip: name, type, origin (`trek` for planned in TREK, `gpx` for a GPX import, `wanderer` for a wanderer snapshot), distance, ascent and descent, the stored routing limit on the SAC scale, and the days of the trip each tour is on | `trips:read` | -| `get_tour_plan` | One tour: its facts, its ordered control points, its provenance (where it came from, when, and whether it was changed in TREK since) and its days. The full track geometry is not included | `trips:read` | +| `list_tours` | The saved tours of a trip: name, type (`hike`, or `kayak` for a paddle tour), origin (`trek` for planned in TREK, `gpx` for a GPX import, `wanderer` for a wanderer snapshot), distance, ascent and descent (unknown for a paddle tour drawn in the planner), the stored routing limit of a hike on the SAC scale, the boat, water and chosen speed of a paddle tour, and the days of the trip each tour is on | `trips:read` | +| `get_tour_plan` | One tour: its facts, its ordered control points, its provenance (where it came from, when, and whether it was changed in TREK since) and its days. A control point can carry a fixed leg to the next one: a via ferrata (`ferrata`) or a stretch drawn by hand (`free`), which no router planned or checked. The full track geometry is not included | `trips:read` | | `find_tours_for_day` | The tours put on one day of a trip, with the same facts as `list_tours` | `trips:read` | ### Road trip _(Road trip addon required)_