Files
TREK/client/src/components/Map/useTripRouteOverview.ts
T
Maurice 8fa75b3c4e feat(tours): show a multi-day tour's stages in the list and put each on a day
A multi-day tour in the planner's list of the trip's tours now unfolds into
its stages (MDT-02), so the stages can be seen together and fitted into the
trip without leaving the one route they are cut from.

- The fold beside a multi-day tour lists its stages in route order, each
  from stop to stop with its distance, climb, descent and moving time, read
  off the saved tour the way the planner reads them, and below them what the
  whole tour adds up to. A value no stage tells is left out, a total some
  stages do not tell is marked as at least that much and says for how many,
  and a stage whose route is not planned yet says so.
- Each stage has its own day: a pill opens the trip's days, moves the stage
  to another one or takes it off again. A stage without a day shows a dashed
  "No day" pill. A day holds a tour, or one of its stages, once, and a stage
  is on one day at most; the plan's own offline path writes it.
- A stage opens in the planner: the tour opens as any tour does and the
  stage is shown once its points are in.
- The day plan names the stage a day holds with its own figures, and the
  day's route runs to that stage's start and on from its end; the trip map
  draws that stretch only. The tour list still counts a tour once, however
  many days hold it or its stages.
- A saved change that takes a stage away (its stage end gone, the tour a day
  tour again, back to the recorded track) takes it off its day in the same
  write and tells every client; the planner says so before the save.
- REST and the assign_place_to_day tool take the stage by the key of the
  stop it starts at, with the same checks; list_tours names the stops
  between the stages and the stage each day holds.

A day assignment remembers its stage by that key (migration 046010); every
assignment made before holds the whole tour, as it did.
2026-10-20 15:28:22 +02:00

136 lines
6.7 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react'
import { type RouteProfileKey } from './RouteCalculator'
import {
assembleTripRoute, emptyAnswers, planTripRoute, routeTripLegs, summariseTripRoute,
type TripRouteAnswers, type TripRouteSummary,
} from './tripRouteGeometry'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import type { RoadtripVia } from '@trek/shared'
import type { Accommodation, AssignmentsMap, Day, Place, Reservation } from '../../types'
import type { TourStageStops } from './dayTourProjection'
export type { TripOverviewDay, TripRouteSummary } from './tripRouteGeometry'
export interface TripRouteOverview extends TripRouteSummary {
/** True until every leg has answered — the totals are a partial sum until then. */
loading: boolean
}
const EMPTY: TripRouteOverview = { ...summariseTripRoute([]), loading: false }
const EMPTY_PLACES: Place[] = []
/** The road trip's vias and the stored day lists their anchors count in. */
interface TripRouteVias {
storedAssignments: AssignmentsMap
viasByDay: Record<number, RoadtripVia[]>
}
/**
* Every travel day's route at once, each day in its own colour, with the trip's total
* distance (#1736).
*
* The geometry is the day view's own — `buildDayRouteRuns` is the same builder
* `useRouteCalculation` draws the selected day from, so a day looks identical whether
* you are looking at it alone or at the whole trip. This hook is the React wrapper:
* the planning and routing themselves live in `tripRouteGeometry`, so the PDF export
* can draw the same trip without a component to hang a hook on. A road picked in the road
* trip is drawn here too (#2580).
*/
export function useTripRouteOverview(
tripId: number | null,
days: Day[],
assignments: AssignmentsMap,
reservations: Reservation[],
accommodations: Accommodation[],
profile: RouteProfileKey,
enabled: boolean,
places: Place[] = EMPTY_PLACES,
vias?: TripRouteVias,
/** MDT-02: where the stages of the trip's multi-day tours run, so a day holding one draws that stage. */
tourStageStops?: TourStageStops,
): TripRouteOverview {
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
const toursEnabled = useAddonStore(s => s.isEnabled('tours'))
// Leg text is formatted at compute time, so a km↔mi switch has to re-run (#1300).
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const [result, setResult] = useState<TripRouteOverview>(EMPTY)
const abortRef = useRef<AbortController | null>(null)
// The map refits its camera whenever a NEW focusPoints array arrives, so the
// array's identity is a camera command. A content edit — a place dropped onto
// a day — re-plans the route, and if that published a fresh frame the map
// would yank itself out from under the edit every time. A new frame is only
// published when the overview is (re)activated or another trip loads; edits
// while it is on reuse the reference and the camera stays where the user put it.
const frameRef = useRef<{ tripId: number | null; points: [number, number][] } | null>(null)
const wasEnabled = useRef(false)
const storedAssignments = vias?.storedAssignments
const viasByDay = vias?.viasByDay
const plan = useMemo(
() => (enabled
? planTripRoute({ days, assignments, reservations, accommodations, optimizeFromAccommodation, toursEnabled, places, storedAssignments, viasByDay, tourStageStops }, profile)
: []),
[enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation, profile, toursEnabled, places, storedAssignments, viasByDay, tourStageStops],
)
// Only geometry and mode decide whether legs have to be fetched again: renaming a
// place or editing its notes must not fire a routing round.
const planKey = useMemo(
() => plan.map(({ day, runs, tourLines }) => `${day.id}@${day.default_transport_mode ?? ''}:${runs
.map(chunks => chunks.map(c => `${c.mode}>${c.waypoints.map(p => `${p.lat.toFixed(5)},${p.lng.toFixed(5)}`).join('|')}`).join('+'))
.join('/')}:tours=${JSON.stringify(tourLines ?? [])}`).join(';'),
[plan],
)
useEffect(() => {
abortRef.current?.abort()
if (!plan.length) {
setResult(EMPTY)
// Off (or nothing to draw): the next activation publishes a fresh frame.
wasEnabled.current = false
return
}
const controller = new AbortController()
abortRef.current = controller
// Straight lines first so the shape of the trip is on screen immediately, then the
// real roads replace them — the same two-step the single-day route draws with.
const first = summariseTripRoute(assembleTripRoute(plan, emptyAnswers(plan)))
// Reframe only on activation or a trip switch, never on a content edit (see
// frameRef above).
const reframe = frameRef.current === null || frameRef.current.tripId !== tripId || !wasEnabled.current
if (reframe) frameRef.current = { tripId, points: first.focusPoints }
wasEnabled.current = true
const frame = frameRef.current!.points
setResult({ ...first, focusPoints: frame, loading: true })
// Published leg by leg: the legs go to the router one at a time with a pause between
// them, so a cold trip takes a second per leg, and roads that fill in as they answer
// read as progress where straight lines that all flip at once read as a hang.
//
// The frame is the exception. A fresh `focusPoints` array is what tells the map to
// fit the camera, and a fit every second would take the map back from wherever the
// reader has panned to. The frame set on the straight lines therefore holds while
// the round answers; a reframe round ends on the routed roads (the two fits the
// overview has always made on activation), an edit round ends on the frame it
// started with and the camera never moves.
const publish = (routed: TripRouteAnswers, loading: boolean): void => {
if (controller.signal.aborted) return
const next = summariseTripRoute(assembleTripRoute(plan, routed))
if (!loading && reframe) frameRef.current = { tripId, points: next.focusPoints }
setResult({ ...next, focusPoints: loading ? frame : frameRef.current!.points, loading })
}
void routeTripLegs(plan, { tripId, signal: controller.signal, unit: distanceUnit, onAnswer: routed => publish(routed, true) })
.then(routed => publish(routed, false))
return () => controller.abort()
// planKey is derived from the same inputs as plan, so keying on the string is
// equivalent while staying stable across unrelated renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [planKey, tripId, distanceUnit])
return result
}