Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {PreferencesProvider, usePreferences} from 'Preferences';
import {NotFoundError} from 'types/requests';
import {formatRequestedTime, RequestedTime} from 'utils/date';

import Mapbox from '@rnmapbox/maps';
import {Integration} from '@sentry/types';
import {TRACE} from 'browser-bunyan';
import * as messages from 'compiled-lang/en.json';
Expand All @@ -66,6 +67,10 @@ import {ZodError} from 'zod';

logger.info('App starting.');

Mapbox.setAccessToken(Constants.expoConfig?.extra?.mapboxAPIKey as string).catch((error: Error) => {
logger.error('Failed to initialize mapbox with error: ', error);
});

if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
logger.info('enabling android layout animations');
UIManager.setLayoutAnimationEnabledExperimental(true);
Expand Down
67 changes: 28 additions & 39 deletions components/AvalancheForecastZoneMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, {useCallback, useRef, useState} from 'react';

import {useFocusEffect, useNavigation} from '@react-navigation/native';
import {View as RNView, StyleSheet, Text, TouchableOpacity, useWindowDimensions} from 'react-native';
import AnimatedMapView, {ClickEvent, PoiClickEvent, Region} from 'react-native-maps';
import {Region} from 'react-native-maps';

import {useBottomTabBarHeight} from '@react-navigation/bottom-tabs';
import {AvalancheDangerIcon} from 'components/AvalancheDangerIcon';
Expand All @@ -12,7 +12,6 @@ import {defaultMapRegionForGeometries, MapViewZone, mapViewZoneFor, ZoneMap} fro
import {Center, HStack, View, VStack} from 'components/core';
import {FocusAwareStatusBar} from 'components/core/FocusAwareStatusBar';
import {DangerScale} from 'components/DangerScale';
import {pointInFeature} from 'components/helpers/geographicCoordinates';
import {TravelAdvice} from 'components/helpers/travelAdvice';
import {AnimatedCards, AnimatedDrawerState, AnimatedMapWithDrawerController, CARD_MARGIN, CARD_WIDTH} from 'components/map/AnimatedCards';
import {AvalancheCenterSelectionModal} from 'components/modals/AvalancheCenterSelectionModal';
Expand All @@ -31,6 +30,9 @@ import {HomeStackNavigationProps, TabNavigationProps} from 'routes';
import {AvalancheCenterID, DangerLevel, ForecastPeriod, MapLayerFeature, ProductType} from 'types/nationalAvalancheCenter';
import {formatRequestedTime, RequestedTime, requestedTimeToUTCDate, utcDateToLocalTimeString} from 'utils/date';

import {Camera, CameraBounds} from '@rnmapbox/maps';
import {Position} from '@turf/helpers';

