diff --git a/client/scripts/eslint-baseline.json b/client/scripts/eslint-baseline.json index 92a3c91aa..af2c39824 100644 --- a/client/scripts/eslint-baseline.json +++ b/client/scripts/eslint-baseline.json @@ -8,7 +8,7 @@ "trek/no-swallowed-catch": 133, "max-params": 35, "trek/store-without-selector": 69, - "react-hooks/refs": 560, + "react-hooks/refs": 559, "react-hooks/set-state-in-effect": 197, "@typescript-eslint/no-floating-promises": 167, "react-hooks/exhaustive-deps": 121, diff --git a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx index 6cfe3e926..cb008aac8 100644 --- a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx +++ b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx @@ -16,11 +16,10 @@ import { isRtlLanguage, useTranslation } from '../../i18n'; import { usePaddleObstacleStore } from '../../store/paddleObstacleStore'; import { useSettingsStore } from '../../store/settingsStore'; import { publishTourFerrataCrossings } from '../../store/tourFerrataCrossingStore'; -import { pickOnTrack, refuseTrackPick, useTrackMarkToolStore } from '../../store/trackMarkToolStore'; -import { projectOntoTrack, TRACK_PICK_TOLERANCE_M } from '../../utils/paddleTrackMarks'; import { useFerrataAdopt } from './useFerrataAdopt'; import { useTourMapPoints } from './useTourMapPoints'; import { useTourStageLayers } from './useTourStageLayers'; +import { useTrackLinePick } from './useTrackLinePick'; import type { useTripPlanner } from './useTripPlanner'; import type { useTripPlannerPage } from './useTripPlannerPage'; @@ -149,21 +148,8 @@ export function TourPlannerMapStage({ guard: closingPress.guard, previewPoints: tourMap.previewPoints, }); - const onAdd = waypoints.onAdd; // PAD-03 on a recorded paddle track: with a marking tool on, a click on the line marks the spot on it. - const trackPick = useTrackMarkToolStore((state) => state.pick); - const trackLine = tourPlanner.mode.type === 'view-gpx' ? analysis?.routeCoordinates : null; - const pickOnLine = canPlaceEdit && trackPick !== null && !!trackLine?.length; - const onTrackClick = closingPress.guard( - pickOnLine - ? (lat: number, lng: number) => { - const projected = projectOntoTrack(trackLine, { lat, lng }); - if (!projected || projected.offM > TRACK_PICK_TOLERANCE_M) refuseTrackPick(); - else pickOnTrack(projected.atM); - } - : undefined - ); - const onAddOrMark = onAdd ?? onTrackClick; + const { pickOnLine, onAddOrMark } = useTrackLinePick(tourPlanner, canPlaceEdit, closingPress.guard, waypoints.onAdd); // What each panel covers of the map, a collapsed one its rail. const leftInset = leftHidden ? 56 : leftPanelPx; const rightInset = rightHidden ? 56 : rightPanelPx; @@ -249,10 +235,10 @@ export function TourPlannerMapStage({ onOutsidePress={closingPress.arm} insetEnd={(isRtlLanguage(language) ? leftInset : rightInset) + MAP_LAYER_SWITCHER_INSET} {...ferrataCardPlacement(terrain3d, bottomRowTop, terrainLegend)} - extra={ - paddleChecklist - ? (detail) => - : undefined + extra={(detail) => + paddleChecklist ? ( + + ) : null } /> {/* "Point set", with the way back, above the bottom row and between the panels. */} diff --git a/client/src/pages/tripPlanner/usePlannerFocusPoints.test.ts b/client/src/pages/tripPlanner/usePlannerFocusPoints.test.ts new file mode 100644 index 000000000..54783238f --- /dev/null +++ b/client/src/pages/tripPlanner/usePlannerFocusPoints.test.ts @@ -0,0 +1,83 @@ +// FE-TP-FOCUSPOINTS-001 to FE-TP-FOCUSPOINTS-004: what the tour planner's map frames: a browsed wanderer trail, +// the recorded track or the open tour's control points, and with nothing open the area the plan map last showed, +// else the trip's places. +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import type { TourPlannerController } from '../../components/Tours/planner/useTourPlanner'; +import type { Place } from '../../types'; +import { usePlannerFocusPoints } from './usePlannerFocusPoints'; + +const PLACES = [ + { id: 1, lat: 48, lng: 11 }, + { id: 2, lat: Number.NaN, lng: 11 }, +] as unknown as Place[]; + +function planner(overrides: Partial> = {}): TourPlannerController { + return { + mode: { type: 'neutral' }, + readOnlyGpxAnalysis: null, + waypoints: [], + newDraftGeneration: 0, + ...overrides, + } as unknown as TourPlannerController; +} + +const closed = { open: false, line: null }; + +describe('usePlannerFocusPoints', () => { + it('FE-TP-FOCUSPOINTS-001: with nothing open the trip places with a position are framed', () => { + const { result } = renderHook(() => usePlannerFocusPoints('tour-planner', PLACES, planner(), closed)); + expect(result.current.focusPoints).toEqual([[48, 11]]); + }); + + it('FE-TP-FOCUSPOINTS-002: the area the plan map last showed wins once the planner opens again', () => { + const { result, rerender } = renderHook(({ tab }) => usePlannerFocusPoints(tab, PLACES, planner(), closed), { + initialProps: { tab: 'plan' }, + }); + act(() => { + result.current.lastPlanMapViewportRef.current = { south: 49, west: 12, north: 49.5, east: 12.5 }; + }); + rerender({ tab: 'tour-planner' }); + expect(result.current.focusPoints).toEqual([ + [49, 12], + [49.5, 12.5], + ]); + }); + + it('FE-TP-FOCUSPOINTS-003: a recorded track, a draft and a saved tour frame their own line or points', () => { + const line: [number, number][] = [ + [49.2, 12], + [49.2, 12.01], + ]; + const waypoints = [ + { id: 'a', lat: 47, lng: 11 }, + { id: 'b', lat: 47.1, lng: 11.1 }, + ]; + const gpx = planner({ mode: { type: 'view-gpx' }, readOnlyGpxAnalysis: { routeCoordinates: line } }); + expect( + renderHook(() => usePlannerFocusPoints('tour-planner', PLACES, gpx, closed)).result.current.focusPoints + ).toBe(line); + for (const type of ['new-draft', 'edit-saved']) { + const open = planner({ mode: { type }, waypoints }); + expect( + renderHook(() => usePlannerFocusPoints('tour-planner', PLACES, open, closed)).result.current.focusPoints + ).toEqual([ + [47, 11], + [47.1, 11.1], + ]); + } + }); + + it('FE-TP-FOCUSPOINTS-004: a browsed wanderer trail frames its line, the fallback while it has none', () => { + const trail: [number, number][] = [[46, 10]]; + expect( + renderHook(() => usePlannerFocusPoints('tour-planner', PLACES, planner(), { open: true, line: trail })).result + .current.focusPoints + ).toBe(trail); + expect( + renderHook(() => usePlannerFocusPoints('tour-planner', PLACES, planner(), { open: true, line: null })).result + .current.focusPoints + ).toEqual([[48, 11]]); + }); +}); diff --git a/client/src/pages/tripPlanner/usePlannerFocusPoints.ts b/client/src/pages/tripPlanner/usePlannerFocusPoints.ts new file mode 100644 index 000000000..b882772af --- /dev/null +++ b/client/src/pages/tripPlanner/usePlannerFocusPoints.ts @@ -0,0 +1,63 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { Bbox } from '../../components/Map/usePoiExplore'; +import type { TourPlannerController } from '../../components/Tours/planner/useTourPlanner'; +import type { WandererBrowser } from '../../components/Tours/wanderer/useWandererBrowser'; +import type { Place } from '../../types'; + +/** + * What the TOUR-PLANNER map frames: a browsed wanderer trail, the recorded + * track or the control points of the open tour, and with nothing open the + * area the plan map last showed (or the trip's places). The plan map reports + * its viewport into the returned ref. + */ +export function usePlannerFocusPoints( + activeTab: string, + places: readonly Place[], + tourPlanner: TourPlannerController, + wandererBrowser: Pick +) { + const lastPlanMapViewportRef = useRef(null); + // Nothing reported yet when the planner first opens; the effect takes the area the plan map last showed. + const [plannerViewportSnapshot, setPlannerViewportSnapshot] = useState(null); + useEffect(() => { + if (activeTab === 'tour-planner') setPlannerViewportSnapshot(lastPlanMapViewportRef.current); + }, [activeTab, tourPlanner.newDraftGeneration]); + + const plannerFallbackFocusPoints = useMemo<[number, number][]>(() => { + const bounds = plannerViewportSnapshot; + if (bounds) { + const latitudeSpan = bounds.north - bounds.south; + const longitudeSpan = bounds.east - bounds.west; + if (latitudeSpan > 0 && latitudeSpan < 160 && longitudeSpan > 0 && longitudeSpan < 340) { + return [ + [bounds.south, bounds.west], + [bounds.north, bounds.east], + ]; + } + } + return places + .filter((place) => Number.isFinite(place.lat) && Number.isFinite(place.lng)) + .map((place) => [place.lat, place.lng] as [number, number]); + }, [places, plannerViewportSnapshot]); + + const focusPoints = useMemo<[number, number][]>(() => { + if (wandererBrowser.open) return wandererBrowser.line ?? plannerFallbackFocusPoints; + if (tourPlanner.mode.type === 'view-gpx') return tourPlanner.readOnlyGpxAnalysis?.routeCoordinates ?? []; + if (tourPlanner.mode.type === 'new-draft') { + return tourPlanner.waypoints.map((point) => [point.lat, point.lng]); + } + if (tourPlanner.mode.type === 'edit-saved' && tourPlanner.waypoints.length) { + return tourPlanner.waypoints.map((point) => [point.lat, point.lng]); + } + return plannerFallbackFocusPoints; + }, [ + wandererBrowser.open, + wandererBrowser.line, + tourPlanner.mode.type, + tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, + tourPlanner.waypoints, + plannerFallbackFocusPoints, + ]); + + return { focusPoints, lastPlanMapViewportRef }; +} diff --git a/client/src/pages/tripPlanner/useTrackLinePick.test.ts b/client/src/pages/tripPlanner/useTrackLinePick.test.ts new file mode 100644 index 000000000..e4ff78429 --- /dev/null +++ b/client/src/pages/tripPlanner/useTrackLinePick.test.ts @@ -0,0 +1,67 @@ +// FE-TP-TRACKPICK-001 to FE-TP-TRACKPICK-004: a click on a recorded paddle track while a marking tool is on marks +// the spot on the line (PAD-03), one too far from it is refused, and otherwise the planner's own click stays. +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { TourPlannerController } from '../../components/Tours/planner/useTourPlanner'; +import { loadTrackMarks, startTrackPick, useTrackMarkToolStore } from '../../store/trackMarkToolStore'; +import { useTrackLinePick } from './useTrackLinePick'; + +const LINE: [number, number][] = [ + [49.2, 12], + [49.2, 12.01], +]; + +function track(mode: 'view-gpx' | 'edit-saved' = 'view-gpx') { + return { + mode: + mode === 'view-gpx' + ? { type: 'view-gpx', placeId: 40, tour: { place_id: 40 } } + : { type: 'edit-saved', placeId: 40 }, + readOnlyGpxAnalysis: { routeCoordinates: LINE }, + } as unknown as Pick; +} + +const passThrough = (handler: ((lat: number, lng: number) => void) | undefined) => handler; + +beforeEach(() => { + loadTrackMarks(40, null, { paddle: null, portage: null }); +}); + +describe('useTrackLinePick', () => { + it('FE-TP-TRACKPICK-001: without a marking tool the planner keeps its own click', () => { + const onAdd = vi.fn(); + const { result } = renderHook(() => useTrackLinePick(track(), true, passThrough, onAdd)); + expect(result.current.pickOnLine).toBe(false); + expect(result.current.onAddOrMark).toBe(onAdd); + }); + + it('FE-TP-TRACKPICK-002: with the lock tool on, a click on the line places the lock there', () => { + startTrackPick('lock'); + const { result } = renderHook(() => useTrackLinePick(track(), true, passThrough, undefined)); + expect(result.current.pickOnLine).toBe(true); + result.current.onAddOrMark?.(49.2, 12.005); + const { marks, dirty } = useTrackMarkToolStore.getState(); + expect(dirty).toBe(true); + expect(marks.locks).toHaveLength(1); + expect(marks.locks[0]?.at_m).toBeGreaterThan(300); + }); + + it('FE-TP-TRACKPICK-003: a click far from the line is refused', () => { + startTrackPick('lock'); + const { result } = renderHook(() => useTrackLinePick(track(), true, passThrough, undefined)); + result.current.onAddOrMark?.(49.25, 12.005); + expect(useTrackMarkToolStore.getState()).toMatchObject({ refused: 'off_line', dirty: false }); + }); + + it('FE-TP-TRACKPICK-004: a viewer, or a tour that is no recorded track, marks nothing', () => { + startTrackPick('lock'); + expect(renderHook(() => useTrackLinePick(track(), false, passThrough, undefined)).result.current).toEqual({ + pickOnLine: false, + onAddOrMark: undefined, + }); + expect( + renderHook(() => useTrackLinePick(track('edit-saved'), true, passThrough, undefined)).result.current.pickOnLine + ).toBe(false); + }); +}); diff --git a/client/src/pages/tripPlanner/useTrackLinePick.ts b/client/src/pages/tripPlanner/useTrackLinePick.ts new file mode 100644 index 000000000..71e465673 --- /dev/null +++ b/client/src/pages/tripPlanner/useTrackLinePick.ts @@ -0,0 +1,31 @@ +import type { TourPlannerController } from '../../components/Tours/planner/useTourPlanner'; +import { pickOnTrack, refuseTrackPick, useTrackMarkToolStore } from '../../store/trackMarkToolStore'; +import { projectOntoTrack, TRACK_PICK_TOLERANCE_M } from '../../utils/paddleTrackMarks'; + +type MapClick = (lat: number, lng: number) => void; + +/** + * PAD-03 on a recorded paddle track: with a marking tool on, a click on the + * line marks the spot on it, one too far from the line is refused. Otherwise + * the planner's own click (adding a waypoint) stays. + */ +export function useTrackLinePick( + tourPlanner: Pick, + canPlaceEdit: boolean, + guard: (handler: MapClick | undefined) => MapClick | undefined, + onAdd: MapClick | undefined +): { pickOnLine: boolean; onAddOrMark: MapClick | undefined } { + const trackPick = useTrackMarkToolStore((state) => state.pick); + const trackLine = tourPlanner.mode.type === 'view-gpx' ? tourPlanner.readOnlyGpxAnalysis?.routeCoordinates : null; + const pickOnLine = canPlaceEdit && trackPick !== null && !!trackLine?.length; + const onTrackClick = guard( + pickOnLine && trackLine + ? (lat, lng) => { + const projected = projectOntoTrack(trackLine, { lat, lng }); + if (!projected || projected.offM > TRACK_PICK_TOLERANCE_M) refuseTrackPick(); + else pickOnTrack(projected.atM); + } + : undefined + ); + return { pickOnLine, onAddOrMark: onAdd ?? onTrackClick }; +} diff --git a/client/src/pages/tripPlanner/useTripPlannerPage.ts b/client/src/pages/tripPlanner/useTripPlannerPage.ts index 7602ec158..62c1b7502 100644 --- a/client/src/pages/tripPlanner/useTripPlannerPage.ts +++ b/client/src/pages/tripPlanner/useTripPlannerPage.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { usePoiExplore, type Bbox } from '../../components/Map/usePoiExplore' import { placeActions } from '../../components/Planner/usePlaceActions' import { tourRouteStyle } from '../../components/Tours/planner/tourRouteStyle' @@ -9,6 +9,7 @@ import { tourPlannedTimes as projectTourPlannedTimes } from '../../components/To import { usePlanMapViewport } from '../../hooks/usePlanMapViewport' import { usePaddleObstacleStore } from '../../store/paddleObstacleStore' import { useMapLocationSearch } from './useMapLocationSearch' +import { usePlannerFocusPoints } from './usePlannerFocusPoints' import { useTripPlanner } from './useTripPlanner' import { useTourWorkspace } from './useTourWorkspace' @@ -31,40 +32,7 @@ export function useTripPlannerPage() { useTourWorkspace(planner, canPlaceEdit, canDayEdit) const tourDetailOpenerRef = useRef<{ placeId: number; element: HTMLElement } | null>(null) - const lastPlanMapViewportRef = useRef(null) - const [plannerViewportSnapshot, setPlannerViewportSnapshot] = useState(lastPlanMapViewportRef.current) - useEffect(() => { - if (activeTab === 'tour-planner') setPlannerViewportSnapshot(lastPlanMapViewportRef.current) - }, [activeTab, tourPlanner.newDraftGeneration]) - - const plannerFallbackFocusPoints = useMemo<[number, number][]>(() => { - const bounds = plannerViewportSnapshot - if (bounds) { - const latitudeSpan = bounds.north - bounds.south - const longitudeSpan = bounds.east - bounds.west - if (latitudeSpan > 0 && latitudeSpan < 160 && longitudeSpan > 0 && longitudeSpan < 340) { - return [[bounds.south, bounds.west], [bounds.north, bounds.east]] - } - } - return places - .filter(place => Number.isFinite(place.lat) && Number.isFinite(place.lng)) - .map(place => [place.lat, place.lng] as [number, number]) - }, [places, plannerViewportSnapshot]) - - const focusPoints = useMemo<[number, number][]>(() => { - if (wandererBrowser.open) return wandererBrowser.line ?? plannerFallbackFocusPoints - if (tourPlanner.mode.type === 'view-gpx') return tourPlanner.readOnlyGpxAnalysis?.routeCoordinates ?? [] - if (tourPlanner.mode.type === 'new-draft') { - return tourPlanner.waypoints.map(point => [point.lat, point.lng]) - } - if (tourPlanner.mode.type === 'edit-saved' && tourPlanner.waypoints.length) { - return tourPlanner.waypoints.map(point => [point.lat, point.lng]) - } - return plannerFallbackFocusPoints - }, [ - wandererBrowser.open, - wandererBrowser.line, - tourPlanner.mode.type, tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, tourPlanner.waypoints, plannerFallbackFocusPoints]) + const { focusPoints, lastPlanMapViewportRef } = usePlannerFocusPoints(activeTab, places, tourPlanner, wandererBrowser) // PAD-06: an obstacle or area the paddle checklist asks for moves the camera as a picked search result does. const paddleFocus = usePaddleObstacleStore(state => state.focus) @@ -122,7 +90,7 @@ export function useTripPlannerPage() { lastPlanMapViewportRef.current = bounds poiViewportChangeRef.current(bounds) reportViewport(bounds) - }, [reportViewport]) + }, [lastPlanMapViewportRef, reportViewport]) // Each tour's planned times keyed by its place, for the day plan's tour rows. const plannedTourDurations = new Map(tours.map(tour => [tour.place_id, { ...projectTourPlannedTimes(tour), tour }] as const))