Skip to content

Commit ad5f942

Browse files
dfallingclaude
andauthored
Add map search for elements, trips, and places (#14)
Users had no way to find something not already in the current viewport. This adds a search overlay that queries elements, trips, and places in a single request and lets the user jump to a result: - Element with a location: fly to it and show the preview card. One with no coordinates can't be placed on the map, so its detail modal opens directly instead of a card pinned over an unrelated view. - Trip: filter the map to that trip's elements only, fit the camera to their extent, and show a clearable filter chip. - Place: recenter at a zoom matched to the place's size (country vs city). Search runs on submit (not typeahead); the query and results persist across dismiss so reopening shows the last search, editable. The single Elements query gained an optional tripId so one hook/cache serves both the viewport and trip-filtered views. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 89922ee commit ad5f942

8 files changed

Lines changed: 904 additions & 71 deletions

File tree

__tests__/placeZoom.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* @format
3+
*/
4+
5+
import {zoomForPlaceTypes} from '../src/map/placeZoom';
6+
7+
test('countries zoom out wide', () => {
8+
expect(zoomForPlaceTypes(['country', 'political'])).toBe(4);
9+
});
10+
11+
test('cities zoom in closer', () => {
12+
expect(zoomForPlaceTypes(['locality', 'political'])).toBe(11);
13+
});
14+
15+
test('first matching type wins (country over locality)', () => {
16+
expect(zoomForPlaceTypes(['country', 'locality'])).toBe(4);
17+
});
18+
19+
test('unknown and empty types fall back to the default zoom', () => {
20+
expect(zoomForPlaceTypes(['establishment'])).toBe(9);
21+
expect(zoomForPlaceTypes([])).toBe(9);
22+
expect(zoomForPlaceTypes(null)).toBe(9);
23+
});

src/graphql/__generated__/types.ts

Lines changed: 170 additions & 57 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/graphql/queries/elements.graphql

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
query Elements($bounds: GeoBounds) {
2-
elements(bounds: $bounds) {
1+
query Elements($bounds: GeoBounds, $tripId: String) {
2+
elements(bounds: $bounds, tripId: $tripId) {
33
id
44
name
55
icon

src/graphql/queries/search.graphql

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
query Search($query: String!) {
2+
elements(search: $query) {
3+
id
4+
name
5+
icon
6+
location {
7+
id
8+
address
9+
latitude
10+
longitude
11+
}
12+
}
13+
trips(search: $query) {
14+
id
15+
name
16+
icon
17+
description
18+
}
19+
placeSearch(query: $query, granularity: REGIONS) {
20+
placeId
21+
name
22+
address
23+
latitude
24+
longitude
25+
types
26+
}
27+
}

src/map/MapScreen.tsx

Lines changed: 149 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,27 @@ import {
1919
} from '../graphql/__generated__/types';
2020
import {ElementDetailModal} from './ElementDetailModal';
2121
import {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';
2230
import {type Viewport, viewportStore} from './viewportStore';
2331

2432
type ElementWithLocation = ElementsQuery['elements'][number];
2533

2634
const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
2735
const 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};
2843
const 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

Comments
 (0)