export interface MapProps {
center: AvalancheCenterID;
requestedTime: RequestedTime;
Expand Down Expand Up @@ -60,22 +62,16 @@ export const AvalancheForecastZoneMap: React.FunctionComponent<MapProps> = ({cen

const navigation = useNavigation<HomeStackNavigationProps & TabNavigationProps>();
const [selectedZoneId, setSelectedZoneId] = useState<number | null>(null);
const onPressMapView = useCallback(
(event: ClickEvent) => {
// we seem to now get this event even if we're *also* getting a polygon press
// event, so we simply ignore the map press if it's inside a region
if (event.nativeEvent.coordinate && mapLayer && mapLayer.features) {
for (const feature of mapLayer.features) {
if (pointInFeature(event.nativeEvent.coordinate, feature)) {
return;
}
}
}

const onMapPress = useCallback(
(_: GeoJSON.Feature) => {
// Since the polygons are layered on the map, this is only called when the map is tapped outside of a polygon
setSelectedZoneId(null);
},
[mapLayer],
[setSelectedZoneId],
);
const onPressPolygon = useCallback(

const onPolygonPress = useCallback(
(zone: MapViewZone) => {
if (selectedZoneId === zone.zone_id) {
navigation.navigate('forecast', {
Expand All @@ -89,27 +85,15 @@ export const AvalancheForecastZoneMap: React.FunctionComponent<MapProps> = ({cen
},
[navigation, selectedZoneId, requestedTime, setSelectedZoneId],
);
// On the Android version of the Google Map layer, when a user taps on a map label (a place name, etc),
// we get a POI click event; we don't get to see the specific point that they tapped on, only the centroid
// of the POI itself. It doesn't look like we can turn off interactivity with POIs without hiding them entirely,
// so it seems like the best UX we can provide is to act as if the user tapped in the center of the POI itself
// and open the forecast zone for that point. When POI labels span multiple zones, that doesn't work perfectly.
const onPressPOI = useCallback(
(event: PoiClickEvent) => {
const matchingZones = event.nativeEvent.coordinate ? mapLayer?.features.filter(feature => pointInFeature(event.nativeEvent.coordinate, feature)) : [];
if (matchingZones && matchingZones.length > 0) {
onPressPolygon(mapViewZoneFor(center, matchingZones[0]));
}
},
[mapLayer?.features, onPressPolygon, center],
);

const avalancheCenterMapRegion: Region = defaultMapRegionForGeometries(mapLayer?.features.map(feature => feature.geometry));

// useRef has to be used here. Animation and gesture handlers can't use props and state,
// and aren't re-evaluated on render. Fun!
const mapView = useRef<AnimatedMapView>(null);
const controller = useRef<AnimatedMapWithDrawerController>(new AnimatedMapWithDrawerController(AnimatedDrawerState.Hidden, avalancheCenterMapRegion, mapView, logger)).current;
const mapCameraRef = useRef<Camera>(null);
const controller = useRef<AnimatedMapWithDrawerController>(
new AnimatedMapWithDrawerController(AnimatedDrawerState.Hidden, avalancheCenterMapRegion, mapCameraRef, logger),
).current;
React.useEffect(() => {
controller.animateUsingUpdatedAvalancheCenterMapRegion(avalancheCenterMapRegion);
}, [avalancheCenterMapRegion, controller]);
Expand Down Expand Up @@ -246,22 +230,27 @@ export const AvalancheForecastZoneMap: React.FunctionComponent<MapProps> = ({cen
});
const zones = Object.keys(zonesById).map(k => zonesById[k]);
const showAvalancheCenterSelectionModal = !preferences.hasSeenCenterPicker;
const nePosition: Position = [
avalancheCenterMapRegion.longitude + avalancheCenterMapRegion.longitudeDelta / 2,
avalancheCenterMapRegion.latitude + avalancheCenterMapRegion.latitudeDelta / 2,
];
const swPosition: Position = [
avalancheCenterMapRegion.longitude - avalancheCenterMapRegion.longitudeDelta / 2,
avalancheCenterMapRegion.latitude - avalancheCenterMapRegion.latitudeDelta / 2,
];
const initialCameraBounds: CameraBounds = {ne: nePosition, sw: swPosition};

return (
<>
<FocusAwareStatusBar barStyle="light-content" translucent backgroundColor={'rgba(0, 0, 0, 0.35)'} />
<ZoneMap
ref={mapView}
animated
ref={mapCameraRef}
style={StyleSheet.absoluteFillObject}
zoomEnabled={true}
scrollEnabled={true}
initialRegion={avalancheCenterMapRegion}
onPress={onPressMapView}
initialCameraBounds={initialCameraBounds}
zones={zones}
selectedZoneId={selectedZoneId}
onPressPolygon={onPressPolygon}
onPoiClick={onPressPOI}
onPolygonPress={onPolygonPress}
onMapPress={onMapPress}
/>
<SafeAreaView>
<View>
Expand Down
73 changes: 51 additions & 22 deletions components/content/ZoneMap.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import React from 'react';

import Constants, {AppOwnership} from 'expo-constants';
import MapView, {MAP_TYPES, MapViewProps, PoiClickEvent, Region} from 'react-native-maps';
import {Region} from 'react-native-maps';

import {RegionBounds, regionFromBounds, updateBoundsToContain} from 'components/helpers/geographicCoordinates';
import {AvalancheForecastZonePolygon, toLatLngList} from 'components/map/AvalancheForecastZonePolygon';
import {useToggle} from 'hooks/useToggle';
import {AvalancheForecastZonePolygon, SelectedAvalancheForecastZonePolygon, toLatLngList} from 'components/map/AvalancheForecastZonePolygon';
import {AvalancheCenterID, DangerLevel, Geometry, MapLayerFeature} from 'types/nationalAvalancheCenter';

import Mapbox, {Camera, CameraBounds, MapView} from '@rnmapbox/maps';
import {ViewProps} from 'react-native';

const defaultAvalancheCenterMapRegionBounds: RegionBounds = {
topLeft: {latitude: 0, longitude: 0},
bottomRight: {latitude: 0, longitude: 0},
Expand Down Expand Up @@ -39,30 +40,58 @@ export type MapViewZone = {
hasWarning: boolean;
};

interface ZoneMapProps extends MapViewProps {
animated: boolean;
interface ZoneMapProps extends ViewProps {
zones: MapViewZone[];
initialCameraBounds: CameraBounds;
onPolygonPress: (zone: MapViewZone) => void;
selectedZoneId?: number | null;
renderFillColor?: boolean;
onPressPolygon: (zone: MapViewZone) => void;
onPoiClick?: (event: PoiClickEvent) => void;
pitchEnabled?: boolean;
rotateEnabled?: boolean;
scrollEnabled?: boolean;
zoomEnabled?: boolean;
onMapPress?: (feature: GeoJSON.Feature) => void;
}

export const ZoneMap = React.forwardRef<MapView, ZoneMapProps>(({animated, zones, selectedZoneId, onPressPolygon, renderFillColor = true, children, ...props}, ref) => {
const [ready, {on: setReady}] = useToggle(false);
const MapComponent = animated ? MapView.Animated : MapView;
const isRunningInExpoGo = Constants.appOwnership === AppOwnership.Expo;

return (
<MapComponent ref={ref} onLayout={setReady} provider={isRunningInExpoGo ? undefined : 'google'} mapType={MAP_TYPES.TERRAIN} {...props}>
{ready &&
zones?.map(zone => (
<AvalancheForecastZonePolygon key={zone.zone_id} zone={zone} selected={selectedZoneId === zone.zone_id} renderFillColor={renderFillColor} onPress={onPressPolygon} />
export const ZoneMap = React.forwardRef<Camera, ZoneMapProps>(
(
{
zones,
selectedZoneId,
initialCameraBounds,
onPolygonPress,
renderFillColor = true,
pitchEnabled = true,
rotateEnabled = true,
scrollEnabled = true,
zoomEnabled = true,
onMapPress = undefined,
children,
...props
},
cameraRef,
) => {
return (
<MapView
styleURL={Mapbox.StyleURL.Outdoors}
scaleBarEnabled={false}
zoomEnabled={zoomEnabled}
pitchEnabled={pitchEnabled}
rotateEnabled={rotateEnabled}
scrollEnabled={scrollEnabled}
onPress={onMapPress}
{...props}>
<Camera ref={cameraRef} defaultSettings={{bounds: initialCameraBounds}} />
{zones?.map(zone => (
<AvalancheForecastZonePolygon key={`${zone.zone_id}-polygon`} zone={zone} renderFillColor={renderFillColor} onPress={onPolygonPress} />
))}
{children}
</MapComponent>
);
});
{selectedZoneId &&
zones?.filter(zone => zone.zone_id === selectedZoneId).map(zone => <SelectedAvalancheForecastZonePolygon key={`${zone.zone_id}-selectedPolygon`} zone={zone} />)}
{children}
</MapView>
);
},
);
ZoneMap.displayName = 'ZoneMap';

export function defaultMapRegionForZones(zones: MapViewZone[]) {
Expand Down
75 changes: 33 additions & 42 deletions components/form/LocationField.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import {AntDesign, FontAwesome} from '@expo/vector-icons';
import {Camera, CameraBounds} from '@rnmapbox/maps';
import {Position} from '@turf/helpers';
import {QueryState, incompleteQueryState} from 'components/content/QueryState';
import {MapViewZone, ZoneMap, defaultMapRegionForGeometries, defaultMapRegionForZones} from 'components/content/ZoneMap';
import {Center, HStack, VStack, View} from 'components/core';
import {KeysMatching} from 'components/form/TextField';
import {AnimatedMapMarker} from 'components/map/AnimatedMapMarker';
import {LocationPoint, ObservationFormData} from 'components/observations/ObservationFormData';
import {Body, BodySmBlack, BodyXSm, Title3Black, bodySize} from 'components/text';
import {useMapLayer} from 'hooks/useMapLayer';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useController} from 'react-hook-form';
import {Modal, PanResponder, View as RNView, TouchableOpacity} from 'react-native';
import MapView, {LatLng, MapMarker, Region} from 'react-native-maps';
import {Modal, View as RNView, TouchableOpacity} from 'react-native';
import {Region} from 'react-native-maps';
import {SafeAreaProvider, SafeAreaView} from 'react-native-safe-area-context';
import {colorLookup} from 'theme';
import {AvalancheCenterID} from 'types/nationalAvalancheCenter';
Expand All @@ -20,12 +23,8 @@ interface LocationFieldProps {
center: AvalancheCenterID;
disabled?: boolean;
}

const latLngToLocationPoint = (latLng: LatLng) => ({lat: latLng.latitude, lng: latLng.longitude});
const locationPointToLatLng = (locationPoint: LocationPoint) => ({
latitude: locationPoint.lat,
longitude: locationPoint.lng,
});
const positionToLocationPoint = (position: Position) => ({lat: position[1], lng: position[0]});
const locationPointToPosition = (locationPoint: LocationPoint) => [locationPoint.lng, locationPoint.lat];

export const LocationField = React.forwardRef<RNView, LocationFieldProps>(({name, label, center, disabled}, ref) => {
const {
Expand Down Expand Up @@ -89,27 +88,16 @@ const LocationMap: React.FunctionComponent<LocationMapProps> = ({center, modalVi
const [initialRegion, setInitialRegion] = useState<Region>(defaultMapRegionForZones([]));
const [selectedLocation, setSelectedLocation] = useState<LocationPoint | undefined>(initialLocation);
const [mapReady, setMapReady] = useState<boolean>(false);
const mapRef = useRef<MapView>(null);

const mapPanResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderRelease: (event, gestureState) => {
if (gestureState.dx != 0 && gestureState.dy != 0) {
return;
}

void (async () => {
const point = {x: event.nativeEvent.locationX, y: event.nativeEvent.locationY};
const coordinate = await mapRef.current?.coordinateForPoint(point);
if (coordinate) {
setSelectedLocation(latLngToLocationPoint(coordinate));
}
})();
},
}),
).current;
const mapCameraRef = useRef<Camera>(null);

const onMapPress = useCallback(
(feature: GeoJSON.Feature) => {
if (feature.geometry.type === 'Point') {
setSelectedLocation(positionToLocationPoint(feature.geometry.coordinates));
}
},
[setSelectedLocation],
);

useEffect(() => {
if (mapLayer && !mapReady) {
Expand All @@ -123,7 +111,7 @@ const LocationMap: React.FunctionComponent<LocationMapProps> = ({center, modalVi
setInitialRegion(initialRegion);
setMapReady(true);
}
}, [initialLocation, mapLayer, setInitialRegion, mapReady, setMapReady]);
}, [initialLocation, mapLayer, mapCameraRef, setInitialRegion, mapReady, setMapReady]);

const zones: MapViewZone[] =
mapLayer?.features.map(
Expand All @@ -147,6 +135,10 @@ const LocationMap: React.FunctionComponent<LocationMapProps> = ({center, modalVi
}
}, [selectedLocation, onSelect]);

const nePosition: Position = [initialRegion.longitude + initialRegion.longitudeDelta / 2, initialRegion.latitude + initialRegion.latitudeDelta / 2];
const swPosition: Position = [initialRegion.longitude - initialRegion.longitudeDelta / 2, initialRegion.latitude - initialRegion.latitudeDelta / 2];
const initialCameraBounds: CameraBounds = {ne: nePosition, sw: swPosition};

return (
<Modal visible={modalVisible} onRequestClose={onClose} animationType="slide">
<SafeAreaProvider>
Expand Down Expand Up @@ -177,18 +169,16 @@ const LocationMap: React.FunctionComponent<LocationMapProps> = ({center, modalVi
<Center width="100%" height="100%">
{incompleteQueryState(mapLayerResult) && <QueryState results={[mapLayerResult]} />}
{mapReady && (
<View {...mapPanResponder.panHandlers}>
<ZoneMap
ref={mapRef}
animated={false}
style={{minWidth: '100%', minHeight: '100%'}}
zones={zones}
initialRegion={initialRegion}
onPressPolygon={emptyHandler}
renderFillColor={false}>
{selectedLocation != null && <MapMarker coordinate={locationPointToLatLng(selectedLocation)} />}
</ZoneMap>
</View>
<ZoneMap
ref={mapCameraRef}
style={{minWidth: '100%', minHeight: '100%'}}
zones={zones}
initialCameraBounds={initialCameraBounds}
onPolygonPress={emptyHandler}
onMapPress={onMapPress}
renderFillColor={false}>
{selectedLocation && <AnimatedMapMarker id="obs-location-marker" coordinate={locationPointToPosition(selectedLocation)} />}
</ZoneMap>
)}
</Center>
</VStack>
Expand All @@ -197,4 +187,5 @@ const LocationMap: React.FunctionComponent<LocationMapProps> = ({center, modalVi
</Modal>
);
};

LocationField.displayName = 'LocationField';
Loading