Skip to content

Commit

Permalink
feat: encoding decoding arbitrary Feature properties
Browse files Browse the repository at this point in the history
AI session: https://chatgpt.com/share/66fc3275-e248-8004-b619-34c3a8fe04ef

Signed-off-by: Aleksei Gurianov <gurianov@gmail.com>
  • Loading branch information
Guria committed Oct 1, 2024
1 parent b5c19b4 commit 653b23f
Show file tree
Hide file tree
Showing 6 changed files with 371 additions and 501 deletions.
2 changes: 1 addition & 1 deletion birdie_snapshots/feature_encode_decode.accepted
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
version: 1.2.3
title: feature_encode_decode
---
{"id":"feature-id","type":"Feature","geometry":{"type":"Point","coordinates":[1.0,2.0]},"properties":null}
{"id":"feature-id","type":"Feature","geometry":{"type":"Point","coordinates":[1.0,2.0]},"properties":{"name":"Test Point","value":42.0}}
5 changes: 0 additions & 5 deletions birdie_snapshots/featurecollection_encode_decode.accepted

This file was deleted.

5 changes: 5 additions & 0 deletions birdie_snapshots/real_life_feature.accepted
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
version: 1.2.3
title: real_life_feature
---
{"id":"yosemite","type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-119.5383,37.8651],[-119.5127,37.8777],[-119.4939,37.8685],[-119.5383,37.8651]]]},"properties":{"name":"Yosemite National Park","area_sq_km":3029.87,"year_established":1890,"is_protected":true}}
5 changes: 5 additions & 0 deletions birdie_snapshots/real_life_featurecollection.accepted
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
version: 1.2.3
title: real_life_featurecollection
---
{"type":"FeatureCollection","features":[{"id":"tokyo","type":"Feature","geometry":{"type":"Point","coordinates":[139.6917,35.6895]},"properties":{"name":"Tokyo","population":37435191,"timezone":"Asia/Tokyo","elevation":40.0}},{"id":"colorado-river","type":"Feature","geometry":{"type":"LineString","coordinates":[[-115.1728,36.1147],[-116.2139,36.5674],[-117.1522,36.6567]]},"properties":{"name":"Colorado River","length_km":2330.0,"countries":["USA","Mexico"]}}]}
132 changes: 78 additions & 54 deletions src/gleojson.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
////
//// Then you can use the provided functions to encode and decode GeoJSON data.

import gleam/dict
import gleam/dynamic
import gleam/json

// import gleam/list
import gleam/option
import gleam/result

Expand All @@ -42,56 +43,53 @@ pub type FeatureId {
}

/// A feature in a GeoJSON object, consisting of a geometry, properties, and an optional id.
pub type Feature {
pub type Feature(properties) {
Feature(
geometry: option.Option(Geometry),
properties: option.Option(dict.Dict(String, dynamic.Dynamic)),
properties: option.Option(properties),
id: option.Option(FeatureId),
)
}

/// A collection of features in a GeoJSON object.
pub type FeatureCollection {
FeatureCollection(features: List(Feature))
pub type FeatureCollection(properties) {
FeatureCollection(features: List(Feature(properties)))
}

/// A GeoJSON object.
pub type GeoJSON {
pub type GeoJSON(properties) {
GeoJSONGeometry(Geometry)
GeoJSONFeature(Feature)
GeoJSONFeatureCollection(FeatureCollection)
GeoJSONFeature(Feature(properties))
GeoJSONFeatureCollection(FeatureCollection(properties))
}

// Encoding Functions

/// Encodes a geometry into a JSON object.
fn encode_geometry(geometry: Geometry) -> json.Json {
case geometry {
Point(coordinates) -> {
Point(coordinates) ->
json.object([
#("type", json.string("Point")),
#("coordinates", json.array(coordinates, of: json.float)),
])
}
MultiPoint(multipoint) -> {
MultiPoint(multipoint) ->
json.object([
#("type", json.string("MultiPoint")),
#(
"coordinates",
json.array(multipoint, of: json.array(_, of: json.float)),
),
])
}
LineString(linestring) -> {
LineString(linestring) ->
json.object([
#("type", json.string("LineString")),
#(
"coordinates",
json.array(linestring, of: json.array(_, of: json.float)),
),
])
}
MultiLineString(multilinestring) -> {
MultiLineString(multilinestring) ->
json.object([
#("type", json.string("MultiLineString")),
#(
Expand All @@ -102,8 +100,7 @@ fn encode_geometry(geometry: Geometry) -> json.Json {
))),
),
])
}
Polygon(polygon) -> {
Polygon(polygon) ->
json.object([
#("type", json.string("Polygon")),
#(
Expand All @@ -114,8 +111,7 @@ fn encode_geometry(geometry: Geometry) -> json.Json {
))),
),
])
}
MultiPolygon(multipolygon) -> {
MultiPolygon(multipolygon) ->
json.object([
#("type", json.string("MultiPolygon")),
#(
Expand All @@ -129,63 +125,77 @@ fn encode_geometry(geometry: Geometry) -> json.Json {
),
),
])
}
GeometryCollection(collection) -> {
GeometryCollection(collection) ->
json.object([
#("type", json.string("GeometryCollection")),
#("geometries", json.array(collection, of: encode_geometry)),
])
}
}
}

