diff --git a/client/src/pages/tripPlanner/useRoadtripDriveShaping.test.ts b/client/src/pages/tripPlanner/useRoadtripDriveShaping.test.ts new file mode 100644 index 000000000..dae5d6a2f --- /dev/null +++ b/client/src/pages/tripPlanner/useRoadtripDriveShaping.test.ts @@ -0,0 +1,357 @@ +// FE-TP-DRIVE-001 to FE-TP-DRIVE-016 +// +// Shaping the drawn drive, driven straight through useRoadtripDriveShaping. The cards +// are plain fixtures laid along straight lines, so every distance in a case can be +// checked by hand: a tenth of a degree of latitude is about 11 km, a ten-thousandth +// about 11 metres. The stop popup's draft is real state, so the cases that hand a stop +// to the popup read what it would open with. +import { useState } from 'react' +import { act, renderHook } from '@testing-library/react' +import type { RoadtripPreferences } from '@trek/shared' +import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup' +import type { CorridorPoi } from '../../components/Roadtrip/useCorridorPois' +import type { RoadtripStop } from '../../components/Roadtrip/useRoadtripRoutes' +import { buildTrip } from '../../../tests/helpers/factories' +import type { Can, Translate } from './plannerTypes' +import type { RoadtripFeed } from './useRoadtripFeed' +import { useRoadtripDriveShaping } from './useRoadtripDriveShaping' + +type Options = Parameters[0] +type Card = RoadtripFeed['roadtripRoutes']['days'][number] + +const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() } +const t: Translate = key => key +let allowed = true +const can: Can = vi.fn(() => allowed) +const trip = buildTrip({ id: 42 }) + +const stop = (lat: number, lng: number, over: Partial = {}) => + ({ name: `${lat},${lng}`, lat, lng, stopType: null, ...over }) as RoadtripStop +const card = (over: Partial) => + ({ dayId: 5, dayNumber: 1, stops: [], legs: [], geometry: [], ...over }) as Card +/** A card driven due east along one parallel, from 10° to 11° of longitude. */ +const eastward = (dayId: number, lat: number, stops: RoadtripStop[], over: Partial = {}) => + card({ dayId, dayNumber: dayId - 4, stops, geometry: [[lat, 10], [lat, 11]], ...over }) + +let vias: { add: ReturnType; move: ReturnType; remove: ReturnType } +let refuel: { ask: ReturnType; close: ReturnType } +let corridor: { visible: CorridorPoi[]; day: Card | null; search: { spine: { lat: number; lng: number }[] }; widthKm: number; insertIndexFor: ReturnType } + +function renderShaping(days: Card[], over: Partial = {}) { + return renderHook(() => { + const [stopDraft, setStopDraft] = useState(null) + const shaping = useRoadtripDriveShaping({ + trip, can, toast, t, + roadtripSettings: { roadtrip_vehicle: 'electric' } as RoadtripPreferences, + setStopDraft, + roadtripRoutes: { days } as unknown as RoadtripFeed['roadtripRoutes'], + roadtripVias: vias as unknown as RoadtripFeed['roadtripVias'], + roadtripCorridor: corridor as unknown as RoadtripFeed['roadtripCorridor'], + refuel: refuel as unknown as RoadtripFeed['refuel'], + ...over, + }) + return { ...shaping, stopDraft, setStopDraft } + }) +} + +const hit = (over: Partial = {}): CorridorPoi => ({ + osm_id: 'node/1', name: 'Rasthof', lat: 52, lng: 10.5, category: 'fuel', poi_type: 'fuel', + address: null, website: null, phone: null, opening_hours: null, cuisine: null, + source: 'osm', offRouteKm: 0.2, alongKm: 30, ...over, +}) +const dry = { legIndex: 1, intoLegKm: 10, drivenMeters: 150_000, sinceKm: 300, lat: 52, lng: 10.5 } + +beforeEach(() => { + vi.clearAllMocks() + allowed = true + vias = { add: vi.fn(async () => undefined), move: vi.fn(async () => undefined), remove: vi.fn(async () => undefined) } + refuel = { ask: vi.fn(async () => undefined), close: vi.fn() } + corridor = { visible: [], day: null, search: { spine: [] }, widthKm: 5, insertIndexFor: vi.fn(() => 1) } +}) + +describe('useRoadtripDriveShaping', () => { + it('FE-TP-DRIVE-001: a click on the line is filed behind the stop it follows, by the day and index that stop is stored at', async () => { + // The card is a date: its first stop is stored on the day before, at index 3 there. + const days = [eastward(5, 52, [stop(52, 10, { ownerDayId: 4, ownerIndex: 3 }), stop(52, 11, { ownerDayId: 5, ownerIndex: 0 })])] + const { result } = renderShaping(days) + + await act(async () => { await result.current.addRoadtripVia(52, 10.5) }) + + expect(vias.add).toHaveBeenCalledWith(4, 3, 52, 10.5) + }) + + it('FE-TP-DRIVE-002: a stop that names no other day is filed by the numbers of its own card', async () => { + const days = [eastward(5, 52, [stop(52, 10), stop(52, 11)])] + const { result } = renderShaping(days) + + await act(async () => { await result.current.addRoadtripVia(52, 10.5) }) + + expect(vias.add).toHaveBeenCalledWith(5, 0, 52, 10.5) + }) + + it('FE-TP-DRIVE-003: a click well off every line, on a ride, or on the hotel drive puts no via anywhere', async () => { + const terminal = { reservationId: 1, type: 'ferry', role: 'arrival', title: 'Ferry', code: null, at: null } as const + const days = [ + eastward(5, 52, [stop(52, 10), stop(52, 11)]), + eastward(6, 53, [stop(53, 10, { carrier: terminal }), stop(53, 11)]), + eastward(7, 54, [stop(54, 10, { bookend: {} as RoadtripStop['bookend'] }), stop(54, 11)]), + ] + const { result } = renderShaping(days) + + await act(async () => { await result.current.addRoadtripVia(52.5, 10.5) }) + await act(async () => { await result.current.addRoadtripVia(53, 10.5) }) + expect(vias.add).not.toHaveBeenCalled() + expect(toast.info).not.toHaveBeenCalled() + + await act(async () => { await result.current.addRoadtripVia(54, 10.5) }) + expect(vias.add).not.toHaveBeenCalled() + expect(toast.info).toHaveBeenCalledWith('roadtrip.bookend.noVia') + }) + + it('FE-TP-DRIVE-004: a pass on the hotel drive beats a pass between places only when it is clearly closer', async () => { + const ordinary = (lat: number) => eastward(5, lat, [stop(lat, 10), stop(lat, 11)]) + const hotelDrive = (lat: number) => eastward(6, lat, [stop(lat, 10, { bookend: {} as RoadtripStop['bookend'] }), stop(lat, 11)]) + + // About 20 m to the places and 10 m to the hotel drive: the same road, so the pass + // that can take a via keeps it. + const near = renderShaping([ordinary(52.00018), hotelDrive(51.99991)]) + await act(async () => { await near.result.current.addRoadtripVia(52, 10.5) }) + expect(vias.add).toHaveBeenCalledWith(5, 0, 52, 10.5) + expect(toast.info).not.toHaveBeenCalled() + near.unmount() + + // About 50 m against none at all: that is the hotel drive, and it says so. + vias.add.mockClear() + const apart = renderShaping([ordinary(52.00045), hotelDrive(52)]) + await act(async () => { await apart.result.current.addRoadtripVia(52, 10.5) }) + expect(vias.add).not.toHaveBeenCalled() + expect(toast.info).toHaveBeenCalledWith('roadtrip.bookend.noVia') + }) + + it('FE-TP-DRIVE-005: a hotel drive with no place between two stops has no inner stretch to ask again', async () => { + const bookend = {} as RoadtripStop['bookend'] + const days = [eastward(5, 52, [stop(52, 10, { bookend }), stop(52, 11, { bookend })])] + const { result } = renderShaping(days) + + await act(async () => { await result.current.addRoadtripVia(52, 10.5) }) + + expect(vias.add).not.toHaveBeenCalled() + expect(toast.info).toHaveBeenCalledWith('roadtrip.bookend.noVia') + }) + + it('FE-TP-DRIVE-006: a point on the drive in from yesterday follows the stop it left from', async () => { + // The card's line starts at 10° but its first stop only at 10.5°. + const from = stop(52, 9, { ownerDayId: 4, ownerIndex: 2 }) + const days = [eastward(5, 52, [stop(52, 10.5), stop(52, 11)], { arrivingFrom: from })] + const { result } = renderShaping(days) + + await act(async () => { await result.current.addRoadtripVia(52, 10.2) }) + expect(vias.add).toHaveBeenCalledWith(4, 2, 52, 10.2) + + // A drag on day 5 cannot take an anchor that day 4 owns. + await act(async () => { await result.current.moveRoadtripVia(5, 9, 52, 10.2) }) + expect(vias.move).toHaveBeenCalledWith(5, 9, 52, 10.2, undefined) + }) + + it('FE-TP-DRIVE-007: a stop arrived from that names no day of its own belongs to the card', async () => { + const days = [eastward(5, 52, [stop(52, 10.5), stop(52, 11)], { arrivingFrom: stop(52, 9) })] + const { result } = renderShaping(days) + + await act(async () => { await result.current.addRoadtripVia(52, 10.2) }) + + expect(vias.add).toHaveBeenCalledWith(5, 0, 52, 10.2) + }) + + it('FE-TP-DRIVE-008: a line with no stops on it anchors nothing', async () => { + const days = [eastward(5, 52, [], { arrivingFrom: stop(52, 9) })] + const { result } = renderShaping(days) + + await act(async () => { await result.current.addRoadtripVia(52, 10.5) }) + + expect(vias.add).not.toHaveBeenCalled() + expect(result.current.manualStopTargetFor(52, 10.5)).toBeNull() + }) + + it('FE-TP-DRIVE-009: a dragged via is re-pinned to the leg it landed on, and a drag that will not save is reported', async () => { + const days = [eastward(5, 52, [stop(52, 10, { ownerDayId: 5, ownerIndex: 0 }), stop(52, 10.5, { ownerDayId: 5, ownerIndex: 1 }), stop(52, 11, { ownerDayId: 5, ownerIndex: 2 })])] + const { result } = renderShaping(days) + + await act(async () => { await result.current.moveRoadtripVia(5, 9, 52, 10.8) }) + expect(vias.move).toHaveBeenCalledWith(5, 9, 52, 10.8, 1) + + vias.move.mockRejectedValueOnce(new Error('offline')) + await act(async () => { await result.current.moveRoadtripVia(5, 9, 52, 10.2) }) + expect(toast.error).toHaveBeenLastCalledWith('offline') + + vias.move.mockRejectedValueOnce('nope') + await act(async () => { await result.current.moveRoadtripVia(5, 9, 52, 10.2) }) + expect(toast.error).toHaveBeenLastCalledWith('common.unknownError') + }) + + it('FE-TP-DRIVE-010: adding or removing a via that will not save is reported, whatever was thrown', async () => { + const days = [eastward(5, 52, [stop(52, 10), stop(52, 11)])] + const { result } = renderShaping(days) + + vias.add.mockRejectedValueOnce('nope') + await act(async () => { await result.current.addRoadtripVia(52, 10.5) }) + expect(toast.error).toHaveBeenLastCalledWith('common.unknownError') + + await act(async () => { await result.current.removeRoadtripVia(5, 9) }) + expect(vias.remove).toHaveBeenCalledWith(5, 9) + + vias.remove.mockRejectedValueOnce(new Error('gone')) + await act(async () => { await result.current.removeRoadtripVia(5, 9) }) + expect(toast.error).toHaveBeenLastCalledWith('gone') + + vias.remove.mockRejectedValueOnce('nope') + await act(async () => { await result.current.removeRoadtripVia(5, 9) }) + expect(toast.error).toHaveBeenLastCalledWith('common.unknownError') + }) + + it('FE-TP-DRIVE-011: a reader shapes nothing at all', async () => { + allowed = false + corridor.visible = [hit()] + const days = [eastward(5, 52, [stop(52, 10), stop(52, 11)], { legs: [{ mode: 'driving', distance: 68_000 }] as Card['legs'] })] + corridor.day = days[0] + corridor.search.spine = [{ lat: 52, lng: 10 }, { lat: 52, lng: 11 }] + const { result } = renderShaping(days) + + await act(async () => { await result.current.addRoadtripVia(52, 10.5) }) + await act(async () => { await result.current.moveRoadtripVia(5, 9, 52, 10.5) }) + await act(async () => { await result.current.removeRoadtripVia(5, 9) }) + act(() => { result.current.acceptRefuel(5, { ...hit(), alongKm: 10 } as never, dry) }) + act(() => { result.current.dropPoiOnRoute('node/1', 52, 10.5) }) + + expect(vias.add).not.toHaveBeenCalled() + expect(vias.move).not.toHaveBeenCalled() + expect(vias.remove).not.toHaveBeenCalled() + expect(refuel.close).not.toHaveBeenCalled() + expect(result.current.stopDraft).toBeNull() + }) + + it('FE-TP-DRIVE-012: a place chosen by hand goes where the card draws the stop it follows', () => { + const departure = { reservationId: 1, type: 'flight', role: 'departure', title: 'Flight', code: null, at: null } as const + const arrival = { ...departure, role: 'arrival' } as const + const days = [ + // Card 6 draws two stops day 5 stores, then one of its own. + eastward(6, 52, [ + stop(52, 10, { ownerDayId: 5, ownerIndex: 1 }), + stop(52, 10.5, { ownerDayId: 5, ownerIndex: 2 }), + stop(52, 11, { ownerDayId: 6, ownerIndex: 0 }), + ]), + eastward(7, 53, [stop(53, 10, { carrier: departure }), stop(53, 11)]), + eastward(8, 54, [stop(54, 10, { carrier: arrival, ownerDayId: 8, ownerIndex: 0 }), stop(54, 11, { ownerDayId: 8, ownerIndex: 1 })]), + // Stops that name no day of their own match no stored stop on any card. + eastward(9, 55, [stop(55, 10), stop(55, 11)]), + ] + const { result } = renderShaping(days) + + expect(result.current.manualStopTargetFor(52, 10.7)).toMatchObject({ dayId: 6, position: 2 }) + expect(result.current.manualStopTargetFor(53, 10.5)).toBeNull() + expect(result.current.manualStopTargetFor(54, 10.5)).toMatchObject({ dayId: 8, position: 1 }) + expect(result.current.manualStopTargetFor(55, 10.5)).toMatchObject({ dayId: 9, position: 1 }) + }) + + it('FE-TP-DRIVE-013: a refuel question starts the tank at the last charge and counts only the road driven', () => { + const days = [ + card({ + dayId: 6, + stops: [stop(52, 10), stop(52, 10.5, { stopType: 'charging' }), stop(52, 10.8), stop(52, 11)], + geometry: [[52, 10], [52, 11]], + drivingGeometry: [[52, 10], [52, 10.9], [52, 11]], + legs: [{ mode: 'driving', distance: 30_000 }, { mode: 'ferry', distance: 5_000 }, { mode: 'driving' }] as Card['legs'], + }), + card({ dayId: 7, stops: [stop(53, 10)], geometry: [[53, 10]] }), + ] + const { result } = renderShaping(days) + + act(() => { result.current.askRefuel(6, { ...dry, legIndex: 2 }) }) + expect(refuel.ask).toHaveBeenCalledWith( + '6:2', + { lat: 52, lng: 10.5 }, + [{ lat: 52, lng: 10 }, { lat: 52, lng: 10.9 }, { lat: 52, lng: 11 }], + 150, + [{ lat: 52, lng: 10 }, { lat: 52, lng: 10.5 }, { lat: 52, lng: 10.8 }, { lat: 52, lng: 11 }], + 30, + ) + + // The drive in from yesterday is measured along its own line. + act(() => { result.current.askRefuel(6, { ...dry, legIndex: 0, inboundLine: [[51, 10], [52, 10]] }) }) + expect(refuel.ask).toHaveBeenLastCalledWith('6:0', expect.anything(), [{ lat: 51, lng: 10 }, { lat: 52, lng: 10 }], 150, expect.anything(), 0) + + // A day the rail has no line for, or no day at all, asks nothing. + refuel.ask.mockClear() + act(() => { result.current.askRefuel(7, dry) }) + act(() => { result.current.askRefuel(99, dry) }) + expect(refuel.ask).not.toHaveBeenCalled() + }) + + it('FE-TP-DRIVE-014: with no vehicle chosen, any refuelling stop fills the tank again', () => { + const days = [card({ + dayId: 6, + stops: [stop(52, 10), stop(52, 10.5, { stopType: 'fuel' }), stop(52, 11)], + geometry: [[52, 10], [52, 11]], + legs: [{ mode: 'driving', distance: 30_000 }, { mode: 'driving', distance: 40_000 }] as Card['legs'], + })] + const { result } = renderShaping(days, { roadtripSettings: { roadtrip_vehicle: null } as unknown as RoadtripPreferences }) + + act(() => { result.current.askRefuel(6, dry) }) + + expect(refuel.ask).toHaveBeenCalledWith('6:1', expect.anything(), expect.anything(), 150, expect.anything(), 30) + }) + + it('FE-TP-DRIVE-015: an accepted station goes to the popup in front of the stop after the leg it stands on', () => { + const days = [card({ + dayId: 6, + dayNumber: 2, + stops: [ + stop(52, 10, { ownerDayId: 5, ownerIndex: 1 }), stop(52, 10.2, { ownerDayId: 6, ownerIndex: 0 }), + stop(52, 10.6, { ownerDayId: 6, ownerIndex: 1 }), stop(52, 11, { ownerDayId: 6, ownerIndex: 2 }), + ], + geometry: [[52, 10], [52, 11]], + legs: [{ mode: 'ferry', distance: 5_000 }, { mode: 'driving', distance: 100_000 }, { mode: 'driving' }] as Card['legs'], + })] + const { result } = renderShaping(days) + const station = { ...hit(), alongKm: 50 } as never + + act(() => { result.current.acceptRefuel(6, station, dry) }) + expect(refuel.close).toHaveBeenCalledTimes(1) + expect(result.current.stopDraft).toMatchObject({ dayId: 6, position: 1, dayNumber: 2 }) + + // Past every driving leg, the station goes in front of the stop after the dry leg. + act(() => { result.current.acceptRefuel(6, { ...hit(), alongKm: 500 } as never, { ...dry, legIndex: 0 }) }) + expect(result.current.stopDraft).toMatchObject({ dayId: 6, position: 0 }) + + // On the drive in from yesterday, and on a day the rail does not draw, nothing opens. + refuel.close.mockClear() + act(() => { result.current.acceptRefuel(6, station, { ...dry, inboundLine: [[51, 10], [52, 10]] }) }) + act(() => { result.current.acceptRefuel(99, station, dry) }) + expect(refuel.close).not.toHaveBeenCalled() + }) + + it('FE-TP-DRIVE-016: a hit dropped on the drive opens the popup where it was dropped, and nowhere else', () => { + const days = [card({ dayId: 6, dayNumber: 2, stops: [stop(52, 10, { ownerDayId: 6, ownerIndex: 0 }), stop(52, 11, { ownerDayId: 6, ownerIndex: 1 })], geometry: [[52, 10], [52, 11]] })] + corridor.visible = [hit()] + corridor.search.spine = [{ lat: 52, lng: 10 }, { lat: 52, lng: 11 }] + corridor.widthKm = 2 + + // No day on the panel yet, a hit the panel no longer lists, a drop too far out. + const { result, rerender } = renderShaping(days) + act(() => { result.current.dropPoiOnRoute('node/1', 52, 10.5) }) + corridor.day = days[0] + rerender() + act(() => { result.current.dropPoiOnRoute('node/2', 52, 10.5) }) + act(() => { result.current.dropPoiOnRoute('node/1', 52.1, 10.5) }) + expect(result.current.stopDraft).toBeNull() + + act(() => { result.current.dropPoiOnRoute('node/1', 52, 10.5) }) + expect(corridor.insertIndexFor).toHaveBeenCalledWith(expect.objectContaining({ offRouteKm: expect.any(Number) })) + expect(result.current.stopDraft).toMatchObject({ poi: { osm_id: 'node/1' }, dayId: 6, position: 1, dayNumber: 2 }) + + // A day with no stops left has nowhere to put it. + corridor.day = card({ dayId: 6, stops: [] }) + act(() => { result.current.setStopDraft(null) }) + act(() => { result.current.dropPoiOnRoute('node/1', 52, 10.5) }) + expect(result.current.stopDraft).toBeNull() + }) +}) diff --git a/client/src/pages/tripPlanner/useRoadtripDriveShaping.ts b/client/src/pages/tripPlanner/useRoadtripDriveShaping.ts new file mode 100644 index 000000000..560af0def --- /dev/null +++ b/client/src/pages/tripPlanner/useRoadtripDriveShaping.ts @@ -0,0 +1,346 @@ +import { useCallback } from 'react' +import type { RoadtripPreferences } from '@trek/shared' +import { isStoredStop, type CarrierTerminal } from '@trek/shared/roadtrip' +import { projectOntoRoute, type CorridorHit } from '../../components/Roadtrip/corridor' +import { roadtripInsertion } from '../../components/Roadtrip/dayWindow' +import type { ManualStopTarget } from '../../components/Roadtrip/manualStop' +import type { RefuelCandidate } from '../../components/Roadtrip/refuelSuggestion' +import { insertIndexForAlong, refuelStopTypeFor, type DryPoint } from '../../components/Roadtrip/roadtripModel' +import type { PlannerBase } from './plannerTypes' +import type { PlannerDialogs } from './usePlannerDialogs' +import type { RoadtripFeed } from './useRoadtripFeed' + +interface RoadtripDriveShapingOptions + extends Pick, + Pick, + Pick { + /** Read for the vehicle, which decides whether a refuel stop is a pump or a charger. */ + roadtripSettings: RoadtripPreferences +} + +/** + * Shaping the drawn drive: which stop a point on the road belongs behind, where a place + * chosen by hand goes, adding, dragging and removing vias, asking for and accepting a + * refuel stop, and a corridor hit dropped onto the route. + * + * Nothing here runs an effect, so where useTripPlanner calls it changes nothing. + */ +export function useRoadtripDriveShaping(options: RoadtripDriveShapingOptions) { + const { trip, can, toast, t, roadtripSettings, setStopDraft, roadtripRoutes, roadtripVias, roadtripCorridor, refuel } = options + /** + * Which stop of which day a point belongs behind, measured along the drive. + * + * Shared by placing a via and by dragging one, because it is the same question both + * times and the answer has to be recomputed both times. A drag used to send only the + * new coordinates, so a via pulled past the stop it used to precede kept claiming the + * earlier leg: the route then ran out to the point and back before carrying on, which + * looks exactly like a drag that did nothing. + */ + const anchorFor = useCallback((lat: number, lng: number, onlyDayId?: number) => { + // `terminal` names the anchor when it is one end of a ride rather than a stored stop + // (#2428). Nothing can be filed against a terminal: it stands in for no assignment, + // so a via anchored to it would be stored at a position that belongs to the stop + // after it and bend that stop's road instead. The callers decide what to refuse. + // + // `bookendLeg` says the drive leaves or reaches a booked night's hotel at the day's + // edge, which files nothing either: the morning's hotel has no index of its own, and + // the evening's drive is reached from the index that shapes the road into tomorrow. + // `card` is where the point fell on the card it was measured on, the day and the index + // of the stop before it there, -1 for the drive in before the card's first stop. + type Anchor = { + dayId: number + afterIndex: number + offRouteKm: number + terminal: CarrierTerminal['role'] | null + bookendLeg: boolean + card: { dayId: number; index: number } + } + // A road the day drives twice, out of the hotel in the morning and past it again later, + // is on the line twice, and the closer pass wins by metres at most. A pass that can + // take a via beats one on the hotel's drive, which files nothing, by this much. + const SAME_ROAD_KM = 0.03 + const beats = (c: Anchor, b: Anchor | null) => { + if (!b) return true + if (c.bookendLeg === b.bookendLeg) return c.offRouteKm < b.offRouteKm + return c.bookendLeg ? c.offRouteKm + SAME_ROAD_KM < b.offRouteKm : c.offRouteKm <= b.offRouteKm + SAME_ROAD_KM + } + // Which stop of this card the point falls behind, for a hit measured on its line. + const readAnchor = (day: (typeof roadtripRoutes.days)[number], hit: CorridorHit, stopsAlong: number[]): Anchor | null => { + // Before the card's first stop means a drive that arrives here but leaves from a + // stop on the card BEFORE this one: the incoming night drive (`nightSpill.ts`), or + // on a trip with connected days the drive from where yesterday ended, which is drawn + // at the head of this card in yesterday's colour. Anchoring either to this card's + // first stop would file the via on the leg AFTER that stop, and the route would run + // forward, double back to the point, and carry on. + // + // Asked of the distance rather than of the index, because the index cannot answer + // it: `insertIndexForAlong` clamps to at least 1 for any list of two or more, and + // the rail only ever publishes cards with two stops or more. Written against the + // index this read as a guard and behaved as dead code, so a via dropped on the + // night stretch went to the first drawn stop after all, which is the exact failure + // the paragraph above describes. + const arrivedFrom = day.spills?.find(sp => sp.at === 0)?.fromStop ?? day.arrivingFrom + if (arrivedFrom && hit.alongKm < (stopsAlong[0] ?? 0)) { + const owner = arrivedFrom.ownerDayId ?? day.dayId + if (onlyDayId !== undefined && owner !== onlyDayId) return null + return { + dayId: owner, + afterIndex: arrivedFrom.ownerIndex ?? 0, + offRouteKm: hit.offRouteKm, + terminal: arrivedFrom.carrier?.role ?? null, + bookendLeg: !!arrivedFrom.bookend || !!day.stops[0]?.bookend, + card: { dayId: day.dayId, index: -1 }, + } + } + const at = insertIndexForAlong(stopsAlong, hit.alongKm) - 1 + // Named by the day the anchor stop is STORED on and its position there, not by the + // card and the position within it. A card is a date and can hold stops from the day + // before (`nightSpill.ts`), so those two numbers differ on any day that received a + // night drive, and a via filed under the card's numbers matches no stop when the + // route is next built, which reads as a drag that did nothing at all. + const anchor = day.stops[at] + if (!anchor) return null + // Falling back to the card's own numbers is not a guard against a bug, it is the + // meaning: a stop that names no other day IS stored on the card it is drawn on, + // which is every stop on a trip that never drives past midnight. + const owner = anchor.ownerDayId ?? day.dayId + // A drag stays on its own day; a fresh click may land wherever it landed. + if (onlyDayId !== undefined && owner !== onlyDayId) return null + return { + dayId: owner, + afterIndex: anchor.ownerIndex ?? at, + offRouteKm: hit.offRouteKm, + terminal: anchor.carrier?.role ?? null, + bookendLeg: !!anchor.bookend || !!day.stops[at + 1]?.bookend, + card: { dayId: day.dayId, index: at }, + } + } + let best: Anchor | null = null + for (const day of roadtripRoutes.days) { + // NOT `day.dayId !== onlyDayId`. A dragged via has to stay on the day it is stored + // on, but that day's stops are no longer all on the card of the same name: after a + // night drive they are drawn on the next one (`nightSpill.ts`). Filtering by card + // measured the new position against a line that no longer covers those stops: a + // point dragged near Brandenburg was projected onto the short remainder of card 1 + // and came back anchored to its last stop, which put the via on the night drive + // itself and pushed the stop before it over midnight. + // + // So every card is measured, and the answer is filtered by the day the ANCHOR is + // stored on. Same promise, kept against the stops rather than against the card. + if (day.geometry.length < 2) continue + const spine = day.geometry.map(([la, ln]) => ({ lat: la, lng: ln })) + const hit = projectOntoRoute({ lat, lng }, spine) + if (!hit) continue + if (best && hit.offRouteKm >= best.offRouteKm + SAME_ROAD_KM) continue + // Which stop the via follows: the last one the car passes before reaching it. + const stopsAlong = day.stops.map(stop => projectOntoRoute({ lat: stop.lat, lng: stop.lng }, spine)?.alongKm ?? 0) + let candidate = readAnchor(day, hit, stopsAlong) + if (candidate?.bookendLeg) { + // Landed on the drive to or from the hotel, a road the day can drive again between + // two of its own stops. Asked once more of the stretch between the day's first and + // last stop, and taken when that answer lies on the same road. + const first = day.stops.findIndex(stop => !stop.bookend) + const last = day.stops.length - 1 - [...day.stops].reverse().findIndex(stop => !stop.bookend) + const fromKm = stopsAlong[first] ?? 0 + const toKm = stopsAlong[last] ?? 0 + const inner = first >= 0 && first < last && fromKm < toKm + ? projectOntoRoute({ lat, lng }, spine, { fromKm, toKm }) + : null + const retry = inner && inner.offRouteKm <= hit.offRouteKm + SAME_ROAD_KM ? readAnchor(day, inner, stopsAlong) : null + if (retry && !retry.bookendLeg) candidate = retry + } + if (candidate && beats(candidate, best)) best = candidate + } + return best + }, [roadtripRoutes.days]) + + /** + * Where a place chosen by hand belongs in the drive, as a card and a position in it. + * + * `anchorFor` answers in the space a via is STORED in: the day the anchor stop belongs + * to, and its index there. A stop is placed at a position counted along the card it is + * drawn on, which is the same thing on every day that does not drive past midnight and + * a different one on the days that do (`nightSpill.ts`). Translating between the two + * happens here, once, rather than at whichever surface asked. + * + * Deliberately with no distance limit, unlike `addRoadtripVia`: the place this answers + * for is the charger the corridor search did not find, which is exactly the one sitting + * further off the drawn line than a via is allowed to be. The projection is the default + * the dialog offers, never a gate it applies. + */ + const manualStopTargetFor = useCallback((lat: number, lng: number): ManualStopTarget | null => { + const anchor = anchorFor(lat, lng) + // Nothing is stopped at on a flight. Behind an arrival terminal or a hire car's desk + // is a road, and a stop there is the first stop after landing or after the pick-up. + if (!anchor || anchor.terminal === 'departure') return null + // Behind a terminal, and on the drive from the hotel a day sets out from or to the one + // it ends at, the place goes where it fell on the card. None of them is a stored stop + // to be found by its index, which each shares with one: after the morning's hotel is + // before the day's first stop, before the evening's is after its last. + if (anchor.bookendLeg || anchor.terminal) { + return { dayId: anchor.card.dayId, position: anchor.card.index + 1, offRouteKm: anchor.offRouteKm } + } + for (const day of roadtripRoutes.days) { + // The stored stop the anchor names, not a terminal or a hotel seated in front of it + // with the same index, which put the place one stop early. + const at = day.stops.findIndex(stop => isStoredStop(stop) && stop.ownerDayId === anchor.dayId && stop.ownerIndex === anchor.afterIndex) + if (at >= 0) return { dayId: day.dayId, position: at + 1, offRouteKm: anchor.offRouteKm } + } + // No card draws that stop, which happens while the rail is between rebuilds. Its own + // numbers are the best answer there is, and `roadtripInsertion` measures them against + // the card again when the stop actually lands. + return { dayId: anchor.dayId, position: anchor.afterIndex + 1, offRouteKm: anchor.offRouteKm } + }, [anchorFor, roadtripRoutes.days]) + + /** + * A click on the drawn route puts a via there, and the drive is redrawn through it. + * + * Which pair of stops it belongs between comes from projecting the click onto the + * day's routed geometry, the same measurement the corridor search uses, so "after the + * third stop" means the same thing everywhere. The day is the one whose line was hit, + * found by trying each day's geometry and keeping the closest. + */ + const addRoadtripVia = useCallback(async (lat: number, lng: number) => { + if (!can('day_edit', trip)) return + const best = anchorFor(lat, lng) + // A click that landed on some other line is not a via anywhere. Neither is one on a + // ride, or on the road out of a terminal: a via is filed by the position of a stored + // stop, and a terminal is not one. + if (!best || best.offRouteKm > 2 || best.terminal) return + // Nor on the drive from or to a booked night's hotel, which says so rather than + // ignoring the click (`legReroutable`). + if (best.bookendLeg) { toast.info(t('roadtrip.bookend.noVia')); return } + try { + await roadtripVias.add(best.dayId, best.afterIndex, lat, lng) + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : t('common.unknownError')) + } + }, [anchorFor, roadtripVias, can, trip, toast, t]) + + /** Dragging a via redraws the route through its new position. */ + const moveRoadtripVia = useCallback(async (dayId: number, id: number, lat: number, lng: number) => { + if (!can('day_edit', trip)) return + // Measured against this day only. A drag is a drag WITHIN a day: letting the nearest + // day win, the way placing one does, would hand the via to a neighbouring day whose + // road happens to pass closer, and it would vanish from the day it was dragged in. + // + // No distance guard either. Dragging a via well off the current road is the whole + // point of dragging it, and refusing that would be refusing the gesture; the anchor + // just says which leg gets bent, and the router answers the rest. + const anchor = anchorFor(lat, lng, dayId) + try { + await roadtripVias.move(dayId, id, lat, lng, anchor && !anchor.terminal && !anchor.bookendLeg ? anchor.afterIndex : undefined) + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : t('common.unknownError')) + } + }, [anchorFor, roadtripVias, can, trip, toast, t]) + + /** + * Somewhere to fill up before this tank runs out. + * + * Measured against the day's DRIVING line, the same one the dry point was placed on, so + * a station's distance along the road is comparable with the distance the fuel lasts. + * The day's own places go in as well: a pump already on the plan should not be offered + * beside itself. + */ + const askRefuel = useCallback((dayId: number, dry: DryPoint & { lat: number; lng: number }) => { + const day = roadtripRoutes.days.find(d => d.dayId === dayId) + if (!day) return + const line = (dry.inboundLine ?? day.drivingGeometry ?? day.geometry).map(([lat, lng]) => ({ lat, lng })) + if (line.length < 2) return + const vehicle = roadtripSettings.roadtrip_vehicle + const refuelTypes = refuelStopTypeFor(vehicle === 'electric' || vehicle === 'combustion' ? vehicle : null) + let fromAlongKm = 0 + let drivenKm = 0 + for (let i = 0; i <= dry.legIndex; i++) { + if (refuelTypes.includes(day.stops[i]?.stopType as 'fuel' | 'charging')) fromAlongKm = drivenKm + const leg = day.legs[i] + if (leg?.mode === 'driving') drivenKm += (leg.distance ?? 0) / 1000 + } + void refuel.ask( + `${dayId}:${dry.legIndex}`, + { lat: dry.lat, lng: dry.lng }, + line, + dry.drivenMeters / 1000, + day.stops.map(stop => ({ lat: stop.lat, lng: stop.lng })), + fromAlongKm, + ) + }, [roadtripRoutes.days, refuel, roadtripSettings.roadtrip_vehicle]) + + /** + * Accepting one hands it to the same popup a corridor hit goes through. + * + * Deliberately not a direct write: the popup is where the stop kind and the time spent + * are decided, it defaults both from the category, and every step after it (the place, + * the assignment at the right position, the via re-anchoring, the re-route) is already + * correct there and pinned by tests. A second path to the same end would be a second + * place for it to go wrong. + */ + const acceptRefuel = useCallback((dayId: number, poi: RefuelCandidate, dry: DryPoint & { lat: number; lng: number }) => { + if (!can('day_edit', trip)) return + const day = roadtripRoutes.days.find(d => d.dayId === dayId) + if (!day) return + // Before the stop the tank would have run out on, which is the leg the dry point + // names. A station reached after the day's last stop is tomorrow's problem, and + // clamping it onto the final leg would re-route the arrival through it. + let drivenKm = 0 + const stationLeg = day.legs.findIndex(leg => { + if (leg?.mode !== 'driving') return false + drivenKm += (leg.distance ?? 0) / 1000 + return poi.alongKm <= drivenKm + }) + const at = dry.inboundLine ? -dry.legIndex - 1 : Math.min((stationLeg >= 0 ? stationLeg : dry.legIndex) + 1, day.stops.length - 1) + // That index counts along the CARD, and after a night drive a card is not one stored + // day: its first stops belong to yesterday. The new stop goes in front of the one it + // was measured against, so it is that stop's own day and position that place it. + // Written against the card's day it would land in the wrong list, at an index that + // means something else there. + const anchor = day.stops[at] + if (!anchor) return + refuel.close() + setStopDraft({ poi, ...roadtripInsertion(day, at)!, dayNumber: day.dayNumber }) + }, [roadtripRoutes.days, refuel, can, trip, setStopDraft]) + + /** Removing a via lets the drive take the direct road again. */ + const removeRoadtripVia = useCallback(async (dayId: number, id: number) => { + if (!can('day_edit', trip)) return + try { + await roadtripVias.remove(dayId, id) + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : t('common.unknownError')) + } + }, [roadtripVias, can, trip, toast, t]) + + /** + * A corridor hit dropped on the map, placed where it was dropped rather than where the + * corridor projected it. + * + * The two differ whenever a drive passes near the same spot twice (a loop, an + * out-and-back), and the automatic projection can only pick one of them. Dropping says + * which, and the drop coordinate is projected onto the same routed line the hits were + * measured along, so the answer is in the same units as everything else. + * + * A drop nowhere near the drive is ignored rather than guessed at: adding a stop + * fifty kilometres off the route because the pointer slipped is worse than nothing + * happening. + */ + const dropPoiOnRoute = useCallback((osmId: string, lat: number, lng: number) => { + if (!can('place_edit', trip)) return + const hit = roadtripCorridor.visible.find(p => p.osm_id === osmId) + const day = roadtripCorridor.day + if (!hit || !day) return + const at = projectOntoRoute({ lat, lng }, roadtripCorridor.search.spine) + if (!at || at.offRouteKm > roadtripCorridor.widthKm) return + const insert = roadtripInsertion(day, roadtripCorridor.insertIndexFor(at)) + if (!insert) return + setStopDraft({ + poi: hit, + ...insert, + dayNumber: day.dayNumber, + }) + }, [roadtripCorridor, can, trip, setStopDraft]) + + return { manualStopTargetFor, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, askRefuel, acceptRefuel, dropPoiOnRoute } +} + +export type RoadtripDriveShaping = ReturnType diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 9dc6cbbdf..1639c9e0b 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -26,20 +26,17 @@ import { useResizablePanels } from '../../hooks/useResizablePanels' import { useTripWebSocket } from '../../hooks/useTripWebSocket' import { useRouteCalculation } from '../../hooks/useRouteCalculation' import { roadtripInsertion } from '../../components/Roadtrip/dayWindow' -import { isStoredStop, type CarrierTerminal } from '@trek/shared/roadtrip' import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview' import { useDayClear } from './useDayClear' -import type { RefuelCandidate } from '../../components/Roadtrip/refuelSuggestion' import { stopArrival } from '../../components/Roadtrip/stopArrival' import type { CorridorPoi } from '../../components/Roadtrip/useCorridorPois' -import { projectOntoRoute, sliceAtMeters, type CorridorHit, type LatLng } from '../../components/Roadtrip/corridor' +import { projectOntoRoute, sliceAtMeters, type LatLng } from '../../components/Roadtrip/corridor' import { - insertIndexForAlong, reanchorAfterInsert, reanchorAfterRemove, reanchorByStopOrder, - isServiceStopType, refuelStopTypeFor, type DryPoint } from '../../components/Roadtrip/roadtripModel' -import type { ManualStopTarget, ServiceStopMode } from '../../components/Roadtrip/manualStop' + isServiceStopType } from '../../components/Roadtrip/roadtripModel' +import type { ServiceStopMode } from '../../components/Roadtrip/manualStop' import { inspectorStay } from '../../components/Roadtrip/stayReading' import { normalizePlaceWebsite, type RoadtripStopType } from '@trek/shared' import { usePlaceSelection } from '../../hooks/usePlaceSelection' @@ -69,6 +66,7 @@ import { useMapPlaces } from './useMapPlaces' import { useRoadtripFeed } from './useRoadtripFeed' import { useRoadtripStopEdits } from './useRoadtripStopEdits' import { useRoadtripAlternatives } from './useRoadtripAlternatives' +import { useRoadtripDriveShaping } from './useRoadtripDriveShaping' import { useIsPhone } from '../../mobile/useIsPhone' /** @@ -709,178 +707,11 @@ export function useTripPlanner() { collapsedRoadtripDays, }) - /** - * A click on the drawn route puts a via there, and the drive is redrawn through it. - * - * Which pair of stops it belongs between comes from projecting the click onto the - * day's routed geometry — the same measurement the corridor search uses, so "after the - * third stop" means the same thing everywhere. The day is the one whose line was hit, - * found by trying each day's geometry and keeping the closest. - */ - /** - * Which stop of which day a point belongs behind, measured along the drive. - * - * Shared by placing a via and by dragging one, because it is the same question both - * times and the answer has to be recomputed both times. A drag used to send only the - * new coordinates, so a via pulled past the stop it used to precede kept claiming the - * earlier leg: the route then ran out to the point and back before carrying on, which - * looks exactly like a drag that did nothing. - */ - const anchorFor = useCallback((lat: number, lng: number, onlyDayId?: number) => { - // `terminal` names the anchor when it is one end of a ride rather than a stored stop - // (#2428). Nothing can be filed against a terminal: it stands in for no assignment, - // so a via anchored to it would be stored at a position that belongs to the stop - // after it and bend that stop's road instead. The callers decide what to refuse. - // - // `bookendLeg` says the drive leaves or reaches a booked night's hotel at the day's - // edge, which files nothing either: the morning's hotel has no index of its own, and - // the evening's drive is reached from the index that shapes the road into tomorrow. - // `card` is where the point fell on the card it was measured on, the day and the index - // of the stop before it there, -1 for the drive in before the card's first stop. - type Anchor = { - dayId: number - afterIndex: number - offRouteKm: number - terminal: CarrierTerminal['role'] | null - bookendLeg: boolean - card: { dayId: number; index: number } - } - // A road the day drives twice, out of the hotel in the morning and past it again later, - // is on the line twice, and the closer pass wins by metres at most. A pass that can - // take a via beats one on the hotel's drive, which files nothing, by this much. - const SAME_ROAD_KM = 0.03 - const beats = (c: Anchor, b: Anchor | null) => { - if (!b) return true - if (c.bookendLeg === b.bookendLeg) return c.offRouteKm < b.offRouteKm - return c.bookendLeg ? c.offRouteKm + SAME_ROAD_KM < b.offRouteKm : c.offRouteKm <= b.offRouteKm + SAME_ROAD_KM - } - // Which stop of this card the point falls behind, for a hit measured on its line. - const readAnchor = (day: (typeof roadtripRoutes.days)[number], hit: CorridorHit, stopsAlong: number[]): Anchor | null => { - // Before the card's first stop means a drive that arrives here but leaves from a - // stop on the card BEFORE this one: the incoming night drive (`nightSpill.ts`), or - // on a trip with connected days the drive from where yesterday ended, which is drawn - // at the head of this card in yesterday's colour. Anchoring either to this card's - // first stop would file the via on the leg AFTER that stop, and the route would run - // forward, double back to the point, and carry on. - // - // Asked of the distance rather than of the index, because the index cannot answer - // it: `insertIndexForAlong` clamps to at least 1 for any list of two or more, and - // the rail only ever publishes cards with two stops or more. Written against the - // index this read as a guard and behaved as dead code, so a via dropped on the - // night stretch went to the first drawn stop after all, which is the exact failure - // the paragraph above describes. - const arrivedFrom = day.spills?.find(sp => sp.at === 0)?.fromStop ?? day.arrivingFrom - if (arrivedFrom && hit.alongKm < (stopsAlong[0] ?? 0)) { - const owner = arrivedFrom.ownerDayId ?? day.dayId - if (onlyDayId !== undefined && owner !== onlyDayId) return null - return { - dayId: owner, - afterIndex: arrivedFrom.ownerIndex ?? 0, - offRouteKm: hit.offRouteKm, - terminal: arrivedFrom.carrier?.role ?? null, - bookendLeg: !!arrivedFrom.bookend || !!day.stops[0]?.bookend, - card: { dayId: day.dayId, index: -1 }, - } - } - const at = insertIndexForAlong(stopsAlong, hit.alongKm) - 1 - // Named by the day the anchor stop is STORED on and its position there, not by the - // card and the position within it. A card is a date and can hold stops from the day - // before (`nightSpill.ts`), so those two numbers differ on any day that received a - // night drive — and a via filed under the card's numbers matches no stop when the - // route is next built, which reads as a drag that did nothing at all. - const anchor = day.stops[at] - if (!anchor) return null - // Falling back to the card's own numbers is not a guard against a bug, it is the - // meaning: a stop that names no other day IS stored on the card it is drawn on, - // which is every stop on a trip that never drives past midnight. - const owner = anchor.ownerDayId ?? day.dayId - // A drag stays on its own day; a fresh click may land wherever it landed. - if (onlyDayId !== undefined && owner !== onlyDayId) return null - return { - dayId: owner, - afterIndex: anchor.ownerIndex ?? at, - offRouteKm: hit.offRouteKm, - terminal: anchor.carrier?.role ?? null, - bookendLeg: !!anchor.bookend || !!day.stops[at + 1]?.bookend, - card: { dayId: day.dayId, index: at }, - } - } - let best: Anchor | null = null - for (const day of roadtripRoutes.days) { - // NOT `day.dayId !== onlyDayId`. A dragged via has to stay on the day it is stored - // on, but that day's stops are no longer all on the card of the same name: after a - // night drive they are drawn on the next one (`nightSpill.ts`). Filtering by card - // measured the new position against a line that no longer covers those stops — a - // point dragged near Brandenburg was projected onto the short remainder of card 1 - // and came back anchored to its last stop, which put the via on the night drive - // itself and pushed the stop before it over midnight. - // - // So every card is measured, and the answer is filtered by the day the ANCHOR is - // stored on. Same promise, kept against the stops rather than against the card. - if (day.geometry.length < 2) continue - const spine = day.geometry.map(([la, ln]) => ({ lat: la, lng: ln })) - const hit = projectOntoRoute({ lat, lng }, spine) - if (!hit) continue - if (best && hit.offRouteKm >= best.offRouteKm + SAME_ROAD_KM) continue - // Which stop the via follows: the last one the car passes before reaching it. - const stopsAlong = day.stops.map(stop => projectOntoRoute({ lat: stop.lat, lng: stop.lng }, spine)?.alongKm ?? 0) - let candidate = readAnchor(day, hit, stopsAlong) - if (candidate?.bookendLeg) { - // Landed on the drive to or from the hotel, a road the day can drive again between - // two of its own stops. Asked once more of the stretch between the day's first and - // last stop, and taken when that answer lies on the same road. - const first = day.stops.findIndex(stop => !stop.bookend) - const last = day.stops.length - 1 - [...day.stops].reverse().findIndex(stop => !stop.bookend) - const fromKm = stopsAlong[first] ?? 0 - const toKm = stopsAlong[last] ?? 0 - const inner = first >= 0 && first < last && fromKm < toKm - ? projectOntoRoute({ lat, lng }, spine, { fromKm, toKm }) - : null - const retry = inner && inner.offRouteKm <= hit.offRouteKm + SAME_ROAD_KM ? readAnchor(day, inner, stopsAlong) : null - if (retry && !retry.bookendLeg) candidate = retry - } - if (candidate && beats(candidate, best)) best = candidate - } - return best - }, [roadtripRoutes.days]) - - /** - * Where a place chosen by hand belongs in the drive, as a card and a position in it. - * - * `anchorFor` answers in the space a via is STORED in: the day the anchor stop belongs - * to, and its index there. A stop is placed at a position counted along the card it is - * drawn on, which is the same thing on every day that does not drive past midnight and - * a different one on the days that do (`nightSpill.ts`). Translating between the two - * happens here, once, rather than at whichever surface asked. - * - * Deliberately with no distance limit, unlike `addRoadtripVia`: the place this answers - * for is the charger the corridor search did not find, which is exactly the one sitting - * further off the drawn line than a via is allowed to be. The projection is the default - * the dialog offers, never a gate it applies. - */ - const manualStopTargetFor = useCallback((lat: number, lng: number): ManualStopTarget | null => { - const anchor = anchorFor(lat, lng) - // Nothing is stopped at on a flight. Behind an arrival terminal or a hire car's desk - // is a road, and a stop there is the first stop after landing or after the pick-up. - if (!anchor || anchor.terminal === 'departure') return null - // Behind a terminal, and on the drive from the hotel a day sets out from or to the one - // it ends at, the place goes where it fell on the card. None of them is a stored stop - // to be found by its index, which each shares with one: after the morning's hotel is - // before the day's first stop, before the evening's is after its last. - if (anchor.bookendLeg || anchor.terminal) { - return { dayId: anchor.card.dayId, position: anchor.card.index + 1, offRouteKm: anchor.offRouteKm } - } - for (const day of roadtripRoutes.days) { - // The stored stop the anchor names, not a terminal or a hotel seated in front of it - // with the same index, which put the place one stop early. - const at = day.stops.findIndex(stop => isStoredStop(stop) && stop.ownerDayId === anchor.dayId && stop.ownerIndex === anchor.afterIndex) - if (at >= 0) return { dayId: day.dayId, position: at + 1, offRouteKm: anchor.offRouteKm } - } - // No card draws that stop, which happens while the rail is between rebuilds. Its own - // numbers are the best answer there is, and `roadtripInsertion` measures them against - // the card again when the stop actually lands. - return { dayId: anchor.dayId, position: anchor.afterIndex + 1, offRouteKm: anchor.offRouteKm } - }, [anchorFor, roadtripRoutes.days]) + const { + manualStopTargetFor, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, askRefuel, acceptRefuel, dropPoiOnRoute, + } = useRoadtripDriveShaping({ + trip, can, toast, t, roadtripSettings, setStopDraft, roadtripRoutes, roadtripVias, roadtripCorridor, refuel, + }) /** * Adding a stop the corridor search never found. @@ -953,146 +784,6 @@ export function useTripPlanner() { } }, [serviceStopForm, serviceStopKind, roadtripRoutes.days, roadtripCorridor.day, manualStopTargetFor]) - const addRoadtripVia = useCallback(async (lat: number, lng: number) => { - if (!can('day_edit', trip)) return - const best = anchorFor(lat, lng) - // A click that landed on some other line is not a via anywhere. Neither is one on a - // ride, or on the road out of a terminal: a via is filed by the position of a stored - // stop, and a terminal is not one. - if (!best || best.offRouteKm > 2 || best.terminal) return - // Nor on the drive from or to a booked night's hotel, which says so rather than - // ignoring the click (`legReroutable`). - if (best.bookendLeg) { toast.info(t('roadtrip.bookend.noVia')); return } - try { - await roadtripVias.add(best.dayId, best.afterIndex, lat, lng) - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : t('common.unknownError')) - } - }, [anchorFor, roadtripVias, can, trip, toast, t]) - - /** Dragging a via redraws the route through its new position. */ - const moveRoadtripVia = useCallback(async (dayId: number, id: number, lat: number, lng: number) => { - if (!can('day_edit', trip)) return - // Measured against this day only. A drag is a drag WITHIN a day: letting the nearest - // day win, the way placing one does, would hand the via to a neighbouring day whose - // road happens to pass closer, and it would vanish from the day it was dragged in. - // - // No distance guard either. Dragging a via well off the current road is the whole - // point of dragging it, and refusing that would be refusing the gesture; the anchor - // just says which leg gets bent, and the router answers the rest. - const anchor = anchorFor(lat, lng, dayId) - try { - await roadtripVias.move(dayId, id, lat, lng, anchor && !anchor.terminal && !anchor.bookendLeg ? anchor.afterIndex : undefined) - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : t('common.unknownError')) - } - }, [anchorFor, roadtripVias, can, trip, toast, t]) - - /** - * Somewhere to fill up before this tank runs out. - * - * Measured against the day's DRIVING line, the same one the dry point was placed on, so - * a station's distance along the road is comparable with the distance the fuel lasts. - * The day's own places go in as well: a pump already on the plan should not be offered - * beside itself. - */ - const askRefuel = useCallback((dayId: number, dry: DryPoint & { lat: number; lng: number }) => { - const day = roadtripRoutes.days.find(d => d.dayId === dayId) - if (!day) return - const line = (dry.inboundLine ?? day.drivingGeometry ?? day.geometry).map(([lat, lng]) => ({ lat, lng })) - if (line.length < 2) return - const vehicle = roadtripSettings.roadtrip_vehicle - const refuelTypes = refuelStopTypeFor(vehicle === 'electric' || vehicle === 'combustion' ? vehicle : null) - let fromAlongKm = 0 - let drivenKm = 0 - for (let i = 0; i <= dry.legIndex; i++) { - if (refuelTypes.includes(day.stops[i]?.stopType as 'fuel' | 'charging')) fromAlongKm = drivenKm - const leg = day.legs[i] - if (leg?.mode === 'driving') drivenKm += (leg.distance ?? 0) / 1000 - } - void refuel.ask( - `${dayId}:${dry.legIndex}`, - { lat: dry.lat, lng: dry.lng }, - line, - dry.drivenMeters / 1000, - day.stops.map(stop => ({ lat: stop.lat, lng: stop.lng })), - fromAlongKm, - ) - }, [roadtripRoutes.days, refuel, roadtripSettings.roadtrip_vehicle]) - - /** - * Accepting one hands it to the same popup a corridor hit goes through. - * - * Deliberately not a direct write: the popup is where the stop kind and the time spent - * are decided, it defaults both from the category, and every step after it — the place, - * the assignment at the right position, the via re-anchoring, the re-route — is already - * correct there and pinned by tests. A second path to the same end would be a second - * place for it to go wrong. - */ - const acceptRefuel = useCallback((dayId: number, poi: RefuelCandidate, dry: DryPoint & { lat: number; lng: number }) => { - if (!can('day_edit', trip)) return - const day = roadtripRoutes.days.find(d => d.dayId === dayId) - if (!day) return - // Before the stop the tank would have run out on, which is the leg the dry point - // names. A station reached after the day's last stop is tomorrow's problem, and - // clamping it onto the final leg would re-route the arrival through it. - let drivenKm = 0 - const stationLeg = day.legs.findIndex(leg => { - if (leg?.mode !== 'driving') return false - drivenKm += (leg.distance ?? 0) / 1000 - return poi.alongKm <= drivenKm - }) - const at = dry.inboundLine ? -dry.legIndex - 1 : Math.min((stationLeg >= 0 ? stationLeg : dry.legIndex) + 1, day.stops.length - 1) - // That index counts along the CARD, and after a night drive a card is not one stored - // day: its first stops belong to yesterday. The new stop goes in front of the one it - // was measured against, so it is that stop's own day and position that place it — - // written against the card's day it would land in the wrong list, at an index that - // means something else there. - const anchor = day.stops[at] - if (!anchor) return - refuel.close() - setStopDraft({ poi, ...roadtripInsertion(day, at)!, dayNumber: day.dayNumber }) - }, [roadtripRoutes.days, refuel, can, trip, setStopDraft]) - - /** Removing a via lets the drive take the direct road again. */ - const removeRoadtripVia = useCallback(async (dayId: number, id: number) => { - if (!can('day_edit', trip)) return - try { - await roadtripVias.remove(dayId, id) - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : t('common.unknownError')) - } - }, [roadtripVias, can, trip, toast, t]) - - /** - * A corridor hit dropped on the map, placed where it was dropped rather than where the - * corridor projected it. - * - * The two differ whenever a drive passes near the same spot twice — a loop, an - * out-and-back — and the automatic projection can only pick one of them. Dropping says - * which, and the drop coordinate is projected onto the same routed line the hits were - * measured along, so the answer is in the same units as everything else. - * - * A drop nowhere near the drive is ignored rather than guessed at: adding a stop - * fifty kilometres off the route because the pointer slipped is worse than nothing - * happening. - */ - const dropPoiOnRoute = useCallback((osmId: string, lat: number, lng: number) => { - if (!can('place_edit', trip)) return - const hit = roadtripCorridor.visible.find(p => p.osm_id === osmId) - const day = roadtripCorridor.day - if (!hit || !day) return - const at = projectOntoRoute({ lat, lng }, roadtripCorridor.search.spine) - if (!at || at.offRouteKm > roadtripCorridor.widthKm) return - const insert = roadtripInsertion(day, roadtripCorridor.insertIndexFor(at)) - if (!insert) return - setStopDraft({ - poi: hit, - ...insert, - dayNumber: day.dayNumber, - }) - }, [roadtripCorridor, can, trip, setStopDraft]) - /** Hands the draft over to the full form, keeping the day and the position it worked out. */ const stopDraftToForm = useCallback((stop?: { stopType: RoadtripStopType | null; dwellMinutes: number }) => { if (!stopDraft) return