@@ -74,6 +86,14 @@ const StyleMapView = styled.div`
// ref: https://vitejs.dev/guide/assets
cursor: url("${DraftSvg}") 15 45, pointer;
}
+
+ &.reposition-cursor,
+ &.reposition-cursor .leaflet-grab,
+ &.reposition-cursor .leaflet-interactive {
+ // when passing a URL of SVG to a manually constructed url(), the variable should be wrapped within double quotes.
+ // ref: https://vitejs.dev/guide/assets
+ cursor: url("${RelocationSvg}") 20 20, pointer;
+ }
`;
export default MapContainer;
diff --git a/source/frontend/src/features/properties/map/__snapshots__/MapContainer.test.tsx.snap b/source/frontend/src/features/properties/map/__snapshots__/MapContainer.test.tsx.snap
index 02f87546d6..484bee2f8a 100644
--- a/source/frontend/src/features/properties/map/__snapshots__/MapContainer.test.tsx.snap
+++ b/source/frontend/src/features/properties/map/__snapshots__/MapContainer.test.tsx.snap
@@ -451,6 +451,12 @@ exports[`MapContainer > Renders the map 1`] = `
cursor: url("/src/assets/images/pins/icon-draft.svg") 15 45,pointer;
}
+.c0.reposition-cursor,
+.c0.reposition-cursor .leaflet-grab,
+.c0.reposition-cursor .leaflet-interactive {
+ cursor: url("/src/assets/images/pins/icon-relocate.svg") 20 20,pointer;
+}
+
diff --git a/source/frontend/src/mocks/geometries.mock.ts b/source/frontend/src/mocks/geometries.mock.ts
new file mode 100644
index 0000000000..e57b1999fb
--- /dev/null
+++ b/source/frontend/src/mocks/geometries.mock.ts
@@ -0,0 +1,49 @@
+import { MultiPolygon, Polygon } from 'geojson';
+
+import { ApiGen_Concepts_Geometry } from '@/models/api/generated/ApiGen_Concepts_Geometry';
+
+export function getMockLocation(lat = 48, lng = -123): ApiGen_Concepts_Geometry {
+ return {
+ coordinate: { x: lng, y: lat },
+ };
+}
+
+export function getMockPolygon(): Polygon {
+ return {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [-123.46, 48.767],
+ [-123.4601, 48.7668],
+ [-123.461, 48.7654],
+ [-123.4623, 48.7652],
+ [-123.4627, 48.7669],
+ [-123.4602, 48.7672],
+ [-123.4601, 48.7672],
+ [-123.4601, 48.7672],
+ [-123.46, 48.767],
+ ],
+ ],
+ };
+}
+
+export function getMockMultiPolygon(): MultiPolygon {
+ return {
+ type: 'MultiPolygon',
+ coordinates: [
+ [
+ [
+ [-123.46, 48.767],
+ [-123.4601, 48.7668],
+ [-123.461, 48.7654],
+ [-123.4623, 48.7652],
+ [-123.4627, 48.7669],
+ [-123.4602, 48.7672],
+ [-123.4601, 48.7672],
+ [-123.4601, 48.7672],
+ [-123.46, 48.767],
+ ],
+ ],
+ ],
+ };
+}
diff --git a/source/frontend/src/mocks/mapFSM.mock.ts b/source/frontend/src/mocks/mapFSM.mock.ts
index eec83c02fd..7e716ab8e6 100644
--- a/source/frontend/src/mocks/mapFSM.mock.ts
+++ b/source/frontend/src/mocks/mapFSM.mock.ts
@@ -32,6 +32,8 @@ export const mapMachineBaseMock: IMapStateMachineContext = {
mapFeatureSelected: null,
mapLocationSelected: null,
mapLocationFeatureDataset: null,
+ repositioningFeatureDataset: null,
+ repositioningPropertyIndex: null,
selectingComponentId: null,
selectedFeatureDataset: null,
showPopup: false,
@@ -42,6 +44,7 @@ export const mapMachineBaseMock: IMapStateMachineContext = {
activePimsPropertyIds: [],
activeLayers: layersTree,
isSelecting: false,
+ isRepositioning: false,
isFiltering: false,
isShowingMapLayers: false,
showDisposed: false,
@@ -61,6 +64,8 @@ export const mapMachineBaseMock: IMapStateMachineContext = {
prepareForCreation: vi.fn(),
startSelection: vi.fn(),
finishSelection: vi.fn(),
+ startReposition: vi.fn(),
+ finishReposition: vi.fn(),
setFilePropertyLocations: vi.fn(),
setVisiblePimsProperties: vi.fn(),
toggleMapFilter: vi.fn(),
diff --git a/source/frontend/src/utils/mapPropertyUtils.test.tsx b/source/frontend/src/utils/mapPropertyUtils.test.tsx
index fa67062d77..a920a64fea 100644
--- a/source/frontend/src/utils/mapPropertyUtils.test.tsx
+++ b/source/frontend/src/utils/mapPropertyUtils.test.tsx
@@ -1,12 +1,22 @@
-import { FeatureCollection, GeoJsonProperties, Geometry } from 'geojson';
+import { polygon } from '@turf/turf';
+import { Feature, FeatureCollection, GeoJsonProperties, Geometry } from 'geojson';
+import { LatLngLiteral } from 'leaflet';
import { LocationFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader';
import { IMapProperty } from '@/components/propertySelector/models';
+import { AreaUnitTypes } from '@/constants';
import {
mockFAParcelLayerResponse,
mockFAParcelLayerResponseMultiPolygon,
} from '@/mocks/faParcelLayerResponse.mock';
import { getMockLocationFeatureDataset } from '@/mocks/featureset.mock';
+import { getEmptyFileProperty } from '@/mocks/fileProperty.mock';
+import { getMockLocation } from '@/mocks/geometries.mock';
+import { ApiGen_Concepts_FileProperty } from '@/models/api/generated/ApiGen_Concepts_FileProperty';
+import { ApiGen_Concepts_Geometry } from '@/models/api/generated/ApiGen_Concepts_Geometry';
+import { getEmptyProperty } from '@/models/defaultInitializers';
+import { PMBC_FullyAttributed_Feature_Properties } from '@/models/layers/parcelMapBC';
+import { PIMS_Property_Location_View } from '@/models/layers/pimsPropertyLocationView';
import {
featuresetToMapProperty,
@@ -15,13 +25,15 @@ import {
getLatLng,
getPrettyLatLng,
getPropertyName,
+ isLatLngInFeatureSetBoundary,
+ latLngFromMapProperty,
+ latLngToApiLocation,
+ locationFromFileProperty,
NameSourceType,
+ pidFromFeatureSet,
+ pinFromFeatureSet,
PropertyName,
} from './mapPropertyUtils';
-import { ApiGen_Concepts_FileProperty } from '@/models/api/generated/ApiGen_Concepts_FileProperty';
-import { getEmptyFileProperty } from '@/mocks/fileProperty.mock';
-import { getEmptyProperty } from '@/models/defaultInitializers';
-import { AreaUnitTypes } from '@/constants';
const expectedMapProperty = {
address: '',
@@ -261,4 +273,200 @@ describe('mapPropertyUtils', () => {
expect(mapProperty).toEqual(expectedPropertyFile);
},
);
+
+ it.each([
+ [
+ { ...getMockLocationFeatureDataset(), pimsFeature: { properties: { PID: '1234' } } as any },
+ '1234',
+ ],
+ [
+ {
+ ...getMockLocationFeatureDataset(),
+ pimsFeature: {} as any,
+ parcelFeature: { properties: { PID: '9999' } } as any,
+ },
+ '9999',
+ ],
+ [
+ {
+ ...getMockLocationFeatureDataset(),
+ pimsFeature: {} as any,
+ parcelFeature: {} as any,
+ },
+ null,
+ ],
+ ])(
+ 'pidFromFeatureSet test with feature set %o - expected %s',
+ (featureSet: LocationFeatureDataset, expectedValue: string | null) => {
+ const pid = pidFromFeatureSet(featureSet);
+ expect(pid).toEqual(expectedValue);
+ },
+ );
+
+ it.each([
+ [
+ { ...getMockLocationFeatureDataset(), pimsFeature: { properties: { PIN: 1234 } } as any },
+ '1234',
+ ],
+ [
+ {
+ ...getMockLocationFeatureDataset(),
+ pimsFeature: {} as any,
+ parcelFeature: { properties: { PIN: 9999 } } as any,
+ },
+ '9999',
+ ],
+ [
+ {
+ ...getMockLocationFeatureDataset(),
+ pimsFeature: {} as any,
+ parcelFeature: {} as any,
+ },
+ null,
+ ],
+ ])(
+ 'pinFromFeatureSet test with feature set %o - expected %s',
+ (featureSet: LocationFeatureDataset, expectedValue: string | null) => {
+ const pid = pinFromFeatureSet(featureSet);
+ expect(pid).toEqual(expectedValue);
+ },
+ );
+
+ it.each([
+ [{ ...getEmptyFileProperty(), location: { ...getMockLocation() } }, { ...getMockLocation() }],
+ [
+ {
+ ...getEmptyFileProperty(),
+ location: null,
+ property: { ...getEmptyProperty(), location: { ...getMockLocation() } },
+ },
+ { ...getMockLocation() },
+ ],
+ [{ ...getEmptyFileProperty(), location: null }, null],
+ ])(
+ 'locationFromFileProperty test with file property %o - expected %o',
+ (
+ fileProperty: ApiGen_Concepts_FileProperty | undefined | null,
+ expectedValue: ApiGen_Concepts_Geometry | null,
+ ) => {
+ const location = locationFromFileProperty(fileProperty);
+ expect(location).toEqual(expectedValue);
+ },
+ );
+
+ it.each([
+ [4, 5, { ...getMockLocation(4, 5) }],
+ [null, null, null],
+ ])(
+ 'latLngToApiLocation test with latitude %s, longitude %s - expected %o',
+ (
+ latitude: number | null,
+ longitude: number | null,
+ expectedValue: ApiGen_Concepts_Geometry | null,
+ ) => {
+ const apiGeometry = latLngToApiLocation(latitude, longitude);
+ expect(apiGeometry).toEqual(expectedValue);
+ },
+ );
+
+ it.each([
+ [{ fileLocation: { lat: 4, lng: 5 } }, { lat: 4, lng: 5 }],
+ [
+ { latitude: 4, longitude: 5 },
+ { lat: 4, lng: 5 },
+ ],
+ [undefined, { lat: 0, lng: 0 }],
+ ])(
+ 'latLngFromMapProperty test with file property %o - expected %o',
+ (mapProperty: IMapProperty | undefined | null, expectedValue: LatLngLiteral | null) => {
+ const latLng = latLngFromMapProperty(mapProperty);
+ expect(latLng).toEqual(expectedValue);
+ },
+ );
+
+ it.each([
+ [
+ { lat: 44, lng: -77 },
+ {
+ ...getMockLocationFeatureDataset(),
+ pimsFeature: polygon([
+ [
+ [-81, 41],
+ [-81, 47],
+ [-72, 47],
+ [-72, 41],
+ [-81, 41],
+ ],
+ ]) as Feature | null,
+ },
+ true,
+ ],
+ [
+ { lat: 44, lng: 80 },
+ {
+ ...getMockLocationFeatureDataset(),
+ pimsFeature: polygon([
+ [
+ [-81, 41],
+ [-81, 47],
+ [-72, 47],
+ [-72, 41],
+ [-81, 41],
+ ],
+ ]) as Feature | null,
+ },
+ false,
+ ],
+ [
+ { lat: 44, lng: -77 },
+ {
+ ...getMockLocationFeatureDataset(),
+ pimsFeature: null,
+ parcelFeature: polygon([
+ [
+ [-81, 41],
+ [-81, 47],
+ [-72, 47],
+ [-72, 41],
+ [-81, 41],
+ ],
+ ]) as Feature | null,
+ },
+ true,
+ ],
+ [
+ { lat: 44, lng: 80 },
+ {
+ ...getMockLocationFeatureDataset(),
+ pimsFeature: null,
+ parcelFeature: polygon([
+ [
+ [-81, 41],
+ [-81, 47],
+ [-72, 47],
+ [-72, 41],
+ [-81, 41],
+ ],
+ ]) as Feature | null,
+ },
+ false,
+ ],
+ [
+ { lat: 44, lng: -77 },
+ {
+ ...getMockLocationFeatureDataset(),
+ location: null,
+ fileLocation: null,
+ pimsFeature: null,
+ parcelFeature: null,
+ },
+ false,
+ ],
+ ])(
+ 'isLatLngInFeatureSetBoundary test with lat/long %o, feature set %o - expected %o',
+ (latLng: LatLngLiteral, featureset: LocationFeatureDataset, expectedValue: boolean) => {
+ const result = isLatLngInFeatureSetBoundary(latLng, featureset);
+ expect(result).toEqual(expectedValue);
+ },
+ );
});
diff --git a/source/frontend/src/utils/mapPropertyUtils.ts b/source/frontend/src/utils/mapPropertyUtils.ts
index 15b964e19e..b2b48de382 100644
--- a/source/frontend/src/utils/mapPropertyUtils.ts
+++ b/source/frontend/src/utils/mapPropertyUtils.ts
@@ -1,3 +1,4 @@
+import { booleanPointInPolygon, point } from '@turf/turf';
import {
Feature,
FeatureCollection,
@@ -261,6 +262,8 @@ export function pidFromFeatureSet(featureset: LocationFeatureDataset): string |
export function pinFromFeatureSet(featureset: LocationFeatureDataset): string | null {
return isValidId(featureset?.pimsFeature?.properties?.PIN)
+ ? featureset?.pimsFeature?.properties?.PIN?.toString()
+ : isValidId(featureset?.parcelFeature?.properties?.PIN)
? featureset?.parcelFeature?.properties?.PIN?.toString()
: null;
}
@@ -279,3 +282,23 @@ export function latLngFromMapProperty(
lng: Number(mapProperty?.fileLocation?.lng ?? mapProperty?.longitude ?? 0),
};
}
+
+/**
+ * Takes a (Lat, Long) value and a FeatureSet and determines if the point resides inside the polygon.
+ * The polygon can be convex or concave. The function accounts for holes.
+ *
+ * @param latLng The input lat/long
+ * @param featureset The input featureset
+ * @returns true if the Point is inside the FeatureSet boundary; false if the Point is not inside the boundary
+ */
+export function isLatLngInFeatureSetBoundary(
+ latLng: LatLngLiteral,
+ featureset: LocationFeatureDataset,
+): boolean {
+ const location = point([latLng.lng, latLng.lat]);
+ const boundary = (featureset?.pimsFeature?.geometry ?? featureset?.parcelFeature?.geometry) as
+ | Polygon
+ | MultiPolygon;
+
+ return exists(boundary) && booleanPointInPolygon(location, boundary);
+}