Skip to content

Commit d01d809

Browse files
committed
add geo module
with some basic functions to work with GeoJSON
1 parent 2bd9943 commit d01d809

File tree

3 files changed

+306
-1
lines changed

3 files changed

+306
-1
lines changed

testing/geo-tests.xqm

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
xquery version "3.1";
2+
3+
module namespace geot="http://xquery.weber-gesamtausgabe.de/modules/geo-tests";
4+
5+
declare namespace test="http://exist-db.org/xquery/xqsuite";
6+
import module namespace geo="http://xquery.weber-gesamtausgabe.de/modules/geo" at "../xquery/geo.xqm";
7+
8+
declare
9+
%test:args(
10+
'{ "type": "Point", "coordinates": [3.5, 2.5] }',
11+
'[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ]'
12+
)
13+
%test:assertTrue
14+
%test:args(
15+
'{ "type": "Point", "coordinates": [0.0, 5.0] }',
16+
'[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ]'
17+
)
18+
%test:assertFalse
19+
%test:args(
20+
'{ "type": "Point", "coordinates": [5.0, 0.0] }',
21+
'[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ]'
22+
)
23+
%test:assertFalse
24+
%test:args(
25+
'{ "type": "Point", "coordinates": [5.0, 2.5] }',
26+
'[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ]'
27+
)
28+
%test:assertFalse
29+
%test:args(
30+
'{ "type": "Point", "coordinates": [3.5, 7.5] }',
31+
'[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ]'
32+
)
33+
%test:assertFalse
34+
%test:args(
35+
'{ "type": "Point", "coordinates": [5.0, 7.5] }',
36+
'[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ]'
37+
)
38+
%test:assertFalse
39+
function geot:test-point-in-ring($point as xs:string, $ring as xs:string) as xs:boolean {
40+
geo:point-in-ring($point => parse-json(), $ring => parse-json())
41+
};
42+
43+
declare
44+
%test:args(
45+
'{ "type": "Point", "coordinates": [3.5, 2.5] }',
46+
'[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ]'
47+
)
48+
%test:assertFalse
49+
%test:args(
50+
'{ "type": "Point", "coordinates": [0.0, 5.0] }',
51+
'[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ]'
52+
)
53+
%test:assertTrue
54+
%test:args(
55+
'{ "type": "Point", "coordinates": [5.0, 0.0] }',
56+
'[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ]'
57+
)
58+
%test:assertTrue
59+
%test:args(
60+
'{ "type": "Point", "coordinates": [5.0, 2.5] }',
61+
'[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ]'
62+
)
63+
%test:assertTrue
64+
%test:args(
65+
'{ "type": "Point", "coordinates": [3.5, 7.5] }',
66+
'[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ]'
67+
)
68+
%test:assertFalse
69+
%test:args(
70+
'{ "type": "Point", "coordinates": [5.0, 7.5] }',
71+
'[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ]'
72+
)
73+
%test:assertFalse
74+
function geot:test-point-on-edge($point as xs:string, $ring as xs:string) as xs:boolean {
75+
geo:point-on-edge($point => parse-json(), $ring => parse-json())
76+
};
77+
78+
declare
79+
%test:args(
80+
'{ "type": "Point", "coordinates": [2.5, 2.5] }',
81+
'{ "type": "Polygon", "coordinates": [[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ]] }'
82+
)
83+
%test:assertTrue
84+
%test:args(
85+
'{ "type": "Point", "coordinates": [2.5, 2.5] }',
86+
'{ "type": "Polygon", "coordinates": [[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ], [ [1.0, 1.0], [1.0, 4.0], [4.0, 4.0], [4.0, 1.0], [1.0, 1.0] ]]}'
87+
)
88+
%test:assertFalse
89+
%test:args(
90+
'{ "type": "Point", "coordinates": [4, 4.43832400000014] }',
91+
'{ "type": "Polygon", "coordinates": [[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ], [ [1.0, 1.0], [1.0, 4.0], [4.0, 4.0], [4.0, 1.0], [1.0, 1.0] ]]}'
92+
)
93+
%test:assertTrue
94+
%test:args(
95+
'{ "type": "Point", "coordinates": [4, 4.43832400000014] }',
96+
'{ "type": "Polygon", "coordinates": [[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ]] }'
97+
)
98+
%test:assertTrue
99+
%test:args(
100+
'{ "type": "Point", "coordinates": [0.0, 5.0] }',
101+
'{ "type": "Polygon", "coordinates": [[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ]] }'
102+
)
103+
%test:assertTrue
104+
%test:args(
105+
'{ "type": "Point", "coordinates": [5.0, 0.0] }',
106+
'{ "type": "Polygon", "coordinates": [[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ]] }'
107+
)
108+
%test:assertTrue
109+
%test:args(
110+
'{ "type": "Point", "coordinates": [5.0, 2.5] }',
111+
'{ "type": "Polygon", "coordinates": [[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ]] }'
112+
)
113+
%test:assertTrue
114+
%test:args(
115+
'{ "type": "Point", "coordinates": [3.5, 7.5] }',
116+
'{ "type": "Polygon", "coordinates": [[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ]] }'
117+
)
118+
%test:assertFalse
119+
%test:args(
120+
'{ "type": "Point", "coordinates": [5.0, 7.5] }',
121+
'{ "type": "Polygon", "coordinates": [[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ]] }'
122+
)
123+
%test:assertFalse
124+
%test:args(
125+
'{ "type": "Point", "coordinates": [2, 4] }',
126+
'{ "type": "Polygon", "coordinates": [[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ], [ [1.0, 1.0], [1.0, 4.0], [4.0, 4.0], [4.0, 1.0], [1.0, 1.0] ]]}'
127+
)
128+
%test:assertFalse
129+
%test:args(
130+
'{ "foo": "bar" }',
131+
'{ "bli": "baz" }'
132+
)
133+
%test:assertError("GeojsonFormatError")
134+
function geot:test-point-in-polygon($point as xs:string, $polygon as xs:string) as xs:boolean {
135+
geo:point-in-polygon($point => parse-json(), $polygon => parse-json())
136+
};

