Skip to content

Commit

Permalink
feat: update draft observation location
Browse files Browse the repository at this point in the history
  • Loading branch information
gmaclennan committed Dec 3, 2024
1 parent a4d27a8 commit 4e6d360
Show file tree
Hide file tree
Showing 2 changed files with 173 additions and 25 deletions.
138 changes: 135 additions & 3 deletions src/frontend/contexts/DraftObservationContext.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
import * as Location from 'expo-location';
import {AppState, type AppStateStatus} from 'react-native';
import CheapRuler from 'cheap-ruler';
import React, {
PropsWithChildren,
createContext,
useContext,
useState,
} from 'react';

import {createDraftObservationStore} from '../hooks/persistedState/usePersistedDraftObservationNew';
import {
convertPosition,
createDraftObservationStore,
type DraftState,
} from '../hooks/persistedState/usePersistedDraftObservationNew';

const DraftObservationContext = createContext<ReturnType<
typeof createDraftObservationStore
> | null>(null);

export function DraftObservationProvider({children}: PropsWithChildren<{}>) {
const [value] = useState(() => createDraftObservationStore());
const [value] = useState(() => {
const store = createDraftObservationStore();
createDraftObservationLocationUpdator(store);
return store;
});

return (
<DraftObservationContext.Provider value={value}>
Expand All @@ -30,3 +40,125 @@ export function useDraftObservationContext() {

return result;
}

const LOCATION_OPTIONS: Location.LocationOptions = {
accuracy: Location.Accuracy.Highest,
timeInterval: 1000,
};
/** We don't update the position of an observation with the location from the
* device location provider if the location is older than this threshold */
const STALE_LOCATION_THRESHOLD_MS = 1000;
/** Over this threshold we consider the user to have moved away from the
* location of the observation, and we stop refining GPS position. We use the
* accuracy of the first location as the default, and use this as a fallback if
* we do not have an accuracy value. */
const MOVED_AWAY_THRESHOLD_METERS = 100;
/** The factor of accuracy that a new location must be to consider the user to
* have moved away. E.g. if the accuracy of the initial position is 10m and the
* factor is 1.5, then we consider the user to have moved away if a location
* update is 15m away. */
const ACCURACY_MOVED_AWAY_FACTOR = 1.5;

export async function createDraftObservationLocationUpdator({
store,
actions: {updatePosition},
}: ReturnType<typeof createDraftObservationStore>) {
let {granted: locationPermissionGranted} =
await Location.getForegroundPermissionsAsync();
let locationSubscriptionPromise: Promise<Location.LocationSubscription> | null =
null;
let appState: AppStateStatus = AppState.currentState;
let isNewlyCreatedDraftInStore = isNewlyCreatedDraft(store.getState());

AppState.addEventListener('change', async nextAppState => {
appState = nextAppState;
if (appState === 'active' && !locationPermissionGranted) {
locationPermissionGranted = (
await Location.getForegroundPermissionsAsync()
).granted;
}
watchPositionIfNeeded();
});

store.subscribe(storeState => {
isNewlyCreatedDraftInStore = isNewlyCreatedDraft(storeState);
watchPositionIfNeeded();
});

async function watchPositionIfNeeded() {
const shouldBeWatchingPosition =
appState === 'active' &&
isNewlyCreatedDraftInStore &&
locationPermissionGranted;

if (shouldBeWatchingPosition && !locationSubscriptionPromise) {
locationSubscriptionPromise = Location.watchPositionAsync(
LOCATION_OPTIONS,
onPositionUpdate,
);
} else if (!shouldBeWatchingPosition && locationSubscriptionPromise) {
// Avoid a race condition by nulling the state before awaiting the promise
const locationSubscriptionPromiseCopy = locationSubscriptionPromise;
locationSubscriptionPromise = null;
const subscription = await locationSubscriptionPromiseCopy;
subscription.remove();
}
}

function onPositionUpdate(location: Location.LocationObject) {
const {value: currentDraft, initialPosition} = store.getState();
if (!currentDraft) return;

const isManualLocation = !!currentDraft.metadata?.manualLocation;
if (isManualLocation) return;

const isStale =
Date.now() - location.timestamp > STALE_LOCATION_THRESHOLD_MS;
if (isStale) return;

store.setState(prev => {
if (prev.initialPosition || !prev.value) return prev;
return {...prev, initialPosition: convertPosition(location)};
});

const initialAccuracy = initialPosition?.coords.accuracy;
const movedAwayThreshold = initialAccuracy
? initialAccuracy * ACCURACY_MOVED_AWAY_FACTOR
: MOVED_AWAY_THRESHOLD_METERS;
const hasMovedAway =
initialPosition &&
distanceBetweenCoords(initialPosition.coords, location.coords) >
movedAwayThreshold;
if (hasMovedAway) return;

const currentDraftCoords = currentDraft.metadata?.position?.coords;

const isMoreAccurate =
!currentDraftCoords ||
!location.coords.accuracy ||
!currentDraftCoords.accuracy ||
location.coords.accuracy < currentDraftCoords.accuracy;

if (!isMoreAccurate) return;

updatePosition({
manualLocation: false,
position: location,
// TODO: Also add positionProvider. Probably needs access to the locationProvider store.
});
}
}

function isNewlyCreatedDraft(storeState: DraftState) {
const isObservationInStore = !!storeState.value;
const isNewlyCreatedObservation = !storeState.id;
return isObservationInStore && isNewlyCreatedObservation;
}

function distanceBetweenCoords(
a: {latitude: number; longitude: number},
b: {latitude: number; longitude: number},
) {
const ruler = new CheapRuler(a.latitude);
return ruler.distance([a.longitude, a.latitude], [b.longitude, b.latitude]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ type DraftStateEmpty = {
value: null;
id: null;
unsavedAttachments: null;
initialPosition: null;
};

type ObservationWithPreset = Exclude<Observation, 'presetRef'> & {
Expand All @@ -83,6 +84,9 @@ export type DraftStatePopulated = {
value: ObservationValueWithPreset;
id: {docId: string; versionId: string} | null;
unsavedAttachments: Map<number, UnsavedAttachment>;
/** Initial (first) position of an observation. Not currently persisted, but
* used for checking if the user moves away from the original location */
initialPosition: Position | null;
};

export type DraftState = DraftStateEmpty | DraftStatePopulated;
Expand All @@ -93,6 +97,15 @@ const THUMBNAIL_COMPRESSION = 0.3;
const PREVIEW_SIZE = 1200;
const PREVIEW_COMPRESSION = 0.3;

function createEmptyStoreState(): DraftStateEmpty {
return {
value: null,
id: null,
unsavedAttachments: null,
initialPosition: null,
};
}

function createEmptyObservationValue(): ObservationValueWithPreset {
return {
schemaName: 'observation',
Expand Down Expand Up @@ -126,11 +139,7 @@ export function createDraftObservationStore() {
let nextAttachmentId = 0;

const store = createPersistedStore<DraftState>(
() => ({
value: null,
id: null,
unsavedAttachments: null,
}),
() => createEmptyStoreState(),
'@MapeoDraft',
);

Expand Down Expand Up @@ -298,26 +307,30 @@ export function createDraftObservationStore() {
}

function clearDraft() {
store.setState({
value: null,
id: null,
unsavedAttachments: null,
});
store.setState(createEmptyStoreState(), true);
}

function createDraft(observation?: ObservationWithPreset) {
if (observation) {
store.setState({
value: valueOf(observation),
id: {docId: observation.docId, versionId: observation.versionId},
unsavedAttachments: new Map(),
});
store.setState(
{
value: valueOf(observation),
id: {docId: observation.docId, versionId: observation.versionId},
unsavedAttachments: new Map(),
initialPosition: null,
},
true,
);
} else {
store.setState({
value: createEmptyObservationValue(),
id: null,
unsavedAttachments: new Map(),
});
store.setState(
{
value: createEmptyObservationValue(),
id: null,
unsavedAttachments: new Map(),
initialPosition: null,
},
true,
);
}
}

Expand All @@ -326,7 +339,8 @@ export function createDraftObservationStore() {
| {
manualLocation: false;
position: LocationObject;
positionProvider: LocationProviderStatus;
// TODO: Optional for now until we integrate this into the draft observation location updater
positionProvider?: LocationProviderStatus;
}
| {
manualLocation: true;
Expand Down Expand Up @@ -461,7 +475,9 @@ function getPhotoRotation(acc?: AccelerometerMeasurement) {
return rotation;
}

function convertPosition(location: LocationObject | ManualPosition): Position {
export function convertPosition(
location: LocationObject | ManualPosition,
): Position {
const {coords} = location;
return {
coords: {
Expand Down

0 comments on commit 4e6d360

Please sign in to comment.