diff --git a/.changeset/young-nails-type.md b/.changeset/young-nails-type.md new file mode 100644 index 000000000..b3beb42d3 --- /dev/null +++ b/.changeset/young-nails-type.md @@ -0,0 +1,5 @@ +--- +'@getodk/web-forms': minor +--- + +Adds support for editing the coordinates of a vertex or point diff --git a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-autoclose-polygon-chromium.png b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-autoclose-polygon-chromium.png index 5e654b6cf..e8b15756f 100644 Binary files a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-autoclose-polygon-chromium.png and b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-autoclose-polygon-chromium.png differ diff --git a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-autoclose-polygon-firefox.png b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-autoclose-polygon-firefox.png index 670cc04fc..a304b6cc5 100644 Binary files a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-autoclose-polygon-firefox.png and b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-autoclose-polygon-firefox.png differ diff --git a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-autoclose-polygon-webkit.png b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-autoclose-polygon-webkit.png index d1896e73b..28ce08d7e 100644 Binary files a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-autoclose-polygon-webkit.png and b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-autoclose-polygon-webkit.png differ diff --git a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-three-points-chromium.png b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-three-points-chromium.png index c6dd5a86d..c7bf1ee90 100644 Binary files a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-three-points-chromium.png and b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-three-points-chromium.png differ diff --git a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-three-points-firefox.png b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-three-points-firefox.png index af48b2b8f..4cf6017b2 100644 Binary files a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-three-points-firefox.png and b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-three-points-firefox.png differ diff --git a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-three-points-webkit.png b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-three-points-webkit.png index 6efcfea7f..36afbcc5c 100644 Binary files a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-three-points-webkit.png and b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geoshape-three-points-webkit.png differ diff --git a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geotrace-two-points-chromium.png b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geotrace-two-points-chromium.png index ce75bbd31..c6e460a8e 100644 Binary files a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geotrace-two-points-chromium.png and b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geotrace-two-points-chromium.png differ diff --git a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geotrace-two-points-firefox.png b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geotrace-two-points-firefox.png index c021836f8..2d6576667 100644 Binary files a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geotrace-two-points-firefox.png and b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geotrace-two-points-firefox.png differ diff --git a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geotrace-two-points-webkit.png b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geotrace-two-points-webkit.png index 244b060df..e07f025ed 100644 Binary files a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geotrace-two-points-webkit.png and b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/geotrace-two-points-webkit.png differ diff --git a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-current-location-saved-chromium.png b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-current-location-saved-chromium.png index d7ea9662f..16b549c9e 100644 Binary files a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-current-location-saved-chromium.png and b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-current-location-saved-chromium.png differ diff --git a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-current-location-saved-firefox.png b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-current-location-saved-firefox.png index afff3b354..b2bf505f7 100644 Binary files a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-current-location-saved-firefox.png and b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-current-location-saved-firefox.png differ diff --git a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-current-location-saved-webkit.png b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-current-location-saved-webkit.png index dbe67e509..242f9f698 100644 Binary files a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-current-location-saved-webkit.png and b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-current-location-saved-webkit.png differ diff --git a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-new-point-saved-chromium.png b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-new-point-saved-chromium.png index e19cdc924..59b5de458 100644 Binary files a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-new-point-saved-chromium.png and b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-new-point-saved-chromium.png differ diff --git a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-new-point-saved-firefox.png b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-new-point-saved-firefox.png index 7899cd859..3df1d151f 100644 Binary files a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-new-point-saved-firefox.png and b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-new-point-saved-firefox.png differ diff --git a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-new-point-saved-webkit.png b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-new-point-saved-webkit.png index 70440800d..c7cd28771 100644 Binary files a/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-new-point-saved-webkit.png and b/packages/web-forms/e2e/test-cases/visual/all-question-types.test.ts-snapshots/placement-map-new-point-saved-webkit.png differ diff --git a/packages/web-forms/src/components/common/IconSVG.vue b/packages/web-forms/src/components/common/IconSVG.vue index c44935775..1d4fe26ab 100644 --- a/packages/web-forms/src/components/common/IconSVG.vue +++ b/packages/web-forms/src/components/common/IconSVG.vue @@ -12,6 +12,7 @@ import { mdiChevronDown, mdiChevronUp, mdiClose, + mdiCogOutline, mdiContentSave, mdiCrosshairsGps, mdiDotsVertical, @@ -48,6 +49,7 @@ const iconMap: Record = { mdiChevronDown, mdiChevronUp, mdiClose, + mdiCogOutline, mdiContentSave, mdiCrosshairsGps, mdiDotsVertical, diff --git a/packages/web-forms/src/components/common/map/AsyncMap.vue b/packages/web-forms/src/components/common/map/AsyncMap.vue index 1b29067f2..bcec021d1 100644 --- a/packages/web-forms/src/components/common/map/AsyncMap.vue +++ b/packages/web-forms/src/components/common/map/AsyncMap.vue @@ -4,21 +4,17 @@ * This prevents unnecessary bloat in the main application bundle, reducing initial load times and improving performance. * Use dynamic imports instead (e.g., `await import(importPath)`) for lazy-loading these dependencies only when required. */ +import { createFeatureCollectionAndProps } from '@/components/common/map/geojson-parsers.ts'; +import type { Mode, SingleFeatureType } from '@/components/common/map/getModeConfig.ts'; import type { SelectItem } from '@getodk/xforms-engine'; +import type { Feature } from 'geojson'; 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, -} from '@/components/common/map/createFeatureCollectionAndProps.ts'; - -type DrawFeatureType = 'shape' | 'trace'; type MapBlockComponent = DefineComponent<{ featureCollection: { type: string; features: Feature[] }; disabled: boolean; - drawFeatureType?: DrawFeatureType; + singleFeatureType?: SingleFeatureType; mode: Mode; orderedExtraProps: Map>; savedFeatureValue: Feature | undefined; @@ -27,7 +23,7 @@ type MapBlockComponent = DefineComponent<{ interface AsyncMapProps { features?: readonly SelectItem[]; disabled: boolean; - drawFeatureType?: DrawFeatureType; + singleFeatureType?: SingleFeatureType; mode: Mode; savedFeatureValue: string | undefined; } @@ -86,7 +82,7 @@ onMounted(loadMap); +import IconSVG from '@/components/common/IconSVG.vue'; +import { toGeoJsonCoordinateArray } from '@/components/common/map/geojson-parsers.ts'; +import { fromLonLat } from 'ol/proj'; +import { ref, watch } from 'vue'; +import type { Coordinate } from 'ol/coordinate'; + +const props = defineProps<{ + isOpen: boolean; + coordinates: Coordinate | null; +}>(); + +const emit = defineEmits(['open-paste-dialog', 'save']); + +const accuracy = ref(); +const latitude = ref(); +const altitude = ref(); +const longitude = ref(); + +watch( + () => props.coordinates, + (newVal) => { + if (newVal) { + [longitude.value, latitude.value, altitude.value, accuracy.value] = newVal; + return; + } + accuracy.value = undefined; + latitude.value = undefined; + altitude.value = undefined; + longitude.value = undefined; + }, + { immediate: true } +); + +const updateVertex = () => { + if (!props.coordinates?.length) { + return; + } + + const [originalLong, originalLat] = props.coordinates; + if (Number(longitude.value) === 0) { + longitude.value = originalLong; + } + + if (Number(latitude.value) === 0) { + latitude.value = originalLat; + } + + const newVertex = toGeoJsonCoordinateArray( + Number(longitude.value), + Number(latitude.value), + Number(altitude.value), + Number(accuracy.value) + ) as Coordinate; + emit('save', fromLonLat(newVertex)); +}; + + + + + diff --git a/packages/web-forms/src/components/common/map/MapBlock.vue b/packages/web-forms/src/components/common/map/MapBlock.vue index dfdadf252..47ef56927 100644 --- a/packages/web-forms/src/components/common/map/MapBlock.vue +++ b/packages/web-forms/src/components/common/map/MapBlock.vue @@ -5,16 +5,22 @@ * load on demand. Avoids main bundle bloat. */ import IconSVG from '@/components/common/IconSVG.vue'; -import type { Mode } from '@/components/common/map/getModeConfig.ts'; -import MapConfirm from '@/components/common/map/MapConfirm.vue'; +import { + type Mode, + type SingleFeatureType, + VERTEX_FEATURES, +} from '@/components/common/map/getModeConfig.ts'; +import MapAdvancedPanel from '@/components/common/map/MapAdvancedPanel.vue'; +import MapConfirmDialog from '@/components/common/map/MapConfirmDialog.vue'; import MapControls from '@/components/common/map/MapControls.vue'; import MapProperties from '@/components/common/map/MapProperties.vue'; import MapStatusBar from '@/components/common/map/MapStatusBar.vue'; +import MapUpdateCoordsDialog from '@/components/common/map/MapUpdateCoordsDialog.vue'; import { STATES, useMapBlock } from '@/components/common/map/useMapBlock.ts'; -import { type DrawFeatureType } from '@/components/common/map/useMapInteractions.ts'; import { QUESTION_HAS_ERROR } from '@/lib/constants/injection-keys.ts'; -import type { Feature, FeatureCollection } from 'geojson'; +import type { Feature, FeatureCollection, Point as PointGeoJSON } from 'geojson'; import type { Coordinate } from 'ol/coordinate'; +import { toLonLat } from 'ol/proj'; import Button from 'primevue/button'; import Message from 'primevue/message'; import { computed, type ComputedRef, inject, onMounted, onUnmounted, ref, watch } from 'vue'; @@ -22,7 +28,7 @@ import { computed, type ComputedRef, inject, onMounted, onUnmounted, ref, watch interface MapBlockProps { featureCollection: FeatureCollection; disabled: boolean; - drawFeatureType?: DrawFeatureType; + singleFeatureType?: SingleFeatureType; mode: Mode; orderedExtraProps: Map>; savedFeatureValue: Feature | undefined; @@ -32,7 +38,9 @@ const props = defineProps(); const emit = defineEmits(['save']); const mapElement = ref(); const isFullScreen = ref(false); +const isAdvancedPanelOpen = ref(false); const confirmDeleteAction = ref(false); +const isUpdateCoordsDialogOpen = ref(false); const selectedVertex = ref(); const showErrorStyle = inject>( QUESTION_HAS_ERROR, @@ -40,13 +48,30 @@ const showErrorStyle = inject>( ); const mapHandler = useMapBlock( - { mode: props.mode, drawFeatureType: props.drawFeatureType }, + { mode: props.mode, singleFeatureType: props.singleFeatureType }, { onFeaturePlacement: () => emitSavedFeature(), onVertexSelect: (vertex) => (selectedVertex.value = vertex), } ); +const advancedPanelCoords = computed(() => { + if (!mapHandler.canOpenAdvacedPanel()) { + return null; + } + + if (props.singleFeatureType && VERTEX_FEATURES.includes(props.singleFeatureType)) { + return selectedVertex.value?.length ? toLonLat(selectedVertex.value) : null; + } + + const geometry = props.savedFeatureValue?.geometry as PointGeoJSON | undefined; + if (geometry?.coordinates?.length) { + return geometry.coordinates; + } + + return null; +}); + const showSecondaryControls = computed(() => { return !props.disabled && (mapHandler.canUndoChange() || mapHandler.canDeleteFeatureOrVertex()); }); @@ -87,6 +112,16 @@ watch( (newValue) => mapHandler.setupMapInteractions(newValue) ); +watch( + () => advancedPanelCoords.value, + (newValue) => { + if (!newValue?.length) { + isAdvancedPanelOpen.value = false; + } + }, + { immediate: true } +); + const handleEscapeKey = (event: KeyboardEvent) => { if (event.key === 'Escape' && isFullScreen.value) { isFullScreen.value = false; @@ -131,6 +166,21 @@ const undoLastChange = () => { mapHandler.undoLastChange(); emitSavedFeature(); }; + +const updateFeatureCoords = (newCoords: Coordinate | Coordinate[] | Coordinate[][]) => { + mapHandler.updateFeatureCoordinates(newCoords); + emitSavedFeature(); +}; + +const saveAdvancedPanelCoords = (newCoords: Coordinate) => { + if (props.singleFeatureType && VERTEX_FEATURES.includes(props.singleFeatureType)) { + mapHandler.updateVertexCoords(newCoords); + emitSavedFeature(); + return; + } + + updateFeatureCoords(newCoords); +}; diff --git a/packages/web-forms/src/components/common/map/MapUpdateCoordsDialog.vue b/packages/web-forms/src/components/common/map/MapUpdateCoordsDialog.vue new file mode 100644 index 000000000..ba47d1b9e --- /dev/null +++ b/packages/web-forms/src/components/common/map/MapUpdateCoordsDialog.vue @@ -0,0 +1,238 @@ + + + + + + + diff --git a/packages/web-forms/src/components/common/map/createFeatureCollectionAndProps.ts b/packages/web-forms/src/components/common/map/geojson-parsers.ts similarity index 58% rename from packages/web-forms/src/components/common/map/createFeatureCollectionAndProps.ts rename to packages/web-forms/src/components/common/map/geojson-parsers.ts index 2ef2355d9..750894f05 100644 --- a/packages/web-forms/src/components/common/map/createFeatureCollectionAndProps.ts +++ b/packages/web-forms/src/components/common/map/geojson-parsers.ts @@ -1,4 +1,11 @@ +/** + * IMPORTANT: OpenLayers is not statically imported here to enable bundling them into a separate chunk. + * This prevents unnecessary bloat in the main application bundle, reducing initial load times and improving performance. + * + * This file is for GeoJSON logic, which should not use OpenLayers types directly. + */ import type { SelectItem } from '@getodk/xforms-engine'; +import type { Feature, FeatureCollection, Geometry, LineString, Point, Polygon } from 'geojson'; const PROPERTY_PREFIX = 'odk_'; // Avoids conflicts with OpenLayers (for example, geometry). @@ -17,25 +24,37 @@ const RESERVED_MAP_PROPERTIES = [ type Coordinates = [longitude: number, latitude: number]; -interface Geometry { - type: 'LineString' | 'Point' | 'Polygon'; - coordinates: Coordinates | Coordinates[] | Coordinates[][]; -} +// Longitude is first for GeoJSON and latitude is second. +export const toGeoJsonCoordinateArray = ( + longitude: number, + latitude: number, + altitude: number | null | undefined, + accuracy: number | null | undefined +): number[] => { + const coords = []; + if (Number.isFinite(longitude) && Number.isFinite(latitude)) { + coords.push(longitude, latitude); + + if (Number.isFinite(accuracy)) { + coords.push(Number.isFinite(altitude) ? altitude! : 0, accuracy!); + } else if (Number.isFinite(altitude)) { + coords.push(altitude!); + } + } -export interface Feature { - type: 'Feature'; - geometry: Geometry; - properties: Record; -} + return coords; +}; const getGeoJSONCoordinates = (geometry: string): [Coordinates, ...Coordinates[]] | undefined => { const coordinates: Coordinates[] = []; for (const coord of geometry.split(';')) { - const [lat, lon] = coord.trim().split(/\s+/).map(Number); + const [lat, lon, alt, acc] = coord.trim().split(/\s+/).map(Number); const isNullLocation = lat === 0 && lon === 0; const isValidLatitude = lat != null && !Number.isNaN(lat) && Math.abs(lat) <= 90; const isValidLongitude = lon != null && !Number.isNaN(lon) && Math.abs(lon) <= 180; + const isAltitudeProvided = alt != null && !Number.isNaN(alt); + const isAccuracyProvided = acc != null && !Number.isNaN(acc); if (isNullLocation || !isValidLatitude || !isValidLongitude) { // eslint-disable-next-line no-console -- Skip silently to match Collect behaviour. @@ -43,7 +62,13 @@ const getGeoJSONCoordinates = (geometry: string): [Coordinates, ...Coordinates[] return; } - coordinates.push([lon, lat]); + const parsedCoords = toGeoJsonCoordinateArray( + lon, + lat, + isAltitudeProvided ? alt : undefined, + isAccuracyProvided ? acc : undefined + ) as Coordinates; + coordinates.push(parsedCoords); } return coordinates.length ? (coordinates as [Coordinates, ...Coordinates[]]) : undefined; @@ -64,6 +89,14 @@ const getGeoJSONGeometry = (coords: [Coordinates, ...Coordinates[]]): Geometry = return { type: 'LineString', coordinates: coords }; }; +export const createGeoJSONGeometry = (value: string): Geometry | undefined => { + const coords = getGeoJSONCoordinates(value); + if (!coords) { + return; + } + return getGeoJSONGeometry(coords); +}; + const normalizeODKFeature = (odkFeature: SelectItem | string) => { if (typeof odkFeature === 'string') { return { @@ -132,3 +165,31 @@ export const createFeatureCollectionAndProps = ( orderedExtraPropsMap, }; }; + +export const parseSingleFeatureFromGeoJSON = (text: string): Geometry | undefined => { + try { + const geojson = JSON.parse(text) as FeatureCollection; + return geojson?.features?.[0]?.geometry as Geometry | undefined; + } catch { + // eslint-disable-next-line no-console -- Skip silently to match createFeatureCollectionAndProps + console.warn(`Invalid GeoJSON: ${text}`); + return; + } +}; + +export const parseSingleFeatureFromCSV = (text: string): Geometry | undefined => { + const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0); + if (lines.length < 2) { + return; + } + + const header = lines[0]?.split(',') ?? []; + const geometryIndex = header.findIndex((col) => col.trim().toLowerCase() === 'geometry'); + if (geometryIndex === -1) { + return; + } + + const firstDataRow = lines[1]?.split(',') ?? []; + const geometryValue = firstDataRow[geometryIndex]?.trim() ?? ''; + return createGeoJSONGeometry(geometryValue); +}; diff --git a/packages/web-forms/src/components/common/map/getModeConfig.ts b/packages/web-forms/src/components/common/map/getModeConfig.ts index 74109f826..e8dc02b0e 100644 --- a/packages/web-forms/src/components/common/map/getModeConfig.ts +++ b/packages/web-forms/src/components/common/map/getModeConfig.ts @@ -1,3 +1,8 @@ +/** + * IMPORTANT: OpenLayers and MapBlock are not statically imported here to enable bundling them into a separate chunk. + * This prevents unnecessary bloat in the main application bundle, reducing initial load times and improving performance. + */ + export const MODES = { SELECT: 'select', // Used in Select one from map question type LOCATION: 'location', // Used in Geopoint with "maps" appearance @@ -6,6 +11,18 @@ export const MODES = { } as const; export type Mode = (typeof MODES)[keyof typeof MODES]; +// Used when loading a single feature from the map. +export const SINGLE_FEATURE_TYPES = { + POINT: 'point', + SHAPE: 'shape', + TRACE: 'trace', +} as const; +export type SingleFeatureType = (typeof SINGLE_FEATURE_TYPES)[keyof typeof SINGLE_FEATURE_TYPES]; +export const VERTEX_FEATURES: SingleFeatureType[] = [ + SINGLE_FEATURE_TYPES.SHAPE, + SINGLE_FEATURE_TYPES.TRACE, +] as const; + export interface ModeCapabilities { canDeleteFeature: boolean; canLoadMultiFeatures: boolean; @@ -15,6 +32,8 @@ export interface ModeCapabilities { canShowMapOverlay: boolean; canShowMapOverlayOnError: boolean; canUndoLastChange: boolean; + canUpdateFeatureCoordinates: boolean; + canUpdateVertexCoordinates: boolean; canViewProperties: boolean; } @@ -46,6 +65,8 @@ export const getModeConfig = (mode: Mode): ModeConfig => { canShowMapOverlay: false, canShowMapOverlayOnError: false, canUndoLastChange: false, + canUpdateFeatureCoordinates: false, + canUpdateVertexCoordinates: false, canViewProperties: false, }, } as const; @@ -89,6 +110,7 @@ export const getModeConfig = (mode: Mode): ModeConfig => { canRemoveCurrentLocation: true, canSaveCurrentLocation: true, canShowMapOverlay: true, + canUpdateFeatureCoordinates: true, }, }; } @@ -106,6 +128,8 @@ export const getModeConfig = (mode: Mode): ModeConfig => { canDeleteFeature: true, canSelectFeatureOrVertex: true, canUndoLastChange: true, + canUpdateFeatureCoordinates: true, + canUpdateVertexCoordinates: true, }, }; } diff --git a/packages/web-forms/src/components/common/map/map-helpers.ts b/packages/web-forms/src/components/common/map/map-helpers.ts index 7e2927a61..1675b6f3d 100644 --- a/packages/web-forms/src/components/common/map/map-helpers.ts +++ b/packages/web-forms/src/components/common/map/map-helpers.ts @@ -1,8 +1,38 @@ -import { getFlatCoordinates } from '@/components/common/map/vertex-geometry.ts'; +import { + SINGLE_FEATURE_TYPES, + type SingleFeatureType, +} from '@/components/common/map/getModeConfig.ts'; +import { getFlatCoordinates, isCoordsEqual } from '@/components/common/map/vertex-geometry.ts'; import type { Coordinate } from 'ol/coordinate'; import type Feature from 'ol/Feature'; import type { LineString, Point, Polygon } from 'ol/geom'; -import { toLonLat } from 'ol/proj'; +import { fromLonLat, toLonLat } from 'ol/proj'; +import type { + LineString as LineStringGeoJSON, + Point as PointGeoJSON, + Polygon as PolygonGeoJSON, +} from 'geojson'; + +// Latitude is first for ODK and longitude is second. +export const toODKCoordinateArray = ( + longitude: number, + latitude: number, + altitude: number | null | undefined, + accuracy: number | null | undefined +): number[] => { + const coords = []; + if (Number.isFinite(latitude) && Number.isFinite(longitude)) { + coords.push(latitude, longitude); + + if (Number.isFinite(accuracy)) { + coords.push(Number.isFinite(altitude) ? altitude! : 0, accuracy!); + } else if (Number.isFinite(altitude)) { + coords.push(altitude!); + } + } + + return coords; +}; export const formatODKValue = (feature: Feature): string => { const geometry = feature.getGeometry(); @@ -11,14 +41,14 @@ export const formatODKValue = (feature: Feature): string => { } const formatCoords = (coords: Coordinate) => { - const [longitude, latitude, altitude, accuracy] = toLonLat(coords); - return [latitude, longitude, altitude, accuracy].filter((item) => item != null).join(' '); + const parsedCoords = toLonLat(coords) as [number, number, number?, number?]; + return toODKCoordinateArray(...parsedCoords).join(' '); }; const featureType = geometry.getType(); if (featureType === 'Point') { const coordinates = (geometry as Point).getCoordinates(); - return coordinates ? formatCoords(coordinates) : ''; + return coordinates?.length ? formatCoords(coordinates) : ''; } const coordinates: Coordinate[] = getFlatCoordinates(geometry as LineString | Polygon); @@ -33,3 +63,47 @@ export const isWebGLAvailable = () => { return false; } }; + +export const getValidCoordinates = ( + geometry: LineStringGeoJSON | PointGeoJSON | PolygonGeoJSON | undefined, + singleFeatureType: SingleFeatureType | undefined +) => { + if (!geometry?.coordinates?.length) { + return; + } + + const coords = geometry.coordinates as Coordinate | Coordinate[] | Coordinate[][]; + if ( + geometry.type === 'Point' && + singleFeatureType === SINGLE_FEATURE_TYPES.POINT && + !Array.isArray(coords[0]) + ) { + return fromLonLat(coords as Coordinate); + } + + const hasRing = Array.isArray(coords[0]) && Array.isArray(coords[0][0]); + let flatCoords = (hasRing ? coords[0] : coords) as Coordinate[]; + if (!flatCoords?.length) { + return; + } + + flatCoords = flatCoords.map((c) => fromLonLat(c)); + const isClosed = isCoordsEqual(flatCoords[0], flatCoords[flatCoords.length - 1]); + if ( + geometry.type === 'LineString' && + singleFeatureType === SINGLE_FEATURE_TYPES.TRACE && + !isClosed && + flatCoords.length >= 2 + ) { + return flatCoords; + } + + if ( + geometry.type === 'Polygon' && + singleFeatureType === SINGLE_FEATURE_TYPES.SHAPE && + isClosed && + flatCoords.length >= 3 + ) { + return [flatCoords]; + } +}; diff --git a/packages/web-forms/src/components/common/map/useMapBlock.ts b/packages/web-forms/src/components/common/map/useMapBlock.ts index 67f6d2cc1..c34ebfdca 100644 --- a/packages/web-forms/src/components/common/map/useMapBlock.ts +++ b/packages/web-forms/src/components/common/map/useMapBlock.ts @@ -1,4 +1,10 @@ -import { getModeConfig, type Mode, MODES } from '@/components/common/map/getModeConfig.ts'; +import { toGeoJsonCoordinateArray } from '@/components/common/map/geojson-parsers.ts'; +import { + getModeConfig, + type Mode, + MODES, + type SingleFeatureType, +} from '@/components/common/map/getModeConfig.ts'; import { formatODKValue, isWebGLAvailable } from '@/components/common/map/map-helpers.ts'; import { getDrawStyles, @@ -16,7 +22,6 @@ import { type UseMapFeatures, } from '@/components/common/map/useMapFeatures.ts'; import { - type DrawFeatureType, useMapInteractions, type UseMapInteractions, } from '@/components/common/map/useMapInteractions.ts'; @@ -30,13 +35,14 @@ import { import { deleteVertexFromFeature, getVertexByIndex, + updateVertexCoordinate, } from '@/components/common/map/vertex-geometry.ts'; import type { FeatureCollection, Feature as GeoJsonFeature } from 'geojson'; import { Map, View } from 'ol'; import { Attribution, Zoom } from 'ol/control'; import type { Coordinate } from 'ol/coordinate'; import Feature from 'ol/Feature'; -import { LineString, Point, Polygon } from 'ol/geom'; +import { LineString, Point, Polygon, SimpleGeometry } from 'ol/geom'; import TileLayer from 'ol/layer/Tile'; import VectorLayer from 'ol/layer/Vector'; import WebGLVectorLayer from 'ol/layer/WebGLVector'; @@ -59,12 +65,12 @@ export const ODK_VALUE_PROPERTY = 'odk_value'; interface MapBlockConfig { mode: Mode; - drawFeatureType?: DrawFeatureType; + singleFeatureType?: SingleFeatureType; } interface MapBlockEvents { onFeaturePlacement: () => void; - onVertexSelect: (vertex: Coordinate) => void; + onVertexSelect: (vertex: Coordinate | undefined) => void; } export function useMapBlock(config: MapBlockConfig, events: MapBlockEvents) { @@ -132,7 +138,7 @@ export function useMapBlock(config: MapBlockConfig, events: MapBlockEvents) { mapInteractions = useMapInteractions( mapInstance, currentMode.capabilities, - config.drawFeatureType + config.singleFeatureType ); mapFeatures = useMapFeatures( mapInstance, @@ -206,6 +212,9 @@ export function useMapBlock(config: MapBlockConfig, events: MapBlockEvents) { const updateAndSaveFeature = (feature: Feature) => { feature.set(ODK_VALUE_PROPERTY, formatODKValue(feature)); mapFeatures?.saveFeature(feature); + // Refresh selected vertex. + const vertexIndex = feature.get(SELECTED_VERTEX_INDEX_PROPERTY) as number | undefined; + events.onVertexSelect(getVertexByIndex(feature, vertexIndex)); }; const handlePointPlacement = (feature: Feature) => { @@ -230,6 +239,7 @@ export function useMapBlock(config: MapBlockConfig, events: MapBlockEvents) { const coordsLeft = deleteVertexFromFeature(feature, vertexIndex); if (coordsLeft > 0) { updateAndSaveFeature(feature); + unselectFeature(); return; } @@ -267,6 +277,7 @@ export function useMapBlock(config: MapBlockConfig, events: MapBlockEvents) { if (previousFeatureState) { featuresSource.addFeature(previousFeatureState); updateAndSaveFeature(previousFeatureState); + unselectFeature(); } } }; @@ -282,6 +293,41 @@ export function useMapBlock(config: MapBlockConfig, events: MapBlockEvents) { mapFeatures?.findAndSaveFeature(featuresSource, savedFeature, true); }; + const updateVertexCoords = (newCoords: Coordinate) => { + if (!newCoords.length || !currentMode.capabilities.canUpdateVertexCoordinates) { + return; + } + + const feature = mapFeatures?.getSavedFeature() as Feature; + const vertexIndex = feature?.get(SELECTED_VERTEX_INDEX_PROPERTY) as number; + if (vertexIndex === undefined) { + return; + } + + mapInteractions?.savePreviousFeatureState(feature); + updateVertexCoordinate(feature, vertexIndex, newCoords); + updateAndSaveFeature(feature); + mapViewControls?.fitToAllFeatures(featuresSource); + }; + + const updateFeatureCoordinates = (newCoords: Coordinate | Coordinate[] | Coordinate[][]) => { + if (!newCoords.length || !currentMode.capabilities.canUpdateFeatureCoordinates) { + return; + } + + const feature = mapFeatures?.getSavedFeature() as Feature; + const geometry = feature?.getGeometry() as SimpleGeometry | undefined; + if (!geometry) { + return; + } + + mapInteractions?.savePreviousFeatureState(feature); + geometry.setCoordinates(newCoords, COORDINATE_LAYOUT_XYZM); + updateAndSaveFeature(feature); + unselectFeature(); + mapViewControls?.fitToAllFeatures(featuresSource); + }; + const loadAndSaveSingleFeature = (feature: Feature | undefined) => { if (!mapInstance || currentMode.capabilities.canLoadMultiFeatures || !feature) { return; @@ -315,14 +361,12 @@ export function useMapBlock(config: MapBlockConfig, events: MapBlockEvents) { return; } - const coords = [location.longitude, location.latitude]; - if (location.altitude != null) { - coords.push(location.altitude); - } - - if (location.accuracy != null) { - coords.push(location.accuracy); - } + const coords = toGeoJsonCoordinateArray( + location.longitude, + location.latitude, + location.altitude, + location.accuracy + ); const feature = new Feature({ geometry: new Point(fromLonLat(coords), COORDINATE_LAYOUT_XYZM), @@ -339,7 +383,10 @@ export function useMapBlock(config: MapBlockConfig, events: MapBlockEvents) { clearMap(); }; - const unselectFeature = () => mapFeatures?.selectFeature(undefined); + const unselectFeature = () => { + mapFeatures?.selectFeature(undefined); + events.onVertexSelect(undefined); + }; const clearSavedFeature = () => mapFeatures?.saveFeature(undefined); @@ -401,6 +448,11 @@ export function useMapBlock(config: MapBlockConfig, events: MapBlockEvents) { return longPress && (dragFeature || dragFeatureAndVertex); }; + const canOpenAdvacedPanel = () => { + const { canUpdateFeatureCoordinates, canUpdateVertexCoordinates } = currentMode.capabilities; + return canUpdateFeatureCoordinates || canUpdateVertexCoordinates; + }; + const watchCurrentLocation = () => { currentState.value = STATES.CAPTURING; @@ -441,6 +493,8 @@ export function useMapBlock(config: MapBlockConfig, events: MapBlockEvents) { initMap, teardownMap, updateFeatureCollection, + updateVertexCoords, + updateFeatureCoordinates, setupMapInteractions, isMapEmpty: () => featuresSource.isEmpty(), @@ -471,6 +525,7 @@ export function useMapBlock(config: MapBlockConfig, events: MapBlockEvents) { canLongPressAndDrag, canViewProperties: () => currentMode.capabilities.canViewProperties, + canOpenAdvacedPanel, shouldShowMapOverlay, }; } diff --git a/packages/web-forms/src/components/common/map/useMapInteractions.ts b/packages/web-forms/src/components/common/map/useMapInteractions.ts index 842576a44..749000d37 100644 --- a/packages/web-forms/src/components/common/map/useMapInteractions.ts +++ b/packages/web-forms/src/components/common/map/useMapInteractions.ts @@ -1,4 +1,8 @@ -import type { ModeCapabilities } from '@/components/common/map/getModeConfig.ts'; +import { + type ModeCapabilities, + SINGLE_FEATURE_TYPES, + type SingleFeatureType, +} from '@/components/common/map/getModeConfig.ts'; import { getPhantomPointStyle } from '@/components/common/map/map-styles.ts'; import { IS_SELECTED_PROPERTY, @@ -23,12 +27,6 @@ import type { Pixel } from 'ol/pixel'; import type VectorSource from 'ol/source/Vector'; import { shallowRef } from 'vue'; -export const DRAW_FEATURE_TYPES = { - SHAPE: 'shape', - TRACE: 'trace', -} as const; -export type DrawFeatureType = (typeof DRAW_FEATURE_TYPES)[keyof typeof DRAW_FEATURE_TYPES]; - export interface UseMapInteractions { hasPreviousFeatureState: () => boolean; popPreviousFeatureState: () => Feature | null | undefined; @@ -52,7 +50,7 @@ const SELECT_HIT_TOLERANCE = 15; export function useMapInteractions( mapInstance: Map, capabilities: ModeCapabilities, - drawFeatureType: DrawFeatureType | undefined + singleFeatureType: SingleFeatureType | undefined ): UseMapInteractions { const currentLocationObserver = shallowRef(); const pointerInteraction = shallowRef(); @@ -170,11 +168,11 @@ export function useMapInteractions( resolution: number, feature: Feature | undefined ) => { - if (drawFeatureType === DRAW_FEATURE_TYPES.SHAPE) { + if (singleFeatureType === SINGLE_FEATURE_TYPES.SHAPE) { return addShapeVertex(resolution, coordinate, feature, LONG_PRESS_HIT_TOLERANCE); } - if (drawFeatureType === DRAW_FEATURE_TYPES.TRACE) { + if (singleFeatureType === SINGLE_FEATURE_TYPES.TRACE) { return addTraceVertex(resolution, coordinate, feature, LONG_PRESS_HIT_TOLERANCE); } @@ -191,7 +189,7 @@ export function useMapInteractions( savePreviousFeatureState(feature ?? null); const updatedFeature = resolveFeatureForLongPress(coordinate, resolution, feature)!; - if (!drawFeatureType && !source.isEmpty()) { + if (singleFeatureType === SINGLE_FEATURE_TYPES.POINT && !source.isEmpty()) { source.clear(true); } diff --git a/packages/web-forms/src/components/common/map/useMapViewControls.ts b/packages/web-forms/src/components/common/map/useMapViewControls.ts index 0c8c3e8a7..99fe53a46 100644 --- a/packages/web-forms/src/components/common/map/useMapViewControls.ts +++ b/packages/web-forms/src/components/common/map/useMapViewControls.ts @@ -1,7 +1,8 @@ +import { toGeoJsonCoordinateArray } from '@/components/common/map/geojson-parsers.ts'; import { Map, type View } from 'ol'; import type { Coordinate } from 'ol/coordinate'; import { easeOut } from 'ol/easing'; -import { getCenter } from 'ol/extent'; +import { getCenter, isEmpty as isExtendEmpty } from 'ol/extent'; import Feature from 'ol/Feature'; import { Point } from 'ol/geom'; import VectorLayer from 'ol/layer/Vector'; @@ -68,7 +69,7 @@ export function useMapViewControls(mapInstance: Map): UseMapViewControls { } const extent = source.getExtent(); - if (!extent?.length) { + if (isExtendEmpty(extent)) { return; } @@ -244,12 +245,13 @@ export function useMapViewControls(mapInstance: Map): UseMapViewControls { return; } - const parsedCoords = fromLonLat([ + const coords = toGeoJsonCoordinateArray( newLocation.longitude, newLocation.latitude, - newLocation.altitude ?? 0, - newLocation.accuracy, - ]); + newLocation.altitude, + newLocation.accuracy + ); + const parsedCoords = fromLonLat(coords); userCurrentLocationFeature.value = new Feature({ geometry: new Point(parsedCoords, COORDINATE_LAYOUT_XYZM), }); diff --git a/packages/web-forms/src/components/common/map/vertex-geometry.ts b/packages/web-forms/src/components/common/map/vertex-geometry.ts index 926915816..07a863b4e 100644 --- a/packages/web-forms/src/components/common/map/vertex-geometry.ts +++ b/packages/web-forms/src/components/common/map/vertex-geometry.ts @@ -1,3 +1,4 @@ +import { COORDINATE_LAYOUT_XYZM } from '@/components/common/map/useMapViewControls.ts'; import type { Coordinate } from 'ol/coordinate'; import Feature from 'ol/Feature'; import { Point, LineString, Polygon } from 'ol/geom'; @@ -116,7 +117,7 @@ export const addTraceVertex = ( coords.push(newVertex); } - geometry?.setCoordinates(coords); + geometry?.setCoordinates(coords, COORDINATE_LAYOUT_XYZM); return feature; }; @@ -156,7 +157,7 @@ export const addShapeVertex = ( ring.push([...firstVertex]); } - geometry?.setCoordinates([ring]); + geometry?.setCoordinates([ring], COORDINATE_LAYOUT_XYZM); return feature; }; @@ -223,8 +224,23 @@ export const deleteVertexFromFeature = ( geometry instanceof LineString ? deleteVertexFromLine(coords, index) : deleteVertexFromPolygon(coords, index); - geometry.setCoordinates(updatedCoords as Coordinate[] & Coordinate[][]); + geometry.setCoordinates(updatedCoords as Coordinate[] & Coordinate[][], COORDINATE_LAYOUT_XYZM); } return coords.length; }; + +export const updateVertexCoordinate = ( + feature: Feature | undefined, + index: number, + newCoordinates: Coordinate +) => { + const geometry = feature?.getGeometry(); + const coords = getFlatCoordinates(geometry); + + if (geometry && index >= 0 && index < coords.length) { + coords[index] = newCoordinates; + const updatedCoords = geometry instanceof LineString ? coords : [coords]; + geometry.setCoordinates(updatedCoords as Coordinate[] & Coordinate[][], COORDINATE_LAYOUT_XYZM); + } +}; 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 d40fad993..85d0b362c 100644 --- a/packages/web-forms/src/components/form-elements/input/InputControl.vue +++ b/packages/web-forms/src/components/form-elements/input/InputControl.vue @@ -1,4 +1,5 @@