testing/run-tests.xql

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import module namespace wust="http://xquery.weber-gesamtausgabe.de/modules/wega-
77
import module namespace dt="http://xquery.weber-gesamtausgabe.de/modules/date-tests" at "date-tests.xqm";
88
import module namespace st="http://xquery.weber-gesamtausgabe.de/modules/str-tests" at "str-tests.xqm";
99
import module namespace mt="http://xquery.weber-gesamtausgabe.de/modules/math-tests" at "math-tests.xqm";
10+
import module namespace geot="http://xquery.weber-gesamtausgabe.de/modules/geo-tests" at "geo-tests.xqm";
1011

1112
(: the test:suite() function will run all the test-annotated functions in the module whose namespace URI you provide :)
1213
test:suite((
1314
util:list-functions("http://xquery.weber-gesamtausgabe.de/modules/date-tests"),
1415
util:list-functions("http://xquery.weber-gesamtausgabe.de/modules/str-tests"),
1516
util:list-functions("http://xquery.weber-gesamtausgabe.de/modules/wega-util-shared-tests"),
16-
util:list-functions("http://xquery.weber-gesamtausgabe.de/modules/math-tests")
17+
util:list-functions("http://xquery.weber-gesamtausgabe.de/modules/math-tests"),
18+
util:list-functions("http://xquery.weber-gesamtausgabe.de/modules/geo-tests")
1719
))

