Interop
Other systems send and expect geometries in their own formats: map libraries take GeoJSON layers, spreadsheets and other systems' APIs hold WKT text, and trackers send pairs of numbers. Jadpo reads and writes each at the edge of your app, and only there does the order of latitude and longitude matter:
| Format | What Jadpo does | Where the axis order lives |
|---|---|---|
| GeoJSON geometries | As Geospatial types has them: strict RFC 7946, every ring written by the right-hand rule | geo.from_geojson and the wire |
GeoJSON Feature, FeatureCollection |
Two standard generic types, Feature<G, P> (a geometry of type G and properties of type P) and FeatureCollection<G, P> (its features), read and written as RFC 7946 objects |
The geometry inside |
GeometryCollection |
Still refused | |
| WKT and EWKT | geo.from_wkt(text) failing malformed or out_of_range, and geo.to_wkt(shape), longitude first as PostGIS writes it; SRID=4326; accepted, any other SRID out_of_range |
Those two operations |
| WKB | The PostgreSQL driver's own encoding; no operation | Inside the driver |
| Other coordinate systems | Left out: EastingNorthing stays a value with its Crs, not converted |
GeoJSON features and layers
A map library such as Leaflet or MapLibre takes a layer of features: a
FeatureCollection, each Feature a geometry with properties such as a
name. Jadpo has the two as standard types, so you don't declare your own.
Here is the bakery map's delivery layer. A feature's identity goes in its properties, so the bakery a patch belongs to is a property. A layer read from a mapping library goes back to it byte for byte:
type PatchProperties = Object { name: PatchName bakery_id: Bakery.id}/// The map's delivery layer, as a mapping library reads it.function patch_layer(patches: List<DeliveryPatch>) -> FeatureCollection<Polygon, PatchProperties> { return FeatureCollection { features: list.map( patches, patch -> Feature { geometry: patch.outline properties: PatchProperties { name: patch.name bakery_id: patch.bakery_id } } ) }}/// A layer as the map is sent it.function layer_sent(layer: FeatureCollection<Polygon, PatchProperties>) -> LayerJson { return LayerJson(json.write(layer))}/// A layer the planner drew, as its mapping library sends it.function drawn_layer( sent: LayerJson) fails NotALayer -> FeatureCollection<Polygon, PatchProperties> { return attempt json.read(sent, as: FeatureCollection<Polygon, PatchProperties>) { malformed: NotALayer mismatch: NotALayer // an `id`, a missing geometry, altitudes on some corners only }}
test "a drawn layer goes back to the map as it came" { var sent: LayerJson = LayerJson( "{\"type\":\"FeatureCollection\",\"features\":[{\"type\":\"Feature\",\"geometry\":{\"type\":\"Polygon\",\"coordinates\":[[[-0.2,51.49],[-0.05,51.49],[-0.05,51.54],[-0.2,51.54],[-0.2,51.49]]]},\"properties\":{\"name\":\"Soho\",\"bakery_id\":\"7b0c1f8e-3a52-4d7e-9a41-2f1d5c6e8b90\"}}]}" ) var layer: FeatureCollection<Polygon, PatchProperties> = attempt drawn_layer(sent) assert layer_sent(layer) == sent assert list.count(layer.features) == 1}test "a feature's identity is in its properties" { assert drawn_layer( LayerJson( "{\"type\":\"FeatureCollection\",\"features\":[{\"type\":\"Feature\",\"id\":7,\"geometry\":{\"type\":\"Polygon\",\"coordinates\":[[[-0.2,51.49],[-0.05,51.49],[-0.05,51.54],[-0.2,51.54],[-0.2,51.49]]]},\"properties\":{\"name\":\"Soho\",\"bakery_id\":\"7b0c1f8e-3a52-4d7e-9a41-2f1d5c6e8b90\"}}]}" ) ) fails NotALayer}test "a patch with one corner at a height is refused" { assert drawn_layer( LayerJson( "{\"type\":\"FeatureCollection\",\"features\":[{\"type\":\"Feature\",\"geometry\":{\"type\":\"Polygon\",\"coordinates\":[[[-0.2,51.49],[-0.05,51.49,35],[-0.05,51.54],[-0.2,51.54],[-0.2,51.49]]]},\"properties\":{\"name\":\"Soho\",\"bakery_id\":\"7b0c1f8e-3a52-4d7e-9a41-2f1d5c6e8b90\"}}]}" ) ) fails NotALayer}
- A feature's arguments.
Gis one of the six geometries orGeometry.Pis anObjecttype of your application's, whose fields are the properties; anInput, anOutputor a standard object isn't allowed. Either kind of wrong argument is refused astype.mismatch, where the type is written or where a literal's values give it. A literal takes its arguments from the type its place expects, or else from its values: a feature's from its geometry and its properties, a collection's from its features. - Read strictly. A feature's members are
type, which must beFeature(another isvariant),geometry,properties, and thebboxthat RFC 7946 §5 lets any object carry. Abboxis checked as four or six numbers and ignored, as a geometry's is. Anidor any other member isunknown_field: your application's identity for a feature goes inproperties. Ageometryofnull, which RFC 7946 allows, isrequired, since a feature with no geometry is left out; so arepropertiesofnullor none. A collection's members aretype,featuresandbbox. A collection of no features is read, as an empty layer, though a multi-geometry of no parts isn't. Each problem is reported at its place, such asfeatures.0.properties.vans. A feature or collection is written in that order,typefirst, with nobbox. - No text form, and no form for people.
text.from,text.parseandtext.formaton a feature aretype.mismatch; its geometry and its properties have theirs. A feature field in an entity is stored as its GeoJSON injsonb, as structured values are.
A route sends a layer bare, as it sends a List or Page of an Output
(Output and status), so a mapping library
fetches it as the whole response, plain RFC 7946 GeoJSON. Its properties
hold only what an Output may, so only fields declared for clients leave
the server. A layer may also be a field of an Output. OpenAPI describes
it as RFC 7946's object with each feature's geometry and properties, and
the API record names it, as, say, FeatureCollection<Point, PinProperties>:
type PinLabel = Text { max_length: 80}type LayerJson = Text {}type PinProperties = Object { label: PinLabel}/// The bakeries on the map, each pin labelled.action bakery_pins() -> FeatureCollection<Point, PinProperties> { return FeatureCollection { features: [ Feature { geometry: Point { latitude: 51.5136 longitude: -0.1365 altitude: none } properties: PinProperties { label: PinLabel("Soho") } } ] }}routes Pins { prefix: /pins POST / { authentication: RouteAuthentication.none output: FeatureCollection<Point, PinProperties> run: bakery_pins() }}policy { actions: { bakery_pins: [Access.public] }}test "the map fetches its pins as GeoJSON" { var response: Response = request { method: HttpMethod.post path: "/pins" } assert response.status == 200 assert response.output == bakery_pins() assert LayerJson(json.write(bakery_pins())) == LayerJson( "{\"type\":\"FeatureCollection\",\"features\":[{\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-0.1365,51.5136]},\"properties\":{\"label\":\"Soho\"}}]}" )}
This route answers POST, since an action builds its layer. A layer of
stored records comes from a query, which a GET route runs, so a mapping
library fetches it from the route's address
(A query giving a map layer).
WKT and EWKT
WKT is the text form of a geometry that spreadsheets, CSV exports and
other systems' APIs use, such as POINT(-0.1276 51.5072), longitude
first. EWKT is PostGIS's form of it, which starts with the coordinate
system, as in SRID=4326;POINT(...). WKB, the binary form, only passes
between programs and databases, so it stays inside the PostgreSQL driver.
The planner's spreadsheet holds each outline as WKT:
type SheetText = Text {}type NotAShape = Failure { kind: FailureKind.invalid_value}/// A patch's outline from the planner's spreadsheet, as WKT.function outline_from(sheet_text: SheetText) fails NotAShape -> Geometry { return attempt geo.from_wkt(sheet_text) { malformed: NotAShape // not WKT, rings that cross, or a height on some corners only out_of_range: NotAShape // past the poles, or another system }}/// An outline for the planner's spreadsheet, longitude first.function sheet_text_for(outline: Geometry) -> SheetText { return SheetText(geo.to_wkt(outline))}
test "a patch comes back from the spreadsheet as it went" { var sheet_text: SheetText = SheetText( "POLYGON((-0.2 51.49,-0.05 51.49,-0.05 51.54,-0.2 51.54,-0.2 51.49))" ) var outline: Geometry = attempt outline_from(sheet_text) assert sheet_text_for(outline) == sheet_text var respaced: Geometry = attempt outline_from( SheetText( "SRID=4326;polygon ((-0.2 51.49, -0.05 51.49, -0.05 51.54, -0.2 51.54, -0.2 51.49))" ) ) assert respaced == outline}test "what isn't an outline is refused" { assert outline_from(SheetText("POINT(-0.1276 91)")) fails NotAShape assert outline_from(SheetText("SRID=4258;POINT(-0.1276 51.5072)")) fails NotAShape assert outline_from(SheetText("POLYGON((0 0,10 10,0 10,10 0,0 0))")) fails NotAShape assert outline_from(SheetText("LINESTRING(0 0,1 1 35)")) fails NotAShape}
geo.from_wktgives aGeometry, the variant of the kind the text names, which your code matches, as it matches aGeometryread from GeoJSON. There is noas:to pick a kind, so you never have to say what a polygon read as a line fails with.- What is read. The six geometries, keywords in any case,
Zwritten apart or joined (POINT Z (...),POINTZ(...)), a multi-point's points with or without brackets, any spacing, numbers with an exponent, andSRID=4326;before it. A position is two or three numbers, three forZ. Each is read as the double it is and held as that double's shortest decimal (Precision and determinism). A third number withoutZis an altitude, as PostGIS reads it. - What is refused.
MandZM,EMPTY,GEOMETRYCOLLECTION, the curves and every other kind aremalformed, and so are positions mixing altitudes (Altitudes are all or nothing). A coordinate out of its range isout_of_range, where PostGIS'sST_GeogFromTextcoerces it into range with a notice (verified, PostGIS 3.4.2). Any SRID but 4326 isout_of_rangetoo, though PostGIS'sgeographytakes other geographic systems (SRID=4258;is read, verified). - Rings are kept as written, as storage keeps them, not rewound: only
GeoJSON winds rings by the right-hand rule. A polygon is checked as one
read from GeoJSON is, closed rings of four points or more and
rings_valid, and one that isn't valid ismalformed. - The exact place. The reader reports the character it stopped at,
counted from 0, with the condition: the start of a word that isn't a
geometry, the bracket of a ring or line that isn't one, the number out
of range, or the SRID's code. The operation's failure carries the
condition only, as
encoding.from_hex's does; the place is for the tests, and for a later failure that receives it. geo.to_wktwritesST_AsText's form: the keyword in capitals,Zbefore the brackets when every position has an altitude, no space after a comma, a multi-point's points each in brackets, each coordinate the decimal the value holds, and an altitude in metres with no trailing zeros.ST_AsTextkeeps at most 15 places after the point, so a coordinate with more (below 10⁻¹⁵ of a degree) comes back from PostGIS rounded:0.30000000000000004is written0.3(verified). Every other geometry, such as a patch with a hole, a patch wound the other way or one at a height, goes into PostGIS as WKT or EWKT and comes back exactly as written.
Pairs of numbers and GeoJSON text
A van's tracker sends its position latitude first, the map's pin longitude first, and a planner pastes a shape from a drawing tool as GeoJSON text. Each operation's name says which number comes first, so a swapped pair is a call that reads wrong, not a value that looks right:
type TrackerReading = Decimal { scale: 9}type PastedText = Text {}type NotAPlace = Failure { kind: FailureKind.invalid_value}/// Where a van's tracker says it is, latitude first.function van_at(tracker_fix: List<TrackerReading>) fails NotAPlace -> Point { return attempt geo.from_lat_lng(tracker_fix) { malformed: NotAPlace // not two numbers, or three with a height out_of_range: NotAPlace // past a pole, or past the antimeridian }}/// Where a pin dropped on the map is, longitude first, as GeoJSON writes it.function pin_at(pin_dropped: List<TrackerReading>) fails NotAPlace -> Point { return attempt geo.from_lng_lat(pin_dropped) { malformed: NotAPlace out_of_range: NotAPlace }}/// A patch pasted from a drawing tool, as GeoJSON.function pasted_patch(pasted: PastedText) fails NotAPlace -> Geometry { return attempt geo.from_geojson(pasted) { malformed: NotAPlace // not GeoJSON, a feature, or rings that cross out_of_range: NotAPlace // past a pole, or past the antimeridian }}
test "each pair says which way round it is" { var van_spot: Point = attempt van_at([51.5136, -0.1365]) var map_pin: Point = attempt pin_at([-0.1365, 51.5136]) assert van_spot == map_pin assert geo.to_wkt(map_pin) == "POINT(-0.1365 51.5136)" assert van_spot == Point { latitude: 51.5136 longitude: -0.1365 altitude: none } assert van_at([91.0, 0.0]) fails NotAPlace var far_east: Point = attempt pin_at([91.0, 0.0]) assert far_east.longitude == 91.0 assert van_at([51.5136]) fails NotAPlace}test "a pasted patch is read longitude first" { var patch_text: PastedText = PastedText( "{\"type\":\"Polygon\",\"coordinates\":[[[-0.2,51.49],[-0.05,51.49],[-0.05,51.54],[-0.2,51.54],[-0.2,51.49]]]}" ) var patch: Geometry = attempt pasted_patch(patch_text) assert PastedText(json.write(patch)) == patch_text assert pasted_patch( PastedText("{\"type\":\"Point\",\"coordinates\":[-0.1365,91]}") ) fails NotAPlace assert pasted_patch(PastedText("POINT(-0.1365 51.5136)")) fails NotAPlace}
geo.from_geojson(text)gives aGeometry, the variant of the kind the text names, asgeo.from_wktdoes, read by the one GeoJSON reader a geometry on the wire is: strict RFC 7946, each ring closed, valid and rewound by the right-hand rule, every position's altitude given or none. Only the six geometries are read; aFeature, aFeatureCollectionor aGeometryCollectionismalformed, since a layer is read withjson.read(text, as: FeatureCollection<G, P>). Text that isn't one JSON document or isn't one of the six ismalformed, and so is a ring that isn't closed or valid, or positions of mixed sizes. A coordinate out of its range, or an altitude past aLength's, isout_of_range. The reader reports the place (coordinates.0.3) and the rule; the operation's failure carries the condition only, asgeo.from_wkt's does. It takesTextonly, notBytes.- A pair is two numbers or three.
geo.from_lng_lat(numbers)andgeo.from_lat_lng(numbers)take aListof values built onIntorDecimaland give aPoint. A third number is the altitude in metres, as GeoJSON's third is, aLengthrounded half to even to the micrometre. Any other count ismalformed. Each coordinate is first held as the shortest decimal of its nearest double, as aPointbuilt in code is (Precision and determinism), and that value is then held to its range, as GeoJSON's numbers are. So90.00000000000000001is 90 and in range; past it isout_of_range. The order lives only in the name: a latitude of 91 isout_of_rangefromfrom_lat_lngand a longitude of 91 fromfrom_lng_lat. - Always attempted. What a list or a text holds is known only when
the program runs, so each call maps both conditions, or
invalid:, even for a list written out, asgeo.from_wktdoes. No literal is decided while compiling. - No
geo.to_geojson.json.writeof a geometry already writes its GeoJSON, longitude first, so a pair or a text read here goes back out through it orgeo.to_wkt.
Markdown for agents: /docs/geospatial-interop.md.Built from Jadpo main at a707efb.