diff --git a/.changeset/afraid-pianos-sniff.md b/.changeset/afraid-pianos-sniff.md new file mode 100644 index 000000000..a6cdcdd5a --- /dev/null +++ b/.changeset/afraid-pianos-sniff.md @@ -0,0 +1,7 @@ +--- +'@getodk/xpath': minor +'@getodk/web-forms': minor +'@getodk/xforms-engine': minor +--- + +Adds geofence xpath function diff --git a/README.md b/README.md index 779711886..f55861fc2 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,7 @@ This section is auto generated. Please update `feature-matrix.json` and then run | indexed-repeat(node-set arg, node-set
repeat1, number index1, [node-set
repeatN, number indexN]{0,2}) | ✅ | | area(node-set ns\|geoshape gs) | ✅ | | distance(node-set ns\|geoshape
gs\|geotrace gt\|(geopoint\|string) arg\*) | ✅ | +| geofence(geopoint p, geoshape gs) | ✅ | | base64-decode(base64Binary input) | ✅ | diff --git a/feature-matrix.json b/feature-matrix.json index 91cbb3e4c..686dfb627 100644 --- a/feature-matrix.json +++ b/feature-matrix.json @@ -220,6 +220,7 @@ "indexed-repeat(node-set arg, node-set repeat1, number index1, [node-set repeatN, number indexN]{0,2})": "✅", "area(node-set ns|geoshape gs)": "✅", "distance(node-set ns|geoshape gs|geotrace gt|(geopoint|string) arg*)": "✅", + "geofence(geopoint p, geoshape gs)": "✅", "base64-decode(base64Binary input)": "✅" } } diff --git a/packages/common/src/fixtures/geolocation/geofence.xml b/packages/common/src/fixtures/geolocation/geofence.xml new file mode 100644 index 000000000..d9a1eb52d --- /dev/null +++ b/packages/common/src/fixtures/geolocation/geofence.xml @@ -0,0 +1,35 @@ + + + + Geofence + + + + + 4.422999031048619 34.232889315986995;3.932561564992426 41.457767618067;-1.7002591367439237 41.53342079400501;-4.562120072949384 39.43137845871903;-0.9439342743115873 33.85462343629687;4.422999031048619 34.232889315986995 + + + + + + + + + + + + + + + + + + + + diff --git a/packages/xpath/src/error/JRCompatibleGeoValueError.ts b/packages/xpath/src/error/JRCompatibleGeoValueError.ts index c160eafd7..11e715985 100644 --- a/packages/xpath/src/error/JRCompatibleGeoValueError.ts +++ b/packages/xpath/src/error/JRCompatibleGeoValueError.ts @@ -3,7 +3,7 @@ import { JRCompatibleError } from './JRCompatibleError.ts'; // prettier-ignore type JRCompatibleFallibleGeoFunction = | 'distance' - | 'geofence' // TODO! + | 'geofence' ; export class JRCompatibleGeoValueError extends JRCompatibleError { diff --git a/packages/xpath/src/functions/xforms/geo.ts b/packages/xpath/src/functions/xforms/geo.ts index a5ec38fb9..c931dc929 100644 --- a/packages/xpath/src/functions/xforms/geo.ts +++ b/packages/xpath/src/functions/xforms/geo.ts @@ -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) => { + 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); + } +); diff --git a/packages/xpath/test/xforms/geo.test.ts b/packages/xpath/test/xforms/geo.test.ts index b3b356d09..026cc163e 100644 --- a/packages/xpath/test/xforms/geo.test.ts +++ b/packages/xpath/test/xforms/geo.test.ts @@ -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(` + + ${point} + ${UNIT_CUBE} + + `); + }; + + [ + { expression: '0.5 0.5 0 0', expected: true }, // inside + { 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("")'); + }); + }); });