Skip to content

Commit

Permalink
Initial views and UI
Browse files Browse the repository at this point in the history
  • Loading branch information
bjtrounson committed Dec 22, 2024
1 parent f419c1f commit 9e74c58
Show file tree
Hide file tree
Showing 7 changed files with 511 additions and 7 deletions.
107 changes: 107 additions & 0 deletions react-native/src/core/NavigationUiState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {
TripState,
type GeographicCoordinate,
type RouteDeviation,
type RouteStep,
type SpokenInstruction,
type TripProgress,
type UserLocation,
type VisualInstruction,
} from '../generated/ferrostar';
import type { NavigationState } from './FerrostarCore';

export class NavigationUiState {
/** The user's location as reported by the location provider. */
location?: UserLocation;
/** The user's location snapped to the route shape. */
snappedLocation?: UserLocation;
/**
* The last known heading of the user.
*
* NOTE: This is distinct from the course over ground (direction of travel), which is included
* in the `location` and `snappedLocation` properties.
*/
heading?: number;
/** The geometry of the full route. */
routeGeometry?: Array<GeographicCoordinate>;
/** Visual instructions which should be displayed based on the user's current progress. */
visualInstruction?: VisualInstruction;
/**
* Instructions which should be spoken via speech synthesis based on the user's current
* progress.
*/
spokenInstruction?: SpokenInstruction;
/** The user's progress through the current trip. */
progress?: TripProgress;
/** If true, the core is currently calculating a new route. */
isCalculatingNewRoute?: boolean;
/** Describes whether the user is believed to be off the correct route. */
routeDeviation?: RouteDeviation;
/** If true, spoken instructions will not be synthesized. */
isMuted?: boolean;
/** The name of the road which the current route step is traversing. */
currentStepRoadName?: string;
/** The remaining steps in the trip (including the current step). */
remainingSteps?: Array<RouteStep>;
/** The route annotation object at the current location. */
// TODO: Annotation implementation
//currentAnnotation: AnnotationWrapper<*>

constructor(
location?: UserLocation,
snappedLocation?: UserLocation,
heading?: number,
routeGeometry?: Array<GeographicCoordinate>,
visualInstruction?: VisualInstruction,
spokenInstruction?: SpokenInstruction,
progress?: TripProgress,
isCalculatingNewRoute?: boolean,
routeDeviation?: RouteDeviation,
isMuted?: boolean,
currentStepRoadName?: string,
remainingSteps?: Array<RouteStep>
) {
this.location = location;
this.snappedLocation = snappedLocation;
this.heading = heading;
this.routeGeometry = routeGeometry;
this.visualInstruction = visualInstruction;
this.spokenInstruction = spokenInstruction;
this.progress = progress;
this.isCalculatingNewRoute = isCalculatingNewRoute;
this.routeDeviation = routeDeviation;
this.isMuted = isMuted;
this.currentStepRoadName = currentStepRoadName;
this.remainingSteps = remainingSteps;
}

static fromFerrostar(
coreState: NavigationState,
isMuted?: boolean,
location?: UserLocation,
snappedLocation?: UserLocation
): NavigationUiState {
let tripState;
if (TripState.Navigating.instanceOf(coreState.tripState)) {
tripState = coreState.tripState;
}
return new NavigationUiState(
location,
snappedLocation,
undefined,
coreState.routeGeometry,
tripState?.inner.visualInstruction,
undefined,
tripState?.inner.progress,
coreState.isCalculatingNewRoute,
tripState?.inner.deviation,
isMuted,
undefined, // TODO: Android seems to have this type but it's not in the TS definition
tripState?.inner.remainingSteps
);
}

isNavigating(): boolean {
return this.progress !== undefined;
}
}
58 changes: 58 additions & 0 deletions react-native/src/views/BorderedPolyline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { LineLayer, ShapeSource } from '@maplibre/maplibre-react-native';

type BorderedPolylineProps = {
points: Array<{ lat: number; lng: number }>;
zIndex?: number;
color?: string;
borderColor?: string;
lineWidth?: number;
borderWidth?: number;
};

const BorderedPolyline = ({
points,
zIndex = 1,
color = '#3583dd',
borderColor = '#ffffff',
lineWidth = 10.0,
borderWidth = 3.0,
}: BorderedPolylineProps) => {
if (points.length < 2) {
return null;
}

return (
<ShapeSource
id="border-polyline"
shape={{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: points.map((p) => [p.lng, p.lat]) as number[][],
},
properties: {},
}}
>
<LineLayer
id="line-border"
style={{
lineCap: 'round',
lineWidth: lineWidth + borderWidth * 2.0,
lineColor: borderColor,
lineSortKey: zIndex,
}}
/>
<LineLayer
id="line"
style={{
lineCap: 'round',
lineWidth,
lineColor: color,
lineSortKey: zIndex,
}}
/>
</ShapeSource>
);
};

