mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
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.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildDayRouteRuns, hotelBookendOf } from './dayRoutePlan'
|
||||
import { projectDayItinerary } from './dayTourProjection'
|
||||
import { projectDayItinerary, tourStageStopsByPlace } from './dayTourProjection'
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
import { planTripRoute, assembleTripRoute, emptyAnswers, summariseTripRoute } from './tripRouteGeometry'
|
||||
import { buildAssignment, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories'
|
||||
@@ -437,6 +437,54 @@ describe('Tour day itinerary segments', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// MDT-02: a day holding one stage of a multi-day tour routes to that stage and draws its stretch.
|
||||
describe('A stage of a multi-day tour on a day', () => {
|
||||
const line: [number, number][] = [[48, 11], [48.01, 11], [48.02, 11], [48.03, 11], [48.04, 11]]
|
||||
const stops = [
|
||||
{ key: 'stop-start', lat: 48, lng: 11 },
|
||||
{ key: 'stop-hut', lat: 48.02, lng: 11 },
|
||||
{ key: 'stop-end', lat: 48.04, lng: 11 },
|
||||
]
|
||||
const hutToHut = (order: number, tour_stage_key: string | null) => {
|
||||
const assignment = at(48, 11, order)
|
||||
return { ...assignment, tour_place_id: assignment.place.id, tour_route_geometry: JSON.stringify(line), tour_stage_key }
|
||||
}
|
||||
|
||||
it('FE-MAP-DRP-017: draws and routes to the stretch of the stage, the stops telling where it runs', () => {
|
||||
const second = hutToHut(1, 'stop-hut')
|
||||
const stageStops = new Map([[second.place.id, stops]])
|
||||
expect(projectDayItinerary([second], true, [], stageStops)[0]).toMatchObject({
|
||||
kind: 'tour', valid: true, start: { lat: 48.02, lng: 11 }, end: { lat: 48.04, lng: 11 }, geometry: line.slice(2),
|
||||
})
|
||||
const first = hutToHut(1, 'stop-start')
|
||||
expect(projectDayItinerary([first], true, [], new Map([[first.place.id, stops]]))[0]).toMatchObject({
|
||||
start: { lat: 48, lng: 11 }, end: { lat: 48.02, lng: 11 },
|
||||
})
|
||||
const runs = buildDayRouteRuns(1, {
|
||||
...inputs({ assignments: { '1': [at(47.99, 11, 0), second, at(48.05, 11, 2)] } }),
|
||||
toursEnabled: true, tourStageStops: stageStops,
|
||||
})
|
||||
expect(runs.map(run => run.map(point => [point.lat, point.lng]))).toEqual([[[47.99, 11], [48.02, 11]], [[48.04, 11], [48.05, 11]]])
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-018: keeps the whole tour where the stops do not tell the stage', () => {
|
||||
const whole = hutToHut(1, null)
|
||||
const unknown = hutToHut(1, 'stop-gone')
|
||||
const last = hutToHut(1, 'stop-end')
|
||||
for (const assignment of [whole, unknown, last]) {
|
||||
expect(projectDayItinerary([assignment], true, [], new Map([[assignment.place.id, stops]]))[0]).toMatchObject({ geometry: line })
|
||||
}
|
||||
const noStops = hutToHut(1, 'stop-hut')
|
||||
expect(projectDayItinerary([noStops], true)[0]).toMatchObject({ geometry: line })
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-019: reads the stage stops of the multi-day tours off the tour list, none for a day tour', () => {
|
||||
const stages = tourStageStopsByPlace([{ place_id: 4, stage_stops: stops }, { place_id: 5, stage_stops: null }, { place_id: 6 }])
|
||||
expect([...stages.keys()]).toEqual([4])
|
||||
expect(stages.get(4)).toBe(stops)
|
||||
})
|
||||
})
|
||||
|
||||
// #2600: an imported GPX/KML place sits at its track's first point, so routing it as a
|
||||
// plain stop drew a second road from the track start to the next stop, alongside the
|
||||
// track itself. Any assigned place with a track now leaves that stretch to the track.
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarri
|
||||
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
|
||||
import { withinDriveRange } from '@trek/shared/roadtrip'
|
||||
import type { Accommodation, AssignmentsMap, Day, Place, Reservation } from '../../types'
|
||||
import { projectDayItinerary } from './dayTourProjection'
|
||||
import { projectDayItinerary, type TourStageStops } from './dayTourProjection'
|
||||
|
||||
export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transit', 'transport_other']
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface DayRouteInputs {
|
||||
optimizeFromAccommodation: boolean | undefined
|
||||
toursEnabled?: boolean
|
||||
places?: Place[]
|
||||
/** MDT-02: where the stages of the trip's multi-day tours run, so a day holding one routes to it. */
|
||||
tourStageStops?: TourStageStops
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,7 +85,7 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
||||
days: allDays,
|
||||
})
|
||||
const merged = getMergedItems({ dayAssignments: da, dayNotes: [], dayTransports, dayId })
|
||||
const projected = new Map(projectDayItinerary(da, input.toursEnabled ?? false, input.places).map(item => [item.assignment.id, item]))
|
||||
const projected = new Map(projectDayItinerary(da, input.toursEnabled ?? false, input.places, input.tourStageStops).map(item => [item.assignment.id, item]))
|
||||
|
||||
type Entry =
|
||||
| { kind: 'place'; id: number; lat: number; lng: number; pos: number; time: string | null; mode: string | null; incoming: string | null }
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
import type { TourListItem, TourStageStop } from '@trek/shared'
|
||||
import { analyzeRouteGeometry } from '../../utils/routeGeometry'
|
||||
import type { Assignment, Place } from '../../types'
|
||||
import { vertexIndicesOf } from '../Tours/stages/tourStages'
|
||||
|
||||
/** MDT-02: the stops each multi-day tour's stages run between, by the tour's place id. */
|
||||
export type TourStageStops = ReadonlyMap<number, readonly TourStageStop[]>
|
||||
|
||||
/** The stage stops of the trip's multi-day tours, from the tour list; a day tour has none. */
|
||||
export function tourStageStopsByPlace(tours: readonly Pick<TourListItem, 'place_id' | 'stage_stops'>[]): TourStageStops {
|
||||
return new Map(tours.flatMap(tour => (tour.stage_stops ? [[tour.place_id, tour.stage_stops] as const] : [])))
|
||||
}
|
||||
|
||||
/**
|
||||
* MDT-02: the stretch of a tour's line one stage runs, from the stop it starts
|
||||
* at to the stop the next one starts at; null where the stops do not tell it
|
||||
* (a stage the tour no longer has, a line that has not reached them yet).
|
||||
*/
|
||||
function stageStretch(
|
||||
line: [number, number][],
|
||||
stops: readonly TourStageStop[] | undefined,
|
||||
stageKey: string | null | undefined,
|
||||
): [number, number][] | null {
|
||||
if (!stageKey || !stops || line.length < 2) return null
|
||||
const index = stops.findIndex(stop => stop.key === stageKey)
|
||||
if (index < 0 || index >= stops.length - 1) return null
|
||||
const at = vertexIndicesOf(stops, line)
|
||||
return at[index + 1] > at[index] ? line.slice(at[index], at[index + 1] + 1) : null
|
||||
}
|
||||
|
||||
export type DayItineraryItem =
|
||||
| { kind: 'stop'; assignment: Assignment }
|
||||
@@ -14,10 +41,17 @@ export type DayItineraryItem =
|
||||
valid: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A day's stops with each tour projected onto its line: the day's route runs
|
||||
* to where the tour starts and on from where it ends. A day holding one stage
|
||||
* of a multi-day tour (MDT-02) runs to that stage's start and on from its end,
|
||||
* and draws that stage's stretch, where `stageStops` tells them.
|
||||
*/
|
||||
export function projectDayItinerary(
|
||||
assignments: Assignment[],
|
||||
toursEnabled: boolean,
|
||||
places: readonly Place[] = [],
|
||||
stageStops?: TourStageStops,
|
||||
): DayItineraryItem[] {
|
||||
const placesById = new Map(places.map(place => [place.id, place]))
|
||||
return assignments.map(assignment => {
|
||||
@@ -37,7 +71,8 @@ export function projectDayItinerary(
|
||||
loop: start.lat === end.lat && start.lng === end.lng, valid: true } as const
|
||||
}
|
||||
const analysis = analyzeRouteGeometry(currentPlace ? currentPlace.route_geometry : assignment.tour_route_geometry)
|
||||
const geometry = analysis?.routeCoordinates ?? []
|
||||
const whole = analysis?.routeCoordinates ?? []
|
||||
const geometry = stageStretch(whole, stageStops?.get(placeId), assignment.tour_stage_key) ?? whole
|
||||
const start = geometry.length >= 2
|
||||
? { lat: geometry[0][0], lng: geometry[0][1] }
|
||||
: assignment.place?.lat != null && assignment.place?.lng != null
|
||||
|
||||
@@ -107,6 +107,7 @@ export function planTripRoute(input: TripRouteInputs, profile: RouteProfileKey):
|
||||
(input.assignments[String(day.id)] ?? []).filter(assignment => assignment.accommodation_id == null),
|
||||
input.toursEnabled ?? false,
|
||||
input.places,
|
||||
input.tourStageStops,
|
||||
).flatMap(item => item.kind === 'tour' && item.geometry.length >= 2 ? [item.geometry] : []),
|
||||
}))
|
||||
.filter(entry => entry.runs.length > 0 || entry.tourLines.length > 0)
|
||||
|
||||
@@ -8,6 +8,7 @@ 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'
|
||||
|
||||
@@ -46,6 +47,8 @@ export function useTripRouteOverview(
|
||||
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'))
|
||||
@@ -66,9 +69,9 @@ export function useTripRouteOverview(
|
||||
const viasByDay = vias?.viasByDay
|
||||
const plan = useMemo(
|
||||
() => (enabled
|
||||
? planTripRoute({ days, assignments, reservations, accommodations, optimizeFromAccommodation, toursEnabled, places, storedAssignments, viasByDay }, profile)
|
||||
? planTripRoute({ days, assignments, reservations, accommodations, optimizeFromAccommodation, toursEnabled, places, storedAssignments, viasByDay, tourStageStops }, profile)
|
||||
: []),
|
||||
[enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation, profile, toursEnabled, places, storedAssignments, viasByDay],
|
||||
[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
|
||||
|
||||
Reference in New Issue
Block a user