Skip to content

Commit

Permalink
PIMS-305 Spatial Analysis (#2716)
Browse files Browse the repository at this point in the history
  • Loading branch information
dbarkowsky authored Oct 8, 2024
1 parent d1ce7ed commit a64fa24
Show file tree
Hide file tree
Showing 20 changed files with 604 additions and 137 deletions.
1 change: 1 addition & 0 deletions express-api/src/controllers/properties/propertiesSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const MapFilterSchema = z.object({
Name: z.string().optional(),
RegionalDistrictIds: arrayFromString(numberSchema),
UserAgencies: z.array(z.number().int()).optional(),
Polygon: z.string().optional(),
});

export type MapFilter = z.infer<typeof MapFilterSchema>;
Expand Down
12 changes: 12 additions & 0 deletions express-api/src/routes/properties.swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,18 @@ paths:
schema:
type: string
example: 19,25
- in: query
name: ProjectStatusId
description: The status of the most recent active project this property was in.
schema:
type: integer
example: 1
- in: query
name: Polygon
desciption: A stringified array of polygons and their coordinates
schema:
type: string
example: '[[[1,1], [2,2], [3,3]]]'
responses:
'200':
description: OK
Expand Down
33 changes: 33 additions & 0 deletions express-api/src/services/properties/propertiesServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { ProjectStatus as ProjectStatusEntity } from '@/typeorm/Entities/Project
import { parentPort } from 'worker_threads';
import { ErrorWithCode } from '@/utilities/customErrors/ErrorWithCode';
import { PimsRequestUser } from '@/middleware/userAuthCheck';
import { isPointInMultiPolygon } from '@/utilities/polygonMath';

/**
* Perform a fuzzy search for properties based on the provided keyword.
Expand Down Expand Up @@ -244,6 +245,10 @@ const getPropertiesForMap = async (filter?: MapFilter) => {
},
],
});

if (filter.Polygon) {
return filterPropertiesByMultiPolygon(JSON.parse(filter.Polygon), properties);
}
return properties;
}
/**
Expand All @@ -257,9 +262,37 @@ const getPropertiesForMap = async (filter?: MapFilter) => {
AgencyId: filter.AgencyIds ? In(filter.AgencyIds) : undefined,
},
});
if (filter.Polygon) {
return filterPropertiesByMultiPolygon(JSON.parse(filter.Polygon), properties);
}
return properties;
};

/**
* Filters a list of properties based on whether they are within the given polygons.
*
* @param multiPolygon - The polygon coordinates in a 3D array.
* @param properties - An array of MapProperties objects to filter.
* @returns An array of MapProperties objects that fall within the specified polygon.
*/
export const filterPropertiesByMultiPolygon = (
multiPolygon: number[][][],
properties: MapProperties[],
) => {
const formattedPolygons: { x: number; y: number }[][] = multiPolygon.map((polygon) =>
polygon.map((point) => ({ x: point.at(1), y: point.at(0) })),
);
// Memoizing the points already checked so they are not checked twice
const checkedPoints: Record<string, boolean> = {};
return properties.filter((property) => {
const stringifiedLocation = JSON.stringify(property.Location);
if (checkedPoints[stringifiedLocation] != undefined) return checkedPoints[stringifiedLocation];
const inPolygon = isPointInMultiPolygon(property.Location, formattedPolygons);
checkedPoints[stringifiedLocation] = inPolygon;
return inPolygon;
});
};

const numberOrNull = (value: any) => {
if (value == '' || value == null) return null;
return typeof value === 'number' ? value : Number(value.replace?.(/-/g, ''));
Expand Down
95 changes: 95 additions & 0 deletions express-api/src/utilities/polygonMath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
const EPSILON = 1e-10; // A small tolerance for floating-point comparison

/**
* Helper function to calculate the 2D cross product of two vectors.
* @param x1 - X component of vector 1
* @param y1 - Y component of vector 1
* @param x2 - X component of vector 2
* @param y2 - Y component of vector 2
* @returns The cross product (a scalar value).
*/
const crossProduct = (x1: number, y1: number, x2: number, y2: number): number => {
return x1 * y2 - y1 * x2;
};

/**
* Helper function to calculate the dot product of two vectors.
* @param x1 - X component of vector 1
* @param y1 - Y component of vector 1
* @param x2 - X component of vector 2
* @param y2 - Y component of vector 2
* @returns The dot product (a scalar value).
*/
const dotProduct = (x1: number, y1: number, x2: number, y2: number): number => {
return x1 * x2 + y1 * y2;
};

/**
* Helper function to calculate the angle between two vectors.
* @param p - The point being checked.
* @param v1 - First vertex of the polygon edge.
* @param v2 - Second vertex of the polygon edge.
* @returns The angle between the vectors formed by the point and the two vertices.
*/
const angleBetweenVectors = (
p: { x: number; y: number },
v1: { x: number; y: number },
v2: { x: number; y: number },
): number => {
// Get the difference between the point and vectors towards the two points
const x1 = v1.x - p.x,
y1 = v1.y - p.y;
const x2 = v2.x - p.x,
y2 = v2.y - p.y;

const cross = crossProduct(x1, y1, x2, y2); // Gives rotation (clockwise, counterclockwise) and sign of angle
const dot = dotProduct(x1, y1, x2, y2); // Gives magnitude and cosine of angle

// atan2 gives the signed angle between two vectors in radians
// https://en.wikipedia.org/wiki/Atan2
return Math.atan2(cross, dot);
};

/**
* Checks if a point is inside a polygon using the Winding Number Algorithm.
* @param point The point to check.
* @param polygon The polygon defined as an array of points.
* @returns True if the point is inside the polygon, false otherwise.
*/
export const isPointInPolygon = (
point: { x: number; y: number },
polygonPoints: { x: number; y: number }[],
): boolean => {
let windingNumber = 0;

// Travel around the polygon, taking pairs of points
for (let i = 0; i < polygonPoints.length; i++) {
const v1 = polygonPoints[i];
const v2 = polygonPoints[(i + 1) % polygonPoints.length];

// Compute the angle between vectors (point -> v1) and (point -> v2)
const angle = angleBetweenVectors(point, v1, v2);
windingNumber += angle;
}

// If the total winding number is not zero, the point is inside
return Math.abs(windingNumber) > EPSILON; // Use tolerance for floating-point errors
};

/**
* Checks if a point is inside any of the polygons in a multipolygon.
* @param point The point to check.
* @param multiPolygon The multipolygon, an array of polygons.
* @returns True if the point is inside any polygon, false otherwise.
*/
export const isPointInMultiPolygon = (
point: { x: number; y: number },
multiPolygon: { x: number; y: number }[][],
): boolean => {
for (const polygon of multiPolygon) {
if (isPointInPolygon(point, polygon)) {
return true;
}
}
return false;
};
24 changes: 24 additions & 0 deletions express-api/tests/testUtils/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { ImportResult } from '@/typeorm/Entities/ImportResult';
import { ProjectJoin } from '@/typeorm/Entities/views/ProjectJoinView';
import { ImportRow } from '@/services/properties/propertiesServices';
import { PimsRequestUser } from '@/middleware/userAuthCheck';
import { MapProperties } from '@/typeorm/Entities/views/MapPropertiesView';

export class MockRes {
statusValue: any;
Expand Down Expand Up @@ -999,6 +1000,29 @@ export const produceAgencyResponse = (props?: Partial<ProjectAgencyResponse>) =>
return response;
};

export const producePropertyForMap = (props?: Partial<MapProperties>) => {
const propertyTypeId = faker.number.int({ min: 0, max: 1 });
const property: MapProperties = {
Id: faker.number.int(),
PID: faker.number.int({ max: 999999999 }),
PIN: faker.number.int({ max: 999999999 }),
PropertyTypeId: propertyTypeId,
AgencyId: faker.number.int(),
ClassificationId: faker.number.int(),
AdministrativeAreaId: faker.number.int(),
ProjectStatusId: faker.number.int(),
Address1: faker.location.streetAddress(),
RegionalDistrictId: faker.number.int(),
Name: faker.location.streetAddress(),
Location: {
x: faker.number.int(),
y: faker.number.int(),
},
...props,
};
return property;
};

export const producePropertyUnion = (props?: Partial<PropertyUnion>) => {
const propertyTypeId = faker.number.int({ min: 0, max: 1 });
const union: PropertyUnion = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import propertyServices, {
setNewBool,
checkForHeaders,
getAdministrativeAreaOrThrow,
filterPropertiesByMultiPolygon,
} from '@/services/properties/propertiesServices';
import userServices from '@/services/users/usersServices';
import { AdministrativeArea } from '@/typeorm/Entities/AdministrativeArea';
Expand Down Expand Up @@ -50,6 +51,7 @@ import {
produceProjectProperty,
produceImportRow,
producePimsRequestUser,
producePropertyForMap,
} from 'tests/testUtils/factories';
import { DeepPartial, EntityTarget, ObjectLiteral } from 'typeorm';
import xlsx, { WorkSheet } from 'xlsx';
Expand Down Expand Up @@ -159,15 +161,15 @@ jest
.mockImplementation(() => _buildingsCreateQueryBuilder);

jest.spyOn(AppDataSource.getRepository(MapProperties), 'find').mockImplementation(async () => [
{
producePropertyForMap({
Id: 1,
Location: {
x: -122.873862825,
y: 49.212751465,
x: 1,
y: 1,
},
PropertyTypeId: 0,
ClassificationId: 3,
} as MapProperties,
}),
]);

jest
Expand Down Expand Up @@ -367,6 +369,14 @@ describe('UNIT - Property Services', () => {
it('should return a list of map property objects', async () => {
const result = await propertyServices.getPropertiesForMap({
Name: 'some name',
Polygon: JSON.stringify([
[
[3, 3],
[3, 0],
[0, 0],
[0, 3],
],
]),
});
expect(Array.isArray(result)).toBe(true);
expect(result.at(0)).toHaveProperty('Id');
Expand Down Expand Up @@ -402,6 +412,27 @@ describe('UNIT - Property Services', () => {
expect(result.at(0)).toHaveProperty('PropertyTypeId');
expect(result.at(0)).toHaveProperty('ClassificationId');
});

it('should return a list of map property objects, following the UserAgencies filter path with polygon filter', async () => {
const result = await propertyServices.getPropertiesForMap({
Name: 'some name',
UserAgencies: [1],
AgencyIds: [1],
Polygon: JSON.stringify([
[
[3, 3],
[3, 0],
[0, 0],
[0, 3],
],
]),
});
expect(Array.isArray(result)).toBe(true);
expect(result.at(0)).toHaveProperty('Id');
expect(result.at(0)).toHaveProperty('Location');
expect(result.at(0)).toHaveProperty('PropertyTypeId');
expect(result.at(0)).toHaveProperty('ClassificationId');
});
});

describe('getImportResults', () => {
Expand Down Expand Up @@ -723,4 +754,43 @@ describe('UNIT - Property Services', () => {
expect((result as unknown as any[])[0].StatusName).toBe('test');
});
});

describe('filterPropertiesByMultiPolygon', () => {
const _isPointInMultiPolygonSpy = jest.fn();
jest.mock('@/utilities/polygonMath', () => ({
isPointInMultiPolygon: _isPointInMultiPolygonSpy,
}));
const multiPolygon = [
[
[0, 0],
[0, 3],
[3, 3],
[3, 0],
],
[
[5, 0],
[5, 3],
[8, 3],
[8, 0],
],
];

it('should remove properties outside of the polygons', () => {
// Three properties, last one shouldn't pass, so true, true, false
_isPointInMultiPolygonSpy
.mockReturnValueOnce(true)
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);

const properties = [
producePropertyForMap({ Location: { x: 1, y: 1 } }),
producePropertyForMap({ Location: { x: 1, y: 1 } }),
producePropertyForMap({ Location: { x: 10, y: 1 } }),
];
const result = filterPropertiesByMultiPolygon(multiPolygon, properties);
expect(result.length).toEqual(2);
expect(result.at(0).Location.x).toEqual(1);
expect(result.at(1).Location.x).toEqual(1);
});
});
});
57 changes: 57 additions & 0 deletions express-api/tests/unit/utilities/polygonMath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { isPointInMultiPolygon, isPointInPolygon } from '@/utilities/polygonMath';

describe('isPointInPolygon', () => {
const polygon = [
{ x: -124.12628173828126, y: 48.721773219750666 },
{ x: -123.89007568359376, y: 48.84664340683584 },
{ x: -124.13177490234376, y: 48.93152205931365 },
{ x: -124.26361083984376, y: 48.828565527993234 },
];
it('should return true if the point is outside the polygon', () => {
const point = {
x: -124.0548,
y: 48.8206,
};
expect(isPointInPolygon(point, polygon)).toEqual(true);
});
it('should return false if the point is outside the polygon', () => {
const point = { x: -125.135, y: 48.8274 };
expect(isPointInPolygon(point, polygon)).toEqual(false);
});
});

describe('isPointInMultiPolygon', () => {
const multiPolygon = [
[
{ x: 0, y: 0 },
{ x: 0, y: 3 },
{ x: 3, y: 3 },
{ x: 3, y: 0 },
],
[
{ x: 5, y: 0 },
{ x: 5, y: 3 },
{ x: 8, y: 3 },
{ x: 8, y: 0 },
],
];
it('should return true when in the left polygon', () => {
const point = { x: 1, y: 1 };
expect(isPointInMultiPolygon(point, multiPolygon)).toEqual(true);
});

it('should return true when in the right polygon', () => {
const point = { x: 6, y: 1 };
expect(isPointInMultiPolygon(point, multiPolygon)).toEqual(true);
});

it('should return false when outside of both polygons', () => {
const point = { x: -1, y: 24 };
expect(isPointInMultiPolygon(point, multiPolygon)).toEqual(false);
});

it('should return false when between both polygons', () => {
const point = { x: 4, y: 1 };
expect(isPointInMultiPolygon(point, multiPolygon)).toEqual(false);
});
});
Loading

0 comments on commit a64fa24

Please sign in to comment.