export default BorderedPolyline;
86 changes: 86 additions & 0 deletions react-native/src/views/NavigationMapViewCamera.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {
Camera,
UserTrackingMode,
type CameraPadding,
} from '@maplibre/maplibre-react-native';
import { useMemo } from 'react';
import { Dimensions, PixelRatio, useWindowDimensions } from 'react-native';

export class NavigationActivity {
zoom: number;
pitch: number;

constructor(zoom: number, pitch: number) {
this.zoom = zoom;
this.pitch = pitch;
}

/* The recommended camera configuration for automotive navigation. */
static Automotive = new NavigationActivity(16.0, 45.0);

/* The recommended camera configuration for bicycle navigation. */
static Bicycle = new NavigationActivity(18.0, 45.0);

/* The recommended camera configuration for pedestrian navigation. */
static Pedestrian = new NavigationActivity(20.0, 10.0);
}

type NavigationMapViewCameraProps = {
activity?: NavigationActivity;
};

/**
* The camera configuration for navigation. This configuration sets the camera to track the user,
* with a high zoom level and moderate pitch for a 2.5D isometric view. It automatically adjusts the
* padding based on the screen size and orientation.
*
* @param activity The type of activity the camera is being used for.
* @return The recommended navigation MapViewCamera
*/
const NavigationMapViewCamera = ({
activity = NavigationActivity.Automotive,
}: NavigationMapViewCameraProps) => {
const { width, height } = useWindowDimensions();
const orientation = useMemo(() => {
return height > width ? 'portrait' : 'landscape';
}, [height, width]);

const start = useMemo(() => {
if (orientation === 'landscape') return 0.5;
return 0.0;
}, [orientation]);

const padding: CameraPadding = useMemo(() => {
const { height: screenHeight, width: screenWidth } =
Dimensions.get('screen');

const screenWidthPx = PixelRatio.getPixelSizeForLayoutSize(screenWidth);
const screenHeightPx = PixelRatio.getPixelSizeForLayoutSize(screenHeight);
// TODO: A way to calculate RTL and LTR padding.
const left = start * screenWidthPx;
const top = 0.5 * screenHeightPx;
const right = 0.0 * screenWidthPx;
const bottom = 0.0 * screenHeightPx;

return {
paddingLeft: left,
paddingTop: top,
paddingRight: right,
paddingBottom: bottom,
};
}, [start]);

return (
<Camera
defaultSettings={{
padding,
}}
zoomLevel={activity.zoom}
pitch={activity.pitch}
followUserLocation
followUserMode={UserTrackingMode.FollowWithHeading}
/>
);
};

export default NavigationMapViewCamera;
61 changes: 54 additions & 7 deletions react-native/src/views/NavigationView.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,75 @@
import MapLibreGL, { MapView } from '@maplibre/maplibre-react-native';
import { useEffect, useState, type ComponentProps } from 'react';
import { FerrostarCore, type NavigationState } from '../core/FerrostarCore';
import MapLibreGL, {
Camera,
MapView,
UserLocation,
} from '@maplibre/maplibre-react-native';
import { useEffect, useMemo, useState, type ComponentProps } from 'react';
import { FerrostarCore } from '../core/FerrostarCore';
import { NavigationUiState } from '../core/NavigationUiState';
import BorderedPolyline from './BorderedPolyline';
import NavigationMapViewCamera from './NavigationMapViewCamera';
import TripProgressView from './TripProgressView';
import { View } from 'react-native';

MapLibreGL.setAccessToken(null);

type NavigationViewProps = ComponentProps<typeof MapView> & {
core: FerrostarCore;
snapUserLocationToRoute?: boolean;
};

const NavigationView = (props: NavigationViewProps) => {
const { core } = props;
const [navigationState, setNavigationState] = useState<NavigationState>();
const { core, children, snapUserLocationToRoute = true } = props;
const [uiState, setUiState] = useState<NavigationUiState>();

const isNavigating = useMemo(() => {
return uiState?.isNavigating() ?? false;
}, [uiState]);

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const location = useMemo(() => {
if (snapUserLocationToRoute && isNavigating) {
return uiState?.snappedLocation;
}

return uiState?.location;
}, [
isNavigating,
snapUserLocationToRoute,
uiState?.location,
uiState?.snappedLocation,
]);

useEffect(() => {
const watchId = core.addStateListener((state) => {
setNavigationState(state);
setUiState(NavigationUiState.fromFerrostar(state));
});

return () => {
core.removeStateListener(watchId);
};
}, [core]);

return <MapView {...props} />;
return (
<View style={{ flex: 1, position: 'relative' }}>
<MapView compassEnabled={false} {...props}>
{isNavigating ? (
<NavigationMapViewCamera />
) : (
<>
<Camera followUserLocation />
<UserLocation />
</>
)}
<BorderedPolyline points={uiState?.routeGeometry ?? []} zIndex={0} />
{children}
</MapView>
<TripProgressView
progress={uiState?.progress}
onTapExit={() => core.stopNavigation()}
/>
</View>
);
};

export default NavigationView;
Loading

0 comments on commit 9e74c58

Please sign in to comment.