From 3ba90518e3a38a3cada0500219f346033736e635 Mon Sep 17 00:00:00 2001
From: latin-panda <66472237+latin-panda@users.noreply.github.com>
Date: Thu, 16 Oct 2025 23:40:11 +0700
Subject: [PATCH 01/25] Initial input setup and loading saved feature
---
.changeset/true-snakes-hear.md | 5 +
.../src/fixtures/geopoint/geopoint-map.xml | 61 ++++++++++
.../src/components/common/map/AsyncMap.vue | 3 +-
.../map/createFeatureCollectionAndProps.ts | 37 ++++--
.../form-elements/input/InputControl.vue | 4 +-
.../input/InputGeopointWithMap.vue | 19 +++
.../createFeatureCollectionAndProps.test.ts | 115 ++++++++++++++----
7 files changed, 207 insertions(+), 37 deletions(-)
create mode 100644 .changeset/true-snakes-hear.md
create mode 100644 packages/common/src/fixtures/geopoint/geopoint-map.xml
create mode 100644 packages/web-forms/src/components/form-elements/input/InputGeopointWithMap.vue
diff --git a/.changeset/true-snakes-hear.md b/.changeset/true-snakes-hear.md
new file mode 100644
index 000000000..3ebb057c2
--- /dev/null
+++ b/.changeset/true-snakes-hear.md
@@ -0,0 +1,5 @@
+---
+'@getodk/web-forms': minor
+---
+
+Adds support for Geopoint with a "maps" appearance.
diff --git a/packages/common/src/fixtures/geopoint/geopoint-map.xml b/packages/common/src/fixtures/geopoint/geopoint-map.xml
new file mode 100644
index 000000000..e4379e0aa
--- /dev/null
+++ b/packages/common/src/fixtures/geopoint/geopoint-map.xml
@@ -0,0 +1,61 @@
+
+
+
+ Geopoint
+
+
+
+
+ Where are you?
+
+
+ Your age
+
+
+ Where are you again?
+
+
+
+
+ Où es-tu?
+
+
+ Ton âge
+
+
+ Où es-tu encore?
+
+
+
+
+
+ 40.7128 -74.0060 100 5
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/web-forms/src/components/common/map/AsyncMap.vue b/packages/web-forms/src/components/common/map/AsyncMap.vue
index cac6e5e87..4f0a205c4 100644
--- a/packages/web-forms/src/components/common/map/AsyncMap.vue
+++ b/packages/web-forms/src/components/common/map/AsyncMap.vue
@@ -20,8 +20,7 @@ type MapBlockComponent = DefineComponent<{
}>;
interface AsyncMapProps {
- // ToDo: Expand typing when implementing Geo Point/Shape/Trace question types.
- features: readonly SelectItem[];
+ features: readonly SelectItem[] | readonly string[];
disabled: boolean;
savedFeatureValue: string | undefined;
}
diff --git a/packages/web-forms/src/components/common/map/createFeatureCollectionAndProps.ts b/packages/web-forms/src/components/common/map/createFeatureCollectionAndProps.ts
index 048206e14..a5a2f0053 100644
--- a/packages/web-forms/src/components/common/map/createFeatureCollectionAndProps.ts
+++ b/packages/web-forms/src/components/common/map/createFeatureCollectionAndProps.ts
@@ -61,18 +61,37 @@ const getGeoJSONGeometry = (coords: Coordinates[]): Geometry => {
return { type: 'LineString', coordinates: coords };
};
-export const createFeatureCollectionAndProps = (odkFeatures: readonly SelectItem[] | undefined) => {
+const normalizeODKFeature = (odkFeature: SelectItem | string) => {
+ if (typeof odkFeature === 'string') {
+ return {
+ label: odkFeature,
+ value: odkFeature,
+ properties: [['geometry', odkFeature]],
+ };
+ }
+
+ return {
+ ...odkFeature,
+ label: odkFeature.label?.asString,
+ };
+};
+
+export const createFeatureCollectionAndProps = (
+ odkFeatures: readonly SelectItem[] | readonly string[] | undefined
+) => {
const orderedExtraPropsMap = new Map>();
const features: Feature[] = [];
- odkFeatures?.forEach((option) => {
+ odkFeatures?.forEach((odkFeature) => {
+ const normalizedFeature = normalizeODKFeature(odkFeature);
+
const orderedProps: Array<[string, string]> = [];
const reservedProps: Record = {
- [PROPERTY_PREFIX + 'label']: option.label?.asString,
- [PROPERTY_PREFIX + 'value']: option.value,
+ [PROPERTY_PREFIX + 'label']: normalizedFeature.label,
+ [PROPERTY_PREFIX + 'value']: normalizedFeature.value,
};
- option.properties.forEach(([key, value]) => {
+ normalizedFeature.properties.forEach(([key, value]) => {
if (RESERVED_MAP_PROPERTIES.includes(key)) {
reservedProps[PROPERTY_PREFIX + key] = value.trim();
} else {
@@ -80,19 +99,21 @@ export const createFeatureCollectionAndProps = (odkFeatures: readonly SelectItem
}
});
- orderedExtraPropsMap.set(option.value, orderedProps);
+ if (orderedProps.length) {
+ orderedExtraPropsMap.set(normalizedFeature.value, orderedProps);
+ }
const geometry = reservedProps[PROPERTY_PREFIX + 'geometry'];
if (!geometry?.length) {
// eslint-disable-next-line no-console -- Skip silently to match Collect behaviour.
- console.warn(`Missing or empty geometry for option: ${option.value}`);
+ console.warn(`Missing or empty geometry for option: ${normalizedFeature.value}`);
return;
}
const geoJSONCoords = getGeoJSONCoordinates(geometry);
if (!geoJSONCoords?.length) {
// eslint-disable-next-line no-console -- Skip silently to match Collect behaviour.
- console.warn(`Missing geo points for option: ${option.value}`);
+ console.warn(`Missing geo points for option: ${normalizedFeature.value}`);
return;
}
diff --git a/packages/web-forms/src/components/form-elements/input/InputControl.vue b/packages/web-forms/src/components/form-elements/input/InputControl.vue
index e5889a69b..d6402cbe4 100644
--- a/packages/web-forms/src/components/form-elements/input/InputControl.vue
+++ b/packages/web-forms/src/components/form-elements/input/InputControl.vue
@@ -2,6 +2,7 @@
import ValidationMessage from '@/components/common/ValidationMessage.vue';
import ControlText from '@/components/form-elements/ControlText.vue';
import InputGeopoint from '@/components/form-elements/input/geopoint/InputGeopoint.vue';
+import InputGeopointWithMap from '@/components/form-elements/input/InputGeopointWithMap.vue';
import InputDate from '@/components/form-elements/input/InputDate.vue';
import InputDecimal from '@/components/form-elements/input/InputDecimal.vue';
import InputInt from '@/components/form-elements/input/InputInt.vue';
@@ -30,7 +31,8 @@ defineProps();
-
+
+
diff --git a/packages/web-forms/src/components/form-elements/input/InputGeopointWithMap.vue b/packages/web-forms/src/components/form-elements/input/InputGeopointWithMap.vue
new file mode 100644
index 000000000..dec3d814d
--- /dev/null
+++ b/packages/web-forms/src/components/form-elements/input/InputGeopointWithMap.vue
@@ -0,0 +1,19 @@
+
+
+
+ question.setValue(value ?? '')"
+ />
+
diff --git a/packages/web-forms/tests/components/common/map/createFeatureCollectionAndProps.test.ts b/packages/web-forms/tests/components/common/map/createFeatureCollectionAndProps.test.ts
index cecae6264..af5e47174 100644
--- a/packages/web-forms/tests/components/common/map/createFeatureCollectionAndProps.test.ts
+++ b/packages/web-forms/tests/components/common/map/createFeatureCollectionAndProps.test.ts
@@ -46,7 +46,7 @@ describe('createFeatureCollectionAndProps', () => {
],
});
- expect(orderedExtraPropsMap).toEqual(new Map([['point1', []]]));
+ expect(orderedExtraPropsMap).toEqual(new Map());
});
it('converts an ODK trace to a GeoJSON LineString feature', () => {
@@ -81,7 +81,7 @@ describe('createFeatureCollectionAndProps', () => {
],
});
- expect(orderedExtraPropsMap).toEqual(new Map([['trace1', []]]));
+ expect(orderedExtraPropsMap).toEqual(new Map());
});
it('converts an ODK shape to a GeoJSON Polygon feature', () => {
@@ -122,7 +122,7 @@ describe('createFeatureCollectionAndProps', () => {
],
});
- expect(orderedExtraPropsMap).toEqual(new Map([['shape1', []]]));
+ expect(orderedExtraPropsMap).toEqual(new Map());
});
it('skips features with invalid ODK geometry and logs warning', () => {
@@ -158,12 +158,7 @@ describe('createFeatureCollectionAndProps', () => {
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining('Invalid geo point coordinates: invalid-geometry')
);
- expect(orderedExtraPropsMap).toEqual(
- new Map([
- ['invalid1', []],
- ['point1', []],
- ])
- );
+ expect(orderedExtraPropsMap).toEqual(new Map());
});
it('skips features with empty ODK geometry and logs warning', () => {
@@ -199,12 +194,7 @@ describe('createFeatureCollectionAndProps', () => {
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining('Missing or empty geometry for option: empty1')
);
- expect(orderedExtraPropsMap).toEqual(
- new Map([
- ['empty1', []],
- ['point1', []],
- ])
- );
+ expect(orderedExtraPropsMap).toEqual(new Map());
});
it('handles undefined odkFeatures input', () => {
@@ -301,7 +291,7 @@ describe('createFeatureCollectionAndProps', () => {
],
});
- expect(orderedExtraPropsMap).toEqual(new Map([['point1', []]]));
+ expect(orderedExtraPropsMap).toEqual(new Map());
});
it('handles invalid ODK coordinates (out of range)', () => {
@@ -337,12 +327,7 @@ describe('createFeatureCollectionAndProps', () => {
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining('Invalid geo point coordinates: 100 -200 100 5')
);
- expect(orderedExtraPropsMap).toEqual(
- new Map([
- ['invalid1', []],
- ['point1', []],
- ])
- );
+ expect(orderedExtraPropsMap).toEqual(new Map());
});
it('handles partial ODK point format (missing altitude/accuracy)', () => {
@@ -372,7 +357,7 @@ describe('createFeatureCollectionAndProps', () => {
],
});
- expect(orderedExtraPropsMap).toEqual(new Map([['point1', []]]));
+ expect(orderedExtraPropsMap).toEqual(new Map());
});
it('handles ODK point with extra whitespace', () => {
@@ -404,7 +389,7 @@ describe('createFeatureCollectionAndProps', () => {
],
});
- expect(orderedExtraPropsMap).toEqual(new Map([['point1', []]]));
+ expect(orderedExtraPropsMap).toEqual(new Map());
});
it('converts multiple ODK features (Point, LineString, Polygon, invalid) to a GeoJSON FeatureCollection', () => {
@@ -490,9 +475,7 @@ describe('createFeatureCollectionAndProps', () => {
expect(orderedExtraPropsMap).toEqual(
new Map([
['point1', [['custom-prop', 'value1']]],
- ['trace1', []],
['shape1', [['another-prop', 'value2']]],
- ['invalid1', []],
])
);
@@ -501,4 +484,84 @@ describe('createFeatureCollectionAndProps', () => {
expect.stringContaining('Invalid geo point coordinates: invalid-geometry')
);
});
+
+ it('converts string ODK features to a GeoJSON FeatureCollection', () => {
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const odkFeatures: string[] = [
+ '40.7128 -74.0060 100 5',
+ '40.7128 -74.0060 100 5;40.7129 -74.0061 100 5',
+ '40.7128 -74.0060 100 5;40.7129 -74.0061 100 5;40.7128 -74.0060 100 5',
+ '', // invalid feature
+ '40.7128', // invalid feature
+ 'abc', // invalid feature
+ ];
+
+ const { featureCollection, orderedExtraPropsMap } =
+ createFeatureCollectionAndProps(odkFeatures);
+
+ expect(featureCollection).toEqual({
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ geometry: {
+ type: 'Point',
+ coordinates: [-74.006, 40.7128],
+ },
+ properties: {
+ odk_label: '40.7128 -74.0060 100 5',
+ odk_value: '40.7128 -74.0060 100 5',
+ odk_geometry: '40.7128 -74.0060 100 5',
+ },
+ },
+ {
+ type: 'Feature',
+ geometry: {
+ type: 'LineString',
+ coordinates: [
+ [-74.006, 40.7128],
+ [-74.0061, 40.7129],
+ ],
+ },
+ properties: {
+ odk_label: '40.7128 -74.0060 100 5;40.7129 -74.0061 100 5',
+ odk_value: '40.7128 -74.0060 100 5;40.7129 -74.0061 100 5',
+ odk_geometry: '40.7128 -74.0060 100 5;40.7129 -74.0061 100 5',
+ },
+ },
+ {
+ type: 'Feature',
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [-74.006, 40.7128],
+ [-74.0061, 40.7129],
+ [-74.006, 40.7128],
+ ],
+ ],
+ },
+ properties: {
+ odk_label: '40.7128 -74.0060 100 5;40.7129 -74.0061 100 5;40.7128 -74.0060 100 5',
+ odk_value: '40.7128 -74.0060 100 5;40.7129 -74.0061 100 5;40.7128 -74.0060 100 5',
+ odk_geometry: '40.7128 -74.0060 100 5;40.7129 -74.0061 100 5;40.7128 -74.0060 100 5',
+ },
+ },
+ ],
+ });
+
+ expect(orderedExtraPropsMap).toEqual(new Map());
+
+ // eslint-disable-next-line no-console
+ expect(console.warn).toHaveBeenCalledWith('Missing or empty geometry for option: ');
+ // eslint-disable-next-line no-console
+ expect(console.warn).toHaveBeenCalledWith('Invalid geo point coordinates: 40.7128');
+ // eslint-disable-next-line no-console
+ expect(console.warn).toHaveBeenCalledWith('Missing geo points for option: 40.7128');
+ // eslint-disable-next-line no-console
+ expect(console.warn).toHaveBeenCalledWith('Invalid geo point coordinates: abc');
+ // eslint-disable-next-line no-console
+ expect(console.warn).toHaveBeenCalledWith('Missing geo points for option: abc');
+ });
});
From 43df2b44d6a9e54ba8d364a230918eb0d02b5786 Mon Sep 17 00:00:00 2001
From: latin-panda <66472237+latin-panda@users.noreply.github.com>
Date: Sat, 18 Oct 2025 01:00:07 +0700
Subject: [PATCH 02/25] Adds watch to current location function
---
.../src/assets/images/location-icon.svg | 25 ++++++
.../src/components/common/map/MapBlock.vue | 1 +
.../src/components/common/map/useMapBlock.ts | 90 ++++++++++++++-----
3 files changed, 92 insertions(+), 24 deletions(-)
create mode 100644 packages/web-forms/src/assets/images/location-icon.svg
diff --git a/packages/web-forms/src/assets/images/location-icon.svg b/packages/web-forms/src/assets/images/location-icon.svg
new file mode 100644
index 000000000..407fdeb31
--- /dev/null
+++ b/packages/web-forms/src/assets/images/location-icon.svg
@@ -0,0 +1,25 @@
+
diff --git a/packages/web-forms/src/components/common/map/MapBlock.vue b/packages/web-forms/src/components/common/map/MapBlock.vue
index ecd2f3d32..90efae0da 100644
--- a/packages/web-forms/src/components/common/map/MapBlock.vue
+++ b/packages/web-forms/src/components/common/map/MapBlock.vue
@@ -43,6 +43,7 @@ onMounted(() => {
onUnmounted(() => {
document.removeEventListener('keydown', handleEscapeKey);
+ mapHandler.stopWatchingCurrentLocation();
});
watch(
diff --git a/packages/web-forms/src/components/common/map/useMapBlock.ts b/packages/web-forms/src/components/common/map/useMapBlock.ts
index 4eeac4f23..c7692816c 100644
--- a/packages/web-forms/src/components/common/map/useMapBlock.ts
+++ b/packages/web-forms/src/components/common/map/useMapBlock.ts
@@ -11,15 +11,21 @@ import Feature from 'ol/Feature';
import GeoJSON from 'ol/format/GeoJSON';
import { LineString, Point, Polygon } from 'ol/geom';
import TileLayer from 'ol/layer/Tile';
+import VectorLayer from 'ol/layer/Vector';
import WebGLVectorLayer from 'ol/layer/WebGLVector';
import type { Pixel } from 'ol/pixel';
import { fromLonLat } from 'ol/proj';
import { OSM } from 'ol/source';
import VectorSource from 'ol/source/Vector';
+import { Icon, Style } from 'ol/style';
import { computed, shallowRef, watch } from 'vue';
import { get as getProjection } from 'ol/proj';
+import locationIcon from '@/assets/images/location-icon.svg';
type GeometryType = LineString | Point | Polygon;
+type LocationWatchID = ReturnType;
+interface BrowserLocation
+ extends Pick {}
const STATES = {
LOADING: 'loading',
@@ -40,9 +46,13 @@ const SAVED_ID_PROPERTY = 'savedId';
const SELECTED_ID_PROPERTY = 'selectedId';
export function useMapBlock() {
+ let mapInstance: Map | undefined;
+
const currentState = shallowRef<(typeof STATES)[keyof typeof STATES]>(STATES.LOADING);
const errorMessage = shallowRef<{ title: string; message: string } | undefined>();
- let mapInstance: Map | undefined;
+ const watchLocation = shallowRef();
+
+ const lastUserLocation = shallowRef();
const savedFeature = shallowRef | undefined>();
const selectedFeature = shallowRef | undefined>();
const selectedFeatureProperties = computed(() => {
@@ -60,6 +70,12 @@ export function useMapBlock() {
variables: { [SAVED_ID_PROPERTY]: '', [SELECTED_ID_PROPERTY]: '' },
});
+ const currentLocationSource = new VectorSource();
+ const currentLocationLayer = new VectorLayer({
+ source: currentLocationSource,
+ style: new Style({ image: new Icon({ src: locationIcon }) }),
+ });
+
const initializeMap = (mapContainer: HTMLElement, geoJSON: FeatureCollection): void => {
if (mapInstance) {
return;
@@ -67,7 +83,7 @@ export function useMapBlock() {
mapInstance = new Map({
target: mapContainer,
- layers: [new TileLayer({ source: new OSM() }), featuresVectorLayer],
+ layers: [new TileLayer({ source: new OSM() }), currentLocationLayer, featuresVectorLayer],
view: new View({
center: DEFAULT_VIEW_CENTER,
zoom: MIN_ZOOM,
@@ -118,33 +134,41 @@ export function useMapBlock() {
}
};
- const centerCurrentLocation = (): void => {
- // TODO: translations
- const friendlyError = {
- title: 'Cannot access location',
- message:
- 'Grant location permission in the browser settings and make sure location is turned on.',
+ const stopWatchingCurrentLocation = () => {
+ if (watchLocation.value) {
+ navigator.geolocation.clearWatch(watchLocation.value);
+ watchLocation.value = undefined;
+ }
+ };
+
+ const centerCurrentLocation = (startWatch?: boolean): void => {
+ const options = { enableHighAccuracy: true, timeout: GEOLOCATION_TIMEOUT_MS };
+
+ const handleSucess = (position: GeolocationPosition) => {
+ const { latitude, longitude, altitude, accuracy } = position.coords;
+ lastUserLocation.value = { latitude, longitude, altitude, accuracy };
};
- if (!navigator.geolocation) {
+ const handleError = () => {
currentState.value = STATES.ERROR;
- errorMessage.value = friendlyError;
+ // TODO: translations
+ errorMessage.value = {
+ title: 'Cannot access location',
+ message:
+ 'Grant location permission in the browser settings and make sure location is turned on.',
+ };
+ };
+
+ if (!navigator.geolocation) {
+ handleError();
+ }
+
+ if (startWatch) {
+ watchLocation.value = navigator.geolocation.watchPosition(handleSucess, handleError, options);
+ return;
}
- navigator.geolocation.getCurrentPosition(
- (position) => {
- const coords = fromLonLat([position.coords.longitude, position.coords.latitude]);
- mapInstance
- ?.getView()
- .animate({ center: coords, zoom: MAX_ZOOM, duration: ANIMATION_TIME });
- currentState.value = STATES.READY;
- },
- () => {
- currentState.value = STATES.ERROR;
- errorMessage.value = friendlyError;
- },
- { enableHighAccuracy: true, timeout: GEOLOCATION_TIMEOUT_MS }
- );
+ navigator.geolocation.getCurrentPosition(handleSucess, handleError, options);
};
const centerFeatureLocation = (feature: Feature): void => {
@@ -285,6 +309,23 @@ export function useMapBlock() {
}
);
+ watch(
+ () => lastUserLocation.value,
+ (newLocation) => {
+ currentLocationSource.clear(true);
+ if (!newLocation) {
+ return;
+ }
+
+ const parsedCoords = fromLonLat([newLocation.longitude, newLocation.latitude]);
+ currentLocationSource.addFeature(new Feature({ geometry: new Point(parsedCoords) }));
+ mapInstance
+ ?.getView()
+ .animate({ center: parsedCoords, zoom: MAX_ZOOM, duration: ANIMATION_TIME });
+ currentState.value = STATES.READY;
+ }
+ );
+
return {
initializeMap,
loadGeometries,
@@ -292,6 +333,7 @@ export function useMapBlock() {
toggleClickBinding,
centerCurrentLocation,
+ stopWatchingCurrentLocation,
fitToAllFeatures,
savedFeature,
From c5c479aca89e4d601cb5ddaa606326e8471c7f2f Mon Sep 17 00:00:00 2001
From: latin-panda <66472237+latin-panda@users.noreply.github.com>
Date: Thu, 30 Oct 2025 00:41:41 +0700
Subject: [PATCH 03/25] maps and placement-maps interactions
---
.../src/components/common/map/AsyncMap.vue | 15 +-
.../src/components/common/map/MapBlock.vue | 94 +++--
.../components/common/map/MapProperties.vue | 9 +-
.../components/common/map/MapStatusBar.vue | 54 ++-
.../components/common/map/getModeConfig.ts | 90 ++++
.../src/components/common/map/map-styles.ts | 12 +-
.../src/components/common/map/useMapBlock.ts | 384 ++++++++++++++++--
.../form-elements/input/InputControl.vue | 2 +-
.../input/InputGeopointWithMap.vue | 20 +-
.../form-elements/select/Select1Control.vue | 2 +
10 files changed, 586 insertions(+), 96 deletions(-)
create mode 100644 packages/web-forms/src/components/common/map/getModeConfig.ts
diff --git a/packages/web-forms/src/components/common/map/AsyncMap.vue b/packages/web-forms/src/components/common/map/AsyncMap.vue
index 4f0a205c4..387fbfd1f 100644
--- a/packages/web-forms/src/components/common/map/AsyncMap.vue
+++ b/packages/web-forms/src/components/common/map/AsyncMap.vue
@@ -7,6 +7,7 @@
import type { SelectItem } from '@getodk/xforms-engine';
import ProgressSpinner from 'primevue/progressspinner';
import { computed, type DefineComponent, onMounted, shallowRef } from 'vue';
+import type { Mode } from '@/components/common/map/getModeConfig.ts';
import {
createFeatureCollectionAndProps,
type Feature,
@@ -15,13 +16,15 @@ import {
type MapBlockComponent = DefineComponent<{
featureCollection: { type: string; features: Feature[] };
disabled: boolean;
+ mode: Mode;
orderedExtraProps: Map>;
- savedFeatureValue: string | undefined;
+ savedFeatureValue: Feature | undefined;
}>;
interface AsyncMapProps {
- features: readonly SelectItem[] | readonly string[];
+ features?: readonly SelectItem[];
disabled: boolean;
+ mode: Mode;
savedFeatureValue: string | undefined;
}
@@ -37,6 +40,13 @@ const STATES = {
const mapComponent = shallowRef(null);
const currentState = shallowRef<(typeof STATES)[keyof typeof STATES]>(STATES.LOADING);
const featureCollectionAndProps = computed(() => createFeatureCollectionAndProps(props.features));
+const savedFeatureValue = computed(() => {
+ if (!props.savedFeatureValue) {
+ return;
+ }
+ const { featureCollection } = createFeatureCollectionAndProps([props.savedFeatureValue]);
+ return featureCollection.features?.[0];
+});
const loadMap = async () => {
currentState.value = STATES.LOADING;
@@ -73,6 +83,7 @@ onMounted(loadMap);
:is="mapComponent"
v-else
:feature-collection="featureCollectionAndProps.featureCollection"
+ :mode="mode"
:ordered-extra-props="featureCollectionAndProps.orderedExtraPropsMap"
:saved-feature-value="savedFeatureValue"
:disabled="disabled"
diff --git a/packages/web-forms/src/components/common/map/MapBlock.vue b/packages/web-forms/src/components/common/map/MapBlock.vue
index 1148f0396..c66074cd8 100644
--- a/packages/web-forms/src/components/common/map/MapBlock.vue
+++ b/packages/web-forms/src/components/common/map/MapBlock.vue
@@ -5,18 +5,21 @@
* load on demand. Avoids main bundle bloat.
*/
import IconSVG from '@/components/common/IconSVG.vue';
+import type { Mode } from '@/components/common/map/getModeConfig.ts';
import MapProperties from '@/components/common/map/MapProperties.vue';
import MapStatusBar from '@/components/common/map/MapStatusBar.vue';
-import { useMapBlock } from '@/components/common/map/useMapBlock.ts';
+import { STATES, useMapBlock } from '@/components/common/map/useMapBlock.ts';
import { QUESTION_HAS_ERROR } from '@/lib/constants/injection-keys.ts';
-import type { FeatureCollection } from 'geojson';
+import type { FeatureCollection, Feature } from 'geojson';
+import Button from 'primevue/button';
import { computed, type ComputedRef, inject, onMounted, onUnmounted, ref, watch } from 'vue';
interface MapBlockProps {
featureCollection: FeatureCollection;
disabled: boolean;
+ mode: Mode;
orderedExtraProps: Map>;
- savedFeatureValue: string | undefined;
+ savedFeatureValue: Feature | undefined;
}
const props = defineProps();
@@ -28,42 +31,36 @@ const showErrorStyle = inject>(
computed(() => false)
);
-const mapHandler = useMapBlock();
+const mapHandler = useMapBlock(props.mode, () => emitSavedFeature());
onMounted(() => {
if (!mapElement.value || !mapHandler) {
return;
}
- mapHandler.initializeMap(mapElement.value, props.featureCollection);
- mapHandler.toggleClickBinding(!props.disabled);
- mapHandler.setSavedByValueProp(props.savedFeatureValue);
+ mapHandler.initMap(mapElement.value, props.featureCollection, props.savedFeatureValue);
+ mapHandler.setupMapInteractions(props.disabled);
document.addEventListener('keydown', handleEscapeKey);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleEscapeKey);
- mapHandler.stopWatchingCurrentLocation();
+ mapHandler.teardownMap();
});
watch(
() => props.featureCollection,
- (newData) => {
- mapHandler.loadGeometries(newData);
- mapHandler.setSavedByValueProp(props.savedFeatureValue);
- },
+ (newData) => mapHandler.updateFeatureCollection(newData, props.savedFeatureValue),
{ deep: true }
);
-watch(
- () => props.savedFeatureValue,
- (newSaved) => mapHandler.setSavedByValueProp(newSaved)
-);
+watch(() => props.savedFeatureValue, mapHandler.setSavedByValueProp);
-watch(
- () => props.disabled,
- (newValue) => mapHandler.toggleClickBinding(!newValue)
-);
+watch(() => props.disabled, mapHandler.setupMapInteractions);
+
+const emitSavedFeature = () => {
+ emit('save', mapHandler.savedFeature.value?.getProperties()?.odk_value);
+};
const handleEscapeKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isFullScreen.value) {
@@ -73,7 +70,7 @@ const handleEscapeKey = (event: KeyboardEvent) => {
const saveSelection = () => {
mapHandler.saveFeature();
- emit('save', mapHandler.savedFeature.value?.getProperties()?.odk_value);
+ emitSavedFeature();
};
const discardSavedFeature = () => {
@@ -87,33 +84,58 @@ const discardSavedFeature = () => {
-
-
+
+
+
+
+
+
+
+ Get location
+
+
+
{
overflow: hidden;
.map-block {
+ position: relative;
background: var(--odk-base-background-color);
width: 100%;
height: 445px;
}
+
+ .map-overlay {
+ position: absolute;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(from var(--odk-muted-background-color) r g b / 0.9);
+ z-index: var(--odk-z-index-overlay);
+ }
}
.map-container.map-full-screen {
diff --git a/packages/web-forms/src/components/common/map/MapProperties.vue b/packages/web-forms/src/components/common/map/MapProperties.vue
index 553272915..ffab6dad2 100644
--- a/packages/web-forms/src/components/common/map/MapProperties.vue
+++ b/packages/web-forms/src/components/common/map/MapProperties.vue
@@ -6,8 +6,9 @@ import { computed } from 'vue';
const props = defineProps<{
reservedProps: Record;
orderedExtraProps: Map>;
- hasSavedFeature: boolean;
- disabled: boolean;
+ isFeatureSaved: boolean;
+ canRemove: boolean;
+ canSave: boolean;
}>();
const emit = defineEmits(['close', 'save', 'discard']);
@@ -32,12 +33,12 @@ const orderedProps = computed(() => {