Files
TREK/client/src/components/Tours/conversion/useGpxConversion.ts
T
Maurice 164a3633ac feat(tours): plan ski and winter tours, snowshoeing and winter hiking among them
Ski becomes "Ski & winter", a type the planner plans with six disciplines
(alpine, snowboard, cross-country, ski touring, snowshoeing, winter hiking),
nothing preselected; saving needs a discipline, and a technique for
cross-country. Every leg is drawn straight between the points through
planner/skiRouting.ts, the one place the piste router will plug in, with its
heights from the elevation source and no ski time worked out.

The hike's winter label goes entirely: its migration, column, field, marks,
GPX rule, MCP field and strings. A hike is summer only; a ski tour with the
snowshoe discipline exports as snowshoeing. The unreleased ski migration takes
the six disciplines and the collection migration loses the winter column.

The pistes are offered for a ski tour, shown until switched off, and with no
tour open, never for a hike. Export, copy, collections, stages (untimed),
actual tracks, the atlas, list and day marks (one glyph per discipline), 3D,
planning over a ski import and list_tours/get_tour_plan know the planned ski
tour.
2026-10-20 18:40:48 +02:00

158 lines
6.8 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, routeRun, routeWalkingTour } from '../planner/tourRouting';
import { paddleFieldsOf } from '../planner/usePaddleFields';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { skiChoiceMissing, skiChoiceOf } from '../ski/skiChoice';
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;
// RUN-01: a run import is planned as a run for its profile, at the pace it holds; a fresh
// import holds none, so its pace is the runner's to enter.
const runProfile = tour?.tour_type === 'run' ? (tour.run_profile ?? 'running') : null;
// SKI-02: a ski import is planned as a ski tour of its discipline, its legs drawn as the planner draws them.
const ski = useMemo(() => (tour?.tour_type === 'ski' ? skiChoiceOf(tour) : null), [tour]);
const drawn = paddle || ski !== 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 = drawn
? 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)
: runProfile
? await routeRun(legs, runProfile, 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, drawn, runProfile, 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 } : {}),
...(runProfile
? { tourType: 'run' as const, runProfile, runPaceSecondsPerKm: tour.run_pace_seconds_per_km ?? null }
: {}),
...(ski ? { tourType: 'ski' as const, ski } : {}),
});
if (started) setState(IDLE);
return started;
},
[bikeProfile, current.points, current.status, difficulty, paddle, runProfile, ski, startDerivedTour, tour]
);
return useMemo(
() => ({
...current,
/**
* The import shown can be planned from: it has a track and the user may add tours. A ski
* track (SKI-02) once it names its discipline, planned as a ski tour with its legs drawn.
* A ride (HOR-01) never: no router plans one, and walking it again on foot would be no ride.
*/
available:
tour !== null &&
(ski === null || skiChoiceMissing(ski) === null) &&
tour.tour_type !== 'horse' &&
(track?.length ?? 0) >= 2 &&
planner.canEdit,
difficulty,
/** The import is a paddle or a ski tour: its version is drawn with straight legs, not routed. */
paddle: drawn,
/** SKI-02: the import is a ski track, planned as a ski tour of its discipline. */
ski: ski !== null,
analyze,
cancel,
adopt,
}),
[adopt, analyze, cancel, current, difficulty, drawn, planner.canEdit, ski, tour, track]
);
}
export type GpxConversion = ReturnType<typeof useGpxConversion>;