@@ -19,12 +19,27 @@ import {
1919} from '../graphql/__generated__/types' ;
2020import { ElementDetailModal } from './ElementDetailModal' ;
2121import { ElementPreviewCard } from './ElementPreviewCard' ;
22+ import { zoomForPlaceTypes } from './placeZoom' ;
23+ import {
24+ type SearchElement ,
25+ SearchOverlay ,
26+ type SearchPlace ,
27+ type SearchTrip ,
28+ } from './SearchOverlay' ;
29+ import { TripFilterChip } from './TripFilterChip' ;
2230import { type Viewport , viewportStore } from './viewportStore' ;
2331
2432type ElementWithLocation = ElementsQuery [ 'elements' ] [ number ] ;
2533
2634const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty' ;
2735const USER_ZOOM = 14 ;
36+ // Zoom used when flying to a searched element, and for single-element trips
37+ // where there is no extent to fit.
38+ const ELEMENT_ZOOM = 15 ;
39+ const TRIP_SINGLE_ZOOM = 13 ;
40+ // Inset (points) kept around a trip's elements when fitting the camera so pins
41+ // aren't flush against the screen edges or hidden under the overlays.
42+ const FIT_PADDING = { top : 120 , right : 60 , bottom : 120 , left : 60 } ;
2843const DEFAULT_INITIAL_VIEW = { center : [ 0 , 20 ] as [ number , number ] , zoom : 1 } ;
2944// Show the recenter button once the viewport center drifts more than this
3045// fraction of the visible span away from the user in either axis.
@@ -43,10 +58,21 @@ export function MapScreen() {
4358 const [ savedViewport , setSavedViewport ] = useState <
4459 Viewport | null | undefined
4560 > ( undefined ) ;
61+ // The element selected on the map (shows the bottom preview card). Separate
62+ // from the element whose full-detail modal is open, so a located element can
63+ // fall back to its preview card after the modal closes while a location-less
64+ // one (which has no map presence) returns straight to the plain map.
4665 const [ selectedElementId , setSelectedElementId ] = useState < string | null > (
4766 null ,
4867 ) ;
49- const [ detailExpanded , setDetailExpanded ] = useState ( false ) ;
68+ const [ detailElementId , setDetailElementId ] = useState < string | null > ( null ) ;
69+ // When set, the map is filtered to a single trip: only that trip's elements
70+ // are shown and the bounds-based query is paused.
71+ const [ tripFilter , setTripFilter ] = useState < {
72+ id : string ;
73+ name : string ;
74+ icon : string ;
75+ } | null > ( null ) ;
5076 const safeAreaInsets = useSafeAreaInsets ( ) ;
5177
5278 useEffect ( ( ) => {
@@ -148,25 +174,30 @@ export function MapScreen() {
148174 return offLng <= OFF_CENTER_THRESHOLD && offLat <= OFF_CENTER_THRESHOLD ;
149175 } , [ position , viewportState ] ) ;
150176
151- const { data} = useElementsQuery ( {
152- skip : ! bounds ,
153- variables : bounds ? { bounds} : undefined ,
154- } ) ;
177+ // One query, two modes: when a trip filter is active fetch that trip's
178+ // elements; otherwise fetch by viewport bounds. Reusing the single hook keeps
179+ // the element shape (and Apollo cache) identical across modes.
180+ const { data, loading : elementsLoading } = useElementsQuery (
181+ tripFilter
182+ ? { variables : { tripId : tripFilter . id } }
183+ : { skip : ! bounds , variables : bounds ? { bounds} : undefined } ,
184+ ) ;
155185
156186 // Accumulate elements across viewport fetches so pins persist while a new
157187 // search is in flight. Fine for our small per-user dataset; revisit if we
158- // ever need eviction or to reflect server-side deletes.
188+ // ever need eviction or to reflect server-side deletes. Skip while filtering
189+ // by trip so trip-only results don't leak into the normal viewport view.
159190 const [ elementsById , setElementsById ] = useState <
160191 ReadonlyMap < string , ElementWithLocation >
161192 > ( new Map ( ) ) ;
162193 useEffect ( ( ) => {
163- if ( ! data ?. elements ) return ;
194+ if ( tripFilter || ! data ?. elements ) return ;
164195 setElementsById ( prev => {
165196 const next = new Map ( prev ) ;
166197 for ( const el of data . elements ) next . set ( el . id , el ) ;
167198 return next ;
168199 } ) ;
169- } , [ data ?. elements ] ) ;
200+ } , [ data ?. elements , tripFilter ] ) ;
170201
171202 // Only render markers whose location falls inside the last-known viewport.
172203 // <Marker> is a native View — rendering offscreen ones still costs layout
@@ -181,6 +212,98 @@ export function MapScreen() {
181212 } ) ;
182213 } , [ elementsById , bounds ] ) ;
183214
215+ // Elements with a location for the active trip; drives both the markers and
216+ // the camera fit while filtering.
217+ const tripElements = useMemo (
218+ ( ) =>
219+ tripFilter
220+ ? ( data ?. elements ?? [ ] ) . filter ( el => el . location != null )
221+ : [ ] ,
222+ [ tripFilter , data ?. elements ] ,
223+ ) ;
224+
225+ // In trip mode render the whole trip (no viewport cull) so panning around it
226+ // doesn't drop pins; otherwise use the bounds-culled accumulated set.
227+ const displayedElements = tripFilter ? tripElements : visibleElements ;
228+
229+ // Fit the camera to the trip's extent once its elements arrive. Guarded by a
230+ // ref so panning/zooming afterwards doesn't snap back, and reset when the
231+ // filter clears so re-selecting the same trip fits again.
232+ const fittedTripRef = useRef < string | null > ( null ) ;
233+ useEffect ( ( ) => {
234+ if ( ! tripFilter ) {
235+ fittedTripRef . current = null ;
236+ return ;
237+ }
238+ if ( fittedTripRef . current === tripFilter . id || elementsLoading ) return ;
239+ if ( tripElements . length === 0 ) return ;
240+ fittedTripRef . current = tripFilter . id ;
241+
242+ let west = Infinity ;
243+ let south = Infinity ;
244+ let east = - Infinity ;
245+ let north = - Infinity ;
246+ for ( const el of tripElements ) {
247+ if ( ! el . location ) continue ;
248+ const { longitude : lng , latitude : lat } = el . location ;
249+ if ( lng < west ) west = lng ;
250+ if ( lng > east ) east = lng ;
251+ if ( lat < south ) south = lat ;
252+ if ( lat > north ) north = lat ;
253+ }
254+ if ( west === east && south === north ) {
255+ cameraRef . current ?. flyTo ( {
256+ center : [ west , south ] ,
257+ zoom : TRIP_SINGLE_ZOOM ,
258+ duration : 1200 ,
259+ } ) ;
260+ } else {
261+ cameraRef . current ?. fitBounds ( [ west , south , east , north ] , {
262+ padding : FIT_PADDING ,
263+ duration : 1200 ,
264+ } ) ;
265+ }
266+ } , [ tripFilter , tripElements , elementsLoading ] ) ;
267+
268+ const handleSelectElement = useCallback ( ( element : SearchElement ) => {
269+ setTripFilter ( null ) ;
270+ if ( element . location ) {
271+ const { longitude, latitude} = element . location ;
272+ // Seed the marker set so the pin is present immediately, before the
273+ // bounds query around the new center returns.
274+ setElementsById ( prev => {
275+ const next = new Map ( prev ) ;
276+ next . set ( element . id , element ) ;
277+ return next ;
278+ } ) ;
279+ setSelectedElementId ( element . id ) ;
280+ cameraRef . current ?. flyTo ( {
281+ center : [ longitude , latitude ] ,
282+ zoom : ELEMENT_ZOOM ,
283+ duration : 1200 ,
284+ } ) ;
285+ } else {
286+ // No coordinates to fly to — a preview card pinned over an unrelated map
287+ // view would be misleading, so go straight to the full details.
288+ setSelectedElementId ( null ) ;
289+ setDetailElementId ( element . id ) ;
290+ }
291+ } , [ ] ) ;
292+
293+ const handleSelectTrip = useCallback ( ( trip : SearchTrip ) => {
294+ setSelectedElementId ( null ) ;
295+ setTripFilter ( { id : trip . id , name : trip . name , icon : trip . icon } ) ;
296+ } , [ ] ) ;
297+
298+ const handleSelectPlace = useCallback ( ( place : SearchPlace ) => {
299+ setTripFilter ( null ) ;
300+ cameraRef . current ?. flyTo ( {
301+ center : [ place . longitude , place . latitude ] ,
302+ zoom : zoomForPlaceTypes ( place . types ) ,
303+ duration : 1200 ,
304+ } ) ;
305+ } , [ ] ) ;
306+
184307 // Brief blank frame while the saved viewport hydrates from storage; the
185308 // camera's initialViewState is set once, so we wait for the resolved value
186309 // rather than rendering the map at the default world view first.
@@ -201,7 +324,7 @@ export function MapScreen() {
201324 onRegionDidChange = { onRegionDidChange } >
202325 < Camera ref = { cameraRef } initialViewState = { initialViewState } />
203326 < UserLocation animated accuracy />
204- { visibleElements . map ( el =>
327+ { displayedElements . map ( el =>
205328 el . location ? (
206329 < Marker
207330 key = { el . id }
@@ -242,12 +365,26 @@ export function MapScreen() {
242365 elementId = { selectedElementId }
243366 bottomOffset = { safeAreaInsets . bottom + BOTTOM_MARGIN }
244367 onClose = { ( ) => setSelectedElementId ( null ) }
245- onExpand = { ( ) => setDetailExpanded ( true ) }
368+ onExpand = { ( ) => setDetailElementId ( selectedElementId ) }
246369 />
247370 ) : null }
371+ { tripFilter ? (
372+ < TripFilterChip
373+ icon = { tripFilter . icon }
374+ name = { tripFilter . name }
375+ topOffset = { safeAreaInsets . top + 12 + 52 }
376+ onClear = { ( ) => setTripFilter ( null ) }
377+ />
378+ ) : null }
379+ < SearchOverlay
380+ topOffset = { safeAreaInsets . top + 12 }
381+ onSelectElement = { handleSelectElement }
382+ onSelectTrip = { handleSelectTrip }
383+ onSelectPlace = { handleSelectPlace }
384+ />
248385 < ElementDetailModal
249- elementId = { detailExpanded ? selectedElementId : null }
250- onClose = { ( ) => setDetailExpanded ( false ) }
386+ elementId = { detailElementId }
387+ onClose = { ( ) => setDetailElementId ( null ) }
251388 />
252389 </ View >
253390 ) ;
0 commit comments