From ae5ce9e57e72ec1bfec1fc59e7188ecd3425fc14 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 3 Dec 2024 11:56:03 +0000 Subject: [PATCH 01/11] WIP: new draft observation hook --- package-lock.json | 55 ++- package.json | 2 + .../usePersistedDraftObservationNew.ts | 449 ++++++++++++++++++ 3 files changed, 490 insertions(+), 16 deletions(-) create mode 100644 src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts diff --git a/package-lock.json b/package-lock.json index 37567860c..d89848e4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "expo-document-picker": "~11.10.1", "expo-file-system": "~16.0.9", "expo-font": "~11.10.3", + "expo-image-manipulator": "~11.8.0", "expo-localization": "~14.8.4", "expo-location": "~16.5.5", "expo-secure-store": "~12.8.1", @@ -56,6 +57,7 @@ "expo-system-ui": "^2.9.4", "expo-task-manager": "~11.7.3", "expo-updates": "~0.24.13", + "filter-obj": "^6.1.0", "geojson": "^0.5.0", "geojson-geometries-lookup": "^0.5.0", "lodash.isequal": "^4.5.0", @@ -14380,6 +14382,27 @@ "expo": "*" } }, + "node_modules/expo-image-loader": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-4.6.0.tgz", + "integrity": "sha512-RHQTDak7/KyhWUxikn2yNzXL7i2cs16cMp6gEAgkHOjVhoCJQoOJ0Ljrt4cKQ3IowxgCuOrAgSUzGkqs7omj8Q==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-manipulator": { + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-11.8.0.tgz", + "integrity": "sha512-ZWVrHnYmwJq6h7auk+ropsxcNi+LyZcPFKQc8oy+JA0SaJosfShvkCm7RADWAunHmfPCmjHrhwPGEu/rs7WG/A==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~4.6.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-json-utils": { "version": "0.12.3", "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.12.3.tgz", @@ -14978,12 +15001,15 @@ } }, "node_modules/filter-obj": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", - "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-6.1.0.tgz", + "integrity": "sha512-xdMtCAODmPloU9qtmPcdBV9Kd27NtMse+4ayThxqIHUES5Z2S6bGpap5PpdmNM56ub7y3i1eyr+vJJIIgWGKmA==", "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/finalhandler": { @@ -22813,6 +22839,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/query-string/node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -25360,18 +25395,6 @@ "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "license": "MIT" }, - "node_modules/styled-map-package/node_modules/filter-obj": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-6.1.0.tgz", - "integrity": "sha512-xdMtCAODmPloU9qtmPcdBV9Kd27NtMse+4ayThxqIHUES5Z2S6bGpap5PpdmNM56ub7y3i1eyr+vJJIIgWGKmA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/styled-map-package/node_modules/is-interactive": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", diff --git a/package.json b/package.json index ca2de120c..9e8b9aebf 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "expo-document-picker": "~11.10.1", "expo-file-system": "~16.0.9", "expo-font": "~11.10.3", + "expo-image-manipulator": "~11.8.0", "expo-localization": "~14.8.4", "expo-location": "~16.5.5", "expo-secure-store": "~12.8.1", @@ -74,6 +75,7 @@ "expo-system-ui": "^2.9.4", "expo-task-manager": "~11.7.3", "expo-updates": "~0.24.13", + "filter-obj": "^6.1.0", "geojson": "^0.5.0", "geojson-geometries-lookup": "^0.5.0", "lodash.isequal": "^4.5.0", diff --git a/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts b/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts new file mode 100644 index 000000000..306c6a2b9 --- /dev/null +++ b/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts @@ -0,0 +1,449 @@ +import { + valueOf, + type Observation, + type ObservationValue, +} from '@comapeo/schema'; +import {createPersistedStore} from './createPersistedState.ts'; +import type {LocationObject, LocationProviderStatus} from 'expo-location'; +import type {AccelerometerMeasurement} from 'expo-sensors'; +import type {CameraCapturedPicture} from 'expo-camera'; +import {manipulateAsync} from 'expo-image-manipulator'; +import {excludeKeys} from 'filter-obj'; +import type {Position} from '../../sharedTypes/index.ts'; + +type ObservationTagValue = Observation['tags'][number]; + +type UnsavedAttachmentBlob = + | { + uri: string; + processingState: 'complete'; + } + | { + uri: null; + processingState: 'pending'; + } + | { + uri: null; + error: Error; + processingState: 'error'; + }; + +type UnsavedPhotoAttachment = { + id: number; + type: 'photo'; + // Represents unprocessed blob (i.e. not resized or rotated) + raw: UnsavedAttachmentBlob; + original: UnsavedAttachmentBlob; + thumbnail: UnsavedAttachmentBlob; + preview: UnsavedAttachmentBlob; + accelerometer?: AccelerometerMeasurement; + location?: LocationObject; + timestamp: number; + abortController: AbortController; +}; + +type PhotoMetadata = Pick< + UnsavedPhotoAttachment, + 'location' | 'accelerometer' | 'timestamp' +>; + +/** Position update from manual coordinate entry */ +type ManualPosition = { + coords: { + latitude: number; + longitude: number; + }; +}; + +type UnsavedAudioAttachment = { + id: number; + original: UnsavedAttachmentBlob; + type: 'audio'; + abortController: AbortController; +}; + +type UnsavedAttachment = UnsavedPhotoAttachment | UnsavedAudioAttachment; + +type DraftStateEmpty = { + value: null; + id: null; + unsavedAttachments: null; +}; + +type DraftStatePopulated = { + value: ObservationValue; + id: {docId: string; versionId: string} | null; + unsavedAttachments: Map; +}; + +type DraftState = DraftStateEmpty | DraftStatePopulated; + +const ORIGINAL_COMPRESSION = 0.75; +const THUMBNAIL_SIZE = 400; +const THUMBNAIL_COMPRESSION = 0.3; +const PREVIEW_SIZE = 1200; +const PREVIEW_COMPRESSION = 0.3; + +function createEmptyObservationValue(): ObservationValue { + return { + schemaName: 'observation', + lat: 0, + lon: 0, + metadata: {manualLocation: false}, + tags: { + notes: '', + }, + attachments: [], + }; +} + +function createNewPhotoAttachment( + id: number, + metadata: PhotoMetadata, +): UnsavedAttachment { + return { + id, + type: 'photo', + raw: {uri: null, processingState: 'pending'}, + original: {uri: null, processingState: 'pending'}, + thumbnail: {uri: null, processingState: 'pending'}, + preview: {uri: null, processingState: 'pending'}, + abortController: new AbortController(), + ...metadata, + }; +} + +export function createDraftObservationStore() { + let nextAttachmentId = 0; + + const store = createPersistedStore( + () => ({ + value: null, + id: null, + unsavedAttachments: null, + }), + '@MapeoDraft', + ); + + /** Helper to set state, but throw if store is empty (no draft observation) */ + function setAssertDraft( + partial: (state: DraftStatePopulated) => Partial, + ): void { + return store.setState(prev => { + if (prev.value === null) { + throw new Error('No observation to update'); + } + return partial(prev); + }); + } + + function _addAttachment(attachment: UnsavedAttachment): void { + setAssertDraft(prev => { + return { + unsavedAttachments: new Map(prev.unsavedAttachments).set( + attachment.id, + attachment, + ), + }; + }); + } + + function _updateAttachment( + type: T, + id: number, + partial: Partial>, + ): void { + setAssertDraft(prev => { + const attachment = prev.unsavedAttachments.get(id); + if (!attachment) return prev; + if (attachment.type !== type) { + throw new Error(`Attachment with id ${id} is not of type ${type}`); + } + + return { + unsavedAttachments: new Map(prev.unsavedAttachments).set(id, { + ...attachment, + ...partial, + }), + }; + }); + } + + async function _processPhotoAttachment({ + id, + outputKey, + processPromise, + }: { + id: number; + outputKey: 'thumbnail' | 'preview' | 'original' | 'raw'; + processPromise: Promise; + }): Promise { + try { + const processResult = await processPromise; + _updateAttachment('photo', id, { + [outputKey]: {uri: processResult.uri, processingState: 'complete'}, + }); + return processResult; + } catch (reason) { + const error = reasonToError(reason); + _updateAttachment('photo', id, { + [outputKey]: {uri: null, processingState: 'error', error}, + }); + throw reason; + } + } + + async function addPhoto( + capturePromise: Promise, + metadata: PhotoMetadata, + ) { + const newAttachment = createNewPhotoAttachment( + nextAttachmentId++, + metadata, + ); + const { + abortController: {signal}, + id, + } = newAttachment; + _addAttachment(newAttachment); + + try { + const {uri: rawUri} = await _processPhotoAttachment({ + id, + outputKey: 'raw', + processPromise: capturePromise, + }); + signal.throwIfAborted(); + + // TODO: Previously, rotation of the original photo could fail on older + // devices with low memory, so we would skip rotation and save the raw + // image as the original, and then rotate the preview and thumbnail, which + // would work. However hopefully the expo image manipulator does not fail + // in the same way, so we will not need that workaround. If we do get + // failures reported, then we should consider adding it back in. + const { + uri: originalUri, + width, + height, + } = await _processPhotoAttachment({ + id, + outputKey: 'original', + processPromise: manipulateAsync( + rawUri, + [{rotate: getPhotoRotation(metadata.accelerometer)}], + {compress: ORIGINAL_COMPRESSION}, + ), + }); + signal.throwIfAborted(); + + const thumbnailDimensions = + width > height ? {width: THUMBNAIL_SIZE} : {height: THUMBNAIL_SIZE}; + await _processPhotoAttachment({ + id, + outputKey: 'thumbnail', + processPromise: manipulateAsync( + originalUri, + [{resize: thumbnailDimensions}], + {compress: THUMBNAIL_COMPRESSION}, + ), + }); + signal.throwIfAborted(); + + const previewDimensions = + width > height ? {width: PREVIEW_SIZE} : {height: PREVIEW_SIZE}; + await _processPhotoAttachment({ + id, + outputKey: 'preview', + processPromise: manipulateAsync( + originalUri, + [{resize: previewDimensions}], + {compress: PREVIEW_COMPRESSION}, + ), + }); + signal.throwIfAborted(); + } catch (reason) { + if (reason instanceof Error && reason.name === 'AbortError') { + // TODO: Remove attachment from state + } + // TODO: Report other errors to Sentry + } + } + + function addAudio(uri: string) { + const newAttachment: UnsavedAudioAttachment = { + id: nextAttachmentId++, + type: 'audio', + original: {uri, processingState: 'complete'}, + abortController: new AbortController(), + }; + _addAttachment(newAttachment); + } + + function deleteUnsavedAttachment(id: number) { + setAssertDraft(prev => { + if (!prev.unsavedAttachments.has(id)) return prev; + const attachments = new Map(prev.unsavedAttachments); + attachments.delete(id); + return {unsavedAttachments: attachments}; + }); + } + + function clearDraft() { + store.setState({ + value: null, + id: null, + unsavedAttachments: null, + }); + } + + function createDraft(observation?: Observation) { + if (observation) { + store.setState({ + value: valueOf(observation), + id: {docId: observation.docId, versionId: observation.versionId}, + unsavedAttachments: new Map(), + }); + } else { + store.setState({ + value: createEmptyObservationValue(), + id: null, + unsavedAttachments: new Map(), + }); + } + } + + function updatePosition( + opts: + | { + manualLocation: false; + position: LocationObject; + positionProvider: LocationProviderStatus; + } + | { + manualLocation: true; + position: ManualPosition; + }, + ) { + const {position, ...rest} = opts; + const metadata: ObservationValue['metadata'] = { + ...rest, + position: convertPosition(position), + }; + setAssertDraft(prev => { + // Forward compat - make sure we keep any other metadata that we don't know about + const prevMetadataToKeep = excludeKeys(prev.value.metadata || {}, [ + 'manualLocation', + 'position', + 'positionProvider', + ]); + return { + value: { + ...prev.value, + lat: position.coords.latitude, + lon: position.coords.longitude, + metadata: { + ...prevMetadataToKeep, + ...metadata, + }, + }, + }; + }); + } + + function updateTag(tagKey: string, value: ObservationTagValue): void { + setAssertDraft(prev => { + return { + value: { + ...prev.value, + tags: {...prev.value.tags, [tagKey]: value}, + }, + }; + }); + } + + function updatePreset(presetRef: {docId: string; versionId: string}) { + // TODO: Need the hellish code to update tags based on preset change + setAssertDraft(prev => { + return { + value: { + ...prev.value, + presetRef: presetRef, + }, + }; + }); + } + + const actions = { + addPhoto, + addAudio, + deleteUnsavedAttachment, + clearDraft, + createDraft, + updatePosition, + updateTag, + updatePreset, + }; + + return {store, actions}; +} + +function reasonToError(reason: unknown): Error { + if (reason instanceof Error) { + return reason; + } + if (typeof reason === 'string') { + return new Error(reason); + } + return new Error('Unknown error'); +} + +const ACC_AT_45_DEG = Math.sin(Math.PI / 4); + +function getPhotoRotation(acc?: AccelerometerMeasurement) { + if (!acc) return 0; + const {x, y, z} = acc; + let rotation = 0; + if (z < -ACC_AT_45_DEG || z > ACC_AT_45_DEG) { + // camera is pointing up or down + if (Math.abs(y) > Math.abs(x)) { + // camera is vertical + if (y <= 0) rotation = 180; + else rotation = 0; + } else { + // camera is horizontal + if (x >= 0) rotation = -90; + else rotation = 90; + } + } else if (x > -ACC_AT_45_DEG && x < ACC_AT_45_DEG) { + // camera is vertical + if (y <= 0) rotation = 180; + else rotation = 0; + } else { + // camera is horizontal + if (x >= 0) rotation = -90; + else rotation = 90; + } + return rotation; +} + +function convertPosition(location: LocationObject | ManualPosition): Position { + const {coords} = location; + return { + coords: { + latitude: coords.latitude, + longitude: coords.longitude, + // @ts-expect-error - too much work to fix this, not a runtime issue + accuracy: coords.accuracy ?? undefined, + // @ts-expect-error - too much work to fix this, not a runtime issue + altitude: coords.altitude ?? undefined, + // @ts-expect-error - too much work to fix this, not a runtime issue + heading: coords.heading ?? undefined, + // @ts-expect-error - too much work to fix this, not a runtime issue + speed: coords.speed ?? undefined, + }, + timestamp: + 'timestamp' in location + ? new Date(location.timestamp).toISOString() + : new Date().toISOString(), + }; +} From 53d93cb92eda64310ddae88574eaff31b3c59319 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Tue, 3 Dec 2024 15:32:54 +0000 Subject: [PATCH 02/11] set up DraftObservationContext --- .../contexts/DraftObservationContext.tsx | 52 +++++++++++++++++++ .../usePersistedDraftObservationNew.ts | 2 +- 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 src/frontend/contexts/DraftObservationContext.tsx diff --git a/src/frontend/contexts/DraftObservationContext.tsx b/src/frontend/contexts/DraftObservationContext.tsx new file mode 100644 index 000000000..24abd3f81 --- /dev/null +++ b/src/frontend/contexts/DraftObservationContext.tsx @@ -0,0 +1,52 @@ +import React, { + PropsWithChildren, + createContext, + useContext, + useState, +} from 'react'; +import { + DraftState, + createDraftObservationStore, +} from '../hooks/persistedState/usePersistedDraftObservationNew'; +import {useStore} from 'zustand'; + +const DraftObservationContext = createContext | null>(null); + +export function DraftObservationProvider({children}: PropsWithChildren<{}>) { + const [value] = useState(() => createDraftObservationStore()); + + return ( + + {children} + + ); +} + +export function useDraftObservationContext() { + const result = useContext(DraftObservationContext); + + if (!result) { + throw new Error('Must set up the DraftObservationContext Provider first'); + } + + return result; +} + +function defaultSelector(state: DraftState) { + return state; +} + +export function useDraftObservation( + selector: (state: DraftState) => Partial = defaultSelector, +) { + const {store} = useDraftObservationContext(); + const draftObservation = useStore(store, selector); + return draftObservation; +} + +export function useDraftObservationActions() { + const {actions} = useDraftObservationContext(); + return actions; +} diff --git a/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts b/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts index 306c6a2b9..8dce96c3f 100644 --- a/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts +++ b/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts @@ -76,7 +76,7 @@ type DraftStatePopulated = { unsavedAttachments: Map; }; -type DraftState = DraftStateEmpty | DraftStatePopulated; +export type DraftState = DraftStateEmpty | DraftStatePopulated; const ORIGINAL_COMPRESSION = 0.75; const THUMBNAIL_SIZE = 400; From b8e7c08b55114174857c0c3de51ddc31ba390ab7 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Tue, 3 Dec 2024 15:48:55 +0000 Subject: [PATCH 03/11] update state selector typing --- src/frontend/contexts/DraftObservationContext.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frontend/contexts/DraftObservationContext.tsx b/src/frontend/contexts/DraftObservationContext.tsx index 24abd3f81..4e1d3946b 100644 --- a/src/frontend/contexts/DraftObservationContext.tsx +++ b/src/frontend/contexts/DraftObservationContext.tsx @@ -38,8 +38,8 @@ function defaultSelector(state: DraftState) { return state; } -export function useDraftObservation( - selector: (state: DraftState) => Partial = defaultSelector, +export function useDraftObservation( + selector: (state: DraftState) => S = defaultSelector as any, ) { const {store} = useDraftObservationContext(); const draftObservation = useStore(store, selector); From a4c5cc4f0acc6727b58be360f217b839f0e7fab0 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Tue, 3 Dec 2024 16:02:24 +0000 Subject: [PATCH 04/11] actually use DraftObservationProvider in app --- src/frontend/contexts/AppProviders.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/frontend/contexts/AppProviders.tsx b/src/frontend/contexts/AppProviders.tsx index 903ddfe1e..090522e54 100644 --- a/src/frontend/contexts/AppProviders.tsx +++ b/src/frontend/contexts/AppProviders.tsx @@ -26,6 +26,7 @@ import {BottomSheetModalProvider} from '@gorhom/bottom-sheet'; import {MetricsProvider} from './MetricsContext'; import {AppDiagnosticMetrics} from '../metrics/AppDiagnosticMetrics'; import {DeviceDiagnosticMetrics} from '../metrics/DeviceDiagnosticMetrics'; +import {DraftObservationProvider} from './DraftObservationContext'; type AppProvidersProps = { children: React.ReactNode; @@ -60,11 +61,13 @@ export const AppProviders = ({ appMetrics={appMetrics} deviceMetrics={deviceMetrics}> - - - {children} - - + + + + {children} + + + From 4417065275d5ebd6ffdd011c03a81e4594de14dd Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Tue, 3 Dec 2024 16:22:27 +0000 Subject: [PATCH 05/11] move draft observation hooks to hooks directory --- .../contexts/DraftObservationContext.tsx | 24 ++----------------- src/frontend/hooks/draftObservation.ts | 21 ++++++++++++++++ 2 files changed, 23 insertions(+), 22 deletions(-) create mode 100644 src/frontend/hooks/draftObservation.ts diff --git a/src/frontend/contexts/DraftObservationContext.tsx b/src/frontend/contexts/DraftObservationContext.tsx index 4e1d3946b..5629a425e 100644 --- a/src/frontend/contexts/DraftObservationContext.tsx +++ b/src/frontend/contexts/DraftObservationContext.tsx @@ -4,11 +4,8 @@ import React, { useContext, useState, } from 'react'; -import { - DraftState, - createDraftObservationStore, -} from '../hooks/persistedState/usePersistedDraftObservationNew'; -import {useStore} from 'zustand'; + +import {createDraftObservationStore} from '../hooks/persistedState/usePersistedDraftObservationNew'; const DraftObservationContext = createContext( - selector: (state: DraftState) => S = defaultSelector as any, -) { - const {store} = useDraftObservationContext(); - const draftObservation = useStore(store, selector); - return draftObservation; -} - -export function useDraftObservationActions() { - const {actions} = useDraftObservationContext(); - return actions; -} diff --git a/src/frontend/hooks/draftObservation.ts b/src/frontend/hooks/draftObservation.ts new file mode 100644 index 000000000..3b8af8de9 --- /dev/null +++ b/src/frontend/hooks/draftObservation.ts @@ -0,0 +1,21 @@ +import {useStore} from 'zustand'; + +import {useDraftObservationContext} from '../contexts/DraftObservationContext'; +import {DraftState} from '../hooks/persistedState/usePersistedDraftObservationNew'; + +function defaultSelector(state: DraftState) { + return state; +} + +export function useDraftObservation( + selector: (state: DraftState) => S = defaultSelector as any, +) { + const {store} = useDraftObservationContext(); + const draftObservation = useStore(store, selector); + return draftObservation; +} + +export function useDraftObservationActions() { + const {actions} = useDraftObservationContext(); + return actions; +} From 9d6f3ab0d3df827c87c406a0f99325ffd9bf74e4 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Tue, 3 Dec 2024 16:22:51 +0000 Subject: [PATCH 06/11] add deprecation annotation to old useDraftObservation() --- src/frontend/hooks/useDraftObservation.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/frontend/hooks/useDraftObservation.ts b/src/frontend/hooks/useDraftObservation.ts index fb15ef035..74cf49099 100644 --- a/src/frontend/hooks/useDraftObservation.ts +++ b/src/frontend/hooks/useDraftObservation.ts @@ -15,6 +15,9 @@ import * as Sentry from '@sentry/react-native'; // draft observation have 2 parts: // 1. All the information, except processed photos are saved to persisted state. // 2. Photos are processed (to be turned into a useable format by mapeo) async. Since promises cannot be stored in persisted storage, we hold those in a context. Once those photos are processed we save them to persisted state. +/** + * @deprecated Use hooks from [draftObservation.ts](./draftObservation.ts) + */ export const useDraftObservation = () => { const {addPhotoPromise, cancelPhotoProcessing, deletePhotoPromise} = usePhotoPromiseContext(); From dd81877ac853fe3ea749b53fc71e74ec816cb23f Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Tue, 3 Dec 2024 16:48:49 +0000 Subject: [PATCH 07/11] assert populated draft state in useDraftObservation() --- src/frontend/hooks/draftObservation.ts | 28 ++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/frontend/hooks/draftObservation.ts b/src/frontend/hooks/draftObservation.ts index 3b8af8de9..6d810e4d7 100644 --- a/src/frontend/hooks/draftObservation.ts +++ b/src/frontend/hooks/draftObservation.ts @@ -1,18 +1,34 @@ import {useStore} from 'zustand'; import {useDraftObservationContext} from '../contexts/DraftObservationContext'; -import {DraftState} from '../hooks/persistedState/usePersistedDraftObservationNew'; +import { + DraftState, + DraftStatePopulated, +} from '../hooks/persistedState/usePersistedDraftObservationNew'; +import {useCallback} from 'react'; -function defaultSelector(state: DraftState) { +function defaultSelector(state: DraftStatePopulated) { return state; } -export function useDraftObservation( - selector: (state: DraftState) => S = defaultSelector as any, +export function useDraftObservation( + selector: (state: DraftStatePopulated) => S = defaultSelector as any, ) { const {store} = useDraftObservationContext(); - const draftObservation = useStore(store, selector); - return draftObservation; + + const assertPopulatedStateSelector = useCallback( + (state: DraftState) => { + if (!state.value) { + throw new Error('No observation to read'); + } + return selector(state); + }, + [selector], + ); + + const value = useStore(store, assertPopulatedStateSelector); + + return value; } export function useDraftObservationActions() { From b7a80d1ef35bd5316bc437e5e539ec3ff2fc9bea Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 3 Dec 2024 16:50:46 +0000 Subject: [PATCH 08/11] fix updatePreset action --- .../usePersistedDraftObservationNew.ts | 63 ++++++++++++++++--- 1 file changed, 56 insertions(+), 7 deletions(-) diff --git a/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts b/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts index 306c6a2b9..010c4b013 100644 --- a/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts +++ b/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts @@ -1,7 +1,8 @@ import { - valueOf, + type MapeoDoc, type Observation, type ObservationValue, + type Preset, } from '@comapeo/schema'; import {createPersistedStore} from './createPersistedState.ts'; import type {LocationObject, LocationProviderStatus} from 'expo-location'; @@ -70,8 +71,16 @@ type DraftStateEmpty = { unsavedAttachments: null; }; +type ObservationWithPreset = Exclude & { + presetRef?: Preset; +}; + +type ObservationValueWithPreset = Exclude & { + presetRef?: Preset; +}; + type DraftStatePopulated = { - value: ObservationValue; + value: ObservationValueWithPreset; id: {docId: string; versionId: string} | null; unsavedAttachments: Map; }; @@ -84,7 +93,7 @@ const THUMBNAIL_COMPRESSION = 0.3; const PREVIEW_SIZE = 1200; const PREVIEW_COMPRESSION = 0.3; -function createEmptyObservationValue(): ObservationValue { +function createEmptyObservationValue(): ObservationValueWithPreset { return { schemaName: 'observation', lat: 0, @@ -296,7 +305,7 @@ export function createDraftObservationStore() { }); } - function createDraft(observation?: Observation) { + function createDraft(observation?: ObservationWithPreset) { if (observation) { store.setState({ value: valueOf(observation), @@ -361,13 +370,39 @@ export function createDraftObservationStore() { }); } - function updatePreset(presetRef: {docId: string; versionId: string}) { - // TODO: Need the hellish code to update tags based on preset change + function updatePreset(preset: Preset) { setAssertDraft(prev => { + const prevPreset = prev.value.presetRef; + if (!prevPreset) { + return { + value: { + ...prev.value, + presetRef: preset, + tags: { + ...prev.value.tags, + ...preset.tags, + ...preset.addTags, + }, + }, + }; + } + // Apply tags from new preset and remove tags from previous preset + const newTags: Observation['tags'] = {...preset.tags, ...preset.addTags}; + for (const [key, value] of Object.entries(prev.value.tags)) { + const tagWasFromPrevPreset = + prevPreset.tags[key] === value || prevPreset.addTags[key] === value; + const shouldRemoveTag = preset.removeTags[key] === value; + // Only keep tags that were not from the previous preset and are not removed by the new preset + if (!tagWasFromPrevPreset && !shouldRemoveTag) { + newTags[key] = value; + } + } + return { value: { ...prev.value, - presetRef: presetRef, + presetRef: preset, + tags: newTags, }, }; }); @@ -447,3 +482,17 @@ function convertPosition(location: LocationObject | ManualPosition): Position { : new Date().toISOString(), }; } + +// TODO: Move this to @mapeo/schema - the current version is not flexible enough +function valueOf(doc: T & {forks?: string[]}) { + return excludeKeys(doc, [ + 'docId', + 'versionId', + 'originalVersionId', + 'links', + 'forks', + 'createdAt', + 'updatedAt', + 'deleted', + ]); +} From 961a4823691bcbf0296d507528ad93786d0922f1 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 3 Dec 2024 16:54:45 +0000 Subject: [PATCH 09/11] fix exported type --- .../hooks/persistedState/usePersistedDraftObservationNew.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts b/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts index 31fb5beca..cf599bbdc 100644 --- a/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts +++ b/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts @@ -79,7 +79,7 @@ type ObservationValueWithPreset = Exclude & { presetRef?: Preset; }; -type DraftStatePopulated = { +export type DraftStatePopulated = { value: ObservationValueWithPreset; id: {docId: string; versionId: string} | null; unsavedAttachments: Map; From a4d27a899932c9506bfcd56318dc4de1faefe8d9 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Tue, 3 Dec 2024 17:09:01 +0000 Subject: [PATCH 10/11] Update CameraView component - Results in updating CameraScreen and AddPhoto screens --- .../usePersistedDraftObservationNew.ts | 2 +- src/frontend/screens/AddPhoto.tsx | 19 ++-- src/frontend/screens/CameraScreen.tsx | 20 ++-- src/frontend/sharedComponents/CameraView.tsx | 104 ++++-------------- 4 files changed, 41 insertions(+), 104 deletions(-) diff --git a/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts b/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts index cf599bbdc..ed728f4f3 100644 --- a/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts +++ b/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts @@ -43,7 +43,7 @@ type UnsavedPhotoAttachment = { abortController: AbortController; }; -type PhotoMetadata = Pick< +export type PhotoMetadata = Pick< UnsavedPhotoAttachment, 'location' | 'accelerometer' | 'timestamp' >; diff --git a/src/frontend/screens/AddPhoto.tsx b/src/frontend/screens/AddPhoto.tsx index 12b83b65e..8e3b11c18 100644 --- a/src/frontend/screens/AddPhoto.tsx +++ b/src/frontend/screens/AddPhoto.tsx @@ -5,9 +5,8 @@ import debug from 'debug'; import {defineMessages, FormattedMessage} from 'react-intl'; import {CameraView} from '../sharedComponents/CameraView'; -import {useDraftObservation} from '../hooks/useDraftObservation'; import {NativeRootNavigationProps} from '../sharedTypes/navigation'; -import {PhotoPromiseWithMetadata} from '../contexts/PhotoPromiseContext/types'; +import {useDraftObservationActions} from '../hooks/draftObservation'; const m = defineMessages({ cancel: { @@ -21,13 +20,7 @@ const log = debug('AddPhotoScreen'); export const AddPhotoScreen = ({ navigation, }: NativeRootNavigationProps<'AddPhoto'>) => { - const {addPhoto} = useDraftObservation(); - - const handleAddPress = (capture: PhotoPromiseWithMetadata) => { - log('pressed add button'); - addPhoto(capture); - navigation.pop(); - }; + const {addPhoto} = useDraftObservationActions(); const handleCancelPress = () => { log('cancelled'); @@ -36,7 +29,13 @@ export const AddPhotoScreen = ({ return ( - + { + log('pressed add photo'); + addPhoto(capturePromise, metadata); + navigation.pop(); + }} + /> diff --git a/src/frontend/screens/CameraScreen.tsx b/src/frontend/screens/CameraScreen.tsx index bdebb5958..530ebfd50 100644 --- a/src/frontend/screens/CameraScreen.tsx +++ b/src/frontend/screens/CameraScreen.tsx @@ -3,24 +3,26 @@ import {View, StyleSheet} from 'react-native'; import {useIsFocused} from '@react-navigation/native'; import {CameraView} from '../sharedComponents/CameraView'; -import {PhotoPromiseWithMetadata} from '../contexts/PhotoPromiseContext/types'; -import {useDraftObservation} from '../hooks/useDraftObservation'; import {NativeHomeTabsNavigationProps} from '../sharedTypes/navigation'; +import {useDraftObservationActions} from '../hooks/draftObservation'; export const CameraScreen = ({ navigation, }: NativeHomeTabsNavigationProps<'Camera'>) => { const isFocused = useIsFocused(); - const {newDraft} = useDraftObservation(); - - function handleAddPress(capture: PhotoPromiseWithMetadata) { - newDraft(capture); - navigation.navigate('PresetChooser'); - } + const {createDraft, addPhoto} = useDraftObservationActions(); return ( - {isFocused ? : null} + {isFocused ? ( + { + createDraft(); + addPhoto(capturePromise, metadata); + navigation.navigate('PresetChooser'); + }} + /> + ) : null} ); }; diff --git a/src/frontend/sharedComponents/CameraView.tsx b/src/frontend/sharedComponents/CameraView.tsx index 30a27e91d..e02244264 100644 --- a/src/frontend/sharedComponents/CameraView.tsx +++ b/src/frontend/sharedComponents/CameraView.tsx @@ -2,17 +2,16 @@ import React from 'react'; import {View, StyleSheet, Text} from 'react-native'; import { Camera, + CameraCapturedPicture, CameraPictureOptions, CameraType, - CameraCapturedPicture, } from 'expo-camera'; -import ImageResizer from '@bam.tech/react-native-image-resizer'; import {Accelerometer, AccelerometerMeasurement} from 'expo-sensors'; import {AddButton} from './AddButton'; import {FormattedMessage, defineMessages} from 'react-intl'; import {Subscription} from 'expo-sensors/build/DeviceSensor'; -import {PhotoPromiseWithMetadata} from '../contexts/PhotoPromiseContext/types'; +import {PhotoMetadata} from '../hooks/persistedState/usePersistedDraftObservationNew'; import {useLocation} from '../hooks/useLocation'; const m = defineMessages({ @@ -26,21 +25,22 @@ const m = defineMessages({ }, }); -const CAPTURE_QUALITY = 75; - -const captureOptions: CameraPictureOptions = { - base64: false, - exif: true, - skipProcessing: true, -}; +function createCameraPictureOptions(): CameraPictureOptions { + return { + base64: false, + exif: true, + skipProcessing: true, + }; +} type Props = { - // Called when the user takes a picture. - onAddPress: (capture: PhotoPromiseWithMetadata) => void; + onAddPress: ( + capturePromise: Promise, + metadata: PhotoMetadata, + ) => void; }; export const CameraView = ({onAddPress}: Props) => { - const [capturing, setCapturing] = React.useState(false); const [cameraReady, setCameraReady] = React.useState(false); const ref = React.useRef(null); const accelerometerMeasurement = React.useRef(); @@ -75,31 +75,14 @@ export const CameraView = ({onAddPress}: Props) => { throw new Error('Camera Not Ready'); } - // if there is a double click of the button => ignore - if (capturing) { - return; - } - - setCapturing(true); + onAddPress(ref.current.takePictureAsync(createCameraPictureOptions()), { + accelerometer: accelerometerMeasurement.current, + location, + timestamp: Date.now(), + }); + }, [onAddPress, location]); - ref.current - .takePictureAsync(captureOptions) - .then(pic => { - onAddPress({ - capturePromise: rotatePhoto(pic, accelerometerMeasurement.current), - mediaMetadata: {location, timestamp: Date.now()}, - }); - }) - .catch(err => { - console.log(err); - setCapturing(false); - }) - .finally(() => { - setCapturing(false); - }); - }, [capturing, setCapturing, onAddPress, location]); - - const disableButton = capturing || !cameraReady; + const disableButton = !cameraReady; const permissionGranted = permissionsResponse?.status === 'granted'; return ( @@ -132,53 +115,6 @@ export const CameraView = ({onAddPress}: Props) => { ); }; -function rotatePhoto( - {uri, width, height}: CameraCapturedPicture, - acc?: AccelerometerMeasurement, -) { - const resizePromise = ImageResizer.createResizedImage( - uri, - width, - height, - 'JPEG', - CAPTURE_QUALITY, - getPhotoRotation(acc), - ).then(({uri}) => { - return {uri}; - }); - - return resizePromise; -} - -const ACC_AT_45_DEG = Math.sin(Math.PI / 4); - -function getPhotoRotation(acc?: AccelerometerMeasurement) { - if (!acc) return 0; - const {x, y, z} = acc; - let rotation = 0; - if (z < -ACC_AT_45_DEG || z > ACC_AT_45_DEG) { - // camera is pointing up or down - if (Math.abs(y) > Math.abs(x)) { - // camera is vertical - if (y <= 0) rotation = 180; - else rotation = 0; - } else { - // camera is horizontal - if (x >= 0) rotation = -90; - else rotation = 90; - } - } else if (x > -ACC_AT_45_DEG && x < ACC_AT_45_DEG) { - // camera is vertical - if (y <= 0) rotation = 180; - else rotation = 0; - } else { - // camera is horizontal - if (x >= 0) rotation = -90; - else rotation = 90; - } - return rotation; -} - const styles = StyleSheet.create({ container: { flex: 1, From 4e6d360a7b58ec1632eb7e191fc7a2907edf301c Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 3 Dec 2024 22:54:04 +0000 Subject: [PATCH 11/11] feat: update draft observation location --- .../contexts/DraftObservationContext.tsx | 138 +++++++++++++++++- .../usePersistedDraftObservationNew.ts | 60 +++++--- 2 files changed, 173 insertions(+), 25 deletions(-) diff --git a/src/frontend/contexts/DraftObservationContext.tsx b/src/frontend/contexts/DraftObservationContext.tsx index 5629a425e..5e4c6ad4d 100644 --- a/src/frontend/contexts/DraftObservationContext.tsx +++ b/src/frontend/contexts/DraftObservationContext.tsx @@ -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 | null>(null); export function DraftObservationProvider({children}: PropsWithChildren<{}>) { - const [value] = useState(() => createDraftObservationStore()); + const [value] = useState(() => { + const store = createDraftObservationStore(); + createDraftObservationLocationUpdator(store); + return store; + }); return ( @@ -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) { + let {granted: locationPermissionGranted} = + await Location.getForegroundPermissionsAsync(); + let locationSubscriptionPromise: Promise | 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]); +} diff --git a/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts b/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts index ed728f4f3..e0c162b1f 100644 --- a/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts +++ b/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts @@ -69,6 +69,7 @@ type DraftStateEmpty = { value: null; id: null; unsavedAttachments: null; + initialPosition: null; }; type ObservationWithPreset = Exclude & { @@ -83,6 +84,9 @@ export type DraftStatePopulated = { value: ObservationValueWithPreset; id: {docId: string; versionId: string} | null; unsavedAttachments: Map; + /** 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; @@ -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', @@ -126,11 +139,7 @@ export function createDraftObservationStore() { let nextAttachmentId = 0; const store = createPersistedStore( - () => ({ - value: null, - id: null, - unsavedAttachments: null, - }), + () => createEmptyStoreState(), '@MapeoDraft', ); @@ -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, + ); } } @@ -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; @@ -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: {