-
Notifications
You must be signed in to change notification settings - Fork 19
feat(#591): add geofence xpath function #604
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@getodk/xpath': minor | ||
| '@getodk/web-forms': minor | ||
| '@getodk/xforms-engine': minor | ||
| --- | ||
|
|
||
| Adds geofence xpath function |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| <?xml version="1.0"?> | ||
| <h:html xmlns="http://www.w3.org/2002/xforms" xmlns:h="http://www.w3.org/1999/xhtml" | ||
| xmlns:ev="http://www.w3.org/2001/xml-events" xmlns:xsd="http://www.w3.org/2001/XMLSchema" | ||
| xmlns:jr="http://openrosa.org/javarosa" xmlns:orx="http://openrosa.org/xforms" | ||
| xmlns:odk="http://www.opendatakit.org/xforms"> | ||
| <h:head> | ||
| <h:title>Geofence</h:title> | ||
| <model odk:xforms-version="1.0.0"> | ||
|
|
||
| <instance> | ||
| <data id="geofence_test" version="202510311"> | ||
| <kenya>4.422999031048619 34.232889315986995;3.932561564992426 41.457767618067;-1.7002591367439237 41.53342079400501;-4.562120072949384 39.43137845871903;-0.9439342743115873 33.85462343629687;4.422999031048619 34.232889315986995</kenya> | ||
| <geo/> | ||
| <meta> | ||
| <instanceID/> | ||
| </meta> | ||
| </data> | ||
| </instance> | ||
|
|
||
| <bind nodeset="/data/geo" type="geopoint" | ||
| constraint="geofence(/data/geo, /data/kenya)" | ||
| jr:constraintMsg="That's not in Kenya" | ||
| required="true()"/> | ||
|
|
||
| <bind nodeset="/data/meta/instanceID" type="string" readonly="true()" jr:preload="uid"/> | ||
| </model> | ||
| </h:head> | ||
| <h:body> | ||
|
|
||
| <input ref="/data/geo" appearance="placement-map"> | ||
| <label>Pick somewhere in Kenya</label> | ||
| </input> | ||
|
|
||
| </h:body> | ||
| </h:html> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,10 @@ | ||
| import type { XPathNode } from '../../adapter/interface/XPathNode.ts'; | ||
| import { EvaluationContext } from '../../context/EvaluationContext.ts'; | ||
| import { JRCompatibleGeoValueError } from '../../error/JRCompatibleGeoValueError.ts'; | ||
| import { BooleanFunction } from '../../evaluator/functions/BooleanFunction.ts'; | ||
| import type { EvaluableArgument } from '../../evaluator/functions/FunctionImplementation.ts'; | ||
| import { NumberFunction } from '../../evaluator/functions/NumberFunction.ts'; | ||
| import { Geopoint } from '../../lib/geo/Geopoint.ts'; | ||
| import { Geotrace } from '../../lib/geo/Geotrace.ts'; | ||
| import type { GeotraceLine } from '../../lib/geo/GeotraceLine.ts'; | ||
|
|
||
|
|
@@ -114,3 +116,70 @@ export const distance = new NumberFunction( | |
| return toAbsolutePrecision(sum(distances), PRECISION); | ||
| } | ||
| ); | ||
|
|
||
| /** | ||
| * Returns whether a geopoint is inside the specified geoshape; aka 'geofencing' | ||
| * @param point the geopoint location to check for inclusion. | ||
| * @param polygon the closed list of geoshape coordinates defining the polygon 'fence'. | ||
| * @return true if the location is inside the polygon; false otherwise. | ||
| * | ||
| * Adapted from https://wrfranklin.org/Research/Short_Notes/pnpoly.html: | ||
| * | ||
| * int pnpoly(int nvert, float *vertx, float *verty, float testx, float testy) { | ||
| * int i, j, c = 0; | ||
| * for (i = 0, j = nvert - 1; i < nvert; j = i++) { | ||
| * if (((verty[i] > testy) != (verty[j] > testy)) && | ||
| * (testx < (vertx[j] - vertx[i]) * (testy - verty[i]) / (verty[j] - verty[i]) + vertx[i])) | ||
| * c = !c; | ||
| * } | ||
| * return c; | ||
| * } | ||
| */ | ||
| const calculateIsPointInGPSPolygon = (point: Geopoint, polygon: Geotrace) => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Is it safe to assume that it excludes points that are vertices or points on the segment (perimeter line)? |
||
| const testx = point.longitude; // x maps to longitude | ||
| const testy = point.latitude; // y maps to latitude | ||
| let result = false; | ||
| for (let i = 1; i < polygon.geopoints.length; i++) { | ||
| // geoshapes already duplicate the first point to last, so unlike the original algorithm there is no need to wrap j | ||
| const p1 = polygon.geopoints[i - 1]; // this is effectively j in the original algorithm | ||
| const p2 = polygon.geopoints[i]; // this is effectively i in the original algorithm | ||
| if (!p1 || !p2) { | ||
| return false; | ||
| } | ||
| const { latitude: p1Lat, longitude: p1long } = p1; | ||
| const { latitude: p2Lat, longitude: p2long } = p2; | ||
| if ( | ||
| p2Lat > testy != p1Lat > testy && | ||
| testx < ((p1long - p2long) * (testy - p2Lat)) / (p1Lat - p2Lat) + p2long | ||
| ) { | ||
| result = !result; | ||
| } | ||
| } | ||
| return result; | ||
| }; | ||
|
|
||
| const validateGeoshape = (shape: Geotrace) => { | ||
| if (shape.geopoints.length < 2) { | ||
| return false; | ||
| } | ||
| const first = shape.geopoints[0]; | ||
| const last = shape.geopoints[shape.geopoints.length - 1]!; | ||
| return first.latitude === last.latitude && first.longitude === last.longitude; | ||
| }; | ||
|
|
||
| export const geofence = new BooleanFunction( | ||
| 'geofence', | ||
| [{ arityType: 'required' }, { arityType: 'required' }], | ||
| (context, args) => { | ||
| const [point, shape] = evaluateArgumentValues(context, args); | ||
| if (!point || !shape) { | ||
| return false; | ||
| } | ||
| const geopoint = Geopoint.fromNodeValue(point); | ||
| const geoshape = Geotrace.fromEncodedGeotrace(shape); | ||
| if (!geopoint || !geoshape || !validateGeoshape(geoshape)) { | ||
| return false; | ||
| } | ||
| return calculateIsPointInGPSPolygon(geopoint, geoshape); | ||
| } | ||
| ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; | |
| import type { XFormsTestContext } from '../helpers.ts'; | ||
| import { createXFormsTestContext } from '../helpers.ts'; | ||
|
|
||
| describe('distance() and area() functions', () => { | ||
| describe('geo functions', () => { | ||
| let testContext: XFormsTestContext; | ||
|
|
||
| beforeEach(() => { | ||
|
|
@@ -129,4 +129,59 @@ describe('distance() and area() functions', () => { | |
| testContext.evaluate(expression); | ||
| }); | ||
| }); | ||
|
|
||
| describe('geofence', () => { | ||
| const UNIT_CUBE = '0 0 0 0;0 1 0 0;1 1 0 0;1 0 0 0;0 0 0 0'; | ||
|
|
||
| const createContext = (point: string) => { | ||
| return createXFormsTestContext(` | ||
| <root> | ||
| <point>${point}</point> | ||
| <area>${UNIT_CUBE}</area> | ||
| </root> | ||
| `); | ||
| }; | ||
|
|
||
| [ | ||
| { expression: '0.5 0.5 0 0', expected: true }, // inside | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please add a case where:
|
||
| { expression: '-1 0.5 0 0', expected: false }, // outside left | ||
| { expression: '2 0.5 0 0', expected: false }, // outside right | ||
| { expression: '0.5 2 0 0', expected: false }, // outside above | ||
| { expression: '0.5 -1 0 0', expected: false }, // outside below | ||
| { expression: '-1 0 0 0', expected: false }, // outside co-linear w/ bottom edge | ||
| { expression: '-1 1 0 0', expected: false }, // outside co-linear w/ top edge | ||
| { expression: '0 -1 0 0', expected: false }, // outside below vertex ("...They were carefully chosen to make the program work correctly when the point is vertically below a vertex.") | ||
| ].forEach(({ expression, expected }) => { | ||
| it(`${expression} is ${expected ? 'inside' : 'outside'} of unit cube`, () => { | ||
| testContext = createContext(expression); | ||
| testContext.assertBooleanValue(`geofence("${expression}", "${UNIT_CUBE}")`, expected); | ||
| testContext.assertBooleanValue(`geofence("${expression}", /root/area)`, expected); | ||
| testContext.assertBooleanValue(`geofence(/root/point, /root/area)`, expected); | ||
| }); | ||
| }); | ||
|
|
||
| it('returns false when path evals to empty', () => { | ||
| testContext = createContext(''); | ||
| testContext.assertBooleanValue('geofence(/root/point, /root/area)', false); | ||
| }); | ||
|
|
||
| it('returns false when second parameter is not valid trace', () => { | ||
| testContext.assertBooleanValue('geofence("0 0 0 0", "")', false); | ||
| }); | ||
|
|
||
| it('returns false when second parameter is not a closed shape', () => { | ||
| testContext.assertBooleanValue( | ||
| 'geofence("0 0 0 0", "0 0 0 0;0 1 0 0;1 1 0 0;1 0 0 0;2 0 0 0")', | ||
| false | ||
| ); | ||
| }); | ||
|
|
||
| it.fails('throws error when no parameters are provided', () => { | ||
| testContext.evaluate('geofence()'); | ||
| }); | ||
|
|
||
| it.fails('throws error when only one parameter provided', () => { | ||
| testContext.evaluate('geofence("")'); | ||
| }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💛