From 9e74c58ddde332bfac0840883b5bf9974190f8ac Mon Sep 17 00:00:00 2001 From: Benjamin Trounson Date: Mon, 23 Dec 2024 07:58:19 +1300 Subject: [PATCH] Initial views and UI --- react-native/src/core/NavigationUiState.ts | 107 ++++++++++++++++++ react-native/src/views/BorderedPolyline.tsx | 58 ++++++++++ .../src/views/NavigationMapViewCamera.tsx | 86 ++++++++++++++ react-native/src/views/NavigationView.tsx | 61 ++++++++-- react-native/src/views/TripProgressView.tsx | 97 ++++++++++++++++ react-native/src/views/_utils.ts | 104 +++++++++++++++++ react-native/src/views/index.tsx | 5 + 7 files changed, 511 insertions(+), 7 deletions(-) create mode 100644 react-native/src/core/NavigationUiState.ts create mode 100644 react-native/src/views/BorderedPolyline.tsx create mode 100644 react-native/src/views/NavigationMapViewCamera.tsx create mode 100644 react-native/src/views/TripProgressView.tsx create mode 100644 react-native/src/views/_utils.ts create mode 100644 react-native/src/views/index.tsx diff --git a/react-native/src/core/NavigationUiState.ts b/react-native/src/core/NavigationUiState.ts new file mode 100644 index 00000000..48c435b4 --- /dev/null +++ b/react-native/src/core/NavigationUiState.ts @@ -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; + /** 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; + /** The route annotation object at the current location. */ + // TODO: Annotation implementation + //currentAnnotation: AnnotationWrapper<*> + + constructor( + location?: UserLocation, + snappedLocation?: UserLocation, + heading?: number, + routeGeometry?: Array, + visualInstruction?: VisualInstruction, + spokenInstruction?: SpokenInstruction, + progress?: TripProgress, + isCalculatingNewRoute?: boolean, + routeDeviation?: RouteDeviation, + isMuted?: boolean, + currentStepRoadName?: string, + remainingSteps?: Array + ) { + 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; + } +} diff --git a/react-native/src/views/BorderedPolyline.tsx b/react-native/src/views/BorderedPolyline.tsx new file mode 100644 index 00000000..cf7adfd4 --- /dev/null +++ b/react-native/src/views/BorderedPolyline.tsx @@ -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 ( + [p.lng, p.lat]) as number[][], + }, + properties: {}, + }} + > + + + + ); +}; + +export default BorderedPolyline; diff --git a/react-native/src/views/NavigationMapViewCamera.tsx b/react-native/src/views/NavigationMapViewCamera.tsx new file mode 100644 index 00000000..68d30b4c --- /dev/null +++ b/react-native/src/views/NavigationMapViewCamera.tsx @@ -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 ( + + ); +}; + +export default NavigationMapViewCamera; diff --git a/react-native/src/views/NavigationView.tsx b/react-native/src/views/NavigationView.tsx index bd618148..c661522a 100644 --- a/react-native/src/views/NavigationView.tsx +++ b/react-native/src/views/NavigationView.tsx @@ -1,20 +1,48 @@ -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 & { core: FerrostarCore; + snapUserLocationToRoute?: boolean; }; const NavigationView = (props: NavigationViewProps) => { - const { core } = props; - const [navigationState, setNavigationState] = useState(); + const { core, children, snapUserLocationToRoute = true } = props; + const [uiState, setUiState] = useState(); + + 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 () => { @@ -22,7 +50,26 @@ const NavigationView = (props: NavigationViewProps) => { }; }, [core]); - return ; + return ( + + + {isNavigating ? ( + + ) : ( + <> + + + + )} + + {children} + + core.stopNavigation()} + /> + + ); }; export default NavigationView; diff --git a/react-native/src/views/TripProgressView.tsx b/react-native/src/views/TripProgressView.tsx new file mode 100644 index 00000000..9db8990b --- /dev/null +++ b/react-native/src/views/TripProgressView.tsx @@ -0,0 +1,97 @@ +import { + StyleSheet, + type ViewStyle, + View, + Pressable, + Text, +} from 'react-native'; +import type { TripProgress } from '../generated/ferrostar'; +import { LocalizedDurationFormatter } from './_utils'; + +type TripProgressViewProps = { + progress?: TripProgress; + style?: ViewStyle; + fromDate?: Date; + onTapExit: () => void | null; +}; + +const DurationFormatter = LocalizedDurationFormatter(); + +const TripProgressView = ({ + progress, + fromDate = new Date(), + onTapExit, +}: TripProgressViewProps) => { + if (progress === undefined) return; + + // TODO: fix this + const estimatedArrival = new Date( + fromDate.getTime() + + progress.distanceRemaining * 1000 + + fromDate.getTimezoneOffset() * 60 * 1000 + ); + + return ( + + + + + {estimatedArrival.toLocaleTimeString()} + + + + + {DurationFormatter.format(progress.durationRemaining)} + + + + + {progress.distanceRemaining.toFixed(0)} + + + {onTapExit != null && ( + + X + + )} + + + ); +}; + +const defaultStyle = StyleSheet.create({ + container: { + flex: 1, + flexDirection: 'column', + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + margin: 10, + }, + box: { + flex: 1, + flexDirection: 'row', + backgroundColor: '#fff', + alignItems: 'center', + justifyContent: 'space-between', + borderRadius: 100, + padding: 10, + }, + // Full circle button + tapExit: { + backgroundColor: '#d3d3d3', + borderRadius: 100, + width: 40, + height: 40, + justifyContent: 'center', + alignItems: 'center', + }, + text: { + fontSize: 16, + fontWeight: 'bold', + color: '#000', + }, +}); + +export default TripProgressView; diff --git a/react-native/src/views/_utils.ts b/react-native/src/views/_utils.ts new file mode 100644 index 00000000..be3b63d7 --- /dev/null +++ b/react-native/src/views/_utils.ts @@ -0,0 +1,104 @@ +type UnitStyle = 'short' | 'long'; + +type DurationUnit = 'days' | 'hours' | 'minutes' | 'seconds'; + +type CalculatedResult = { + days: number; + hours: number; + minutes: number; + seconds: number; +}; + +export const LocalizedDurationFormatter = () => { + const units: DurationUnit[] = ['days', 'hours', 'minutes', 'seconds']; + const unitStyle: UnitStyle = 'short'; + + function calculate(durationSeconds: number): CalculatedResult { + let remainingDuration = durationSeconds; + const result: CalculatedResult = { + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + }; + + // Extract the days from the duration + if (units.find((u) => u === 'days')) { + const days = parseInt( + (remainingDuration / (24 * 60 * 60)).toFixed(0), + 10 + ); + remainingDuration %= 24 * 60 * 60; + result.days = days; + } + + // Extract the hours from the duration + if (units.find((u) => u === 'hours')) { + const hours = parseInt((remainingDuration / (60 * 60)).toFixed(0), 10); + remainingDuration %= 60 * 60; + result.hours = hours; + } + + // Extract the minutes from the duration + if (units.find((u) => u === 'minutes')) { + const minutes = parseInt((remainingDuration / 60).toFixed(0), 10); + remainingDuration %= 60; + result.minutes = minutes; + } + + // Extract the seconds from the duration + if (units.find((u) => u === 'seconds')) { + const seconds = parseInt(remainingDuration.toFixed(0), 10); + result.seconds = seconds; + } + + return result; + } + + function getUnitString(unit: DurationUnit, value: number): string { + const plural = value != 1 ? 's' : ''; + + switch (unitStyle) { + case 'short': + switch (unit) { + case 'seconds': + return 's'; + case 'minutes': + return 'm'; + case 'hours': + return 'h'; + case 'days': + return 'd'; + } + break; + case 'long': + switch (unit) { + case 'seconds': + return `${value} ${unit}${plural}`; + case 'minutes': + return `${value} ${unit}${plural}`; + case 'hours': + return `${value} ${unit}${plural}`; + case 'days': + return `${value} ${unit}${plural}`; + } + break; + default: + return ''; + } + } + + function format(durtionSeconds: number): string { + const durationRecord = calculate(durtionSeconds); + + console.log(Object.entries(durationRecord)); + return Object.entries(durationRecord) + .filter((it) => it[1] > 0) + .flatMap((it) => `${it[1]}${getUnitString(it[0] as DurationUnit, it[1])}`) + .join(' '); + } + + return { + format, + }; +}; diff --git a/react-native/src/views/index.tsx b/react-native/src/views/index.tsx new file mode 100644 index 00000000..d9242636 --- /dev/null +++ b/react-native/src/views/index.tsx @@ -0,0 +1,5 @@ +import NavigationView from './NavigationView'; +import NavigationMapViewCamera from './NavigationMapViewCamera'; +import BorderedPolyline from './BorderedPolyline'; + +export { NavigationView, NavigationMapViewCamera, BorderedPolyline };