feat(maps): search the view in road trip mode too

The category pill was switched off whenever road trip mode was on, which left the
corridor search as the only way to find anything. The two answer different questions
though — 'what is along this drive' against 'what is in view right now' — and looking
for a hotel at tonight's stop is exactly the second one. Furkot shows accommodation
at a stop for the same reason (disc #1797 thread).

Both searches now draw together. A place found by both appears once, as the corridor
knows it, because only that copy carries how far along the drive it sits, and the
merged list keeps its reference while nothing changes so the map does not rebuild
every pin on each render.
This commit is contained in:
Maurice
2026-09-09 14:44:21 +02:00
committed by Maurice
parent a46d3fe88a
commit 88129e4546
3 changed files with 98 additions and 7 deletions
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useMergedMapPois } from './useMergedMapPois'
import type { Poi } from './poiCategories'
/**
* FE-MAP-MERGEPOI-001..006 — the two searches that can run at once in road trip mode.
*
* The corridor answers "what is along the drive", the category pill answers "what is in
* view". Both are drawn, the corridor's copy of a shared hit wins, and the reference is
* only new when the content really is — the map rebuilds every pin off it.
*/
const poi = (osm_id: string, name = osm_id): Poi => ({
osm_id, name, lat: 48, lng: 2, category: 'hotel', source: 'openstreetmap',
} as Poi)
describe('useMergedMapPois', () => {
it('FE-MAP-MERGEPOI-001: outside road trip mode only the pill hits are drawn', () => {
const explore = [poi('n1')]
const { result } = renderHook(() => useMergedMapPois(null, explore))
expect(result.current).toBe(explore)
})
it('FE-MAP-MERGEPOI-002: with nothing found in view the corridor list passes through as-is', () => {
const corridor = [poi('n1')]
const { result } = renderHook(() => useMergedMapPois(corridor, []))
expect(result.current).toBe(corridor)
})
it('FE-MAP-MERGEPOI-003: before a corridor search the pill hits pass through as-is', () => {
const explore = [poi('n2')]
const { result } = renderHook(() => useMergedMapPois([], explore))
expect(result.current).toBe(explore)
})
it('FE-MAP-MERGEPOI-004: both searches draw together, corridor first', () => {
const { result } = renderHook(() => useMergedMapPois([poi('n1')], [poi('n2')]))
expect(result.current.map(p => p.osm_id)).toEqual(['n1', 'n2'])
})
it('FE-MAP-MERGEPOI-005: a place both searches found is drawn once, as the corridor knows it', () => {
// The corridor copy carries how far along the drive it sits, which the pill's does not.
const corridor = [poi('n1', 'Hotel am Weg')]
const { result } = renderHook(() => useMergedMapPois(corridor, [poi('n1', 'Hotel am Weg (Sicht)')]))
expect(result.current).toBe(corridor)
expect(result.current).toHaveLength(1)
expect(result.current[0].name).toBe('Hotel am Weg')
})
it('FE-MAP-MERGEPOI-006: an unchanged pair keeps its reference, so no pin is rebuilt', () => {
const corridor = [poi('n1')]
const explore = [poi('n2')]
const { result, rerender } = renderHook(() => useMergedMapPois(corridor, explore))
const first = result.current
rerender()
expect(result.current).toBe(first)
})
})
@@ -0,0 +1,28 @@
import { useMemo } from 'react'
import type { Poi } from './poiCategories'
/**
* The findings the map should draw, when two searches can be running at once.
*
* In road trip mode the corridor search answers "what is along this drive". The category
* pill answers a different question — "what is in view right now" — and a hotel at tonight's
* stop is exactly the case where someone wants the second one without giving up the first,
* so the two lists are drawn together rather than one replacing the other.
*
* Identity is `osm_id`, the same key the corridor search dedupes its own overlapping tiles
* on: a hotel found both along the route and in the viewport is one pin, and the corridor's
* copy wins because it carries how far along the drive it sits.
*
* Returns the input array itself whenever there is nothing to merge. The map redraws off
* this reference, so handing it a fresh array on every render would rebuild every pin.
*/
export function useMergedMapPois(corridor: Poi[] | null, explore: Poi[]): Poi[] {
return useMemo(() => {
if (!corridor) return explore
if (!explore.length) return corridor
if (!corridor.length) return explore
const seen = new Set(corridor.map(p => p.osm_id))
const extra = explore.filter(p => !seen.has(p.osm_id))
return extra.length ? [...corridor, ...extra] : corridor
}, [corridor, explore])
}
+11 -7
View File
@@ -47,6 +47,7 @@ import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, T
import { ListTodo, Download, Plus, Trash2, FolderPlus } from 'lucide-react'
import { useTripPlanner } from './tripPlanner/useTripPlanner'
import { usePoiExplore } from '../components/Map/usePoiExplore'
import { useMergedMapPois } from '../components/Map/useMergedMapPois'
import PoiCategoryPill from '../components/Map/PoiCategoryPill'
import { useTouchDragBridge } from '../hooks/useTouchDragBridge'
@@ -320,7 +321,11 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
const [glMap, setGlMap] = useState<CompassMap | null>(null)
// The corridor search draws into the same map channel and answers the same question for
// a drive, so the explore pill stands down while road trip mode is on.
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false && !roadtripActive
// Also in road trip mode: searching the view is a different question from searching the
// drive ("is there a hotel at tonight's stop" versus "what is along the way"), and the
// two answers are drawn side by side rather than one hiding the other.
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
const mapPois = useMergedMapPois(roadtripActive ? roadtripCorridor.visible : null, poi.pois)
// Costs expense editor opened from a booking modal (save-then-open). Lives at the
// page level so it has tripMembers / base currency / current user available.
@@ -425,12 +430,11 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
const r = reservations.find(x => x.id === rid)
if (r) setMapTransportDetail(r)
}}
pois={roadtripActive
/* `visible`, not `search.results`: the map is the picture of this very
list, and filtering the list while seventy pins stay on the map no
longer answers "which of these". */
? roadtripCorridor.visible
: poi.pois}
/* In road trip mode the corridor's `visible` (not `search.results`: the map is
the picture of that very list, and filtering the list while seventy pins stay
on the map no longer answers "which of these") plus whatever the category
pill found in view. Outside it, only the pill's hits. */
pois={mapPois}
onPoiClick={handlePoiClick}
// Only while road trip mode is on: outside it there is no drive to drop onto.
onPoiDropOnRoute={roadtripActive ? dropPoiOnRoute : undefined}