diff --git a/.changeset/four-steaks-try.md b/.changeset/four-steaks-try.md new file mode 100644 index 000000000..aab9abe7f --- /dev/null +++ b/.changeset/four-steaks-try.md @@ -0,0 +1,8 @@ +--- +'@getodk/xforms-engine': minor +'@getodk/web-forms': minor +'@getodk/scenario': patch +'@getodk/common': patch +--- + +Support select one from map diff --git a/packages/common/src/fixtures/select/geodataCsv.csv b/packages/common/src/fixtures/select/geodataCsv.csv new file mode 100644 index 000000000..181afdda9 --- /dev/null +++ b/packages/common/src/fixtures/select/geodataCsv.csv @@ -0,0 +1,10 @@ +name,label,geometry,population_size,num_hospitals +1,Lisbon,38.7 -9.15,500000,120 +2,Dublin,53.33 -6.25,1200000,200 +3,Edinburgh,55.953251 -3.188267,500000,150 +4,Amsterdam-Brussels-Luxembourg,52.37 4.9; 50.85 4.35; 49.6 6.13,3000000,450 +5,Zurich-Geneva-Basel,47.3667 8.55; 46.2022 6.1457; 47.55839 7.57327,1500000,300 +6,Barcelona-Madrid,41.390205 2.154007; 40.38 -3.7,5000000,600 +7,London-Paris-Amsterdam-London,51.5 -0.12; 48.85 2.35; 52.37 4.9; 51.5 -0.12,20000000,1000 +8,Berlin-Frankfurt-Munich-Berlin,52.52 13.4; 50.11552 8.68417; 48.13743 11.57549; 52.52 13.4,7000000,800 +9,Vienna-Salzburg-Innsbruck-Vienna,48.2 16.37; 47.79941 13.04399; 47.26266 11.39454; 48.2 16.37,3000000,400 diff --git a/packages/common/src/fixtures/select/geodataGeoJson.geojson b/packages/common/src/fixtures/select/geodataGeoJson.geojson new file mode 100644 index 000000000..ac5da0a89 --- /dev/null +++ b/packages/common/src/fixtures/select/geodataGeoJson.geojson @@ -0,0 +1,251 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -73.56, + 45.52 + ] + }, + "properties": { + "nurses": 19, + "doctors": 10, + "patients": 390, + "id": "fea-07", + "title": "Chinatown Medical Center" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -73.585, + 45.5 + ] + }, + "properties": { + "nurses": 23, + "doctors": 13, + "patients": 550, + "id": "fea-08", + "title": "West End Health Clinic" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -73.555, + 45.485 + ] + }, + "properties": { + "nurses": 16, + "doctors": 6, + "patients": 300, + "id": "fea-09", + "title": "Verdun Community Clinic" + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -73.59, + 45.53 + ], + [ + -73.58, + 45.53 + ], + [ + -73.57, + 45.53 + ] + ] + }, + "properties": { + "id": "fea-018", + "title": "Canada Goose", + "quantities": 4000, + "migration_days": 12, + "start_date": "2024-09-10", + "end_date": "2024-09-22" + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -73.59, + 45.47 + ], + [ + -73.58, + 45.47 + ], + [ + -73.57, + 45.47 + ] + ] + }, + "properties": { + "id": "fea-019", + "title": "Snowy Owl", + "quantities": 150, + "migration_days": 8, + "start_date": "2024-11-15", + "end_date": "2024-11-23" + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -73.6, + 45.5 + ], + [ + -73.6, + 45.51 + ], + [ + -73.6, + 45.52 + ] + ] + }, + "properties": { + "id": "fea-020", + "title": "Peregrine Falcon", + "quantities": 300, + "migration_days": 5, + "start_date": "2024-10-01", + "end_date": "2024-10-06" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -73.59, + 45.54 + ], + [ + -73.58, + 45.54 + ], + [ + -73.58, + 45.55 + ], + [ + -73.59, + 45.55 + ], + [ + -73.59, + 45.54 + ] + ] + ] + }, + "properties": { + "id": "fea-021", + "title": "Montreal Urban Farm 1", + "size": "200 hectares", + "harvest_quantity": "400 tons", + "harvest_date": "2024-09-05" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -73.61, + 45.46 + ], + [ + -73.6, + 45.46 + ], + [ + -73.6, + 45.47 + ], + [ + -73.61, + 45.47 + ], + [ + -73.61, + 45.46 + ] + ] + ] + }, + "properties": { + "id": "fea-022", + "title": "Montreal Urban Farm 2", + "size": "150 hectares", + "harvest_quantity": "300 tons", + "harvest_date": "2024-10-10" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -73.54, + 45.53 + ], + [ + -73.53, + 45.53 + ], + [ + -73.53, + 45.54 + ], + [ + -73.54, + 45.54 + ], + [ + -73.54, + 45.53 + ] + ] + ] + }, + "properties": { + "id": "fea-023", + "title": "Montreal Urban Farm 3", + "size": "250 hectares", + "harvest_quantity": "500 tons", + "harvest_date": "2024-08-15" + } + } + ] +} diff --git a/packages/common/src/fixtures/select/select-one-from-map-external-files.xml b/packages/common/src/fixtures/select/select-one-from-map-external-files.xml new file mode 100644 index 000000000..731e9337a --- /dev/null +++ b/packages/common/src/fixtures/select/select-one-from-map-external-files.xml @@ -0,0 +1,124 @@ + + + + select-one-from-map-external-files + + + + + + + + + + + + + + + + + + + 1 + + 40.446254412914385 -3.7445467750577563 + + + 2 + + 52.18544754541023 21.1006145352125 + + + 3 + + 59.31284185390564 18.04203801493341; 55.7237702447557 12.59922155107742; 52.48574101841746 + 13.337427033540052 + + + + 4 + + 47.485570717616696 19.04921763281365; 48.19954394662233 16.42105774490628; 50.139932923435055 + 14.417694964430638 + + + + 5 + + 53.40903732686215 -2.991905620523312; 51.505094241512666 -0.1321803079139272; 53.8052722823615 + -1.5363611594108022; 53.40903732686215 -2.991905620523312 + + + + 6 + + 48.840915452897946 2.3146557958955327; 47.54579786412256 7.63542665300713; 50.10149750150822 + 8.703168961933983; 48.840915452897946 2.3146557958955327 + + + + + + + + + + + + + + + + + + yes + + + + no + + + + + + + + + + + + + + + + yes + + + + no + + + + + + + + + + + + + + + + + + + diff --git a/packages/scenario/src/jr/select/SelectChoice.ts b/packages/scenario/src/jr/select/SelectChoice.ts index 2d0705ee5..c49deea56 100644 --- a/packages/scenario/src/jr/select/SelectChoice.ts +++ b/packages/scenario/src/jr/select/SelectChoice.ts @@ -1,10 +1,9 @@ import type { SelectItem } from '@getodk/xforms-engine'; import { ComparableChoice } from '../../choice/ComparableChoice.ts'; -import { UnclearApplicabilityError } from '../../error/UnclearApplicabilityError.ts'; -import type { SelectChoiceArbitraryChildList } from './SelectChoiceArbitraryChildList.ts'; export class SelectChoice extends ComparableChoice { readonly value: string; + readonly properties: Array<[string, string]>; get label(): string { return this.selectItem.label.asString; @@ -14,37 +13,11 @@ export class SelectChoice extends ComparableChoice { super(); this.value = selectItem.value; + this.properties = selectItem.properties; } - /** - * **PORTING NOTES** - * - * This was - * {@link https://github.com/getodk/javarosa/pull/660 | introduced in JavaRosa to support geo functionality}. - * The method may access a well-known `geometry` child (or any arbitrary - * child) by name, - * {@link https://github.com/getodk/web-forms/pull/110#discussion_r1610611805 | where}… - * - * > Could be a repeat, a secondary instance or an external secondary instance - * > (csv, xml or geojson). The `map` appearance that uses the `geometry` - * > child for mapping works identically for all of those. - * - * @todo This is currently unimplemented (and will cause failures in tests - * accessing it, even if other functionality is introduced to unblock code - * paths leading to its access) until we deem it appropriate to address. - * Addressing it will likely involve understanding how it relates to and/or - * affected tests can be expressed at an integration level. - */ - getChild(_childName: string): string | null { - throw new UnclearApplicabilityError( - 'select choice: getting arbitrary child of secondary instance nodes by name' - ); - } - - getAdditionalChildren(): SelectChoiceArbitraryChildList { - throw new UnclearApplicabilityError( - `select choice: getting arbitrary children of secondary instance nodes` - ); + getProperties(): Array<[string, string]> { + return this.properties; } getValue(): string { diff --git a/packages/scenario/src/jr/select/SelectChoiceArbitraryChildList.ts b/packages/scenario/src/jr/select/SelectChoiceArbitraryChildList.ts deleted file mode 100644 index 850eb2025..000000000 --- a/packages/scenario/src/jr/select/SelectChoiceArbitraryChildList.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { JavaUtilList } from '../../java/util/List.ts'; - -export interface SelectChoiceArbitraryChildList extends JavaUtilList {} diff --git a/packages/scenario/test/select.test.ts b/packages/scenario/test/select.test.ts index 090ed3cd0..8bee275f3 100644 --- a/packages/scenario/test/select.test.ts +++ b/packages/scenario/test/select.test.ts @@ -26,7 +26,6 @@ import { setUpSimpleReferenceManager } from '../src/jr/reference/ReferenceManage import { r } from '../src/jr/resource/ResourcePathHelper.ts'; import type { SelectChoice } from '../src/jr/select/SelectChoice.ts'; import { ANSWER_REQUIRED_BUT_EMPTY } from '../src/jr/validation/ValidateOutcome.ts'; -import { nullValue } from '../src/value/ExpectedNullValue.ts'; // Ported as of https://github.com/getodk/javarosa/commit/5ae68946c47419b83e7d28290132d846e457eea6 describe('DynamicSelectUpdateTest.java', () => { @@ -624,165 +623,62 @@ describe('SelectChoiceTest.java', () => { /** * **PORTING NOTES** * - * The tests in this sub-suite are currently blocked by several absent features: + * Some tests in this suite are blocked by the absence of support for repeat-based itemsets. * - * 1. Retrieving external secondary instance resources - * 2. Support for external secondary instance resources when evaluating - * XPath expressions referencing them - * 3. Any notion of engine API access to the well-known GeoJSON `geometry` - * property, or any other arbitrary named child nodes present in any - * secondary instance (whether external or otherwise) */ describe('`getChild`', () => { - it.fails('returns named child when choices are from secondary instance', async () => { + it('returns properties in order when choices are from secondary instance', async () => { setUpSimpleReferenceManager(r('external-select-geojson.xml').getParent(), 'file'); const scenario = await Scenario.init('external-select-geojson.xml'); - expect(scenario.choicesOf('/data/q').get(1)?.getChild('geometry')).toBe('0.5 104 0 0'); - expect(scenario.choicesOf('/data/q').get(1)?.getChild('special-property')).toBe( - 'special value' - ); - }); - - it.fails( - 'returns null when choices are from secondary instance and requested child does not exist', - async () => { - setUpSimpleReferenceManager(r('external-select-geojson.xml').getParent(), 'file'); - - const scenario = await Scenario.init('external-select-geojson.xml'); - - expect(scenario.choicesOf('/data/q').get(1)?.getChild('non-existent')).toBe(null); - } - ); - - it.fails( - 'returns empty string when choices are from secondary instance and requested child has no value', - async () => { - const scenario = await Scenario.init( - 'Select with empty value', - html( - head( - title('Select with empty value'), - model( - mainInstance(t("data id='select-empty'", t('select'))), - instance('choices', t('item', t('label', 'Item'), t('property', ''))) - ) - ), - body(select1Dynamic('/data/select', "instance('choices')/root/item", 'name', 'label')) - ) - ); + const firstChoiceProperties = scenario.choicesOf('/data/q').get(0)?.getProperties(); + expect(firstChoiceProperties?.length).toEqual(4); + expect(firstChoiceProperties).deep.equal([ + ['geometry', '0.5 102 0 0'], + ['id', 'fs87b'], + ['name', 'My cool point'], + ['foo', 'bar'], + ]); - expect(scenario.choicesOf('/data/select').get(0)?.getChild('property')).toBe(''); - } - ); + const secondChoiceProperties = scenario.choicesOf('/data/q').get(1)?.getProperties(); + expect(secondChoiceProperties?.length).toEqual(5); + expect(scenario.choicesOf('/data/q').get(1)?.getProperties()).deep.equal([ + ['geometry', '0.5 104 0 0'], + ['id', '67'], + ['name', 'Your cool point'], + ['foo', 'quux'], + ['special-property', 'special value'], + ]); + }); - /** - * **PORTING NOTES** - * - * This test is also blocked on lack of support for repeat-based itemsets. - */ - it.fails('updates when choices are from repeat', async () => { + it('returns properties with empty string when no value and choices are from secondary instance', async () => { const scenario = await Scenario.init( - 'Select from repeat', + 'Select with empty value', html( head( - title('Select from repeat'), + title('Select with empty value'), model( - mainInstance( - t( - "data id='repeat-select'", - t('repeat', t('value'), t('label'), t('special-property')), - t('filter'), - t('select') - ) - ) + mainInstance(t("data id='select-empty'", t('select'))), + instance('choices', t('item', t('label', 'Item'), t('details', ''))) ) ), - body( - repeat('/data/repeat', input('value'), input('label'), input('special-property')), - input('filter'), - select1Dynamic('/data/select', '../repeat') - ) + body(select1Dynamic('/data/select', "instance('choices')/root/item", 'name', 'label')) ) ); - scenario.answer('/data/repeat[0]/value', 'a'); - scenario.answer('/data/repeat[0]/label', 'A'); - scenario.answer('/data/repeat[0]/special-property', 'AA'); - expect(scenario.choicesOf('/data/select').get(0)?.getValue()).toBe('a'); - expect(scenario.choicesOf('/data/select').get(0)?.getChild('special-property')).toBe('AA'); - - scenario.answer('/data/repeat[0]/special-property', 'changed'); - - expect(scenario.choicesOf('/data/select').get(0)?.getChild('special-property')).toBe( - 'changed' - ); - }); - - /** - * **PORTING NOTES** - * - * In theory, this could be made to pass! It makes more sense to fail it - * with the same error as the others above, as it is also subject to the - * same API design considerations. It also may be moot depending on our - * posture towards inline select items generally. - */ - it.fails('returns null when called on a choice from [an] inline select', async () => { - const scenario = await Scenario.init( - 'Static select', - html( - head( - title('Static select'), - model(mainInstance(t("data id='static-select'", t('select')))) - ), - body(select1('/data/select', item('one', 'One'), item('two', 'Two'))) - ) - ); - - expect(scenario.choicesOf('/data/select').get(0)?.getChild('invalid-property')).toBe( - nullValue() - ); - }); - }); - - /** - * **PORTING NOTES** - * - * It is already obvious at the outset that this API will fall into the same - * category as `getChild` above. Minimal effort has gone into porting these. - * Any further notes that might arise will come from further analysis when the - * affected functionality is prioritized. - */ - describe('`getAdditionalChildren`', () => { - it.fails('returns children in order when choices are from secondary instance', async () => { - setUpSimpleReferenceManager(r('external-select-geojson.xml').getParent(), 'file'); - - const scenario = await Scenario.init('external-select-geojson.xml'); - - const firstNodeChildren = scenario.choicesOf('/data/q').get(0)?.getAdditionalChildren(); - - expect(firstNodeChildren?.size()).toBe(3); - expect(firstNodeChildren?.get(0)).toEqual(['geometry', '0.5 102 0 0']); - expect(firstNodeChildren?.get(1)).toEqual(['id', 'fs87b']); - expect(firstNodeChildren?.get(2)).toEqual(['foo', 'bar']); - - const secondNodeChildren = scenario.choicesOf('/data/q').get(1)?.getAdditionalChildren(); - - expect(secondNodeChildren?.size()).toBe(4); - expect(secondNodeChildren?.get(0)).toEqual(['geometry', '0.5 104 0 0']); - expect(secondNodeChildren?.get(1)).toEqual(['id', '67']); - expect(secondNodeChildren?.get(2)).toEqual(['foo', 'quux']); - expect(secondNodeChildren?.get(3)).toEqual(['special-property', 'special value']); + expect(scenario.choicesOf('/data/select').get(0)?.getProperties()).deep.equal([ + ['label', 'Item'], + ['details', ''], + ]); }); /** * **PORTING NOTES** * - * The corresponding JavaRosa test name begins with `getChildren`, which seems - * to be a typo (or surprising shorthand) for `getAdditionalChildren + * This test is blocked on lack of support for repeat-based itemsets. */ - it.fails('updates when choices are from repeat', async () => { + it.fails('updates when choices are from repeat and preserve order', async () => { const scenario = await Scenario.init( 'Select from repeat', html( @@ -811,28 +707,18 @@ describe('SelectChoiceTest.java', () => { scenario.answer('/data/repeat[0]/special-property', 'AA'); expect(scenario.choicesOf('/data/select').get(0)?.getValue()).toBe('a'); - - let children = scenario.choicesOf('/data/select').get(0)?.getAdditionalChildren(); - - expect(children?.size()).toBe(2); - expect(children?.get(0)).toEqual(['value', 'a']); - expect(children?.get(1)).toEqual(['special-property', 'AA']); + expect(scenario.choicesOf('/data/select').get(0)?.getProperties()).deep.equal([ + ['special-property', 'AA'], + ]); scenario.answer('/data/repeat[0]/special-property', 'changed'); - children = scenario.choicesOf('/data/select').get(0)?.getAdditionalChildren(); - - expect(children?.get(1)).toEqual(['special-property', 'changed']); + expect(scenario.choicesOf('/data/select').get(0)?.getProperties()).deep.equal([ + ['special-property', 'changed'], + ]); }); - /** - * **PORTING NOTES** - * - * Like the inline (non-itemset) select test for `getChild`, this could be - * made to pass, but was left failing with the rest of the sub-suite based - * on the same reasoning. - */ - it.fails('returns empty when called on a choice from inline select', async () => { + it('returns empty properties when called on a choice from [an] inline select', async () => { const scenario = await Scenario.init( 'Static select', html( @@ -844,9 +730,7 @@ describe('SelectChoiceTest.java', () => { ) ); - expect(scenario.choicesOf('/data/select').get(0)?.getAdditionalChildren().isEmpty()).toBe( - true - ); + expect(scenario.choicesOf('/data/select').get(0)?.getProperties()).deep.equal([]); }); }); }); diff --git a/packages/web-forms/package.json b/packages/web-forms/package.json index 77be3c45b..4a52bd023 100644 --- a/packages/web-forms/package.json +++ b/packages/web-forms/package.json @@ -78,8 +78,9 @@ "vue": "^3.5.18" }, "dependencies": { - "vue-draggable-plus": "^0.6.0", - "@mdi/js": "^7.4.47" + "@mdi/js": "^7.4.47", + "ol": "^10.6.1", + "vue-draggable-plus": "^0.6.0" }, "publishConfig": { "access": "public" diff --git a/packages/web-forms/src/assets/images/map-location.svg b/packages/web-forms/src/assets/images/map-location.svg new file mode 100644 index 000000000..8aae37881 --- /dev/null +++ b/packages/web-forms/src/assets/images/map-location.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/web-forms/src/assets/styles/reset.scss b/packages/web-forms/src/assets/styles/reset.scss index c4bf99e2f..57571705b 100644 --- a/packages/web-forms/src/assets/styles/reset.scss +++ b/packages/web-forms/src/assets/styles/reset.scss @@ -2,6 +2,7 @@ ::before, ::after { box-sizing: inherit; + -webkit-tap-highlight-color: transparent; } html { diff --git a/packages/web-forms/src/assets/styles/style.scss b/packages/web-forms/src/assets/styles/style.scss index 313a535fc..d289de837 100644 --- a/packages/web-forms/src/assets/styles/style.scss +++ b/packages/web-forms/src/assets/styles/style.scss @@ -34,6 +34,9 @@ --odk-warning-text-color: var(--p-yellow-600); --odk-warning-background-color: var(--p-yellow-50); + --odk-success-text-color: var(--p-green-500); + --odk-success-background-color: var(--p-green-50); + --odk-text-color: var(--p-surface-900); --odk-inverted-text-color: var(--p-surface-0); --odk-muted-text-color: var(--p-surface-500); diff --git a/packages/web-forms/src/components/common/IconSVG.vue b/packages/web-forms/src/components/common/IconSVG.vue index c553f595e..50adc3500 100644 --- a/packages/web-forms/src/components/common/IconSVG.vue +++ b/packages/web-forms/src/components/common/IconSVG.vue @@ -3,19 +3,25 @@ import { computed } from 'vue'; import { mdiAlert, mdiAlertCircleOutline, + mdiArrowExpandAll, mdiCamera, mdiCheck, + mdiCheckCircle, mdiChevronDown, mdiChevronUp, mdiClose, + mdiContentSave, + mdiCrosshairsGps, mdiDotsVertical, mdiDownload, mdiDragVertical, mdiEyeOutline, mdiFileOutline, + mdiFullscreen, mdiImage, mdiMapMarkerOutline, mdiMenu, + mdiPencil, mdiPlus, mdiPrinter, mdiRefresh, @@ -28,19 +34,25 @@ import { const iconMap: Record = { mdiAlert, mdiAlertCircleOutline, + mdiArrowExpandAll, mdiCamera, mdiCheck, + mdiCheckCircle, mdiChevronDown, mdiChevronUp, mdiClose, + mdiContentSave, + mdiCrosshairsGps, mdiDotsVertical, mdiDownload, mdiDragVertical, mdiEyeOutline, mdiFileOutline, + mdiFullscreen, mdiImage, mdiMapMarkerOutline, mdiMenu, + mdiPencil, mdiPlus, mdiPrinter, mdiRefresh, @@ -50,7 +62,7 @@ const iconMap: Record = { }; type IconName = keyof typeof iconMap; -type IconVariant = 'base' | 'error' | 'inverted' | 'muted' | 'primary' | 'warning'; +type IconVariant = 'base' | 'error' | 'inverted' | 'muted' | 'primary' | 'success' | 'warning'; type IconSize = 'md' | 'sm'; /** @@ -113,6 +125,10 @@ const iconSize = computed(() => props.size ?? 'md'); fill: var(--odk-warning-text-color); } +.odk-icon.success path { + fill: var(--odk-success-text-color); +} + .odk-icon.odk-icon-sm { height: 14px; width: 14px; diff --git a/packages/web-forms/src/components/common/map/AsyncMap.vue b/packages/web-forms/src/components/common/map/AsyncMap.vue new file mode 100644 index 000000000..b49689d27 --- /dev/null +++ b/packages/web-forms/src/components/common/map/AsyncMap.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/packages/web-forms/src/components/common/map/MapBlock.vue b/packages/web-forms/src/components/common/map/MapBlock.vue new file mode 100644 index 000000000..1bd8cbb68 --- /dev/null +++ b/packages/web-forms/src/components/common/map/MapBlock.vue @@ -0,0 +1,282 @@ + + + + + diff --git a/packages/web-forms/src/components/common/map/MapProperties.vue b/packages/web-forms/src/components/common/map/MapProperties.vue new file mode 100644 index 000000000..a93b37cad --- /dev/null +++ b/packages/web-forms/src/components/common/map/MapProperties.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/packages/web-forms/src/components/common/map/MapStatusBar.vue b/packages/web-forms/src/components/common/map/MapStatusBar.vue new file mode 100644 index 000000000..cea8d353f --- /dev/null +++ b/packages/web-forms/src/components/common/map/MapStatusBar.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/packages/web-forms/src/components/common/map/map-styles.ts b/packages/web-forms/src/components/common/map/map-styles.ts new file mode 100644 index 000000000..e166dede3 --- /dev/null +++ b/packages/web-forms/src/components/common/map/map-styles.ts @@ -0,0 +1,129 @@ +import type { Rule } from 'ol/style/flat'; +import mapLocationIcon from '@/assets/images/map-location.svg'; + +const DEFAULT_STROKE_COLOR = '#3E9FCC'; + +const DEFAULT_POINT_STYLE = { + 'icon-src': mapLocationIcon, + 'icon-width': 40, + 'icon-height': 40, +}; + +const DEFAULT_FEATURE_STYLE = { + 'stroke-width': 4, + 'stroke-color': DEFAULT_STROKE_COLOR, + 'fill-color': 'rgba(233, 248, 255, 0.8)', +}; + +const SCALE_POINT_STYLE = { + 'icon-src': mapLocationIcon, + 'icon-width': 50, + 'icon-height': 50, +}; + +const SCALE_FEATURE_STYLE = { + 'stroke-width': 6, +}; + +const OUTLINE_STROKE_WIDTH = 20; + +const BLUE_GLOW_COLOR = 'rgba(148, 224, 237, 0.7)'; + +const BLUE_GLOW_POINT_STYLE = { + 'circle-radius': 30, + 'circle-fill-color': BLUE_GLOW_COLOR, + 'circle-displacement': [0, 0], +}; + +const BLUE_GLOW_FEATURE_STYLE = { + 'stroke-width': OUTLINE_STROKE_WIDTH, + 'stroke-color': BLUE_GLOW_COLOR, + 'fill-color': 'transparent', +}; + +const GREEN_GLOW_COLOR = 'rgba(34, 197, 94, 0.6)'; + +const GREEN_GLOW_POINT_STYLE = { + 'circle-radius': 30, + 'circle-fill-color': GREEN_GLOW_COLOR, + 'circle-displacement': [0, 0], +}; + +const GREEN_GLOW_FEATURE_STYLE = { + 'stroke-width': OUTLINE_STROKE_WIDTH, + 'stroke-color': GREEN_GLOW_COLOR, + 'fill-color': 'transparent', +}; + +// Increases the clickable area of the Line feature. +const LINE_HIT_TOLERANCE = { + 'stroke-width': OUTLINE_STROKE_WIDTH, + 'stroke-color': 'rgba( 255, 255, 255, 0.1)', +}; + +const makeFilter = (types: string[], additionalFilters: unknown[]) => { + return ['all', ['in', ['geometry-type'], ['literal', types]], ...additionalFilters]; +}; + +export function getUnselectedStyles( + featureIdProp: string, + selectedPropName: string, + savedPropName: string +): Rule[] { + const filters = [ + ['!=', ['get', featureIdProp], ['var', selectedPropName]], + ['!=', ['get', featureIdProp], ['var', savedPropName]], + ]; + + return [ + { + filter: makeFilter(['Point'], filters), + style: DEFAULT_POINT_STYLE, + }, + { + filter: makeFilter(['LineString', 'Polygon'], filters), + style: DEFAULT_FEATURE_STYLE, + }, + { + filter: makeFilter(['LineString'], filters), + style: LINE_HIT_TOLERANCE, + }, + ]; +} + +export function getSelectedStyles( + featureIdProp: string, + selectedPropName: string, + savedPropName: string +): Rule[] { + const filters = [ + ['==', ['get', featureIdProp], ['var', selectedPropName]], + ['!=', ['get', featureIdProp], ['var', savedPropName]], + ]; + + return [ + { + filter: makeFilter(['Point'], filters), + style: [BLUE_GLOW_POINT_STYLE, DEFAULT_POINT_STYLE, SCALE_POINT_STYLE], + }, + { + filter: makeFilter(['LineString', 'Polygon'], filters), + style: [BLUE_GLOW_FEATURE_STYLE, DEFAULT_FEATURE_STYLE, SCALE_FEATURE_STYLE], + }, + ]; +} + +export function getSavedStyles(featureIdProp: string, savedPropName: string): Rule[] { + const filter = ['==', ['get', featureIdProp], ['var', savedPropName]]; + + return [ + { + filter: makeFilter(['Point'], [filter]), + style: [GREEN_GLOW_POINT_STYLE, DEFAULT_POINT_STYLE, SCALE_POINT_STYLE], + }, + { + filter: makeFilter(['LineString', 'Polygon'], [filter]), + style: [GREEN_GLOW_FEATURE_STYLE, DEFAULT_FEATURE_STYLE, SCALE_FEATURE_STYLE], + }, + ]; +} diff --git a/packages/web-forms/src/components/common/map/useMapBlock.ts b/packages/web-forms/src/components/common/map/useMapBlock.ts new file mode 100644 index 000000000..f5783e849 --- /dev/null +++ b/packages/web-forms/src/components/common/map/useMapBlock.ts @@ -0,0 +1,307 @@ +import { + getSavedStyles, + getSelectedStyles, + getUnselectedStyles, +} from '@/components/common/map/map-styles.ts'; +import type { FeatureCollection } from 'geojson'; +import { Map, MapBrowserEvent, View } from 'ol'; +import { Zoom } from 'ol/control'; +import { getCenter } from 'ol/extent'; +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 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 { computed, shallowRef, watch } from 'vue'; +import { get as getProjection } from 'ol/proj'; + +type GeometryType = LineString | Point | Polygon; + +const STATES = { + LOADING: 'loading', + READY: 'ready', + ERROR: 'error', +} as const; + +const DEFAULT_GEOJSON_PROJECTION = 'EPSG:4326'; +const DEFAULT_VIEW_PROJECTION = 'EPSG:3857'; +const DEFAULT_VIEW_CENTER = [0, 0]; +const MAX_ZOOM = 16; +const MIN_ZOOM = 2; +const GEOLOCATION_TIMEOUT_MS = 10 * 1000; +const ANIMATION_TIME = 1000; +const SMALL_DEVICE_WIDTH = 576; +const FEATURE_ID_PROPERTY = 'odk_feature_id'; +const SAVED_ID_PROPERTY = 'savedId'; +const SELECTED_ID_PROPERTY = 'selectedId'; + +export function useMapBlock() { + const currentState = shallowRef<(typeof STATES)[keyof typeof STATES]>(STATES.LOADING); + const errorMessage = shallowRef<{ title: string; message: string } | undefined>(); + let mapInstance: Map | undefined; + const savedFeature = shallowRef | undefined>(); + const selectedFeature = shallowRef | undefined>(); + const selectedFeatureProperties = computed(() => { + return selectedFeature.value?.getProperties(); + }); + + const featuresSource = new VectorSource(); + const featuresVectorLayer = new WebGLVectorLayer({ + source: featuresSource, + style: [ + ...getUnselectedStyles(FEATURE_ID_PROPERTY, SELECTED_ID_PROPERTY, SAVED_ID_PROPERTY), + ...getSelectedStyles(FEATURE_ID_PROPERTY, SELECTED_ID_PROPERTY, SAVED_ID_PROPERTY), + ...getSavedStyles(FEATURE_ID_PROPERTY, SAVED_ID_PROPERTY), + ], + variables: { [SAVED_ID_PROPERTY]: '', [SELECTED_ID_PROPERTY]: '' }, + }); + + const initializeMap = (mapContainer: HTMLElement, geoJSON: FeatureCollection): void => { + if (mapInstance) { + return; + } + + mapInstance = new Map({ + target: mapContainer, + layers: [new TileLayer({ source: new OSM() }), featuresVectorLayer], + view: new View({ + center: DEFAULT_VIEW_CENTER, + zoom: MIN_ZOOM, + // Prevent map cloning at low zoom during panning, which disrupts feature selection. + multiWorld: false, + projection: DEFAULT_VIEW_PROJECTION, + extent: getProjection(DEFAULT_VIEW_PROJECTION)?.getExtent(), + }), + controls: [new Zoom()], + }); + + currentState.value = STATES.READY; + loadGeometries(geoJSON); + }; + + const setCursorPointer = (event: MapBrowserEvent) => { + if (event.dragging || !mapInstance) { + return; + } + + const hit = mapInstance.hasFeatureAtPixel(event.pixel, { + layerFilter: (layer) => layer instanceof WebGLVectorLayer, + }); + + mapInstance.getTargetElement().style.cursor = hit ? 'pointer' : ''; + }; + + const handleClick = (event: MapBrowserEvent) => selectFeatureByPosition(event.pixel); + + const toggleClickBinding = (bindClick: boolean) => { + mapInstance?.un('click', handleClick); + mapInstance?.un('pointermove', setCursorPointer); + + if (bindClick) { + mapInstance?.on('click', handleClick); + mapInstance?.on('pointermove', setCursorPointer); + } + }; + + const fitToAllFeatures = (): void => { + const extent = featuresSource.getExtent(); + if (extent) { + mapInstance?.getView().fit(extent, { + padding: [50, 50, 50, 50], + duration: ANIMATION_TIME, + maxZoom: MAX_ZOOM, + }); + } + }; + + 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.', + }; + + if (!navigator.geolocation) { + currentState.value = STATES.ERROR; + errorMessage.value = friendlyError; + } + + 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 } + ); + }; + + const centerFeatureLocation = (feature: Feature): void => { + const geometry = feature.getGeometry(); + const view = mapInstance?.getView(); + const mapWidth = mapInstance?.getSize()?.[0]; + if (!geometry || !view || mapWidth == null) { + return; + } + + const pixelOffsetY = mapWidth < SMALL_DEVICE_WIDTH ? -130 : 0; + const pixelOffsetX = mapWidth < SMALL_DEVICE_WIDTH ? 0 : -70; + + const zoomResolution = view.getResolution() ?? 1; + const xOffsetInMapUnits = -pixelOffsetX * zoomResolution; + const yOffsetInMapUnits = -pixelOffsetY * zoomResolution; + + // Turning angles into usable numbers + const rotation = view.getRotation(); + const cosRotation = Math.cos(rotation); + const sinRotation = Math.sin(rotation); + + const [featureCenterLong, featureCenterLat] = getCenter(geometry.getExtent()); + const targetCoordinates = [ + featureCenterLong - xOffsetInMapUnits * cosRotation + yOffsetInMapUnits * sinRotation, + featureCenterLat - xOffsetInMapUnits * sinRotation - yOffsetInMapUnits * cosRotation, + ]; + + view.animate({ + center: targetCoordinates, + duration: ANIMATION_TIME, + }); + }; + + const loadGeometries = (geoJSON: FeatureCollection): void => { + if (!mapInstance) { + return; + } + + currentState.value = STATES.LOADING; + selectFeature(undefined); + saveFeature(undefined); + featuresSource.clear(true); + + if (!geoJSON.features.length) { + mapInstance?.getView().animate({ + center: DEFAULT_VIEW_CENTER, + zoom: MIN_ZOOM, + duration: ANIMATION_TIME, + }); + currentState.value = STATES.READY; + return; + } + + const features = new GeoJSON().readFeatures(geoJSON, { + dataProjection: DEFAULT_GEOJSON_PROJECTION, + featureProjection: mapInstance.getView().getProjection(), + }); + + features.forEach((feature) => { + if (!feature.get(FEATURE_ID_PROPERTY)) { + feature.set(FEATURE_ID_PROPERTY, crypto.randomUUID()); + } + }); + featuresSource.addFeatures(features); + currentState.value = STATES.READY; + + fitToAllFeatures(); + }; + + const selectFeatureByPosition = (position: Pixel): void => { + const hitFeatures = mapInstance?.getFeaturesAtPixel(position, { + layerFilter: (layer) => layer instanceof WebGLVectorLayer, + }); + + const featureToSelect = hitFeatures?.length + ? (hitFeatures[0] as Feature) + : undefined; + + selectFeature(featureToSelect); + }; + + const selectFeature = (feature?: Feature) => (selectedFeature.value = feature); + + const saveFeature = (feature?: Feature) => (savedFeature.value = feature); + + const setSavedByValueProp = (value: string | undefined): void => { + if (!value?.length) { + return; + } + + const featureToSave = featuresSource.forEachFeature((feature) => { + return feature.getProperties()?.odk_value === value ? feature : undefined; + }); + + if (!featureToSave) { + return; + } + + saveFeature(featureToSave as Feature); + centerFeatureLocation(featureToSave as Feature); + }; + + const isSelectedFeatureSaved = (): boolean => { + const savedId = savedFeature.value?.get(FEATURE_ID_PROPERTY) as string; + const selectedId = selectedFeature.value?.get(FEATURE_ID_PROPERTY) as string; + return savedId?.length > 0 && savedId === selectedId; + }; + + watch( + () => currentState.value, + (newState) => { + if (newState !== STATES.ERROR) { + errorMessage.value = undefined; + } + } + ); + + watch( + () => selectedFeature.value, + (newSelectedFeature) => { + featuresVectorLayer.updateStyleVariables({ + [SELECTED_ID_PROPERTY]: (newSelectedFeature?.get(FEATURE_ID_PROPERTY) as string) ?? '', + }); + + if (newSelectedFeature != null) { + centerFeatureLocation(newSelectedFeature); + } + } + ); + + watch( + () => savedFeature.value, + (newSavedFeature) => { + featuresVectorLayer.updateStyleVariables({ + [SAVED_ID_PROPERTY]: (newSavedFeature?.get(FEATURE_ID_PROPERTY) as string) ?? '', + }); + } + ); + + return { + initializeMap, + loadGeometries, + errorMessage, + toggleClickBinding, + + centerCurrentLocation, + fitToAllFeatures, + + savedFeature, + discardSavedFeature: () => saveFeature(undefined), + saveFeature: () => saveFeature(selectedFeature.value), + setSavedByValueProp, + + selectedFeatureProperties, + selectSavedFeature: () => selectFeature(savedFeature.value), + unselectFeature: () => selectFeature(undefined), + isSelectedFeatureSaved, + }; +} diff --git a/packages/web-forms/src/components/form-elements/select/Select1Control.vue b/packages/web-forms/src/components/form-elements/select/Select1Control.vue index 9ee74d14d..4ab0e359b 100644 --- a/packages/web-forms/src/components/form-elements/select/Select1Control.vue +++ b/packages/web-forms/src/components/form-elements/select/Select1Control.vue @@ -2,6 +2,7 @@ import ColumnarAppearance from '@/components/appearances/ColumnarAppearance.vue'; import FieldListTable from '@/components/appearances/FieldListTable.vue'; import UnsupportedAppearance from '@/components/appearances/UnsupportedAppearance.vue'; +import AsyncMap from '@/components/common/map/AsyncMap.vue'; import ControlText from '@/components/form-elements/ControlText.vue'; import ValidationMessage from '@/components/common/ValidationMessage.vue'; import LikertWidget from '@/components/common/LikertWidget.vue'; @@ -18,6 +19,12 @@ const props = defineProps(); const isSelectWithImages = computed(() => props.question.currentState.isSelectWithImages); const hasColumnsAppearance = ref(false); const hasFieldListRelatedAppearance = ref(false); +const savedFeatureValue = computed(() => { + if (!props.question.appearances.map) { + return; + } + return props.question.currentState.value?.[0]; +}); watchEffect(() => { const appearances = [...props.question.appearances]; @@ -47,7 +54,19 @@ watchEffect(() => { :question="question" /> - + + + @@ -56,12 +75,16 @@ watchEffect(() => { - +