diff --git a/__tests__/placeZoom.test.ts b/__tests__/placeZoom.test.ts new file mode 100644 index 0000000..f73803a --- /dev/null +++ b/__tests__/placeZoom.test.ts @@ -0,0 +1,23 @@ +/** + * @format + */ + +import {zoomForPlaceTypes} from '../src/map/placeZoom'; + +test('countries zoom out wide', () => { + expect(zoomForPlaceTypes(['country', 'political'])).toBe(4); +}); + +test('cities zoom in closer', () => { + expect(zoomForPlaceTypes(['locality', 'political'])).toBe(11); +}); + +test('first matching type wins (country over locality)', () => { + expect(zoomForPlaceTypes(['country', 'locality'])).toBe(4); +}); + +test('unknown and empty types fall back to the default zoom', () => { + expect(zoomForPlaceTypes(['establishment'])).toBe(9); + expect(zoomForPlaceTypes([])).toBe(9); + expect(zoomForPlaceTypes(null)).toBe(9); +}); diff --git a/src/graphql/__generated__/types.ts b/src/graphql/__generated__/types.ts index 6575d85..c62e7a5 100644 --- a/src/graphql/__generated__/types.ts +++ b/src/graphql/__generated__/types.ts @@ -118,6 +118,14 @@ export type GooglePlace = { website?: Maybe; }; +/** How a list of labels is matched against an element's labels */ +export enum LabelMatchMode { + /** Element has every one of the given labels */ + All = 'ALL', + /** Element has at least one of the given labels */ + Any = 'ANY' +} + export type Location = { __typename?: 'Location'; address: Scalars['String']['output']; @@ -222,6 +230,18 @@ export type PhotoUser = { portfolioUrl?: Maybe; }; +/** Restricts place search results to a category of place */ +export enum PlaceGranularity { + /** Precise street addresses only */ + Address = 'ADDRESS', + /** Cities only (locality / administrative_area_level_3) */ + Cities = 'CITIES', + /** Businesses and points of interest only */ + Establishment = 'ESTABLISHMENT', + /** Countries, states, regions, and cities */ + Regions = 'REGIONS' +} + export type RegisterInput = { email: Scalars['String']['input']; password: Scalars['String']['input']; @@ -424,8 +444,12 @@ export type RootQueryTypeElementsArgs = { afterDate?: InputMaybe; bounds?: InputMaybe; completed?: InputMaybe; + deleted?: InputMaybe; excludeTripId?: InputMaybe; hasSchedule?: InputMaybe; + labels?: InputMaybe>; + labelsMatch?: InputMaybe; + search?: InputMaybe; sortLocation?: InputMaybe; tripId?: InputMaybe; }; @@ -442,6 +466,7 @@ export type RootQueryTypePhotoSearchArgs = { export type RootQueryTypePlaceSearchArgs = { + granularity?: InputMaybe; query: Scalars['String']['input']; }; @@ -450,6 +475,12 @@ export type RootQueryTypeTripArgs = { id: Scalars['String']['input']; }; + +export type RootQueryTypeTripsArgs = { + deleted?: InputMaybe; + search?: InputMaybe; +}; + export type S3PhotoInput = { description: Scalars['String']['input']; storageKey: Scalars['String']['input']; @@ -536,19 +567,20 @@ export type User = { locale?: Maybe; }; -export type ElementsQueryVariables = Exact<{ - bounds?: InputMaybe; +export type ElementDetailQueryVariables = Exact<{ + id: Scalars['String']['input']; }>; -export type ElementsQuery = { __typename?: 'RootQueryType', elements: Array<{ __typename?: 'Element', id: string, name: string, icon: string, location?: { __typename?: 'Location', id: string, latitude: number, longitude: number } | null }> }; +export type ElementDetailQuery = { __typename?: 'RootQueryType', element: { __typename?: 'Element', id: string, name: string, icon: string, description: string, completed: boolean, labels: Array, location?: { __typename?: 'Location', id: string, address: string, latitude: number, longitude: number } | null, photos: Array<{ __typename?: 'Photo', id: string, thumbnail: string, regular: string, description: string }>, schedule?: { __typename?: 'Schedule', id: string, allDay: boolean, startDate: any, endDate: any, startTime?: any | null, endTime?: any | null } | null } }; -export type ElementDetailQueryVariables = Exact<{ - id: Scalars['String']['input']; +export type ElementsQueryVariables = Exact<{ + bounds?: InputMaybe; + tripId?: InputMaybe; }>; -export type ElementDetailQuery = { __typename?: 'RootQueryType', element: { __typename?: 'Element', id: string, name: string, icon: string, description: string, completed: boolean, labels: Array, location?: { __typename?: 'Location', id: string, address: string, latitude: number, longitude: number } | null, photos: Array<{ __typename?: 'Photo', id: string, thumbnail: string, regular: string, description: string }>, schedule?: { __typename?: 'Schedule', id: string, allDay: boolean, startDate: any, endDate: any, startTime?: any | null, endTime?: any | null } | null } }; +export type ElementsQuery = { __typename?: 'RootQueryType', elements: Array<{ __typename?: 'Element', id: string, name: string, icon: string, location?: { __typename?: 'Location', id: string, latitude: number, longitude: number } | null }> }; export type LoginMutationVariables = Exact<{ input: LoginInput; @@ -571,57 +603,14 @@ export type RenewTokenMutationVariables = Exact<{ export type RenewTokenMutation = { __typename?: 'RootMutationType', renewToken: { __typename?: 'LoginToken', accessToken: string, refreshToken: string, expiresAt: any, user: { __typename?: 'User', id: string, email: string, locale?: string | null } } }; +export type SearchQueryVariables = Exact<{ + query: Scalars['String']['input']; +}>; + + +export type SearchQuery = { __typename?: 'RootQueryType', elements: Array<{ __typename?: 'Element', id: string, name: string, icon: string, location?: { __typename?: 'Location', id: string, address: string, latitude: number, longitude: number } | null }>, trips: Array<{ __typename?: 'Trip', id: string, name: string, icon: string, description: string }>, placeSearch: Array<{ __typename?: 'GooglePlace', placeId: string, name: string, address: string, latitude: number, longitude: number, types?: Array | null }> }; -export const ElementsDocument = gql` - query Elements($bounds: GeoBounds) { - elements(bounds: $bounds) { - id - name - icon - location { - id - latitude - longitude - } - } -} - `; -/** - * __useElementsQuery__ - * - * To run a query within a React component, call `useElementsQuery` and pass it any options that fit your needs. - * When your component renders, `useElementsQuery` returns an object from Apollo Client that contains loading, error, and data properties - * you can use to render your UI. - * - * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; - * - * @example - * const { data, loading, error } = useElementsQuery({ - * variables: { - * bounds: // value for 'bounds' - * }, - * }); - */ -export function useElementsQuery(baseOptions?: Apollo.QueryHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useQuery(ElementsDocument, options); - } -export function useElementsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useLazyQuery(ElementsDocument, options); - } -// @ts-ignore -export function useElementsSuspenseQuery(baseOptions?: Apollo.SuspenseQueryHookOptions): Apollo.UseSuspenseQueryResult; -export function useElementsSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions): Apollo.UseSuspenseQueryResult; -export function useElementsSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions) { - const options = baseOptions === Apollo.skipToken ? baseOptions : {...defaultOptions, ...baseOptions} - return Apollo.useSuspenseQuery(ElementsDocument, options); - } -export type ElementsQueryHookResult = ReturnType; -export type ElementsLazyQueryHookResult = ReturnType; -export type ElementsSuspenseQueryHookResult = ReturnType; -export type ElementsQueryResult = Apollo.QueryResult; export const ElementDetailDocument = gql` query ElementDetail($id: String!) { element(id: $id) { @@ -671,7 +660,7 @@ export const ElementDetailDocument = gql` * }, * }); */ -export function useElementDetailQuery(baseOptions: Apollo.QueryHookOptions & ({ variables: ElementDetailQueryVariables; skip?: boolean; } | { skip: boolean; })) { +export function useElementDetailQuery(baseOptions: Apollo.QueryHookOptions & ({ variables: ElementDetailQueryVariables; skip?: boolean; } | { skip: boolean; }) ) { const options = {...defaultOptions, ...baseOptions} return Apollo.useQuery(ElementDetailDocument, options); } @@ -679,9 +668,68 @@ export function useElementDetailLazyQuery(baseOptions?: Apollo.LazyQueryHookOpti const options = {...defaultOptions, ...baseOptions} return Apollo.useLazyQuery(ElementDetailDocument, options); } +// @ts-ignore +export function useElementDetailSuspenseQuery(baseOptions?: Apollo.SuspenseQueryHookOptions): Apollo.UseSuspenseQueryResult; +export function useElementDetailSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions): Apollo.UseSuspenseQueryResult; +export function useElementDetailSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions) { + const options = baseOptions === Apollo.skipToken ? baseOptions : {...defaultOptions, ...baseOptions} + return Apollo.useSuspenseQuery(ElementDetailDocument, options); + } export type ElementDetailQueryHookResult = ReturnType; export type ElementDetailLazyQueryHookResult = ReturnType; +export type ElementDetailSuspenseQueryHookResult = ReturnType; export type ElementDetailQueryResult = Apollo.QueryResult; +export const ElementsDocument = gql` + query Elements($bounds: GeoBounds, $tripId: String) { + elements(bounds: $bounds, tripId: $tripId) { + id + name + icon + location { + id + latitude + longitude + } + } +} + `; + +/** + * __useElementsQuery__ + * + * To run a query within a React component, call `useElementsQuery` and pass it any options that fit your needs. + * When your component renders, `useElementsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useElementsQuery({ + * variables: { + * bounds: // value for 'bounds' + * tripId: // value for 'tripId' + * }, + * }); + */ +export function useElementsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(ElementsDocument, options); + } +export function useElementsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(ElementsDocument, options); + } +// @ts-ignore +export function useElementsSuspenseQuery(baseOptions?: Apollo.SuspenseQueryHookOptions): Apollo.UseSuspenseQueryResult; +export function useElementsSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions): Apollo.UseSuspenseQueryResult; +export function useElementsSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions) { + const options = baseOptions === Apollo.skipToken ? baseOptions : {...defaultOptions, ...baseOptions} + return Apollo.useSuspenseQuery(ElementsDocument, options); + } +export type ElementsQueryHookResult = ReturnType; +export type ElementsLazyQueryHookResult = ReturnType; +export type ElementsSuspenseQueryHookResult = ReturnType; +export type ElementsQueryResult = Apollo.QueryResult; export const LoginDocument = gql` mutation Login($input: LoginInput!) { login(input: $input) { @@ -794,4 +842,69 @@ export function useRenewTokenMutation(baseOptions?: Apollo.MutationHookOptions; export type RenewTokenMutationResult = Apollo.MutationResult; -export type RenewTokenMutationOptions = Apollo.BaseMutationOptions; \ No newline at end of file +export type RenewTokenMutationOptions = Apollo.BaseMutationOptions; +export const SearchDocument = gql` + query Search($query: String!) { + elements(search: $query) { + id + name + icon + location { + id + address + latitude + longitude + } + } + trips(search: $query) { + id + name + icon + description + } + placeSearch(query: $query, granularity: REGIONS) { + placeId + name + address + latitude + longitude + types + } +} + `; + +/** + * __useSearchQuery__ + * + * To run a query within a React component, call `useSearchQuery` and pass it any options that fit your needs. + * When your component renders, `useSearchQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useSearchQuery({ + * variables: { + * query: // value for 'query' + * }, + * }); + */ +export function useSearchQuery(baseOptions: Apollo.QueryHookOptions & ({ variables: SearchQueryVariables; skip?: boolean; } | { skip: boolean; }) ) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(SearchDocument, options); + } +export function useSearchLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(SearchDocument, options); + } +// @ts-ignore +export function useSearchSuspenseQuery(baseOptions?: Apollo.SuspenseQueryHookOptions): Apollo.UseSuspenseQueryResult; +export function useSearchSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions): Apollo.UseSuspenseQueryResult; +export function useSearchSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions) { + const options = baseOptions === Apollo.skipToken ? baseOptions : {...defaultOptions, ...baseOptions} + return Apollo.useSuspenseQuery(SearchDocument, options); + } +export type SearchQueryHookResult = ReturnType; +export type SearchLazyQueryHookResult = ReturnType; +export type SearchSuspenseQueryHookResult = ReturnType; +export type SearchQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/src/graphql/queries/elements.graphql b/src/graphql/queries/elements.graphql index 96e91b0..2846dc6 100644 --- a/src/graphql/queries/elements.graphql +++ b/src/graphql/queries/elements.graphql @@ -1,5 +1,5 @@ -query Elements($bounds: GeoBounds) { - elements(bounds: $bounds) { +query Elements($bounds: GeoBounds, $tripId: String) { + elements(bounds: $bounds, tripId: $tripId) { id name icon diff --git a/src/graphql/queries/search.graphql b/src/graphql/queries/search.graphql new file mode 100644 index 0000000..89c647e --- /dev/null +++ b/src/graphql/queries/search.graphql @@ -0,0 +1,27 @@ +query Search($query: String!) { + elements(search: $query) { + id + name + icon + location { + id + address + latitude + longitude + } + } + trips(search: $query) { + id + name + icon + description + } + placeSearch(query: $query, granularity: REGIONS) { + placeId + name + address + latitude + longitude + types + } +} diff --git a/src/map/MapScreen.tsx b/src/map/MapScreen.tsx index f1d554f..8bc99c9 100644 --- a/src/map/MapScreen.tsx +++ b/src/map/MapScreen.tsx @@ -19,12 +19,27 @@ import { } from '../graphql/__generated__/types'; import {ElementDetailModal} from './ElementDetailModal'; import {ElementPreviewCard} from './ElementPreviewCard'; +import {zoomForPlaceTypes} from './placeZoom'; +import { + type SearchElement, + SearchOverlay, + type SearchPlace, + type SearchTrip, +} from './SearchOverlay'; +import {TripFilterChip} from './TripFilterChip'; import {type Viewport, viewportStore} from './viewportStore'; type ElementWithLocation = ElementsQuery['elements'][number]; const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty'; const USER_ZOOM = 14; +// Zoom used when flying to a searched element, and for single-element trips +// where there is no extent to fit. +const ELEMENT_ZOOM = 15; +const TRIP_SINGLE_ZOOM = 13; +// Inset (points) kept around a trip's elements when fitting the camera so pins +// aren't flush against the screen edges or hidden under the overlays. +const FIT_PADDING = {top: 120, right: 60, bottom: 120, left: 60}; const DEFAULT_INITIAL_VIEW = {center: [0, 20] as [number, number], zoom: 1}; // Show the recenter button once the viewport center drifts more than this // fraction of the visible span away from the user in either axis. @@ -43,10 +58,21 @@ export function MapScreen() { const [savedViewport, setSavedViewport] = useState< Viewport | null | undefined >(undefined); + // The element selected on the map (shows the bottom preview card). Separate + // from the element whose full-detail modal is open, so a located element can + // fall back to its preview card after the modal closes while a location-less + // one (which has no map presence) returns straight to the plain map. const [selectedElementId, setSelectedElementId] = useState( null, ); - const [detailExpanded, setDetailExpanded] = useState(false); + const [detailElementId, setDetailElementId] = useState(null); + // When set, the map is filtered to a single trip: only that trip's elements + // are shown and the bounds-based query is paused. + const [tripFilter, setTripFilter] = useState<{ + id: string; + name: string; + icon: string; + } | null>(null); const safeAreaInsets = useSafeAreaInsets(); useEffect(() => { @@ -148,25 +174,30 @@ export function MapScreen() { return offLng <= OFF_CENTER_THRESHOLD && offLat <= OFF_CENTER_THRESHOLD; }, [position, viewportState]); - const {data} = useElementsQuery({ - skip: !bounds, - variables: bounds ? {bounds} : undefined, - }); + // One query, two modes: when a trip filter is active fetch that trip's + // elements; otherwise fetch by viewport bounds. Reusing the single hook keeps + // the element shape (and Apollo cache) identical across modes. + const {data, loading: elementsLoading} = useElementsQuery( + tripFilter + ? {variables: {tripId: tripFilter.id}} + : {skip: !bounds, variables: bounds ? {bounds} : undefined}, + ); // Accumulate elements across viewport fetches so pins persist while a new // search is in flight. Fine for our small per-user dataset; revisit if we - // ever need eviction or to reflect server-side deletes. + // ever need eviction or to reflect server-side deletes. Skip while filtering + // by trip so trip-only results don't leak into the normal viewport view. const [elementsById, setElementsById] = useState< ReadonlyMap >(new Map()); useEffect(() => { - if (!data?.elements) return; + if (tripFilter || !data?.elements) return; setElementsById(prev => { const next = new Map(prev); for (const el of data.elements) next.set(el.id, el); return next; }); - }, [data?.elements]); + }, [data?.elements, tripFilter]); // Only render markers whose location falls inside the last-known viewport. // is a native View — rendering offscreen ones still costs layout @@ -181,6 +212,98 @@ export function MapScreen() { }); }, [elementsById, bounds]); + // Elements with a location for the active trip; drives both the markers and + // the camera fit while filtering. + const tripElements = useMemo( + () => + tripFilter + ? (data?.elements ?? []).filter(el => el.location != null) + : [], + [tripFilter, data?.elements], + ); + + // In trip mode render the whole trip (no viewport cull) so panning around it + // doesn't drop pins; otherwise use the bounds-culled accumulated set. + const displayedElements = tripFilter ? tripElements : visibleElements; + + // Fit the camera to the trip's extent once its elements arrive. Guarded by a + // ref so panning/zooming afterwards doesn't snap back, and reset when the + // filter clears so re-selecting the same trip fits again. + const fittedTripRef = useRef(null); + useEffect(() => { + if (!tripFilter) { + fittedTripRef.current = null; + return; + } + if (fittedTripRef.current === tripFilter.id || elementsLoading) return; + if (tripElements.length === 0) return; + fittedTripRef.current = tripFilter.id; + + let west = Infinity; + let south = Infinity; + let east = -Infinity; + let north = -Infinity; + for (const el of tripElements) { + if (!el.location) continue; + const {longitude: lng, latitude: lat} = el.location; + if (lng < west) west = lng; + if (lng > east) east = lng; + if (lat < south) south = lat; + if (lat > north) north = lat; + } + if (west === east && south === north) { + cameraRef.current?.flyTo({ + center: [west, south], + zoom: TRIP_SINGLE_ZOOM, + duration: 1200, + }); + } else { + cameraRef.current?.fitBounds([west, south, east, north], { + padding: FIT_PADDING, + duration: 1200, + }); + } + }, [tripFilter, tripElements, elementsLoading]); + + const handleSelectElement = useCallback((element: SearchElement) => { + setTripFilter(null); + if (element.location) { + const {longitude, latitude} = element.location; + // Seed the marker set so the pin is present immediately, before the + // bounds query around the new center returns. + setElementsById(prev => { + const next = new Map(prev); + next.set(element.id, element); + return next; + }); + setSelectedElementId(element.id); + cameraRef.current?.flyTo({ + center: [longitude, latitude], + zoom: ELEMENT_ZOOM, + duration: 1200, + }); + } else { + // No coordinates to fly to — a preview card pinned over an unrelated map + // view would be misleading, so go straight to the full details. + setSelectedElementId(null); + setDetailElementId(element.id); + } + }, []); + + const handleSelectTrip = useCallback((trip: SearchTrip) => { + setSelectedElementId(null); + setTripFilter({id: trip.id, name: trip.name, icon: trip.icon}); + }, []); + + const handleSelectPlace = useCallback((place: SearchPlace) => { + setTripFilter(null); + cameraRef.current?.flyTo({ + center: [place.longitude, place.latitude], + zoom: zoomForPlaceTypes(place.types), + duration: 1200, + }); + }, []); + // Brief blank frame while the saved viewport hydrates from storage; the // camera's initialViewState is set once, so we wait for the resolved value // rather than rendering the map at the default world view first. @@ -201,7 +324,7 @@ export function MapScreen() { onRegionDidChange={onRegionDidChange}> - {visibleElements.map(el => + {displayedElements.map(el => el.location ? ( setSelectedElementId(null)} - onExpand={() => setDetailExpanded(true)} + onExpand={() => setDetailElementId(selectedElementId)} /> ) : null} + {tripFilter ? ( + setTripFilter(null)} + /> + ) : null} + setDetailExpanded(false)} + elementId={detailElementId} + onClose={() => setDetailElementId(null)} /> ); diff --git a/src/map/SearchOverlay.tsx b/src/map/SearchOverlay.tsx new file mode 100644 index 0000000..1b0eb92 --- /dev/null +++ b/src/map/SearchOverlay.tsx @@ -0,0 +1,426 @@ +import {useCallback, useRef, useState} from 'react'; +import { + ActivityIndicator, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { + type SearchQuery, + useSearchLazyQuery, +} from '../graphql/__generated__/types'; + +export type SearchElement = SearchQuery['elements'][number]; +export type SearchTrip = SearchQuery['trips'][number]; +export type SearchPlace = SearchQuery['placeSearch'][number]; + +type Props = { + /** Distance from the top of the screen (safe-area inset + margin). */ + topOffset: number; + onSelectElement: (element: SearchElement) => void; + onSelectTrip: (trip: SearchTrip) => void; + onSelectPlace: (place: SearchPlace) => void; +}; + +/** + * Map search control. Collapsed to a single icon button; tapping it expands an + * input field. Search is explicit (runs on submit, not per keystroke) and + * queries elements, trips, and places in one request. Results are shown in + * grouped sections, each type rendered distinctly. + */ +export function SearchOverlay({ + topOffset, + onSelectElement, + onSelectTrip, + onSelectPlace, +}: Props) { + const [expanded, setExpanded] = useState(false); + const [text, setText] = useState(''); + const [submitted, setSubmitted] = useState(false); + const inputRef = useRef(null); + const [runSearch, {data, loading}] = useSearchLazyQuery({ + fetchPolicy: 'network-only', + }); + + // Collapse back to the icon but keep the query text and results, so reopening + // shows what was last searched and lets the user edit it. + const collapse = useCallback(() => setExpanded(false), []); + + // The field's × empties the query for a fresh search (vs. collapse, which + // dismisses but preserves it). + const clear = useCallback(() => { + setText(''); + setSubmitted(false); + inputRef.current?.focus(); + }, []); + + const submit = useCallback(() => { + const query = text.trim(); + if (!query) return; + setSubmitted(true); + runSearch({variables: {query}}); + }, [text, runSearch]); + + if (!expanded) { + return ( + setExpanded(true)} + style={({pressed}) => [ + styles.iconButton, + {top: topOffset}, + pressed && styles.pressed, + ]}> + + + ); + } + + const elements = data?.elements ?? []; + const trips = data?.trips ?? []; + const places = data?.placeSearch ?? []; + const hasResults = + elements.length > 0 || trips.length > 0 || places.length > 0; + + return ( + + {/* Tap anywhere outside the field/results to dismiss and return to the + map, keeping the query for next time. */} + + + + + + {text ? ( + + × + + ) : null} + + + {submitted ? ( + + {loading ? ( + + + + ) : hasResults ? ( + + {elements.length > 0 ? ( +
+ {elements.map(el => ( + { + onSelectElement(el); + collapse(); + }} + /> + ))} +
+ ) : null} + + {trips.length > 0 ? ( +
+ {trips.map(trip => ( + { + onSelectTrip(trip); + collapse(); + }} + /> + ))} +
+ ) : null} + + {places.length > 0 ? ( +
+ {places.map(place => ( + { + onSelectPlace(place); + collapse(); + }} + /> + ))} +
+ ) : null} +
+ ) : ( + + No results + + )} +
+ ) : null} +
+
+ ); +} + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + {title} + {children} + + ); +} + +function ResultRow({ + glyph, + glyphStyle, + title, + subtitle, + badge, + accessibilityLabel, + onPress, +}: { + glyph: string; + glyphStyle: object; + title: string; + subtitle?: string; + badge?: string; + accessibilityLabel: string; + onPress: () => void; +}) { + return ( + [styles.row, pressed && styles.rowPressed]}> + + {glyph} + + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + {badge ? ( + + {badge} + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + iconButton: { + position: 'absolute', + left: 16, + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#ffffff', + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOpacity: 0.2, + shadowRadius: 4, + shadowOffset: {width: 0, height: 2}, + elevation: 4, + }, + pressed: {opacity: 0.8}, + searchGlyph: { + fontSize: 22, + lineHeight: 26, + color: '#1d6fe0', + }, + expandedRoot: { + position: 'absolute', + left: 16, + // Leave room for the account avatar pinned at the top-right. + right: 64, + }, + fieldRow: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#ffffff', + borderRadius: 22, + paddingLeft: 14, + paddingRight: 6, + height: 44, + shadowColor: '#000', + shadowOpacity: 0.2, + shadowRadius: 4, + shadowOffset: {width: 0, height: 2}, + elevation: 4, + }, + fieldGlyph: { + fontSize: 20, + lineHeight: 24, + color: '#888', + marginRight: 8, + }, + input: { + flex: 1, + fontSize: 15, + color: '#111', + padding: 0, + }, + fieldClose: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#f1f1f1', + }, + fieldCloseIcon: { + fontSize: 18, + lineHeight: 20, + color: '#444', + }, + resultsCard: { + marginTop: 8, + backgroundColor: '#ffffff', + borderRadius: 12, + shadowColor: '#000', + shadowOpacity: 0.15, + shadowRadius: 8, + shadowOffset: {width: 0, height: 2}, + elevation: 6, + overflow: 'hidden', + }, + resultsScroll: { + maxHeight: 360, + }, + statusPane: { + paddingVertical: 24, + alignItems: 'center', + justifyContent: 'center', + }, + emptyText: { + fontSize: 14, + color: '#888', + }, + section: { + paddingTop: 10, + paddingBottom: 4, + }, + sectionTitle: { + fontSize: 11, + fontWeight: '700', + color: '#888', + textTransform: 'uppercase', + letterSpacing: 0.5, + paddingHorizontal: 14, + marginBottom: 4, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 8, + }, + rowPressed: { + backgroundColor: '#f4f7fc', + }, + glyphWrap: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + elementGlyphWrap: { + backgroundColor: '#1d6fe0', + }, + tripGlyphWrap: { + backgroundColor: '#7a3ff2', + }, + placeGlyphWrap: { + backgroundColor: '#e9eef6', + }, + glyph: { + fontSize: 18, + lineHeight: 22, + textAlign: 'center', + }, + rowBody: { + flex: 1, + marginRight: 8, + }, + rowTitle: { + fontSize: 15, + fontWeight: '600', + color: '#111', + }, + rowSubtitle: { + fontSize: 12, + color: '#666', + marginTop: 2, + }, + badge: { + backgroundColor: '#efe9fd', + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 10, + }, + badgeText: { + color: '#7a3ff2', + fontSize: 11, + fontWeight: '600', + }, +}); diff --git a/src/map/TripFilterChip.tsx b/src/map/TripFilterChip.tsx new file mode 100644 index 0000000..04b78bc --- /dev/null +++ b/src/map/TripFilterChip.tsx @@ -0,0 +1,84 @@ +import {Pressable, StyleSheet, Text, View} from 'react-native'; + +type Props = { + /** Trip emoji icon (may be empty). */ + icon: string; + name: string; + topOffset: number; + onClear: () => void; +}; + +/** + * Floating pill shown while the map is filtered to a single trip. Indicates the + * active filter and offers an `×` to clear it (restoring normal bounds-based + * element loading). + */ +export function TripFilterChip({icon, name, topOffset, onClear}: Props) { + return ( + + + {icon ? {icon} : null} + + {name} + + + × + + + + ); +} + +const styles = StyleSheet.create({ + wrap: { + position: 'absolute', + left: 0, + right: 0, + alignItems: 'center', + }, + chip: { + flexDirection: 'row', + alignItems: 'center', + maxWidth: '80%', + backgroundColor: '#1d6fe0', + borderRadius: 20, + paddingLeft: 14, + paddingRight: 6, + paddingVertical: 6, + shadowColor: '#000', + shadowOpacity: 0.2, + shadowRadius: 4, + shadowOffset: {width: 0, height: 2}, + elevation: 4, + }, + icon: { + fontSize: 15, + lineHeight: 18, + marginRight: 6, + }, + label: { + flexShrink: 1, + color: '#ffffff', + fontSize: 14, + fontWeight: '600', + }, + clearButton: { + width: 24, + height: 24, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + marginLeft: 8, + backgroundColor: 'rgba(255,255,255,0.25)', + }, + clearIcon: { + color: '#ffffff', + fontSize: 16, + lineHeight: 18, + }, +}); diff --git a/src/map/placeZoom.ts b/src/map/placeZoom.ts new file mode 100644 index 0000000..6ed9b78 --- /dev/null +++ b/src/map/placeZoom.ts @@ -0,0 +1,23 @@ +// Map a Google place's `types` to a camera zoom level so that a country zooms +// out wide and a city zooms in closer. Places come back from `placeSearch` with +// REGIONS granularity, so the relevant types are country / admin areas / +// localities. First match wins; anything unrecognized gets a middle zoom. +const ZOOM_BY_TYPE: ReadonlyArray<[string, number]> = [ + ['country', 4], + ['administrative_area_level_1', 6], + ['administrative_area_level_2', 8], + ['locality', 11], + ['postal_town', 11], +]; + +const DEFAULT_ZOOM = 9; + +export function zoomForPlaceTypes( + types: ReadonlyArray | null | undefined, +): number { + if (!types) return DEFAULT_ZOOM; + for (const [type, zoom] of ZOOM_BY_TYPE) { + if (types.includes(type)) return zoom; + } + return DEFAULT_ZOOM; +}