+
{routeAnalysis && (
diff --git a/client/src/components/Tours/planner/TourWaypointList.tsx b/client/src/components/Tours/planner/TourWaypointList.tsx
index d3319b77d..92c991582 100644
--- a/client/src/components/Tours/planner/TourWaypointList.tsx
+++ b/client/src/components/Tours/planner/TourWaypointList.tsx
@@ -1,7 +1,15 @@
import { ArrowDown, ArrowUp, GripVertical, MapPin, MapPinPlus, Trash2 } from 'lucide-react';
-import { useState, type ReactNode } from 'react';
+import { Fragment, useState, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
+import { useSettingsStore } from '../../../store/settingsStore';
+import {
+ useTourFerrataCrossingStore,
+ type TourFerrataCrossing,
+ type TourWaypointFerrata,
+} from '../../../store/tourFerrataCrossingStore';
import { toggleTourPlacing, useTourPlaceToolStore } from '../../../store/tourPlaceToolStore';
+import { formatDistance } from '../../../utils/units';
+import { FerrataGlyph } from '../../Ferratas/FerrataGlyph';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
@@ -65,6 +73,78 @@ function WaypointEmptyState() {
);
}
+/**
+ * A via ferrata the route climbs between two waypoints, as a badge on that leg:
+ * the ladder, its name, and its grade and length as chips inside, in the
+ * ferrata violet the map draws it in.
+ */
+function FerrataLegRow({ crossing }: { crossing: TourFerrataCrossing }) {
+ const { t } = useTranslation();
+ const unit = useSettingsStore((state) => state.settings.distance_unit);
+ const length = formatDistance((crossing.toM - crossing.fromM) / 1000, unit ?? 'metric');
+ return (
+
+
+
+
+ {crossing.name}
+
+ {crossing.grade && (
+
+ {crossing.grade}
+
+ )}
+
+ {length}
+
+
+
+ );
+}
+
+/**
+ * A waypoint's number, as the map pins show it. At the foot or the top of a via
+ * ferrata it takes a violet ring and the ferrata's ladder at its corner, named
+ * by the tooltip.
+ */
+function WaypointNumber({
+ index,
+ selected,
+ ferrata,
+}: {
+ index: number;
+ selected: boolean;
+ ferrata: TourWaypointFerrata | null;
+}) {
+ const { t } = useTranslation();
+ const label = t('tours.planner.waypointLabel', { n: index + 1 });
+ const number = (
+
+ {index + 1}
+ {ferrata && }
+
+ );
+ if (!ferrata) return number;
+ const grade = ferrata.grade ? ` (${ferrata.grade})` : '';
+ return
{number} ;
+}
+
/**
* The row under the last waypoint that turns on "set points" for the map the
* planner shows, the same switch as the one in the map's layer pill; while it
@@ -107,6 +187,13 @@ export default function TourWaypointList({
const [draggedWaypointId, setDraggedWaypointId] = useState
(null);
const [dropTarget, setDropTarget] = useState(null);
const placeNames = useWaypointPlaceNames(planner.waypoints);
+ const ferrataEnds = useTourFerrataCrossingStore((state) => state.waypoints);
+ const crossings = useTourFerrataCrossingStore((state) => state.crossings);
+ const legCrossings = new Map();
+ for (const crossing of crossings) {
+ if (!crossing.afterWaypoint) continue;
+ legCrossings.set(crossing.afterWaypoint, [...(legCrossings.get(crossing.afterWaypoint) ?? []), crossing]);
+ }
const lastIndex = planner.waypoints.length - 1;
const movable = canEdit && !isSaving;
const endDrag = () => {
@@ -132,121 +219,118 @@ export default function TourWaypointList({
const placeName = placeNames[point.id];
const coordinates = `${point.lat.toFixed(5)}, ${point.lng.toFixed(5)}`;
return (
- {
- if (!movable) {
+
+ {
+ if (!movable) {
+ event.preventDefault();
+ return;
+ }
+ if (event.dataTransfer) {
+ event.dataTransfer.effectAllowed = 'move';
+ event.dataTransfer.setData('text/plain', point.id);
+ }
+ setDraggedWaypointId(point.id);
+ setDropTarget(null);
+ }}
+ onDragOver={(event) => {
+ if (!movable || !draggedWaypointId || draggedWaypointId === point.id) return;
event.preventDefault();
- return;
- }
- if (event.dataTransfer) {
- event.dataTransfer.effectAllowed = 'move';
- event.dataTransfer.setData('text/plain', point.id);
- }
- setDraggedWaypointId(point.id);
- setDropTarget(null);
- }}
- onDragOver={(event) => {
- if (!movable || !draggedWaypointId || draggedWaypointId === point.id) return;
- event.preventDefault();
- if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
- const bounds = event.currentTarget.getBoundingClientRect();
- const placement = event.clientY >= bounds.top + bounds.height / 2 ? 'after' : 'before';
- setDropTarget((current) =>
- current?.id === point.id && current.placement === placement ? current : { id: point.id, placement }
- );
- }}
- onDrop={(event) => {
- event.preventDefault();
- const draggedId = draggedWaypointId || event.dataTransfer.getData('text/plain');
- if (movable && draggedId && draggedId !== point.id && dropTarget?.id === point.id) {
- planner.reorderWaypoint(draggedId, point.id, dropTarget.placement);
- }
- endDrag();
- }}
- onDragEnd={endDrag}
- className={`relative rounded-[12px] ${draggedWaypointId === point.id ? 'opacity-45' : ''} ${isDropTarget ? 'bg-surface-hover' : ''}`}
- >
- {isDropTarget && (
-
- )}
-
-
-
-
-
- {index + 1}
-
-
planner.setSelectedWaypointId(point.id)}
- className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-start outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)]"
- aria-current={selected ? 'true' : undefined}
- >
-
-
- {placeName ?? coordinates}
-
- {t(ROLE_KEY[point.role])}
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
+ const bounds = event.currentTarget.getBoundingClientRect();
+ const placement = event.clientY >= bounds.top + bounds.height / 2 ? 'after' : 'before';
+ setDropTarget((current) =>
+ current?.id === point.id && current.placement === placement ? current : { id: point.id, placement }
+ );
+ }}
+ onDrop={(event) => {
+ event.preventDefault();
+ const draggedId = draggedWaypointId || event.dataTransfer.getData('text/plain');
+ if (movable && draggedId && draggedId !== point.id && dropTarget?.id === point.id) {
+ planner.reorderWaypoint(draggedId, point.id, dropTarget.placement);
+ }
+ endDrag();
+ }}
+ onDragEnd={endDrag}
+ className={`relative rounded-[12px] ${draggedWaypointId === point.id ? 'opacity-45' : ''} ${isDropTarget ? 'bg-surface-hover' : ''}`}
+ >
+ {isDropTarget && (
+
+ )}
+
+
+
- {placeName && (
+
+
planner.setSelectedWaypointId(point.id)}
+ className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-start outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)]"
+ aria-current={selected ? 'true' : undefined}
+ >
+
+
+ {placeName ?? coordinates}
+
+ {t(ROLE_KEY[point.role])}
+
+ {placeName && (
+
+ {coordinates}
+
+ )}
+
+ {canEdit && (
- {coordinates}
+ planner.moveWaypoint(point.id, -1)}
+ >
+
+
+ planner.moveWaypoint(point.id, 1)}
+ >
+
+
+ planner.removeWaypoint(point.id)}
+ >
+
+
)}
-
- {canEdit && (
-
- planner.moveWaypoint(point.id, -1)}
- >
-
-
- planner.moveWaypoint(point.id, 1)}
- >
-
-
- planner.removeWaypoint(point.id)}
- >
-
-
-
- )}
-
-
+
+
+ {(legCrossings.get(point.id) ?? []).map((crossing) => (
+
+ ))}
+
);
})}
diff --git a/client/src/components/Tours/planner/tourFixedLegs.test.ts b/client/src/components/Tours/planner/tourFixedLegs.test.ts
new file mode 100644
index 000000000..752f5e91e
--- /dev/null
+++ b/client/src/components/Tours/planner/tourFixedLegs.test.ts
@@ -0,0 +1,43 @@
+// FE-TOURS-FIXEDLEG-001 to -002: a fixed leg holds only while it joins its two waypoints.
+import { describe, expect, it } from 'vitest';
+import { keepJoinedFixedLegs, lineLengthM } from './tourFixedLegs';
+
+const leg = {
+ kind: 'ferrata' as const,
+ ref: 'way/1',
+ name: 'Via A',
+ geometry: [
+ [47.4, 11.0],
+ [47.401, 11.0],
+ ] as [number, number][],
+};
+
+describe('keepJoinedFixedLegs', () => {
+ it('FE-TOURS-FIXEDLEG-001: keeps a leg that joins its waypoints and drops one that a move, a removal or a bad row broke', () => {
+ const joined = [
+ { lat: 47.4, lng: 11.0, fixedLeg: leg },
+ { lat: 47.401, lng: 11.0 },
+ ];
+ expect(keepJoinedFixedLegs(joined)[0]?.fixedLeg).toBe(leg);
+
+ const moved = [
+ { lat: 47.4, lng: 11.0, fixedLeg: leg },
+ { lat: 47.41, lng: 11.0 },
+ ];
+ expect(keepJoinedFixedLegs(moved)[0]).not.toHaveProperty('fixedLeg');
+
+ const last = [{ lat: 47.401, lng: 11.0, fixedLeg: leg }];
+ expect(keepJoinedFixedLegs(last)[0]).not.toHaveProperty('fixedLeg');
+
+ const broken = [
+ { lat: 47.4, lng: 11.0, fixedLeg: { ...leg, kind: 'boat' } as never },
+ { lat: 47.401, lng: 11.0 },
+ ];
+ expect(keepJoinedFixedLegs(broken)[0]).not.toHaveProperty('fixedLeg');
+ });
+
+ it('FE-TOURS-FIXEDLEG-002: measures a line in metres', () => {
+ expect(lineLengthM(leg.geometry)).toBeCloseTo(111.2, 0);
+ expect(lineLengthM([])).toBe(0);
+ });
+});
diff --git a/client/src/components/Tours/planner/tourFixedLegs.ts b/client/src/components/Tours/planner/tourFixedLegs.ts
new file mode 100644
index 000000000..e72948f6e
--- /dev/null
+++ b/client/src/components/Tours/planner/tourFixedLegs.ts
@@ -0,0 +1,52 @@
+import { TOUR_FIXED_LEG_END_TOLERANCE_M, tourFixedLegSchema, type TourFixedLeg } from '@trek/shared';
+import { haversineKm } from '../../../utils/geo';
+
+/**
+ * Legs the router does not plan: a via ferrata put onto the tour, whose own
+ * line from OpenStreetMap the tour follows from one waypoint to the next. A
+ * fixed leg only holds while it still joins its two waypoints, so moving,
+ * removing or reordering either of them turns the leg back into a routed one.
+ */
+export interface FixedLegPoint {
+ lat: number;
+ lng: number;
+ fixedLeg?: TourFixedLeg | null;
+}
+
+/** Read as a plain array: this workspace compiles without strict mode, where the schema's tuples are "maybe two numbers". */
+function gapM(a: { lat: number; lng: number }, end: readonly unknown[]): number {
+ return haversineKm(a, { lat: Number(end[0]), lng: Number(end[1]) }) * 1000;
+}
+
+/** Whether a leg still runs from this waypoint to the next one. */
+export function fixedLegJoins(point: FixedLegPoint, next: FixedLegPoint | undefined, leg: TourFixedLeg): boolean {
+ const first = leg.geometry[0];
+ const last = leg.geometry[leg.geometry.length - 1];
+ return (
+ !!next &&
+ !!first &&
+ !!last &&
+ gapM(point, first) <= TOUR_FIXED_LEG_END_TOLERANCE_M &&
+ gapM(next, last) <= TOUR_FIXED_LEG_END_TOLERANCE_M
+ );
+}
+
+/** The points with every leg that no longer joins its waypoints (or does not parse) dropped. */
+export function keepJoinedFixedLegs(points: readonly T[]): T[] {
+ return points.map((point, index) => {
+ if (!point.fixedLeg) return point;
+ const parsed = tourFixedLegSchema.safeParse(point.fixedLeg);
+ if (parsed.success && fixedLegJoins(point, points[index + 1], parsed.data)) return point;
+ const { fixedLeg: _dropped, ...rest } = point;
+ return rest as T;
+ });
+}
+
+/** The length of a line, in metres. */
+export function lineLengthM(line: readonly (readonly [number, number])[]): number {
+ let total = 0;
+ for (let i = 1; i < line.length; i += 1) {
+ total += haversineKm({ lat: line[i - 1][0], lng: line[i - 1][1] }, { lat: line[i][0], lng: line[i][1] }) * 1000;
+ }
+ return total;
+}
diff --git a/client/src/components/Tours/planner/tourRouting.test.ts b/client/src/components/Tours/planner/tourRouting.test.ts
index d829d966f..d9ec45602 100644
--- a/client/src/components/Tours/planner/tourRouting.test.ts
+++ b/client/src/components/Tours/planner/tourRouting.test.ts
@@ -35,6 +35,41 @@ describe('routeWalkingTour', () => {
})
})
+ it('TOUR-ROUTING-020: takes a fixed leg as its own line and routes only the runs around it', async () => {
+ const leg = {
+ kind: 'ferrata' as const,
+ ref: 'way/1',
+ name: 'Via A',
+ geometry: [[47.39, 11.01], [47.391, 11.011], [47.392, 11.012]] as [number, number][],
+ }
+ valhallaRun
+ .mockResolvedValueOnce({ total: { coordinates: [[47.38, 11.0], [47.39, 11.01]], distance: 1500, duration: 1200 } })
+ .mockResolvedValueOnce({ total: { coordinates: [[47.392, 11.012], [47.4, 11.02]], distance: 1000, duration: 800 } })
+
+ const routed = await routeWalkingTour([
+ { lat: 47.38, lng: 11.0 },
+ { lat: 47.39, lng: 11.01, fixedLeg: leg },
+ { lat: 47.392, lng: 11.012 },
+ { lat: 47.4, lng: 11.02 },
+ ])
+
+ expect(valhallaRun).toHaveBeenCalledTimes(2)
+ expect(valhallaRun.mock.calls[0]?.[0]).toEqual([{ lat: 47.38, lng: 11.0 }, { lat: 47.39, lng: 11.01 }])
+ expect(valhallaRun.mock.calls[1]?.[0]).toEqual([{ lat: 47.392, lng: 11.012 }, { lat: 47.4, lng: 11.02 }])
+ expect(routed?.coordinates).toEqual([[47.38, 11.0], [47.39, 11.01], [47.391, 11.011], [47.392, 11.012], [47.4, 11.02]])
+ expect(routed?.distanceMeters).toBeGreaterThan(2500)
+ })
+
+ it('TOUR-ROUTING-021: a tour that is all fixed leg asks the router nothing, and a failed run fails the whole route', async () => {
+ const leg = { kind: 'ferrata' as const, ref: 'way/1', name: null, geometry: [[47.39, 11.01], [47.392, 11.012]] as [number, number][] }
+ const fixedOnly = await routeWalkingTour([{ lat: 47.39, lng: 11.01, fixedLeg: leg }, { lat: 47.392, lng: 11.012 }])
+ expect(valhallaRun).not.toHaveBeenCalled()
+ expect(fixedOnly?.coordinates).toEqual([[47.39, 11.01], [47.392, 11.012]])
+
+ valhallaRun.mockResolvedValueOnce(null)
+ expect(await routeWalkingTour([{ lat: 47.38, lng: 11.0 }, { lat: 47.39, lng: 11.01, fixedLeg: leg }, { lat: 47.392, lng: 11.012 }])).toBeNull()
+ })
+
it.each([1, 2, 3, 4, 5, 6] as const)('TOUR-ROUTING-002: forwards T%i unchanged to Valhalla', async difficulty => {
await routeWalkingTour(waypoints, undefined, difficulty)
diff --git a/client/src/components/Tours/planner/tourRouting.ts b/client/src/components/Tours/planner/tourRouting.ts
index 975af0f5a..2a46211ba 100644
--- a/client/src/components/Tours/planner/tourRouting.ts
+++ b/client/src/components/Tours/planner/tourRouting.ts
@@ -1,5 +1,7 @@
+import type { TourFixedLeg } from '@trek/shared'
import type { Waypoint } from '../../../types'
import { valhallaBase, valhallaRun, valhallaTurn } from '../../Map/valhallaRoute'
+import { lineLengthM } from './tourFixedLegs'
export interface TourRouteResult {
coordinates: [number, number][]
@@ -13,21 +15,55 @@ export interface TourRouteResult {
*/
const TOURS_MAX_HIKING_DIFFICULTY = 2
-/** Tours deliberately bypasses the OSRM-first RouteCalculator. */
+/** How fast a fixed leg is reckoned until the heights are in (a via ferrata climbs slowly). */
+const FIXED_LEG_KM_PER_HOUR = 1.5
+
+/**
+ * Tours deliberately bypasses the OSRM-first RouteCalculator. A waypoint whose
+ * leg is fixed (a via ferrata on the tour) hands its own line over for that
+ * leg; the router only plans the runs between fixed legs, one request each.
+ */
export async function routeWalkingTour(
- waypoints: Pick[],
+ waypoints: Array & { fixedLeg?: TourFixedLeg | null }>,
signal?: AbortSignal,
maxHikingDifficulty = TOURS_MAX_HIKING_DIFFICULTY,
): Promise {
- const run = await valhallaRun(waypoints, 'walking', [], signal, {
- max_hiking_difficulty: maxHikingDifficulty,
- })
- if (!run) return null
- return {
- coordinates: run.total.coordinates,
- distanceMeters: run.total.distance,
- durationSeconds: run.total.duration,
+ const lines: [number, number][][] = []
+ let distanceMeters = 0
+ let durationSeconds = 0
+ const routeRun = async (run: Array>) => {
+ if (run.length < 2) return true
+ const routed = await valhallaRun(run.map(({ lat, lng }) => ({ lat, lng })), 'walking', [], signal, {
+ max_hiking_difficulty: maxHikingDifficulty,
+ })
+ if (!routed) return false
+ lines.push(routed.total.coordinates)
+ distanceMeters += routed.total.distance
+ durationSeconds += routed.total.duration
+ return true
}
+
+ let run: Array> = waypoints.slice(0, 1)
+ for (let i = 0; i < waypoints.length - 1; i += 1) {
+ const leg = waypoints[i].fixedLeg
+ if (!leg) {
+ run.push(waypoints[i + 1])
+ continue
+ }
+ if (!(await routeRun(run))) return null
+ const line = leg.geometry.map(([lat, lng]): [number, number] => [lat, lng])
+ const length = lineLengthM(line)
+ lines.push(line)
+ distanceMeters += length
+ durationSeconds += (length / 1000 / FIXED_LEG_KM_PER_HOUR) * 3600
+ run = [waypoints[i + 1]]
+ }
+ if (!(await routeRun(run))) return null
+ if (lines.length === 0) return null
+
+ // One line: each part after the first starts where the one before it ended, so its first point goes.
+ const coordinates = lines.flatMap((line, index) => (index === 0 ? line : line.slice(1)))
+ return { coordinates, distanceMeters, durationSeconds }
}
const HEIGHT_CHUNK_SIZE = 500
diff --git a/client/src/components/Tours/planner/useTourPlanner.ts b/client/src/components/Tours/planner/useTourPlanner.ts
index b936dea3c..e80f1fcd4 100644
--- a/client/src/components/Tours/planner/useTourPlanner.ts
+++ b/client/src/components/Tours/planner/useTourPlanner.ts
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { MAX_PLANNED_TOUR_DURATION_MINUTES, tourWebsiteSchema, type TourCreateResponse, type TourListItem, type TourWaypointRole, type TourMaxHikingDifficulty } from '@trek/shared'
+import { MAX_PLANNED_TOUR_DURATION_MINUTES, tourWebsiteSchema, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourWaypointRole, type TourMaxHikingDifficulty } from '@trek/shared'
import { tourRepo } from '../../../repo/tourRepo'
import { randomId } from '../../../utils/randomId'
import type { TourBaseLayer } from '../../Map/MapLayerSwitcher'
import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/routeGeometry'
import { enrichTourElevations, routeWalkingTour, type TourElevationPoint } from './tourRouting'
+import { keepJoinedFixedLegs } from './tourFixedLegs'
import { hikingTimeMinutes } from '../hikingTime'
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
@@ -13,6 +14,8 @@ export interface TourPlannerWaypoint {
lat: number
lng: number
role: TourWaypointRole
+ /** The leg to the next waypoint as a fixed line (a via ferrata on the tour), not routed. */
+ fixedLeg?: TourFixedLeg | null
}
export type TourPlannerStatus =
@@ -51,14 +54,14 @@ function hasCompleteElevation(points: TourElevationPoint[] | null): points is [n
}
export function normalizePlannerWaypoints(points: TourPlannerWaypoint[]): TourPlannerWaypoint[] {
- return points.map((point, index) => ({
+ return keepJoinedFixedLegs(points).map((point, index) => ({
...point,
role: index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'via',
}))
}
export function tourWaypointKey(points: TourPlannerWaypoint[]): string {
- return points.map(point => `${point.id}:${point.lat.toFixed(7)}:${point.lng.toFixed(7)}`).join('|')
+ return points.map(point => `${point.id}:${point.lat.toFixed(7)}:${point.lng.toFixed(7)}${point.fixedLeg ? `:${point.fixedLeg.ref}` : ''}`).join('|')
}
function sameWaypointSnapshot(a: TourPlannerWaypoint[], b: TourPlannerWaypoint[]): boolean {
@@ -282,6 +285,25 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setSelectedWaypointId(id)
}, [commitWaypoints])
+ /**
+ * A fixed leg onto the end of the tour, as one step of the history: a
+ * waypoint at the line's start that carries it, and one at its end.
+ */
+ const appendFixedLeg = useCallback((leg: TourFixedLeg) => {
+ if (!editPermissionRef.current) return
+ const first = leg.geometry[0]
+ const last = leg.geometry[leg.geometry.length - 1]
+ if (!first || !last) return
+ const startId = randomId()
+ const endId = randomId()
+ commitWaypoints(current => [
+ ...current,
+ { id: startId, lat: Number(first[0]), lng: Number(first[1]), role: 'via', fixedLeg: leg },
+ { id: endId, lat: Number(last[0]), lng: Number(last[1]), role: 'end' },
+ ])
+ setSelectedWaypointId(endId)
+ }, [commitWaypoints])
+
const setWaypointPosition = useCallback((id: string, lat: number, lng: number) => {
if (!editPermissionRef.current || saveInFlightRef.current || !Number.isFinite(lat) || !Number.isFinite(lng)) return false
const current = waypointsRef.current.find(point => point.id === id)
@@ -562,6 +584,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
lat: point.lat,
lng: point.lng,
role: point.role,
+ ...(point.fixed_leg ? { fixedLeg: point.fixed_leg } : {}),
})))
localStorage.removeItem(storageKey)
contextGenerationRef.current += 1
@@ -716,6 +739,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
lng: point.lng,
role: point.role,
sequence,
+ ...(point.fixedLeg ? { fixed_leg: point.fixedLeg } : {}),
})),
max_hiking_difficulty: maxHikingDifficulty,
duration_seconds: walkingDurationMinutes === null ? durationSeconds : walkingDurationMinutes * 60,
@@ -770,7 +794,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
breakAdditionalMinutes, setBreakAdditionalMinutes, breakAdditionalInvalid,
walkingDurationMinutes, plannedTotalMinutes, plannedTotalInvalid,
waypoints, selectedWaypointId, setSelectedWaypointId,
- addWaypoint, setWaypointPosition, removeWaypoint, moveWaypoint, reorderWaypoint,
+ addWaypoint, appendFixedLeg, setWaypointPosition, removeWaypoint, moveWaypoint, reorderWaypoint,
undo, redo, canUndo: history.past.length > 0, canRedo: history.future.length > 0,
status, error, retry, isSaving,
route, enrichedGeometry, routeAnalysis, distanceMeters, durationSeconds,
diff --git a/client/src/components/Tours/tourParts.tsx b/client/src/components/Tours/tourParts.tsx
index d115760fa..13e34a246 100644
--- a/client/src/components/Tours/tourParts.tsx
+++ b/client/src/components/Tours/tourParts.tsx
@@ -68,7 +68,7 @@ function TourMetricPill({ icon: Icon, label, value, prefix }: { icon: LucideIcon
}
/** The facts of a tour as white pills, kept to one short row: how far, up and down, how long, how hard, and where it came from when that was not TREK. */
-export function TourFactPills({ tour, className = '' }: { tour: TourListItem; className?: string }) {
+export function TourFactPills({ tour, className = '', children }: { tour: TourListItem; className?: string; children?: ReactNode }) {
const { t } = useTranslation()
const unit = useSettingsStore(state => state.settings.distance_unit)
const times = tourPlannedTimes(tour)
@@ -99,6 +99,7 @@ export function TourFactPills({ tour, className = '' }: { tour: TourListItem; cl
)}
+ {children}
)
}
diff --git a/client/src/components/shared/ElevationProfile.test.tsx b/client/src/components/shared/ElevationProfile.test.tsx
index 97f5c5275..d30165d8a 100644
--- a/client/src/components/shared/ElevationProfile.test.tsx
+++ b/client/src/components/shared/ElevationProfile.test.tsx
@@ -154,4 +154,14 @@ describe('ElevationProfile', () => {
expect(prepared).toHaveBeenCalledTimes(2)
unmount()
})
+
+ it('bands the stretches it is given behind the line, placed by distance along the route', () => {
+ const { container } = render(
+