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("")');
+ });
+ });
});