Files
TREK/client/src/components/Tours/conversion/useGpxConversion.ts
T
Maurice 73237ffedb feat(tours): bike tours in the planner, CyclOSM, and the planned cards' last points (BIK-01 to BIK-06, RTE-01, IO-01, LIB-02)
The tour type is a dropdown; a bike tour picks its profile, routes for it,
says when a point lies off every way, and shows what it runs on: surfaces,
kinds of way and, for a mountain bike, the trail grades, each pointed at on
the map. A type's own map (OpenHikingMap for a hike, CyclOSM for a bike tour)
is offered while a tour of that type is open, with an admin switch each.

From the planned cards: a hike avoids steep climbs, steps or ferries, nothing
else; a saved tour can be shared where the platform takes GPX files; and the
tours rail adds a tour from one menu: plan new, import GPX, or take one over
from another trip.
2026-10-19 21:33:49 +02:00

134 lines
5.5 KiB
TypeScript

import type { TourMaxHikingDifficulty } from '@trek/shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { freeLegBetween } from '../planner/tourRouteActions';
import { isTourRoute } from '../planner/tourRouteOutcome';
import { routeBikeTour, routeWalkingTour } from '../planner/tourRouting';
import { paddleFieldsOf } from '../planner/usePaddleFields';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { compareRoutes, deriveControlPoints, type RouteComparison, type TrackPoint } from './gpxConversion';
/** The grade a planned version routes with when the import carries none: the paths a T3 walk may take. */
const DEFAULT_DIFFICULTY: TourMaxHikingDifficulty = 3;
type GpxConversionStatus = 'idle' | 'analyzing' | 'ready' | 'failed';
interface ConversionState {
/** The import the analysis belongs to; another import shown means it is idle again. */
placeId: number | null;
status: GpxConversionStatus;
points: TrackPoint[];
line: [number, number][] | null;
comparison: RouteComparison | null;
/** Why it failed: the track is too short to plan from, or the router found no way. */
error: 'track' | 'routing' | null;
}
const IDLE: ConversionState = { placeId: null, status: 'idle', points: [], line: null, comparison: null, error: null };
/**
* Planning an editable tour from an import shown in the tour planner (PR1-05A/B).
* The analysis starts only when asked: control points from the track, a route
* through them by the planner's own router, and how far that route strays from
* the track. Nothing is stored. Adopting hands the points to the planner as a
* new draft that remembers the import; only saving that draft creates a tour,
* and the import stays as it is.
*/
export function useGpxConversion(planner: TourPlannerController) {
const [state, setState] = useState<ConversionState>(IDLE);
const abort = useRef<AbortController | null>(null);
const tour = planner.mode.type === 'view-gpx' ? (planner.readOnlyGpxTour?.tour ?? null) : null;
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';
// BIK-01: a bike import is planned for its profile.
const bikeProfile = tour?.tour_type === 'bike' ? (tour.bike_profile ?? 'touring') : null;
useEffect(() => () => abort.current?.abort(), []);
const analyze = useCallback(async () => {
if (!tour || !track) return;
abort.current?.abort();
const controller = new AbortController();
abort.current = controller;
const trackPoints = track.map(([lat, lng]) => ({ lat, lng }));
const points = deriveControlPoints(trackPoints);
const base = { ...IDLE, placeId: tour.place_id, points };
if (points.length < 2) {
setState({ ...base, status: 'failed', error: 'track' });
return;
}
setState({ ...base, status: 'analyzing' });
const legs = paddle
? points.map((point, index) => ({
...point,
fixedLeg: index < points.length - 1 ? freeLegBetween(point, points[index + 1]) : null,
}))
: points;
const routed = bikeProfile
? await routeBikeTour(legs, bikeProfile, controller.signal)
: await routeWalkingTour(legs, controller.signal, difficulty);
if (controller.signal.aborted) return;
if (!isTourRoute(routed)) {
setState({ ...base, status: 'failed', error: 'routing' });
return;
}
const suggestion = routed.coordinates.map(([lat, lng]) => ({ lat, lng }));
setState({
...base,
status: 'ready',
line: routed.coordinates,
comparison: compareRoutes(trackPoints, suggestion),
});
}, [bikeProfile, difficulty, paddle, tour, track]);
const cancel = useCallback(() => {
abort.current?.abort();
setState(IDLE);
}, []);
const { startDerivedTour } = planner;
/**
* The suggestion as a draft in the planner: the import planned over in place
* (`replace`), or a new tour beside it. True once the planner took it.
*/
const adopt = useCallback(
(how: 'replace' | 'new') => {
if (!tour || current.status !== 'ready') return false;
const started = startDerivedTour({
name: tour.name,
description: tour.description ?? null,
website: tour.website ?? null,
difficulty,
points: current.points,
sourcePlaceId: tour.place_id,
inPlace: how === 'replace',
revision: tour.revision,
...(paddle ? { tourType: 'kayak' as const, paddle: paddleFieldsOf(tour) } : {}),
...(bikeProfile ? { tourType: 'bike' as const, bikeProfile } : {}),
});
if (started) setState(IDLE);
return started;
},
[bikeProfile, current.points, current.status, difficulty, paddle, startDerivedTour, tour]
);
return useMemo(
() => ({
...current,
/** 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, paddle, planner.canEdit, tour, track]
);
}
export type GpxConversion = ReturnType<typeof useGpxConversion>;