diff --git a/common/util.js b/common/util.js index 1dc315089e67f3e98dd219161b7d66d9cb669872..f506a1cfb2ec41dab07677dbf4edf5bcbd04df37 100644 --- a/common/util.js +++ b/common/util.js @@ -108,4 +108,15 @@ return [ h, s, l ]; } + + exports.verifyPositionArg = val => { + return ( + Array.isArray(val) && + val.length === 3 && + val.every(n => + typeof n === 'number' && + !Number.isNaN(n) + ) + ) + } })(typeof exports === 'undefined' ? module.exports : exports) diff --git a/src/ui/parcellationRegion/region.base.spec.ts b/src/ui/parcellationRegion/region.base.spec.ts index 0e2153d3cf3ad0b3ddb198879728d19d7ea1e91e..0b09bc95d1ec46e3345fcc4f2596b00ba1d09ef1 100644 --- a/src/ui/parcellationRegion/region.base.spec.ts +++ b/src/ui/parcellationRegion/region.base.spec.ts @@ -1,4 +1,6 @@ -import { regionInOtherTemplateSelector } from './region.base' +import { TestBed } from '@angular/core/testing' +import { MockStore, provideMockStore } from '@ngrx/store/testing' +import { RegionBase, regionInOtherTemplateSelector, getRegionParentParcRefSpace } from './region.base' const mr1wrong = { labelIndex: 1, @@ -141,9 +143,10 @@ const mtWrong = { const mockFetchedTemplates = [ mt0, mt1, mt2, mt3, mtWrong ] describe('> region.base.ts', () => { - describe('> getRegionInOtherTemplatesSelector', () => { - describe('> no hemisphere selected, simulates big brain cyto map', () => { + describe('> regionInOtherTemplateSelector', () => { + describe('> no hemisphere selected, simulates big brain cyto map', () => { + let result: any[] beforeAll(() => { result = regionInOtherTemplateSelector.projector({ fetchedTemplates: mockFetchedTemplates, templateSelected: mt0 }, { region: mr0 }) @@ -245,5 +248,86 @@ describe('> region.base.ts', () => { ) }) }) + + }) + + describe('> RegionBase', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideMockStore() + ] + }) + }) + describe('> position', () => { + let regionBase: RegionBase + beforeEach(() => { + const mockStore = TestBed.inject(MockStore) + mockStore.overrideSelector(regionInOtherTemplateSelector, []) + mockStore.overrideSelector(getRegionParentParcRefSpace, { template: null, parcellation: null }) + regionBase = new RegionBase(mockStore) + }) + it('> does not populate if position property is absent', () => { + regionBase.region = { + ...mr0 + } + expect(regionBase.position).toBeFalsy() + }) + + describe('> does not populate if position property is malformed', () => { + + it('> if position property is string', () => { + regionBase.region = { + ...mr0, + position: 'hello world' + } + expect(regionBase.position).toBeFalsy() + }) + it('> if position property is object', () => { + regionBase.region = { + ...mr0, + position: { + x: 0, + y: 0, + z: 0 + } + } + expect(regionBase.position).toBeFalsy() + }) + + it('> if position property is array of incorrect length', () => { + regionBase.region = { + ...mr0, + position: [] + } + expect(regionBase.position).toBeFalsy() + }) + + it('> if position property is array contain non number elements', () => { + regionBase.region = { + ...mr0, + position: [1, 2, 'hello world'] + } + expect(regionBase.position).toBeFalsy() + }) + + + it('> if position property is array contain NaN', () => { + regionBase.region = { + ...mr0, + position: [1, 2, NaN] + } + expect(regionBase.position).toBeFalsy() + }) + }) + + it('> populates if position property is array with length 3 and non NaN element', () => { + regionBase.region = { + ...mr0, + position: [1, 2, 3] + } + expect(regionBase.position).toBeTruthy() + }) + }) }) }) diff --git a/src/ui/parcellationRegion/region.base.ts b/src/ui/parcellationRegion/region.base.ts index 955d0591de032699d4b8453a620a80b117800e62..1655dcfb0193f25c5c3c2e57d8e91c2bbd81260e 100644 --- a/src/ui/parcellationRegion/region.base.ts +++ b/src/ui/parcellationRegion/region.base.ts @@ -7,7 +7,7 @@ import { ARIA_LABELS } from 'common/constants' import { flattenRegions, getIdFromFullId, rgbToHsl } from 'common/util' import { viewerStateSetConnectivityRegion, viewerStateNavigateToRegion, viewerStateToggleRegionSelect } from "src/services/state/viewerState.store.helper"; import { viewerStateGetSelectedAtlas } from "src/services/state/viewerState/selectors"; -import { intToRgb } from 'common/util' +import { intToRgb, verifyPositionArg } from 'common/util' export class RegionBase { @@ -16,10 +16,24 @@ export class RegionBase { private _region: any + private _position: [number, number, number] + set position(val){ + if (verifyPositionArg(val)) { + this._position = val + } else { + this._position = null + } + } + + get position(){ + return this._position + } + @Input() set region(val) { this._region = val this.region$.next(this._region) + this.position = val.position if (!this._region) return const rgb = this._region.rgb || (this._region.labelIndex && intToRgb(Number(this._region.labelIndex))) || [255, 200, 200] @@ -60,10 +74,10 @@ export class RegionBase { )) ) - this.regionOriginDatasetLabels$ = combineLatest( + this.regionOriginDatasetLabels$ = combineLatest([ this.store$, this.region$ - ).pipe( + ]).pipe( map(([state, region]) => getRegionParentParcRefSpace(state, { region })), map(({ template }) => (template && template.originalDatasetFormats) || []) ) diff --git a/src/ui/parcellationRegion/regionListSimpleView/regionListSimpleView.template.html b/src/ui/parcellationRegion/regionListSimpleView/regionListSimpleView.template.html index 12b4c2e6a25cae2923d90d596980edf5baa36b1e..1b4636a59534a5b5fd6cfe1269814e79ab520c2a 100644 --- a/src/ui/parcellationRegion/regionListSimpleView/regionListSimpleView.template.html +++ b/src/ui/parcellationRegion/regionListSimpleView/regionListSimpleView.template.html @@ -8,7 +8,7 @@ </small> <button mat-icon-button - *ngIf="region.position" + *ngIf="position" class="flex-grow-0 flex-shrink-0" (click)="navigateToRegion()" > <i *ngIf="isSelected" class="fas fa-map-marked-alt"></i> diff --git a/src/ui/parcellationRegion/regionMenu/regionMenu.template.html b/src/ui/parcellationRegion/regionMenu/regionMenu.template.html index 9c99eef72e25df206a061bb3ca41b202d77151a9..b8af09822ad398f590fdaabed6056755b1fc8d5b 100644 --- a/src/ui/parcellationRegion/regionMenu/regionMenu.template.html +++ b/src/ui/parcellationRegion/regionMenu/regionMenu.template.html @@ -31,9 +31,9 @@ <mat-divider vertical="true" class="ml-2 h-2rem"></mat-divider> <!-- position --> - <button mat-icon-button *ngIf="region?.position" + <button mat-icon-button *ngIf="position" (click)="navigateToRegion()" - [matTooltip]="GO_TO_REGION_CENTROID + ': ' + (region.position | nmToMm | addUnitAndJoin : 'mm')"> + [matTooltip]="GO_TO_REGION_CENTROID + ': ' + (position | nmToMm | addUnitAndJoin : 'mm')"> <mat-icon fontSet="fas" fontIcon="fa-map-marked-alt"> </mat-icon> </button> diff --git a/src/ui/parcellationRegion/regionSimple/regionSimple.template.html b/src/ui/parcellationRegion/regionSimple/regionSimple.template.html index f0aa1566281cd83ea2582e1d312d39150f95222e..b724ab6f27d2ece74413959934d913d7b48888f5 100644 --- a/src/ui/parcellationRegion/regionSimple/regionSimple.template.html +++ b/src/ui/parcellationRegion/regionSimple/regionSimple.template.html @@ -7,7 +7,7 @@ <div class="flex-grow-0 flex-shrink-0 d-flex flex-row"> <!-- if has position defined --> - <button *ngIf="region.position" + <button *ngIf="position" iav-stop="click" (click)="navigateToRegion()" mat-icon-button> diff --git a/src/ui/ui.module.ts b/src/ui/ui.module.ts index c5a8b0e9b9e2fa909d49957107570d71013964e3..56f68a49650ea23ec098a723fc43e4b6b23e87ec 100644 --- a/src/ui/ui.module.ts +++ b/src/ui/ui.module.ts @@ -64,8 +64,6 @@ import { TakeScreenshotComponent } from "src/ui/takeScreenshot/takeScreenshot.co import { FixedMouseContextualContainerDirective } from "src/util/directives/FixedMouseContextualContainerDirective.directive"; import { RegionHierarchy } from './viewerStateController/regionHierachy/regionHierarchy.component' import { RegionTextSearchAutocomplete } from "./viewerStateController/regionSearch/regionSearch.component"; -import { CurrentlySelectedRegions } from './viewerStateController/regionsListView/currentlySelectedRegions/currentlySelectedRegions.component' -import { RegionsListView } from "./viewerStateController/regionsListView/simpleRegionsListView/regionListView.component"; import { ConnectivityBrowserComponent } from "src/ui/connectivityBrowser/connectivityBrowser.component"; import { RegionMenuComponent } from 'src/ui/parcellationRegion/regionMenu/regionMenu.component' @@ -136,10 +134,8 @@ import { RegionAccordionTooltipTextPipe } from './util' CurrentLayout, ViewerStateMini, RegionHierarchy, - CurrentlySelectedRegions, MaximmisePanelButton, RegionTextSearchAutocomplete, - RegionsListView, TakeScreenshotComponent, RegionMenuComponent, ConnectivityBrowserComponent, diff --git a/src/ui/viewerStateController/regionsListView/currentlySelectedRegions/currentlySelectedRegions.component.ts b/src/ui/viewerStateController/regionsListView/currentlySelectedRegions/currentlySelectedRegions.component.ts deleted file mode 100644 index fb4e5f7b0de37703c608b3104527b06f03625ddf..0000000000000000000000000000000000000000 --- a/src/ui/viewerStateController/regionsListView/currentlySelectedRegions/currentlySelectedRegions.component.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Component } from "@angular/core"; -import { select, Store } from "@ngrx/store"; -import { Observable } from "rxjs"; -import { distinctUntilChanged, startWith } from "rxjs/operators"; -import { DESELECT_REGIONS } from "src/services/state/viewerState.store"; -import { IavRootStoreInterface } from "src/services/stateStore.service"; -import { viewerStateNavigateToRegion } from "src/services/state/viewerState.store.helper"; - -@Component({ - selector: 'currently-selected-regions', - templateUrl: './currentlySelectedRegions.template.html', - styleUrls: [ - './currentlySelectedRegions.style.css', - ], -}) - -export class CurrentlySelectedRegions { - - public regionSelected$: Observable<any[]> - - constructor( - private store$: Store<IavRootStoreInterface>, - ) { - - this.regionSelected$ = this.store$.pipe( - select('viewerState'), - select('regionsSelected'), - startWith([]), - distinctUntilChanged(), - ) - } - - public deselectRegion(event: MouseEvent, region: any) { - this.store$.dispatch({ - type: DESELECT_REGIONS, - deselectRegions: [region], - }) - } - - public gotoRegion(event: MouseEvent, region: any) { - this.store$.dispatch( - viewerStateNavigateToRegion({ - payload: { region } - }) - ) - } -} diff --git a/src/ui/viewerStateController/regionsListView/currentlySelectedRegions/currentlySelectedRegions.style.css b/src/ui/viewerStateController/regionsListView/currentlySelectedRegions/currentlySelectedRegions.style.css deleted file mode 100644 index c8d6803350d048c3d3214d787612704d3487a520..0000000000000000000000000000000000000000 --- a/src/ui/viewerStateController/regionsListView/currentlySelectedRegions/currentlySelectedRegions.style.css +++ /dev/null @@ -1,9 +0,0 @@ -mat-chip-list >>> .mat-chip-list-wrapper -{ - height: 100%; -} - -mat-chip-list -{ - padding: 0.5rem; -} diff --git a/src/ui/viewerStateController/regionsListView/currentlySelectedRegions/currentlySelectedRegions.template.html b/src/ui/viewerStateController/regionsListView/currentlySelectedRegions/currentlySelectedRegions.template.html deleted file mode 100644 index 0a3e1fa75dfbb6698122c160e4c27c7b5358ede3..0000000000000000000000000000000000000000 --- a/src/ui/viewerStateController/regionsListView/currentlySelectedRegions/currentlySelectedRegions.template.html +++ /dev/null @@ -1,24 +0,0 @@ -<mat-chip-list class="d-block h-100"> - <cdk-virtual-scroll-viewport - class="w-100 h-100 overflow-x-hidden" - [itemSize]="32"> - <mat-chip - *cdkVirtualFor="let region of (regionSelected$ | async)" - class="w-90"> - <span class="flex-grow-1 flex-shrink-1 text-truncate"> - {{ region.name }} - </span> - <button - *ngIf="region.position" - (click)="gotoRegion($event, region)" - mat-icon-button> - <i class="fas fa-map-marked-alt"></i> - </button> - <button - (click)="deselectRegion($event, region)" - mat-icon-button> - <i class="fas fa-trash"></i> - </button> - </mat-chip> - </cdk-virtual-scroll-viewport> -</mat-chip-list> diff --git a/src/ui/viewerStateController/regionsListView/simpleRegionsListView/regionListView.component.ts b/src/ui/viewerStateController/regionsListView/simpleRegionsListView/regionListView.component.ts deleted file mode 100644 index f2f52d328448c9610a3d4846ac1c9a0e28bb49b3..0000000000000000000000000000000000000000 --- a/src/ui/viewerStateController/regionsListView/simpleRegionsListView/regionListView.component.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from "@angular/core"; - -@Component({ - selector: 'regions-list-view', - templateUrl: './regionListView.template.html', - styleUrls: [ - './regionListView.style.css', - ], - changeDetection: ChangeDetectionStrategy.OnPush, -}) - -export class RegionsListView { - @Input() public horizontal: boolean = false - - @Input() public regionsSelected: any[] = [] - @Output() public deselectRegion: EventEmitter<any> = new EventEmitter() - @Output() public gotoRegion: EventEmitter<any> = new EventEmitter() -} diff --git a/src/ui/viewerStateController/regionsListView/simpleRegionsListView/regionListView.style.css b/src/ui/viewerStateController/regionsListView/simpleRegionsListView/regionListView.style.css deleted file mode 100644 index fda82d0bacf99f1f7d596212fe2d15051e9d5354..0000000000000000000000000000000000000000 --- a/src/ui/viewerStateController/regionsListView/simpleRegionsListView/regionListView.style.css +++ /dev/null @@ -1,20 +0,0 @@ -mat-chip-list >>> .mat-chip-list-wrapper -{ - height: 100%; -} -cdk-virtual-scroll-viewport.cdk-virtual-scroll-orientation-horizontal -{ - overflow-y: hidden; -} -cdk-virtual-scroll-viewport.cdk-virtual-scroll-orientation-horizontal >>> .cdk-virtual-scroll-content-wrapper -{ - display: flex; - flex-direction: row; - flex-wrap: nowrap; -} - - -cdk-virtual-scroll-viewport.cdk-virtual-scroll-orientation-horizontal mat-chip -{ - flex: 0 0 200px; -} \ No newline at end of file diff --git a/src/ui/viewerStateController/regionsListView/simpleRegionsListView/regionListView.template.html b/src/ui/viewerStateController/regionsListView/simpleRegionsListView/regionListView.template.html deleted file mode 100644 index 82f37f44b76aae6af087a2714eef32d241186f01..0000000000000000000000000000000000000000 --- a/src/ui/viewerStateController/regionsListView/simpleRegionsListView/regionListView.template.html +++ /dev/null @@ -1,64 +0,0 @@ -<mat-chip-list class="p-1 d-block h-100 w-100"> - <ng-container *ngTemplateOutlet="horizontal ? horizontalTmpl : verticalTmpl"> - </ng-container> -</mat-chip-list> - -<!-- whilst it would be quite clever to dynamically change the few properties -virtual scroll needs to be re init'ed for it to work well -TODO check if this can be achieved with detach/attach --> -<ng-template #verticalTmpl> - - <cdk-virtual-scroll-viewport - orientation="vertical" - class="w-100 h-100 overflow-hidden" - itemSize="32"> - - <mat-chip *cdkVirtualFor="let region of regionsSelected" - [matTooltip]="region.name" - class="w-100" > - <span class="flex-grow-1 flex-shrink-1 text-truncate"> - {{ region.name }} - </span> - <button - *ngIf="region.position" - iav-stop="mousedown click" - (click)="gotoRegion.emit(region)" - mat-icon-button> - <i class="fas fa-map-marked-alt"></i> - </button> - <button - iav-stop="mousedown click" - (click)="deselectRegion.emit(region)" - mat-icon-button> - <i class="fas fa-trash"></i> - </button> - </mat-chip> - </cdk-virtual-scroll-viewport> -</ng-template> - -<ng-template #horizontalTmpl> - <cdk-virtual-scroll-viewport - orientation="horizontal" - class="w-100 h-100" - itemSize="200"> - - <mat-chip *cdkVirtualFor="let region of regionsSelected"> - <span class="flex-grow-1 flex-shrink-1 text-truncate"> - {{ region.name }} - </span> - <button - *ngIf="region.position" - iav-stop="mousedown click" - (click)="gotoRegion.emit(region)" - mat-icon-button> - <i class="fas fa-map-marked-alt"></i> - </button> - <button - iav-stop="mousedown click" - (click)="deselectRegion.emit(region)" - mat-icon-button> - <i class="fas fa-trash"></i> - </button> - </mat-chip> - </cdk-virtual-scroll-viewport> -</ng-template> \ No newline at end of file diff --git a/src/ui/viewerStateController/viewerState.useEffect.spec.ts b/src/ui/viewerStateController/viewerState.useEffect.spec.ts index b1eeb09408029608558233406555263d33cf6f73..d720dfa2c33a0b095aa5a69332d6cf155209501f 100644 --- a/src/ui/viewerStateController/viewerState.useEffect.spec.ts +++ b/src/ui/viewerStateController/viewerState.useEffect.spec.ts @@ -3,7 +3,7 @@ import { Observable, of } from 'rxjs' import { TestBed, async } from '@angular/core/testing' import { provideMockActions } from '@ngrx/effects/testing' import { MockStore, provideMockStore } from '@ngrx/store/testing' -import { defaultRootState, NEWVIEWER } from 'src/services/stateStore.service' +import { defaultRootState, generalActionError, NEWVIEWER } from 'src/services/stateStore.service' import { Injectable } from '@angular/core' import { TemplateCoordinatesTransformation, ITemplateCoordXformResp } from 'src/services/templateCoordinatesTransformation.service' import { hot } from 'jasmine-marbles' @@ -11,7 +11,7 @@ import { AngularMaterialModule } from '../sharedModules/angularMaterial.module' import { HttpClientModule } from '@angular/common/http' import { WidgetModule } from 'src/widget' import { PluginModule } from 'src/atlasViewer/pluginUnit/plugin.module' -import { viewerStateNavigationStateSelector, viewerStateNewViewer, viewerStateSelectTemplateWithName } from 'src/services/state/viewerState.store.helper' +import { viewerStateNavigateToRegion, viewerStateNavigationStateSelector, viewerStateNewViewer, viewerStateSelectTemplateWithName } from 'src/services/state/viewerState.store.helper' const bigbrainJson = require('!json-loader!src/res/ext/bigbrain.json') const bigBrainNehubaConfig = require('!json-loader!src/res/ext/bigbrainNehubaConfig.json') @@ -57,18 +57,12 @@ initialState.viewerState.navigation = currentNavigation describe('> viewerState.useEffect.ts', () => { describe('> ViewerStateControllerUseEffect', () => { let actions$: Observable<any> - let spy: any + let spy: jasmine.Spy beforeEach(async(() => { const mock = new MockCoordXformService() spy = spyOn(mock, 'getPointCoordinatesForTemplate').and.callThrough() returnPosition = null - actions$ = hot( - 'a', - { - a: viewerStateSelectTemplateWithName({ payload: reconstitutedColin }) - } - ) TestBed.configureTestingModule({ imports: [ @@ -90,6 +84,15 @@ describe('> viewerState.useEffect.ts', () => { })) describe('> selectTemplate$', () => { + beforeEach(() => { + + actions$ = hot( + 'a', + { + a: viewerStateSelectTemplateWithName({ payload: reconstitutedColin }) + } + ) + }) describe('> when transiting from template A to template B', () => { describe('> if the current navigation is correctly formed', () => { it('> uses current navigation param', () => { @@ -227,6 +230,122 @@ describe('> viewerState.useEffect.ts', () => { }) }) + + describe('> navigateToRegion$', () => { + const setAction = region => { + actions$ = hot( + 'a', + { + a: viewerStateNavigateToRegion({ + payload: { region } + }) + } + ) + } + describe('> if the region has malformed position property', () => { + describe('> if the region has no position property', () => { + const region = { + name: 'foobar' + } + beforeEach(() => { + setAction(region) + }) + + it('> should result in general action error', () => { + const ctrlUseEffect = TestBed.inject(ViewerStateControllerUseEffect) + expect(ctrlUseEffect.navigateToRegion$).toBeObservable( + hot('a', { + a: generalActionError({ + message: `${region.name} - does not have a position defined` + }) + }) + ) + }) + + describe('> if the region has non array position property', () => { + const region = { + name: 'foo bar2', + position: {'hello': 'world'} + } + beforeEach(() => { + setAction(region) + }) + it('> should result in general action error', () => { + const ctrlUseEffect = TestBed.inject(ViewerStateControllerUseEffect) + expect(ctrlUseEffect.navigateToRegion$).toBeObservable( + hot('a', { + a: generalActionError({ + message: `${region.name} has malformed position property: ${JSON.stringify(region.position)}` + }) + }) + ) + }) + }) + + describe('> if the region has array position, but not all elements are number', () => { + const region = { + name: 'foo bar2', + position: [0, 1, 'hello world'] + } + beforeEach(() => { + setAction(region) + }) + it('> should result in general action error', () => { + const ctrlUseEffect = TestBed.inject(ViewerStateControllerUseEffect) + expect(ctrlUseEffect.navigateToRegion$).toBeObservable( + hot('a', { + a: generalActionError({ + message: `${region.name} has malformed position property: ${JSON.stringify(region.position)}` + }) + }) + ) + }) + }) + + describe('> if the region has array position, but some elements are NaN', () => { + const region = { + name: 'foo bar2', + position: [0, 1, NaN] + } + beforeEach(() => { + setAction(region) + }) + it('> should result in general action error', () => { + const ctrlUseEffect = TestBed.inject(ViewerStateControllerUseEffect) + expect(ctrlUseEffect.navigateToRegion$).toBeObservable( + hot('a', { + a: generalActionError({ + message: `${region.name} has malformed position property: ${JSON.stringify(region.position)}` + }) + }) + ) + }) + }) + + + describe('> if the region has array position, with incorrect length', () => { + const region = { + name: 'foo bar2', + position: [] + } + beforeEach(() => { + setAction(region) + }) + it('> should result in general action error', () => { + const ctrlUseEffect = TestBed.inject(ViewerStateControllerUseEffect) + expect(ctrlUseEffect.navigateToRegion$).toBeObservable( + hot('a', { + a: generalActionError({ + message: `${region.name} has malformed position property: ${JSON.stringify(region.position)}` + }) + }) + ) + }) + }) + + }) + }) + }) }) describe('> cvtNehubaConfigToNavigationObj', () => { diff --git a/src/ui/viewerStateController/viewerState.useEffect.ts b/src/ui/viewerStateController/viewerState.useEffect.ts index 292d47435e236ea0c07a8f2c8bea00f7b108cf26..8e9f842a3132628fb3afe72898b72db3047b8bf2 100644 --- a/src/ui/viewerStateController/viewerState.useEffect.ts +++ b/src/ui/viewerStateController/viewerState.useEffect.ts @@ -11,6 +11,7 @@ import { viewerStateToggleRegionSelect, viewerStateHelperSelectParcellationWithI import { ngViewerSelectorClearViewEntries } from "src/services/state/ngViewerState/selectors"; import { ngViewerActionClearView } from "src/services/state/ngViewerState/actions"; import { PureContantService } from "src/util"; +import { verifyPositionArg } from 'common/util' const defaultPerspectiveZoom = 1e6 const defaultZoom = 1e6 @@ -377,6 +378,12 @@ export class ViewerStateControllerUseEffect implements OnDestroy { }) } + if (!verifyPositionArg(position)){ + return generalActionError({ + message: `${region.name} has malformed position property: ${JSON.stringify(position)}` + }) + } + return { type: CHANGE_NAVIGATION, navigation: {