Skip to content

Commit

Permalink
feat: strongly typed position type
Browse files Browse the repository at this point in the history
Helper functions to construct strongly typed positions added.

BREAKING CHANGE:
Position type is now a strongly typed variant with 2D and 3D options, replacing the previous list-based representation. This change affects all functions and types that use or return Position objects.

Signed-off-by: Aleksei Gurianov <[email protected]>
  • Loading branch information
Guria committed Oct 2, 2024
1 parent 4fee976 commit c5e25e8
Show file tree
Hide file tree
Showing 3 changed files with 175 additions and 52 deletions.
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ While **gleojson** aims to fully implement the GeoJSON specification (RFC 7946),
1. Antimeridian and pole handling
1. Bounding box support
1. Right-hand rule enforcement for polygon orientation
1. Position array length limitation
1. GeometryCollection usage recommendations

Despite these limitations, **gleojson** is fully functional for most common GeoJSON use cases.
Expand All @@ -47,7 +46,7 @@ import gleam/io
pub fn main() {
// Create a Point geometry
let point = gleojson.Point([125.6, 10.1])
let point = gleojson.Point(gleojson.position_2d(lon: 125.6, lat: 10.1))
// Create a Feature with the Point geometry
let feature = gleojson.Feature(
Expand Down
149 changes: 116 additions & 33 deletions src/gleojson.gleam
Original file line number Diff line number Diff line change
@@ -1,26 +1,24 @@
//// Functions for working with GeoJSON data.
////
//// This module provides types and functions for encoding and decoding GeoJSON data.
//// It supports all GeoJSON object types including Point, MultiPoint, LineString,
//// MultiLineString, Polygon, MultiPolygon, GeometryCollection, Feature, and FeatureCollection.
////
//// ## Usage
////
//// To use this module, you can import it in your Gleam code:
////
//// ```gleam
//// import gleojson
//// ```
////
//// Then you can use the provided functions to encode and decode GeoJSON data.

import gleam/dynamic
import gleam/json
import gleam/option
import gleam/result

pub type Position =
List(Float)
pub type Lon {
Lon(Float)
}

pub type Lat {
Lat(Float)
}

pub type Alt {
Alt(Float)
}

pub type Position {
Position2D(#(Lon, Lat))
Position3D(#(Lon, Lat, Alt))
}

pub type Geometry {
Point(coordinates: Position)
Expand Down Expand Up @@ -55,48 +53,116 @@ pub type GeoJSON(properties) {
GeoJSONFeatureCollection(FeatureCollection(properties))
}

/// Creates a 2D Position object from longitude and latitude values.
///
/// This function is a convenience helper for creating a Position object
/// with two dimensions (longitude and latitude).
///
/// ## Arguments
///
/// - `lon`: The longitude value as a Float.
/// - `lat`: The latitude value as a Float.
///
/// ## Returns
///
/// A Position object representing a 2D coordinate.
///
/// ## Example
///
/// ```gleam
/// import gleojson
///
/// pub fn main() {
/// let position = gleojson.position_2d(lon: 125.6, lat: 10.1)
/// // Use this position in your GeoJSON objects, e.g., in a Point geometry
/// let point = gleojson.Point(coordinates: position)
/// }
/// ```
pub fn position_2d(lon lon: Float, lat lat: Float) -> Position {
Position2D(#(Lon(lon), Lat(lat)))
}

/// Creates a 3D Position object from longitude, latitude, and altitude values.
///
/// This function is a convenience helper for creating a Position object
/// with three dimensions (longitude, latitude, and altitude).
///
/// ## Arguments
///
/// - `lon`: The longitude value as a Float.
/// - `lat`: The latitude value as a Float.
/// - `alt`: The altitude value as a Float.
///
/// ## Returns
///
/// A Position object representing a 3D coordinate.
///
/// ## Example
///
/// ```gleam
/// import gleojson
///
/// pub fn main() {
/// let position = gleojson.position_3d(lon: 125.6, lat: 10.1, alt: 100.0)
/// // Use this position in your GeoJSON objects, e.g., in a Point geometry
/// let point = gleojson.Point(coordinates: position)
/// }
/// ```
pub fn position_3d(lon lon: Float, lat lat: Float, alt alt: Float) -> Position {
Position3D(#(Lon(lon), Lat(lat), Alt(alt)))
}

fn encode_position(position: Position) -> json.Json {
case position {
Position2D(#(Lon(lon), Lat(lat))) -> json.array([lon, lat], json.float)
Position3D(#(Lon(lon), Lat(lat), Alt(alt))) ->
json.array([lon, lat, alt], json.float)
}
}

fn encode_geometry(geometry: Geometry) -> json.Json {
case geometry {
Point(coordinates) ->
json.object([
#("type", json.string("Point")),
#("coordinates", json.array(coordinates, json.float)),
#("coordinates", encode_position(coordinates)),
])
MultiPoint(multipoint) ->
json.object([
#("type", json.string("MultiPoint")),
#("coordinates", json.array(multipoint, json.array(_, json.float))),
#("coordinates", json.array(multipoint, encode_position)),
])
LineString(linestring) ->
json.object([
#("type", json.string("LineString")),
#("coordinates", json.array(linestring, json.array(_, json.float))),
#("coordinates", json.array(linestring, encode_position)),
])
MultiLineString(multilinestring) ->
json.object([
#("type", json.string("MultiLineString")),
#(
"coordinates",
json.array(multilinestring, json.array(_, json.array(_, json.float))),
json.array(multilinestring, fn(line) {
json.array(line, encode_position)
}),
),
])
Polygon(polygon) ->
json.object([
#("type", json.string("Polygon")),
#(
"coordinates",
json.array(polygon, json.array(_, json.array(_, json.float))),
json.array(polygon, fn(ring) { json.array(ring, encode_position) }),
),
])
MultiPolygon(multipolygon) ->
json.object([
#("type", json.string("MultiPolygon")),
#(
"coordinates",
json.array(multipolygon, json.array(_, json.array(_, json.array(
_,
json.float,
)))),
json.array(multipolygon, fn(polygon) {
json.array(polygon, fn(ring) { json.array(ring, encode_position) })
}),
),
])
GeometryCollection(collection) ->
Expand Down Expand Up @@ -184,7 +250,7 @@ fn encode_featurecollection(
/// }
///
/// pub fn main() {
/// let point = gleojson.Point([0.0, 0.0])
/// let point = gleojson.Point(gleojson.position_2d(lon: 0.0, lat: 0.0))
/// let properties = CustomProperties("Example", 42.0)
/// let feature = gleojson.Feature(
/// geometry: option.Some(point),
Expand Down Expand Up @@ -212,7 +278,23 @@ pub fn encode_geojson(
fn position_decoder(
dyn_value: dynamic.Dynamic,
) -> Result(Position, List(dynamic.DecodeError)) {
dynamic.list(dynamic.float)(dyn_value)
dynamic.any([
dynamic.decode1(
Position3D,
dynamic.tuple3(
dynamic.decode1(Lon, dynamic.float),
dynamic.decode1(Lat, dynamic.float),
dynamic.decode1(Alt, dynamic.float),
),
),
dynamic.decode1(
Position2D,
dynamic.tuple2(
dynamic.decode1(Lon, dynamic.float),
dynamic.decode1(Lat, dynamic.float),
),
),
])(dyn_value)
}

fn positions_decoder(
Expand Down Expand Up @@ -268,7 +350,7 @@ fn geometry_decoder(
_ ->
Error([
dynamic.DecodeError(
expected: "Known Geometry Type",
expected: "one of [Point, MultiPoint, LineString, MultiLineString, Polygon, MultiPolygon, GeometryCollection]",
found: type_str,
path: ["type"],
),
Expand All @@ -279,9 +361,10 @@ fn geometry_decoder(
fn feature_id_decoder(
dyn_value: dynamic.Dynamic,
) -> Result(FeatureId, List(dynamic.DecodeError)) {
dynamic.string(dyn_value)
|> result.map(StringId)
|> result.lazy_or(fn() { dynamic.float(dyn_value) |> result.map(NumberId) })
dynamic.any([
dynamic.decode1(StringId, dynamic.string),
dynamic.decode1(NumberId, dynamic.float),
])(dyn_value)
}

fn feature_decoder(properties_decoder: dynamic.Decoder(properties)) {
Expand Down
75 changes: 58 additions & 17 deletions test/gleojson_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,10 @@ fn assert_encode_decode(
// Test functions for separate geometries

pub fn point_encode_decode_test() {
let geojson = gleojson.GeoJSONGeometry(gleojson.Point([1.0, 2.0]))
let geojson =
gleojson.GeoJSONGeometry(
gleojson.Point(gleojson.position_2d(lon: 1.0, lat: 2.0)),
)

assert_encode_decode(
geojson,
Expand All @@ -146,7 +149,12 @@ pub fn point_encode_decode_test() {

pub fn multipoint_encode_decode_test() {
let geojson =
gleojson.GeoJSONGeometry(gleojson.MultiPoint([[1.0, 2.0], [3.0, 4.0]]))
gleojson.GeoJSONGeometry(
gleojson.MultiPoint([
gleojson.position_2d(lon: 1.0, lat: 2.0),
gleojson.position_2d(lon: 3.0, lat: 4.0),
]),
)

assert_encode_decode(
geojson,
Expand All @@ -158,7 +166,12 @@ pub fn multipoint_encode_decode_test() {

pub fn linestring_encode_decode_test() {
let geojson =
gleojson.GeoJSONGeometry(gleojson.LineString([[1.0, 2.0], [3.0, 4.0]]))
gleojson.GeoJSONGeometry(
gleojson.LineString([
gleojson.position_2d(lon: 1.0, lat: 2.0),
gleojson.position_2d(lon: 3.0, lat: 4.0),
]),
)

assert_encode_decode(
geojson,
Expand All @@ -171,7 +184,14 @@ pub fn linestring_encode_decode_test() {
pub fn polygon_encode_decode_test() {
let geojson =
gleojson.GeoJSONGeometry(
gleojson.Polygon([[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [1.0, 2.0]]]),
gleojson.Polygon([
[
gleojson.position_2d(lon: 1.0, lat: 2.0),
gleojson.position_2d(lon: 3.0, lat: 4.0),
gleojson.position_2d(lon: 5.0, lat: 6.0),
gleojson.position_2d(lon: 1.0, lat: 2.0),
],
]),
)

assert_encode_decode(
Expand All @@ -186,8 +206,22 @@ pub fn multipolygon_encode_decode_test() {
let geojson =
gleojson.GeoJSONGeometry(
gleojson.MultiPolygon([
[[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [1.0, 2.0]]],
[[[7.0, 8.0], [9.0, 10.0], [11.0, 12.0], [7.0, 8.0]]],
[
[
gleojson.position_2d(lon: 1.0, lat: 2.0),
gleojson.position_2d(lon: 3.0, lat: 4.0),
gleojson.position_2d(lon: 5.0, lat: 6.0),
gleojson.position_2d(lon: 1.0, lat: 2.0),
],
],
[
[
gleojson.position_2d(lon: 7.0, lat: 8.0),
gleojson.position_2d(lon: 9.0, lat: 10.0),
gleojson.position_2d(lon: 11.0, lat: 12.0),
gleojson.position_2d(lon: 7.0, lat: 8.0),
],
],
]),
)

Expand All @@ -203,8 +237,11 @@ pub fn geometrycollection_encode_decode_test() {
let geojson =
gleojson.GeoJSONGeometry(
gleojson.GeometryCollection([
gleojson.Point([1.0, 2.0]),
gleojson.LineString([[3.0, 4.0], [5.0, 6.0]]),
gleojson.Point(gleojson.position_2d(lon: 1.0, lat: 2.0)),
gleojson.LineString([
gleojson.position_2d(lon: 3.0, lat: 4.0),
gleojson.position_2d(lon: 5.0, lat: 6.0),
]),
]),
)

Expand All @@ -221,7 +258,9 @@ pub fn feature_encode_decode_test() {

let feature =
gleojson.Feature(
geometry: option.Some(gleojson.Point([1.0, 2.0])),
geometry: option.Some(
gleojson.Point(gleojson.position_2d(lon: 1.0, lat: 2.0)),
),
properties: option.Some(properties),
id: option.Some(gleojson.StringId("feature-id")),
)
Expand All @@ -244,10 +283,10 @@ pub fn real_life_feature_test() {
geometry: option.Some(
gleojson.Polygon([
[
[-119.5383, 37.8651],
[-119.5127, 37.8777],
[-119.4939, 37.8685],
[-119.5383, 37.8651],
gleojson.position_2d(lon: -119.5383, lat: 37.8651),
gleojson.position_2d(lon: -119.5127, lat: 37.8777),
gleojson.position_2d(lon: -119.4939, lat: 37.8685),
gleojson.position_2d(lon: -119.5383, lat: 37.8651),
],
]),
),
Expand All @@ -270,7 +309,9 @@ pub fn real_life_featurecollection_test() {

let city_feature =
gleojson.Feature(
geometry: option.Some(gleojson.Point([139.6917, 35.6895])),
geometry: option.Some(
gleojson.Point(gleojson.position_2d(lon: 139.6917, lat: 35.6895)),
),
properties: option.Some(city_properties),
id: option.Some(gleojson.StringId("tokyo")),
)
Expand All @@ -282,9 +323,9 @@ pub fn real_life_featurecollection_test() {
gleojson.Feature(
geometry: option.Some(
gleojson.LineString([
[-115.1728, 36.1147],
[-116.2139, 36.5674],
[-117.1522, 36.6567],
gleojson.position_2d(lon: -115.1728, lat: 36.1147),
gleojson.position_2d(lon: -116.2139, lat: 36.5674),
gleojson.position_2d(lon: -117.1522, lat: 36.6567),
]),
),
properties: option.Some(river_properties),
Expand Down

0 comments on commit c5e25e8

Please sign in to comment.