Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/afraid-pianos-sniff.md
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ This section is auto generated. Please update `feature-matrix.json` and then run
| indexed-repeat(node-set arg, node-set<br/>repeat1, number index1, [node-set<br/>repeatN, number indexN]{0,2}) | ✅ |
| area(node-set ns\|geoshape gs) | ✅ |
| distance(node-set ns\|geoshape<br/>gs\|geotrace gt\|(geopoint\|string) arg\*) | ✅ |
| geofence(geopoint p, geoshape gs) | ✅ |
| base64-decode(base64Binary input) | ✅ |

</details>
Expand Down
1 change: 1 addition & 0 deletions feature-matrix.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)": "✅"
}
}
35 changes: 35 additions & 0 deletions packages/common/src/fixtures/geolocation/geofence.xml
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>
2 changes: 1 addition & 1 deletion packages/xpath/src/error/JRCompatibleGeoValueError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { JRCompatibleError } from './JRCompatibleError.ts';
// prettier-ignore
type JRCompatibleFallibleGeoFunction =
| 'distance'
| 'geofence' // TODO!
| 'geofence'
;

export class JRCompatibleGeoValueError extends JRCompatibleError {
Expand Down
69 changes: 69 additions & 0 deletions packages/xpath/src/functions/xforms/geo.ts
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';

Expand Down Expand Up @@ -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:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💛

*
* 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) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the specs

Returns True if the specified point is inside the specified geoshape, False otherwise.

Is it safe to assume that it excludes points that are vertices or points on the segment (perimeter line)?
I tested this, and it returns false.

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);
}
);
57 changes: 56 additions & 1 deletion packages/xpath/test/xforms/geo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a case where:

  • The point is one of the vertices
  • The point is on a segment (directly on the polygon line) but not a vertex

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