Skip to content

Commit

Permalink
PSP-7158 Allow moving the position of map markers (within the file) (#…
Browse files Browse the repository at this point in the history
…4233)

* Initial work to trigger relocation of file markers

* Add custom cursor for marker relocation

* Style tweaks

* Add various callbacks to reposition file markers upon map click

* Rename relocation to reposition

* Update map state machine to hold on to file marker being repositioned

* Highlight the property boundary when repositioning file marker

* Add geojson spatial analysis library - turf.js

* Reposition file marker only if it falls within original property boundary

* Code cleanup and fixes

* Code cleanup

* Test updates

* Update snapshots

* PR feedback
  • Loading branch information
asanchezr authored Aug 7, 2024
1 parent 7a5b63d commit b678d7f
Show file tree
Hide file tree
Showing 37 changed files with 3,681 additions and 249 deletions.
2,448 changes: 2,398 additions & 50 deletions source/frontend/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions source/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"@bcgov/design-tokens": "3.0.0-rc1",
"@react-keycloak/web": "3.4.0",
"@reduxjs/toolkit": "1.8.6",
"@turf/turf": "7.0.0",
"@types/polylabel": "1.0.5",
"@xstate/react": "3.2.2",
"axios": "1.6.7",
Expand Down
5 changes: 5 additions & 0 deletions source/frontend/src/assets/images/pins/icon-relocate.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export interface IMapStateMachineContext {
mapLocationSelected: LatLngLiteral | null;
mapLocationFeatureDataset: LocationFeatureDataset | null;
selectedFeatureDataset: LocationFeatureDataset | null;
repositioningFeatureDataset: LocationFeatureDataset | null;
repositioningPropertyIndex: number | null;
showPopup: boolean;
isLoading: boolean;
mapSearchCriteria: IPropertyFilter | null;
Expand All @@ -35,6 +37,7 @@ export interface IMapStateMachineContext {
pendingFitBounds: boolean;
requestedFitBounds: LatLngBounds;
isSelecting: boolean;
isRepositioning: boolean;
selectingComponentId: string | null;
isFiltering: boolean;
isShowingMapLayers: boolean;
Expand All @@ -60,6 +63,12 @@ export interface IMapStateMachineContext {
prepareForCreation: () => void;
startSelection: (selectingComponentId?: string) => void;
finishSelection: () => void;
startReposition: (
repositioningFeatureDataset: LocationFeatureDataset,
index: number,
selectingComponentId?: string,
) => void;
finishReposition: () => void;
toggleMapFilter: () => void;
toggleMapLayer: () => void;
setFilePropertyLocations: (locations: LatLngLiteral[]) => void;
Expand Down Expand Up @@ -255,6 +264,26 @@ export const MapStateMachineProvider: React.FC<React.PropsWithChildren<unknown>>
serviceSend({ type: 'FINISH_SELECTION' });
}, [serviceSend]);

const startReposition = useCallback(
(
repositioningFeatureDataset: LocationFeatureDataset,
index: number,
selectingComponentId?: string,
) => {
serviceSend({
type: 'START_REPOSITION',
repositioningFeatureDataset,
repositioningPropertyIndex: index,
selectingComponentId,
});
},
[serviceSend],
);

const finishReposition = useCallback(() => {
serviceSend({ type: 'FINISH_REPOSITION' });
}, [serviceSend]);

const setFilePropertyLocations = useCallback(
(locations: LatLngLiteral[]) => {
serviceSend({ type: 'SET_FILE_PROPERTY_LOCATIONS', locations });
Expand Down Expand Up @@ -316,9 +345,14 @@ export const MapStateMachineProvider: React.FC<React.PropsWithChildren<unknown>>
serviceSend({ type: 'TOGGLE_LAYERS' });
}, [serviceSend]);

const isRepositioning = useMemo(() => {
return state.matches({ mapVisible: { featureView: 'repositioning' } });
}, [state]);

// disable map popup when repositioning file markers
const showPopup = useMemo(() => {
return state.context.mapLocationFeatureDataset !== null;
}, [state.context.mapLocationFeatureDataset]);
return state.context.mapLocationFeatureDataset !== null && !isRepositioning;
}, [isRepositioning, state.context.mapLocationFeatureDataset]);