xquery/geo.xqm

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
xquery version "3.1" encoding "UTF-8";
2+
3+
(:~
4+
: XQuery module for geospatial functions
5+
~:)
6+
module namespace geo = "http://xquery.weber-gesamtausgabe.de/modules/geo";
7+
8+
declare namespace array="http://www.w3.org/2005/xpath-functions/array";
9+
10+
declare variable $geo:GEOJSON_FORMAT_ERROR := QName("http://xquery.weber-gesamtausgabe.de/modules/geo", "GeojsonFormatError");
11+
12+
(:~
13+
: Function to check if a point is inside a GeoJSON polygon, considering holes.
14+
: A point is considered inside the polygon if it is inside the exterior ring
15+
: and outside all interior rings.
16+
:
17+
: @param $point The GeoJSON point to check, e.g. map { "type": "Point", "coordinates": [3.5, 2.5] }
18+
: @param $polygon The GeoJSON polygon with possible holes, e.g. map { "type": "Polygon", "coordinates": [[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ]] }
19+
: @return xs:boolean True if the point is inside the polygon or on one of the edges, false otherwise.
20+
:)
21+
declare function geo:point-in-polygon($point as map(*), $polygon as map(*)) as xs:boolean {
22+
if(geo:is-valid-point($point) and geo:is-valid-polygon($polygon))
23+
then
24+
let $exterior as array(*) := $polygon("coordinates")(1)
25+
let $interiors as array(*)? :=
26+
if(array:size($polygon("coordinates")) gt 1)
27+
then array:tail($polygon("coordinates"))
28+
else ()
29+
let $inExterior as xs:boolean := geo:point-in-ring($point, $exterior)
30+
let $onExteriorEdge as xs:boolean := geo:point-on-edge($point, $exterior)
31+
let $inAnyInterior as xs:boolean :=
32+
if(exists($interiors))
33+
then some $interior in $interiors?* satisfies geo:point-in-ring($point, $interior)
34+
else false()
35+
let $onAnyInteriorEdge as xs:boolean :=
36+
if(exists($interiors))
37+
then some $interior in $interiors?* satisfies geo:point-on-edge($point, $interior)
38+
else false()
39+
return ($inExterior or $onExteriorEdge) and not ($inAnyInterior or $onAnyInteriorEdge)
40+
else error($geo:GEOJSON_FORMAT_ERROR, 'invalid geojson format')
41+
};
42+
43+
(:~
44+
: Function to check if a point is inside a ring (exterior or interior).
45+
: Uses the ray-casting algorithm to count the number of intersections of a ray
46+
: starting from the point.
47+
:
48+
: @param $point The GeoJSON point to check, e.g. map { "type": "Point", "coordinates": [3.5, 2.5] }.
49+
: @param $ring The coordinates of the ring (array of arrays), e.g. [[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ]].
50+
: @return xs:boolean True if the point is inside the ring, false otherwise.
51+
:)
52+
declare function geo:point-in-ring($point as map(*), $ring as array(*)) as xs:boolean {
53+
if(geo:is-valid-point($point) and geo:is-valid-ring($ring))
54+
then
55+
let $px as xs:double := $point("coordinates")(1)
56+
let $py as xs:double := $point("coordinates")(2)
57+
let $n as xs:integer := array:size($ring)
58+
let $intersections :=
59+
for $i in 1 to $n
60+
let $xi as xs:double := $ring($i)(1)
61+
let $yi as xs:double := $ring($i)(2)
62+
(: $j = Index of the previous vertex, which helps in forming edges :)
63+
let $j as xs:integer :=
64+
if ($i = 1)
65+
then $n
66+
else $i - 1
67+
let $xj as xs:double := $ring($j)(1)
68+
let $yj as xs:double := $ring($j)(2)
69+
return
70+
if (
71+
(($yi > $py) != ($yj > $py)) and
72+
($px < (($xj - $xi) * ($py - $yi) div ($yj - $yi) + $xi))
73+
)
74+
then 1
75+
else 0
76+
return
77+
(sum($intersections) mod 2) = 1
78+
else error($geo:GEOJSON_FORMAT_ERROR, 'invalid geojson format')
79+
};
80+
81+
(:~
82+
: XQuery function that checks whether a point is on the edge of a polygon ring (exterior or interior).
83+
:
84+
: @param $point The GeoJSON point to check, e.g. map { "type": "Point", "coordinates": [3.5, 2.5] }.
85+
: @param $ring The coordinates of the ring (array of arrays), e.g. [[ [0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0] ]].
86+
: @return True if the point is on one of the edges, false otherwise.
87+
:)
88+
declare function geo:point-on-edge($point as map(*), $ring as array(*)) as xs:boolean {
89+
if(geo:is-valid-point($point) and geo:is-valid-ring($ring))
90+
then
91+
let $px as xs:double := $point("coordinates")(1)
92+
let $py as xs:double := $point("coordinates")(2)
93+
let $n as xs:integer := array:size($ring)
94+
let $onEdge :=
95+
for $i in 1 to $n
96+
let $xi as xs:double := $ring($i)(1)
97+
let $yi as xs:double := $ring($i)(2)
98+
let $j as xs:integer :=
99+
if ($i = 1)
100+
then $n
101+
else $i - 1
102+
let $xj as xs:double := $ring($j)(1)
103+
let $yj as xs:double := $ring($j)(2)
104+
return
105+
if (
106+
($px - $xi) * ($yj - $yi) = ($py - $yi) * ($xj - $xi) and
107+
min(($xi, $xj)) <= $px and
108+
$px <= max(($xi, $xj)) and
109+
min(($yi, $yj)) <= $py and
110+
$py <= max(($yi, $yj))
111+
)
112+
then true() else false()
113+
return some $x in $onEdge satisfies $x
114+
else error($geo:GEOJSON_FORMAT_ERROR, 'invalid geojson format')
115+
};
116+
117+
(:~
118+
: Function to validate a GeoJSON point.
119+
:
120+
: @param $point The GeoJSON point to validate.
121+
: @return xs:boolean True if the point is valid, false otherwise.
122+
:)
123+
declare function geo:is-valid-point($point as map(*)) as xs:boolean {
124+
($point?type = "Point") and
125+
exists($point?coordinates) and
126+
(array:size($point?coordinates) = 2) and
127+
(every $coord in $point?coordinates?* satisfies $coord instance of xs:double)
128+
};
129+
130+
(:~
131+
: Function to validate a GeoJSON ring (array of coordinates).
132+
:
133+
: @param $ring The ring to validate.
134+
: @return xs:boolean True if the ring is valid, false otherwise.
135+
:)
136+
declare function geo:is-valid-ring($ring as array(*)) as xs:boolean {
137+
array:size($ring) >= 4 and
138+
$ring(1) = $ring(array:size($ring)) and
139+
(every $coordinate in $ring?* satisfies (
140+
array:size($coordinate) = 2 and
141+
$coordinate(1) instance of xs:double and
142+
$coordinate(2) instance of xs:double
143+
))
144+
};
145+
146+
(:~
147+
: Function to validate a GeoJSON polygon.
148+
:
149+
: @param $polygon The GeoJSON polygon to validate.
150+
: @return xs:boolean True if the polygon is valid, false otherwise.
151+
:)
152+
declare function geo:is-valid-polygon($polygon as map(*)) as xs:boolean {
153+
$polygon?type = "Polygon" and
154+
exists($polygon?coordinates) and
155+
(every $ring in $polygon?coordinates?* satisfies geo:is-valid-ring($ring))
156+
};
157+
158+
(:~
159+
: Function to validate GeoJSON input (either Point or Polygon).
160+
:
161+
: @param $geojson The GeoJSON data to validate.
162+
: @return xs:boolean True if the GeoJSON data is valid, false otherwise.
163+
:)
164+
declare function local:is-valid-geojson($geojson as map(*)) as xs:boolean {
165+
($geojson?type = "Point" and geo:is-valid-point($geojson)) or
166+
($geojson?type = "Polygon" and geo:is-valid-polygon($geojson))
167+
};

0 commit comments

Comments
 (0)