From 8a3079c2114ef2e2f639894fe526ec0d580cb71d Mon Sep 17 00:00:00 2001 From: Dennis Falling Date: Sun, 24 May 2026 23:27:46 +0100 Subject: [PATCH] Fetch and render trip elements on the map Adds an Elements GraphQL query bound to the visible map viewport and renders each element with a location as an emoji-icon pin. The query is gated on the initial fly-to so we don't fetch the whole world at the default zoom-1 framing, and re-runs as the user pans/zooms via onRegionDidChange. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/graphql/__generated__/types.ts | 57 ++++++++++++++++++++++++++ src/graphql/queries/elements.graphql | 12 ++++++ src/map/MapScreen.tsx | 61 ++++++++++++++++++++++++++-- 3 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 src/graphql/queries/elements.graphql diff --git a/src/graphql/__generated__/types.ts b/src/graphql/__generated__/types.ts index d550993..bb5f6a3 100644 --- a/src/graphql/__generated__/types.ts +++ b/src/graphql/__generated__/types.ts @@ -536,6 +536,13 @@ export type User = { locale?: Maybe; }; +export type ElementsQueryVariables = Exact<{ + bounds?: InputMaybe; +}>; + + +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; }>; @@ -558,6 +565,56 @@ 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 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 LoginDocument = gql` mutation Login($input: LoginInput!) { login(input: $input) { diff --git a/src/graphql/queries/elements.graphql b/src/graphql/queries/elements.graphql new file mode 100644 index 0000000..96e91b0 --- /dev/null +++ b/src/graphql/queries/elements.graphql @@ -0,0 +1,12 @@ +query Elements($bounds: GeoBounds) { + elements(bounds: $bounds) { + id + name + icon + location { + id + latitude + longitude + } + } +} diff --git a/src/map/MapScreen.tsx b/src/map/MapScreen.tsx index d6e1160..66aeb1a 100644 --- a/src/map/MapScreen.tsx +++ b/src/map/MapScreen.tsx @@ -3,11 +3,18 @@ import { type CameraRef, LocationManager, Map as MapLibreMap, + Marker, UserLocation, useCurrentPosition, + type ViewStateChangeEvent, } from '@maplibre/maplibre-react-native'; -import {useEffect, useRef, useState} from 'react'; -import {StyleSheet, View} from 'react-native'; +import {useCallback, useEffect, useRef, useState} from 'react'; +import type {NativeSyntheticEvent} from 'react-native'; +import {StyleSheet, Text, View} from 'react-native'; +import { + type ElementsQueryVariables, + useElementsQuery, +} from '../graphql/__generated__/types'; const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty'; const USER_ZOOM = 14; @@ -42,11 +49,44 @@ export function MapScreen() { }); }, [position]); + const [bounds, setBounds] = useState(); + + const onRegionDidChange = useCallback( + (event: NativeSyntheticEvent) => { + // Skip viewport events until we've flown to the user's location, so + // we don't fetch the entire world at the initial zoom-1 framing. + if (!hasCenteredRef.current) return; + const [west, south, east, north] = event.nativeEvent.bounds; + setBounds({left: west, bottom: south, right: east, top: north}); + }, + [], + ); + + const {data} = useElementsQuery({ + skip: !bounds, + variables: bounds ? {bounds} : undefined, + }); + return ( - + + {data?.elements.map(el => + el.location ? ( + + + {el.icon ? {el.icon} : null} + + + ) : null, + )} ); @@ -59,4 +99,19 @@ const styles = StyleSheet.create({ map: { flex: 1, }, + pin: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: '#1d6fe0', + borderWidth: 2, + borderColor: '#ffffff', + alignItems: 'center', + justifyContent: 'center', + }, + pinIcon: { + fontSize: 18, + lineHeight: 22, + textAlign: 'center', + }, });