const isFiltering = useMemo(() => {
return state.matches({ mapVisible: { featureView: 'filtering' } });
Expand All @@ -339,6 +373,8 @@ export const MapStateMachineProvider: React.FC<React.PropsWithChildren<unknown>>
mapLocationSelected: state.context.mapLocationSelected,
mapLocationFeatureDataset: state.context.mapLocationFeatureDataset,
selectedFeatureDataset: state.context.selectedFeatureDataset,
repositioningFeatureDataset: state.context.repositioningFeatureDataset,
repositioningPropertyIndex: state.context.repositioningPropertyIndex,
showPopup: showPopup,
isLoading: state.context.isLoading,
mapSearchCriteria: state.context.searchCriteria,
Expand All @@ -347,6 +383,7 @@ export const MapStateMachineProvider: React.FC<React.PropsWithChildren<unknown>>
pendingFitBounds: state.matches({ mapVisible: { mapRequest: 'pendingFitBounds' } }),
requestedFitBounds: state.context.requestedFitBounds,
isSelecting: state.matches({ mapVisible: { featureView: 'selecting' } }),
isRepositioning: isRepositioning,
selectingComponentId: state.context.selectingComponentId,
isFiltering: isFiltering,
isShowingMapLayers: isShowingMapLayers,
Expand All @@ -369,6 +406,8 @@ export const MapStateMachineProvider: React.FC<React.PropsWithChildren<unknown>>
prepareForCreation,
startSelection,
finishSelection,
startReposition,
finishReposition,
toggleMapFilter,
toggleMapLayer,
toggleSidebarDisplay,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ const featureViewStates = {
assign({ selectingComponentId: (_, event: any) => event.selectingComponentId }),
],
},
START_REPOSITION: {
target: 'repositioning',
actions: [
assign({
selectingComponentId: (_, event: any) => event.selectingComponentId,
repositioningFeatureDataset: (_, event: any) => event.repositioningFeatureDataset,
repositioningPropertyIndex: (_, event: any) => event.repositioningPropertyIndex,
}),
],
},
TOGGLE_FILTER: {
target: 'filtering',
},
Expand All @@ -31,7 +41,30 @@ const featureViewStates = {
},
selecting: {
on: {
FINISH_SELECTION: { target: 'browsing' },
FINISH_SELECTION: {
target: 'browsing',
actions: [assign({ selectingComponentId: () => null })],
},
SET_FILE_PROPERTY_LOCATIONS: {
actions: [
assign({ filePropertyLocations: (_, event: any) => event.locations }),
raise('REQUEST_FIT_BOUNDS'),
],
},
},
},
repositioning: {
on: {
FINISH_REPOSITION: {
target: 'browsing',
actions: [
assign({
repositioningFeatureDataset: () => null,
repositioningPropertyIndex: () => null,
selectingComponentId: () => null,
}),
],
},
SET_FILE_PROPERTY_LOCATIONS: {
actions: [
assign({ filePropertyLocations: (_, event: any) => event.locations }),
Expand Down Expand Up @@ -382,6 +415,8 @@ export const mapMachine = createMachine<MachineContext>({
mapFeatureSelected: null,
mapLocationFeatureDataset: null,
selectedFeatureDataset: null,
repositioningFeatureDataset: null,
repositioningPropertyIndex: null,
selectingComponentId: null,
isLoading: false,
searchCriteria: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export type MachineContext = {
mapLocationSelected: LatLngLiteral | null;
mapLocationFeatureDataset: LocationFeatureDataset | null;
selectedFeatureDataset: LocationFeatureDataset | null;
repositioningFeatureDataset: LocationFeatureDataset | null;
repositioningPropertyIndex: number | null;
selectingComponentId: string | null;

mapFeatureData: MapFeatureData;
Expand Down
45 changes: 29 additions & 16 deletions source/frontend/src/components/maps/MapLeafletView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,28 +96,41 @@ const MapLeafletView: React.FC<React.PropsWithChildren<MapLeafletViewProps>> = (
zoom,
]);

const mapMachineMapLocationFeatureDataset = mapMachine.mapLocationFeatureDataset;
const { mapLocationFeatureDataset, repositioningFeatureDataset, isRepositioning } = mapMachine;

useEffect(() => {
activeFeatureLayer?.clearLayers();
if (mapMachineMapLocationFeatureDataset !== null) {
const location = mapMachineMapLocationFeatureDataset.location;

let activeFeature: Feature<Geometry, GeoJsonProperties> = {
geometry: { coordinates: [location.lng, location.lat], type: 'Point' },
type: 'Feature',
properties: {},
};
if (mapMachineMapLocationFeatureDataset.parcelFeature !== null) {
activeFeature = mapMachineMapLocationFeatureDataset.parcelFeature;
activeFeatureLayer?.addData(activeFeature);
} else if (mapMachineMapLocationFeatureDataset.municipalityFeature !== null) {
activeFeature = mapMachineMapLocationFeatureDataset.municipalityFeature;
if (mapMachineMapLocationFeatureDataset.municipalityFeature?.geometry?.type === 'Polygon') {

if (isRepositioning) {
if (
repositioningFeatureDataset !== null &&
repositioningFeatureDataset.pimsFeature !== null
) {
// File marker repositioning is active - highlight the property and the corresponding boundary when user triggers the relocate action.
activeFeatureLayer?.addData(repositioningFeatureDataset.pimsFeature);
}
} else {
// Not repositioning - highlight parcels on map click as usual workflow
if (mapLocationFeatureDataset !== null) {
const location = mapLocationFeatureDataset.location;

let activeFeature: Feature<Geometry, GeoJsonProperties> = {
geometry: { coordinates: [location.lng, location.lat], type: 'Point' },
type: 'Feature',
properties: {},
};
if (mapLocationFeatureDataset.parcelFeature !== null) {
activeFeature = mapLocationFeatureDataset.parcelFeature;
activeFeatureLayer?.addData(activeFeature);
} else if (mapLocationFeatureDataset.municipalityFeature !== null) {
activeFeature = mapLocationFeatureDataset.municipalityFeature;
if (mapLocationFeatureDataset.municipalityFeature?.geometry?.type === 'Polygon') {
activeFeatureLayer?.addData(activeFeature);
}
}
}
}
}, [activeFeatureLayer, mapMachineMapLocationFeatureDataset]);
}, [activeFeatureLayer, isRepositioning, mapLocationFeatureDataset, repositioningFeatureDataset]);

const hasPendingFlyTo = mapMachine.pendingFlyTo;
const requestedFlyTo = mapMachine.requestedFlyTo;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { LatLngLiteral } from 'leaflet';

import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineContext';
import useDraftMarkerSynchronizer from '@/hooks/useDraftMarkerSynchronizer';
import { usePrevious } from '@/hooks/usePrevious';
Expand All @@ -7,14 +9,22 @@ import { featuresetToMapProperty } from '@/utils/mapPropertyUtils';
import { LocationFeatureDataset } from '../common/mapFSM/useLocationFeatureLoader';

interface IMapClickMonitorProps {
addProperty: (property: LocationFeatureDataset) => void; // TODO: This should be a featureDataset
addProperty: (property: LocationFeatureDataset) => void;
repositionProperty: (
property: LocationFeatureDataset,
latLng: LatLngLiteral,
propertyIndex: number | null,
) => void;
modifiedProperties: LocationFeatureDataset[]; // TODO: this should be just a list of lat longs
selectedComponentId: string | null;
}

export const MapClickMonitor: React.FunctionComponent<
React.PropsWithChildren<IMapClickMonitorProps>
> = ({ addProperty, modifiedProperties, selectedComponentId }) => {
export const MapClickMonitor: React.FunctionComponent<IMapClickMonitorProps> = ({
addProperty,
repositionProperty,
modifiedProperties,
selectedComponentId,
}) => {
const mapMachine = useMapStateMachine();

const previous = usePrevious(mapMachine.mapLocationFeatureDataset);
Expand All @@ -32,7 +42,30 @@ export const MapClickMonitor: React.FunctionComponent<
) {
addProperty(mapMachine.mapLocationFeatureDataset);
}
}, [addProperty, mapMachine.isSelecting, mapMachine.mapLocationFeatureDataset, previous]);

if (
mapMachine.isRepositioning &&
mapMachine.repositioningFeatureDataset &&
mapMachine.mapLocationFeatureDataset &&
previous !== mapMachine.mapLocationFeatureDataset &&
previous !== undefined &&
(!selectedComponentId ||
selectedComponentId === mapMachine.mapLocationFeatureDataset.selectingComponentId)
) {
repositionProperty(
mapMachine.repositioningFeatureDataset,
mapMachine.mapLocationFeatureDataset.location,
mapMachine.repositioningPropertyIndex,
);
}
}, [
addProperty,
mapMachine.isSelecting,
mapMachine.isRepositioning,
mapMachine.mapLocationFeatureDataset,
mapMachine.repositioningFeatureDataset,
previous,
]);
return <></>;
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { act, screen } from '@testing-library/react';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { Formik } from 'formik';
import noop from 'lodash/noop';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';

import { useMapProperties } from '@/hooks/repositories/useMapProperties';
import { mockFAParcelLayerResponse, mockGeocoderOptions } from '@/mocks/index.mock';
import { fillInput, render, RenderOptions, userEvent } from '@/utils/test-utils';
import { mapMachineBaseMock } from '@/mocks/mapFSM.mock';
import { act, fillInput, render, RenderOptions, screen, userEvent } from '@/utils/test-utils';

import { PropertyForm } from '../../features/mapSideBar/shared/models';
import MapSelectorContainer, { IMapSelectorContainerProps } from './MapSelectorContainer';
import { IMapProperty } from './models';
import { getMockLocationFeatureDataset } from '@/mocks/featureset.mock';
import { useMapProperties } from '@/hooks/repositories/useMapProperties';
import { IMapStateMachineContext } from '../common/mapFSM/MapStateMachineContext';

const mockStore = configureMockStore([thunk]);

Expand All @@ -22,6 +22,7 @@ const mockAxios = new MockAdapter(axios);
const store = mockStore({});

const onSelectedProperties = vi.fn();
const onRepositionSelectedProperty = vi.fn();

const testProperty: IMapProperty = {
propertyId: 123,
Expand Down Expand Up @@ -55,12 +56,14 @@ describe('MapSelectorContainer component', () => {
<Formik initialValues={{ properties: [] }} onSubmit={noop}>
<MapSelectorContainer
addSelectedProperties={onSelectedProperties}
repositionSelectedProperty={onRepositionSelectedProperty}
modifiedProperties={renderOptions.modifiedProperties ?? []}
/>
</Formik>,
{
...renderOptions,
store: store,
mockMapMachine: renderOptions.mockMapMachine ?? mapMachineBaseMock,
},
);

Expand Down Expand Up @@ -295,4 +298,35 @@ describe('MapSelectorContainer component', () => {
);
expect(toast[0]).toBeVisible();
});

it(`calls "repositionSelectedProperty" callback when file marker has been repositioned`, async () => {
const testMapMock: IMapStateMachineContext = { ...mapMachineBaseMock };
const mapProperties = [
PropertyForm.fromMapProperty({ ...testProperty, pid: '009-727-493' }).toFeatureDataset(),
];

const { rerender } = setup({
modifiedProperties: mapProperties,
mockMapMachine: testMapMock,
});

// simulate file marker repositioning via the map state machine
await act(async () => {
testMapMock.isRepositioning = true;
testMapMock.repositioningFeatureDataset = {} as any;
testMapMock.mapLocationFeatureDataset = {} as any;
});

rerender(
<Formik initialValues={{ properties: [] }} onSubmit={noop}>
<MapSelectorContainer
addSelectedProperties={onSelectedProperties}
repositionSelectedProperty={onRepositionSelectedProperty}
modifiedProperties={mapProperties}
/>
</Formik>,
);

expect(onRepositionSelectedProperty).toHaveBeenCalled();
});
});
Loading

0 comments on commit b678d7f

Please sign in to comment.