/// Encodes a feature into a JSON object.
fn encode_feature(feature: Feature) -> json.Json {
let Feature(geometry_opt, _properties_opt, id_opt) = feature
fn encode_feature(
properties_encoder: fn(properties) -> json.Json,
feature: Feature(properties),
) -> json.Json {
let Feature(geometry_opt, properties_opt, id_opt) = feature
let geometry_json = case geometry_opt {
option.Some(geometry) -> encode_geometry(geometry)
option.None -> json.null()
}
// let properties_json = case properties_opt {
// option.Some(props) -> json.object(props)
// option.None -> json.object([])
// }
let properties_json = case properties_opt {
option.Some(props) -> properties_encoder(props)
option.None -> json.null()
}

let base_obj = [
#("type", json.string("Feature")),
#("geometry", geometry_json),
#("properties", json.null()),
#("properties", properties_json),
]
case id_opt {
let full_obj = case id_opt {
option.Some(StringId(id)) -> [#("id", json.string(id)), ..base_obj]
option.Some(NumberId(id)) -> [#("id", json.float(id)), ..base_obj]
option.None -> base_obj
}
|> json.object
json.object(full_obj)
}

/// Encodes a feature collection into a JSON object.
fn encode_featurecollection(collection: FeatureCollection) -> json.Json {
fn encode_featurecollection(
properties_encoder: fn(properties) -> json.Json,
collection: FeatureCollection(properties),
) -> json.Json {
let FeatureCollection(features) = collection
json.object([
#("type", json.string("FeatureCollection")),
#("features", json.array(features, of: encode_feature)),
#(
"features",
json.array(features, of: fn(feature) {
encode_feature(properties_encoder, feature)
}),
),
])
}

/// Encodes a GeoJSON object into a dynamic value.
/// Encodes a GeoJSON object into a JSON value.
///
/// ## Example
///
/// ```gleam
/// let point = GeoJSONGeometry(Point([0.0, 0.0]))
/// let encoded = encode_geojson(point)
/// // encoded will be a dynamic representation of the GeoJSON object
/// let encoded = encode_geojson(point, properties_encoder)
/// // encoded will be a JSON representation of the GeoJSON object
/// ```
pub fn encode_geojson(geojson: GeoJSON) -> json.Json {
pub fn encode_geojson(
geojson: GeoJSON(properties),
properties_encoder: fn(properties) -> json.Json,
) -> json.Json {
case geojson {
GeoJSONGeometry(geometry) -> encode_geometry(geometry)
GeoJSONFeature(feature) -> encode_feature(feature)
GeoJSONFeatureCollection(collection) -> encode_featurecollection(collection)
GeoJSONFeature(feature) -> encode_feature(properties_encoder, feature)
GeoJSONFeatureCollection(collection) ->
encode_featurecollection(properties_encoder, collection)
}
}

Expand Down Expand Up @@ -219,6 +229,12 @@ fn positions_list_list_decoder(
dynamic.list(of: positions_list_decoder)(dyn_value)
}

fn decode_type_field(
dyn_value: dynamic.Dynamic,
) -> Result(String, List(dynamic.DecodeError)) {
dynamic.field(named: "type", of: dynamic.string)(dyn_value)
}

/// Decodes a geometry from a dynamic value.
fn geometry_decoder(
dyn_value: dynamic.Dynamic,
Expand Down Expand Up @@ -272,8 +288,9 @@ fn feature_id_decoder(

/// Decodes a feature from a dynamic value.
fn feature_decoder(
properties_decoder: dynamic.Decoder(properties),
dyn_value: dynamic.Dynamic,
) -> Result(Feature, List(dynamic.DecodeError)) {
) -> Result(Feature(properties), List(dynamic.DecodeError)) {
use type_str <- result.try(decode_type_field(dyn_value))
case type_str {
"Feature" -> {
Expand All @@ -285,7 +302,7 @@ fn feature_decoder(
let properties_result =
dynamic.field(
named: "properties",
of: dynamic.optional(dynamic.dict(dynamic.string, dynamic.dynamic)),
of: dynamic.optional(properties_decoder),
)(dyn_value)
|> result.map_error(fn(_errs) {
[
Expand Down Expand Up @@ -326,14 +343,18 @@ fn feature_decoder(

/// Decodes a feature collection from a dynamic value.
fn featurecollection_decoder(
dyn_value,
) -> Result(FeatureCollection, List(dynamic.DecodeError)) {
properties_decoder: dynamic.Decoder(properties),
dyn_value: dynamic.Dynamic,
) -> Result(FeatureCollection(properties), List(dynamic.DecodeError)) {
use type_str <- result.try(decode_type_field(dyn_value))
case type_str {
"FeatureCollection" ->
dynamic.field(named: "features", of: dynamic.list(of: feature_decoder))(
dyn_value,
)
dynamic.field(
named: "features",
of: dynamic.list(of: fn(dyn_value) {
feature_decoder(properties_decoder, dyn_value)
}),
)(dyn_value)
|> result.map(FeatureCollection)
_ ->
Error([
Expand All @@ -353,22 +374,25 @@ fn featurecollection_decoder(
/// ```gleam
/// let json_string = "{\"type\":\"Point\",\"coordinates\":[0.0,0.0]}"
/// let decoded = json.decode(json_string)
/// |> result.then(geojson_decoder)
/// |> result.then(fn dyn_value { geojson_decoder(properties_decoder, dyn_value) })
/// // decoded will be Ok(GeoJSONGeometry(Point([0.0, 0.0]))) if successful
/// ```
///
/// Note: This function expects a valid GeoJSON structure. Invalid or incomplete
/// GeoJSON data will result in a decode error.
pub fn geojson_decoder(dyn_value) -> Result(GeoJSON, List(dynamic.DecodeError)) {
pub fn geojson_decoder(
properties_decoder: dynamic.Decoder(properties),
dyn_value: dynamic.Dynamic,
) -> Result(GeoJSON(properties), List(dynamic.DecodeError)) {
use type_str <- result.try(decode_type_field(dyn_value))
case type_str {
"Feature" -> result.map(feature_decoder(dyn_value), GeoJSONFeature)
"Feature" ->
result.map(feature_decoder(properties_decoder, dyn_value), GeoJSONFeature)
"FeatureCollection" ->
result.map(featurecollection_decoder(dyn_value), GeoJSONFeatureCollection)
result.map(
featurecollection_decoder(properties_decoder, dyn_value),
GeoJSONFeatureCollection,
)
_ -> result.map(geometry_decoder(dyn_value), GeoJSONGeometry)
}
}

fn decode_type_field(dyn_value) -> Result(String, List(dynamic.DecodeError)) {
dynamic.field(named: "type", of: dynamic.string)(dyn_value)
}
Loading

0 comments on commit 653b23f

Please sign in to comment.