Skip to content
Draft
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
1 change: 1 addition & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ EXPO_PUBLIC_SENTRY_DSN=
#
ANDROID_GOOGLE_MAPS_API_KEY=
IOS_GOOGLE_MAPS_API_KEY=
MAPBOX_API_KEY=

#
# Set this to a value to enable Sentry in development mode. Off by default.
Expand Down
5 changes: 1 addition & 4 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,4 +1 @@
yarn ci:prettier
yarn ci:eslint
yarn ci:tsc
yarn test

1 change: 1 addition & 0 deletions app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default ({config}: ConfigContext): Partial<ExpoConfig> => {
config.ios!.config!.googleMapsApiKey = process.env.IOS_GOOGLE_MAPS_API_KEY as string;
config.android!.config!.googleMaps!.apiKey = process.env.ANDROID_GOOGLE_MAPS_API_KEY as string;
config.extra!.log_level = process.env.LOG_LEVEL != null ? (process.env.LOG_LEVEL as string) : 'info';
config.extra!.mapBoxAPIKey = process.env.MAPBOX_API_KEY as string;

if (process.env.APP_VARIANT === 'preview') {
// The iOS App Store requires that the version we publish has been pre-created in the developer portal.
Expand Down
1 change: 1 addition & 0 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
},
"plugins": [
"expo-localization",
"@rnmapbox/maps",
[
"expo-build-properties",
{
Expand Down
4 changes: 2 additions & 2 deletions components/AvalancheForecastZoneMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export const AvalancheForecastZoneMap: React.FunctionComponent<MapProps> = ({cen
// 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 controller = useRef<AnimatedMapWithDrawerController>(new AnimatedMapWithDrawerController(AnimatedDrawerState.Hidden, avalancheCenterMapRegion, logger, mapView)).current;
React.useEffect(() => {
controller.animateUsingUpdatedAvalancheCenterMapRegion(avalancheCenterMapRegion);
}, [avalancheCenterMapRegion, controller]);
Expand Down Expand Up @@ -285,7 +285,7 @@ export const AvalancheForecastZoneMap: React.FunctionComponent<MapProps> = ({cen
);
};

const AvalancheForecastZoneCards: React.FunctionComponent<{
export const AvalancheForecastZoneCards: React.FunctionComponent<{
center_id: AvalancheCenterID;
date: RequestedTime;
zones: MapViewZone[];
Expand Down
14 changes: 11 additions & 3 deletions components/map/AnimatedCards.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {AntDesign} from '@expo/vector-icons';
import {Camera} from '@rnmapbox/maps';
import {Logger} from 'browser-bunyan';
import {HStack, View} from 'components/core';
import {add, isAfter} from 'date-fns';
Expand Down Expand Up @@ -66,11 +67,12 @@
topElementsHeight = 0;
cardDrawerMaximumHeight = 0;
tabBarHeight = 0;
mapView: RefObject<AnimatedMapView>;
mapView?: RefObject<AnimatedMapView> | undefined;
cameraView?: RefObject<Camera> | undefined;
// We store the last time we logged a region calculation so as to continue logging but not spam
lastLogged: Record<string, string>; // mapping hash of parameters to the time we last logged it

constructor(state = AnimatedDrawerState.Docked, region: Region, mapView: RefObject<AnimatedMapView>, logger: Logger) {
constructor(state = AnimatedDrawerState.Docked, region: Region, logger: Logger, mapView?: RefObject<AnimatedMapView> | undefined, cameraView?: RefObject<Camera> | undefined) {
this.logger = logger;
this.state = state;
this.baseOffset = AnimatedMapWithDrawerController.OFFSETS[state];
Expand All @@ -79,6 +81,7 @@
this.buttonYOffset = new Animated.Value(this.baseOffset);
this.baseAvalancheCenterMapRegion = region;
this.mapView = mapView;
this.cameraView = cameraView;
this.lastLogged = {};
}

Expand Down Expand Up @@ -299,6 +302,11 @@
this.lastLogged[parameterHash] = toISOStringUTC(now);
}
this.mapView?.current?.animateToRegion(targetRegion);

const neBound = [targetRegion.longitude + targetRegion.longitudeDelta / 2, targetRegion.latitude + targetRegion.latitudeDelta / 2];
const swBound = [targetRegion.longitude - targetRegion.longitudeDelta / 2, targetRegion.latitude - targetRegion.latitudeDelta / 2];

this.cameraView?.current?.setCamera({bounds: {ne: neBound, sw: swBound}, heading: 0});
}, this.ANIMATION_DEBOUNCE_MS);
}

Expand Down Expand Up @@ -398,7 +406,7 @@
// map correctly updates.
const itemId = getItemId(items[index]);
if (itemId !== selectedItemId) {
logger.debug('handleScroll setting selected item ID');
console.log('handleScroll setting selected item ID');

Check failure on line 409 in components/map/AnimatedCards.tsx

View workflow job for this annotation

GitHub Actions / ci / eslint

Unexpected console statement
setSelectedItemId(itemId);
// Set the previously selected item ID as well to avoid a programmatic scroll
setPreviouslySelectedItemId(itemId);
Expand Down
222 changes: 222 additions & 0 deletions components/map/MapsV2/ForecastMapViewV2.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import {useBottomTabBarHeight} from '@react-navigation/bottom-tabs';
import Mapbox, {Animated, Camera, FillLayer, LineLayer, MapView, ShapeSource} from '@rnmapbox/maps';
import {OnPressEvent} from '@rnmapbox/maps/lib/typescript/src/types/OnPressEvent';
import {colorFor} from 'components/AvalancheDangerTriangle';
import {AvalancheForecastZoneCards} from 'components/AvalancheForecastZoneMap';
import {QueryState, incompleteQueryState} from 'components/content/QueryState';
import {MapViewZone, defaultMapRegionForGeometries, mapViewZoneFor} from 'components/content/ZoneMap';
import {Center, View} from 'components/core';
import {AnimatedDrawerState, AnimatedMapWithDrawerController} from 'components/map/AnimatedCards';
import {isAfter} from 'date-fns';
import {toDate} from 'date-fns-tz';
import Constants from 'expo-constants';
import {useAllMapLayers} from 'hooks/useAllMapLayers';
import {useAvalancheCenterMetadata} from 'hooks/useAvalancheCenterMetadata';
import {useMapLayer} from 'hooks/useMapLayer';
import {useMapLayerAvalancheForecasts} from 'hooks/useMapLayerAvalancheForecasts';
import {useMapLayerAvalancheWarnings} from 'hooks/useMapLayerAvalancheWarnings';
import {LoggerContext, LoggerProps} from 'loggerContext';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useWindowDimensions} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
import {colorLookup} from 'theme';
import {AvalancheCenterID, DangerLevel, ForecastPeriod, MapLayerFeature, ProductType} from 'types/nationalAvalancheCenter';
import {RequestedTime, requestedTimeToUTCDate} from 'utils/date';

Mapbox.setAccessToken(Constants.expoConfig?.extra?.mapBoxAPIKey as string);

Check failure on line 26 in components/map/MapsV2/ForecastMapViewV2.tsx

View workflow job for this annotation

GitHub Actions / ci / eslint

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

export const ForecastMapViewV2: React.FunctionComponent<{requestedTime: RequestedTime}> = ({requestedTime}) => {
const {logger} = React.useContext<LoggerProps>(LoggerContext);
const [center, setCenter] = useState<AvalancheCenterID>('NWAC');
const allMapLayerResult = useAllMapLayers(center);
const mapLayerResult = useMapLayer(center);
const centerMetadataResult = useAvalancheCenterMetadata(center);
const [selectedZoneId, setSelectedZoneId] = useState<number | null>(null);
const mapLayer = mapLayerResult.data;
const allMapLayer = allMapLayerResult.data;
const centerMetadata = centerMetadataResult.data;

const forecastResults = useMapLayerAvalancheForecasts(center, requestedTime, mapLayer, centerMetadata);
const warningResults = useMapLayerAvalancheWarnings(center, requestedTime, mapLayer);

const cameraRef = useRef<Camera>(null);

const initialRegion = defaultMapRegionForGeometries(mapLayer?.features.map(feature => feature.geometry));
const neBound = [initialRegion.longitude + initialRegion.longitudeDelta / 2, initialRegion.latitude + initialRegion.latitudeDelta / 2];
const swBound = [initialRegion.longitude - initialRegion.longitudeDelta / 2, initialRegion.latitude - initialRegion.latitudeDelta / 2];

const controller = useRef<AnimatedMapWithDrawerController>(new AnimatedMapWithDrawerController(AnimatedDrawerState.Hidden, initialRegion, logger, undefined, cameraRef)).current;
React.useEffect(() => {
controller.animateUsingUpdatedAvalancheCenterMapRegion(initialRegion);
}, [initialRegion, controller]);

const {width: windowWidth, height: windowHeight} = useWindowDimensions();
React.useEffect(() => {
controller.animateUsingUpdatedWindowDimensions(windowWidth, windowHeight);
}, [windowWidth, windowHeight, controller]);

const tabBarHeight = useBottomTabBarHeight();
React.useEffect(() => {
controller.animateUsingUpdatedTabBarHeight(tabBarHeight);
}, [tabBarHeight, controller]);

useEffect(() => {
if (allMapLayer) {
const mapFeaturesForCenter = allMapLayer.features.filter(feature => center === (feature.properties['center_id'] as AvalancheCenterID));
if (mapFeaturesForCenter) {
const initialRegion = defaultMapRegionForGeometries(mapFeaturesForCenter.map(feature => feature.geometry));
if (initialRegion.latitude != 0 && initialRegion.longitude != 0) {
const neBound = [initialRegion.longitude + initialRegion.longitudeDelta / 2, initialRegion.latitude + initialRegion.latitudeDelta / 2];
const swBound = [initialRegion.longitude - initialRegion.longitudeDelta / 2, initialRegion.latitude - initialRegion.latitudeDelta / 2];
cameraRef.current?.setCamera({bounds: {ne: neBound, sw: swBound}, heading: 0});
}
}
}
}, [allMapLayer, cameraRef, center]);

const onPress = useCallback(
(event: OnPressEvent) => {
const feature = event.features[0];
const properties = feature.properties;
if (properties) {
const centerId = properties['center_id'] as AvalancheCenterID;
if (center !== centerId) {
setCenter(centerId);
}
}
const id = Number(feature.id);
if (selectedZoneId != id) {
setSelectedZoneId(id);
}
},
[setCenter, setSelectedZoneId, selectedZoneId, center],
);

if (incompleteQueryState(mapLayerResult, centerMetadataResult, ...forecastResults, ...warningResults) || !mapLayer || !centerMetadata) {
return (
<SafeAreaView edges={['top', 'left', 'right']}>
<Center width="100%" height="100%">
<QueryState
results={[mapLayerResult, centerMetadataResult, ...forecastResults, ...warningResults]}
terminal
customMessage={{
notFound: () => ({
headline: 'Missing forecast',
body: 'There may not be a forecast available for today.',
}),
}}
/>
</Center>
</SafeAreaView>
);
}

const outline = colorLookup('gray.700');
const highlight = colorLookup('blue.100');

// default to the values in the map layer, but update it with the forecasts and warnings we've fetched
const zonesById: Record<string, MapViewZone> = mapLayer.features.reduce((accum: Record<string, MapViewZone>, feature: MapLayerFeature) => {
accum[feature.id] = mapViewZoneFor(center, feature);
return accum;
}, {});
forecastResults
.map(result => result.data) // get data from the results
.filter(data => data) // only operate on results that have succeeded
.forEach(forecast => {
forecast &&
forecast.forecast_zone?.forEach(({id}) => {
if (zonesById[id]) {
// the map layer will expose old forecasts with their danger level as appropriate, but the map expects to show a card
// that doesn't divulge the old forecast's rating, travel advice or publication/expiry times, so we clear things out
if (
!zonesById[id].end_date ||
(zonesById[id].end_date &&
isAfter(requestedTimeToUTCDate(requestedTime), toDate(new Date(zonesById[id].end_date || '2000-01-01'), {timeZone: 'UTC'}))) /* requesting after expiry */
) {
zonesById[id].danger_level = DangerLevel.None;
zonesById[id].end_date = null;
zonesById[id].start_date = null;
}
// product-specific queries can give us results that are expired or older than the map layer, in which case we don't
// want to use them
if (
(forecast.product_type === ProductType.Forecast || forecast.product_type === ProductType.Summary) &&
forecast.expires_time &&
zonesById[id].end_date &&
(isAfter(toDate(new Date(forecast.expires_time), {timeZone: 'UTC'}), requestedTimeToUTCDate(requestedTime)) /* product is not expired */ ||
isAfter(
toDate(new Date(forecast.expires_time), {timeZone: 'UTC'}),
toDate(new Date(zonesById[id].end_date || '2000-01-01'), {timeZone: 'UTC'}),
)) /* product newer than map layer */
) {
if (forecast.product_type === ProductType.Forecast) {
const currentDanger = forecast.danger.find(d => d.valid_day === ForecastPeriod.Current);
if (currentDanger) {
zonesById[id].danger_level = Math.max(currentDanger.lower, currentDanger.middle, currentDanger.upper) as DangerLevel;
}
}

// Regardless if the product type is a summary or forecast, we want to use the forecast API timestamp as it has timezone information
zonesById[id].start_date = forecast.published_time;
zonesById[id].end_date = forecast.expires_time;
}
}
});
});
warningResults
.map(result => result.data) // get data from the results
.forEach(warning => {
if (!warning) {
return;
}
// the warnings endpoint can return warnings, watches and special bulletins; we only want to make the map flash
// when there's an active warning for the zone
if (
'product_type' in warning.data &&
warning.data.product_type === ProductType.Warning &&
'expires_time' in warning.data &&
isAfter(toDate(new Date(warning.data.expires_time), {timeZone: 'UTC'}), requestedTimeToUTCDate(requestedTime))
) {
const mapViewZoneData = zonesById[warning.zone_id];
if (mapViewZoneData) {
mapViewZoneData.hasWarning = true;
}
}
});
const zones = Object.keys(zonesById).map(k => zonesById[k]);

return (
<View flex={1}>
<MapView style={{flex: 1}} styleURL={Mapbox.StyleURL.Outdoors} scaleBarEnabled={false}>
<Camera ref={cameraRef} defaultSettings={{bounds: {ne: neBound, sw: swBound}}} />
{allMapLayer &&
allMapLayer.features.map(feature => (
<ShapeSource key={`${feature.id}`} id={`${feature.id}`} shape={feature} onPress={onPress} hitbox={{width: 0, height: 0}}>
<FillLayer id={`${feature.id}-fillLayer`} style={{fillColor: colorFor(feature.properties.danger_level).alpha(feature.properties.fillOpacity).string()}} />
<LineLayer id={`${feature.id}-lineLayer`} style={{lineColor: outline.toString(), lineWidth: 2}} />
</ShapeSource>
))}

{/* Set the whole feature as the selected state to not have to filter this */}
{allMapLayer &&
allMapLayer.features
.filter(feature => feature.id === selectedZoneId)
.map(feature => (
<Animated.ShapeSource key={`${feature.id}+selected`} id={`${feature.id}+selected`} shape={feature} hitbox={{width: 0, height: 0}}>
<LineLayer id={`${feature.id}-lineLayer-selected`} style={{lineColor: highlight.toString(), lineWidth: 4}} />
</Animated.ShapeSource>
))}
</MapView>

<AvalancheForecastZoneCards
key={center}
center_id={center}
date={requestedTime}
zones={zones}
selectedZoneId={selectedZoneId}
setSelectedZoneId={setSelectedZoneId}
controller={controller}
/>
</View>
);
};
7 changes: 7 additions & 0 deletions components/screens/MenuScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {Button} from 'components/content/Button';
import {Card} from 'components/content/Card';
import {incompleteQueryState, QueryState} from 'components/content/QueryState';
import {FeatureFlagsDebuggerScreen} from 'components/FeatureFlagsDebugger';
import {ForecastMapViewV2} from 'components/map/MapsV2/ForecastMapViewV2';
import {ForecastScreen} from 'components/screens/ForecastScreen';
import {MapScreen} from 'components/screens/MapScreen';
import {AboutScreen} from 'components/screens/menu/AboutScreen';
Expand All @@ -49,6 +50,7 @@ import {usePostHog} from 'posthog-react-native';
import {usePreferences} from 'Preferences';
import {colorLookup} from 'theme';
import {AvalancheCenterID, userFacingCenterId} from 'types/nationalAvalancheCenter';
import {parseRequestedTimeString} from 'utils/date';

const MenuStack = createNativeStackNavigator<MenuStackParamList>();
export const MenuStackScreen = (
Expand Down Expand Up @@ -88,10 +90,15 @@ export const MenuStackScreen = (
<MenuStack.Screen name="outcome" component={OutcomeScreen} options={{title: `Outcome Preview`}} />
<MenuStack.Screen name="expoConfig" component={ExpoConfigScreen} options={{title: `Expo Configuration Viewer`}} />
<MenuStack.Screen name="featureFlags" component={FeatureFlagsDebuggerScreen} options={{title: `Feature Flag Debugger`}} />
<MenuStack.Screen name="forecastMapV2" component={ForecastMapViewV2Screen} options={{title: 'Forecast Map V2'}} />
</MenuStack.Navigator>
);
};

export const ForecastMapViewV2Screen = () => {
return <ForecastMapViewV2 requestedTime={parseRequestedTimeString('latest')} />;
};

export const MenuScreen = (queryCache: QueryCache, avalancheCenterId: AvalancheCenterID, staging: boolean, setStaging: React.Dispatch<React.SetStateAction<boolean>>) => {
const MenuScreen = function (_: NativeStackScreenProps<MenuStackParamList, 'menu'>) {
const {logger} = React.useContext<LoggerProps>(LoggerContext);
Expand Down
7 changes: 7 additions & 0 deletions components/screens/menu/DeveloperMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,13 @@ export const DeveloperMenu: React.FC<DeveloperMenuProps> = ({staging, setStaging
});
},
},
{
label: 'View Forecast Map V2',
data: null,
action: () => {
navigation.navigate('forecastMapV2');
},
},
]}
/>
<ActionList
Expand Down
Loading
Loading