diff --git a/.tool-versions b/.tool-versions index 8f0129a..113598f 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -elixir 1.18.4-otp-28 -erlang 28.0.2 \ No newline at end of file +elixir 1.19.3-otp-28 +erlang 28.2 \ No newline at end of file diff --git a/README.md b/README.md index 69b37b2..6a0e99e 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,7 @@ layer = %Layer{ # Add a feature with attributes layer = Layer.add_feature( layer, - %Feature{ - type: :POINT, - # "Raw" geometry specification as per https://github.com/mapbox/vector-tile-spec/tree/master/2.1#43-geometry-encoding - geometry: [9, 128, 128] - }, + Feature.point([128, 128]), count: 42, size: "large" ) diff --git a/lib/vector_tile/coordinates.ex b/lib/vector_tile/coordinates.ex new file mode 100644 index 0000000..ebc167b --- /dev/null +++ b/lib/vector_tile/coordinates.ex @@ -0,0 +1,95 @@ +defmodule VectorTile.Coordinates do + @moduledoc """ + Functions for working with coordinates in vector tiles, including interpolation into a tile's internal coordinate + system and zigzag encoding of parameter integers. + """ + + @doc """ + Interpolates a point `[x, y]` to a [tile's internal coordinate + system](https://github.com/mapbox/vector-tile-spec/tree/master/2.1#43-geometry-encoding) based on bounding box and + extent. + + You can call this function with a `VectorTile.Tile` that has been initialized with a bounding box and an extent, or + provide a keyword list with these keys: + + - `:bbox` - list of four numbers representing `[west, south, east, north]` + - `:extent` - integer representing the tile's extent (default is 4096) + + ## Example + + iex> tile = VectorTile.Tile.new(bbox: [10, 0, 20, 10], extent: 4096) + iex> interpolate([15, 10], tile) + [2048, 0] + # x = 2048, because it's halfway between the east and west edges + # y = 0, because it's on the north edge of the tile's bounding box + + """ + @spec interpolate(coordinate :: list(integer()), tile :: VectorTile.Tile.t() | Keyword.t()) :: + list(integer()) + def interpolate([x, y] = _coordinate, opts) when is_number(x) and is_number(y) do + bbox = Access.fetch!(opts, :bbox) + extent = Access.get(opts, :extent, 4096) + + [ + interpolate_x(bbox, extent, x), + interpolate_y(bbox, extent, y) + ] + end + + defp interpolate_x([tile_w, _tile_s, tile_e, _tile_n], extent, x) do + if tile_e == tile_w, + do: 0, + else: ((x - tile_w) / (tile_e - tile_w) * extent) |> floor() + end + + defp interpolate_y([_tile_w, tile_s, _tile_e, tile_n], extent, y) do + if tile_n == tile_s, + do: 0, + else: ((tile_n - y) / (tile_n - tile_s) * extent) |> floor() + end + + @neg_max -1 * 2 ** 31 + @pos_max 2 ** 31 + + @doc """ + Zigzag encodes an + [*ParameterInteger*](https://github.com/mapbox/vector-tile-spec/tree/master/2.1#432-parameter-integers). + + Supports values greater than -2^31 and less than 2^31. + + ## Example + + iex> Coordinates.zigzag(0) + 0 + + iex> Coordinates.zigzag(1) + 2 + + iex> Coordinates.zigzag(-1) + 1 + + iex> Coordinates.zigzag(2) + 4 + + iex> Coordinates.zigzag(-2) + 3 + + iex> Coordinates.zigzag(2 ** 31 - 1) + 4294967294 + + iex> Coordinates.zigzag(-1 * (2 ** 31 - 1)) + 4294967293 + + iex> Coordinates.zigzag(2 ** 31) + ** (FunctionClauseError) no function clause matching in VectorTile.Coordinates.zigzag/1 + + iex> Coordinates.zigzag(-1 * 2 ** 31) + ** (FunctionClauseError) no function clause matching in VectorTile.Coordinates.zigzag/1 + """ + @spec zigzag(integer()) :: integer() + def zigzag(value) when is_integer(value) and @neg_max < value and value < @pos_max do + import Bitwise + + Bitwise.bxor(value <<< 1, value >>> 31) + end +end diff --git a/lib/vector_tile/feature.pb.ex b/lib/vector_tile/feature.pb.ex index ef6c55e..46e484e 100644 --- a/lib/vector_tile/feature.pb.ex +++ b/lib/vector_tile/feature.pb.ex @@ -6,54 +6,270 @@ defmodule VectorTile.Feature do Due to the way attributes are reused across a layer's features, don't manage `tags` directly and use [`Layer.add_feature/3`](`VectorTile.Layer.add_feature/3`) instead. - See [Geometry Encoding](https://github.com/mapbox/vector-tile-spec/tree/master/2.1#43-geometry-encoding) when defining - the geometry of a feature, as this library currently provides no helpers to do this except for `zigzag/1`. - - To produce a feature's geometry with known geo coordinates in WGS 84, the steps are generally as follows: + To produce a feature's geometry with geo coordinates in WGS 84, the steps are generally as follows: 1. Project the feature's coordinates & the tile's bounds to [Web Mercator](https://en.wikipedia.org/wiki/Web_Mercator_projection), e.g. using [SphericalMercator](https://hex.pm/packages/spherical_mercator). - 2. Perform a linear interpolation relative to the tile's boundaries into the 4096x4096 "pixel" grid. + 2. Perform a linear interpolation relative to the tile's boundaries into the 4096x4096 "pixel" grid. You can use the + `VectorTile.Coordinates.interpolate/2` function for this. 3. Choose the correct - [*CommandInteger*(s)](https://github.com/mapbox/vector-tile-spec/tree/master/2.1#431-command-integers) and - zigzag-encode the x and y coordinates - ([*ParameterInteger*](https://github.com/mapbox/vector-tile-spec/tree/master/2.1#432-parameter-integers)). + [*CommandInteger*](https://github.com/mapbox/vector-tile-spec/tree/master/2.1#431-command-integers), e.g. through + `command/2` and zigzag-encode the x and y coordinates + ([*ParameterInteger*](https://github.com/mapbox/vector-tile-spec/tree/master/2.1#432-parameter-integers)) using + `VectorTile.Coordinates.zigzag/1`. - ## Example + ## Geometry Helpers + + For some geometry types, helper functions are provided to create features from geometry structures. The geometry is + expected to be provided as a list of coordinate pairs, each as `[x, y]`. + + - `point/2` - creates a `:POINT` feature from a single point `[x, y]`. + - `multi_point/2` - creates a `:POINT` feature from multiple points `[[x1, y1], [x2, y2], ...]`. + - `polygon/2` - creates a `:POLYGON` feature from a single polygon `[[x1, y1], [x2, y2], ...]`. + - `multi_polygon/2` - creates a `:POLYGON` feature from multiple polygons `[[[x11, y11], [x12, y12], ...], [[x21, + y21], [x22, y22], ...], ...]`. + + All of these functions accept an optional `:project` option, which should be a function that takes a point `[x, y]` + and returns a projected point `[x', y']`. This can be used to perform coordinate system transformations before + encoding the geometry, without needing to manually project each point beforehand. + + Note that zigzag encoding and command integers are handled automatically by these helper functions. + + #### Example + + iex> tile = VectorTile.Tile.new(bbox: [10, 0, 20, 10], extent: 4096) + ...> multi_point( + ...> [ + ...> [15, 5], + ...> [16, 6] + ...> ], + ...> project: &VectorTile.Coordinates.interpolate(&1, tile) + ...> ) + %Feature{ + type: :POINT, + geometry: [17, 4096, 4096, 818, 819] + } + + ## Raw Geometry Example + + Construct a feature in accordance with [Geometry + Encoding](https://github.com/mapbox/vector-tile-spec/tree/master/2.1#43-geometry-encoding) from raw geometry commands + and coordinates relative to the tile's coordinate system: - iex> import VectorTile.Feature - iex> feature = %VectorTile.Feature{ + iex> %Feature{ ...> type: :POINT, ...> geometry: [ ...> 9, # MoveTo command with 1 following pair of coordinates - ...> zigzag(250), - ...> zigzag(500) + ...> VectorTile.Coordinates.zigzag(250), + ...> VectorTile.Coordinates.zigzag(500) ...> ] ...> } - %VectorTile.Feature{} + %Feature{ + type: :POINT, + geometry: [9, 500, 1000] + } """ use Protobuf, protoc_gen_elixir_version: "0.15.0", syntax: :proto2 + import Bitwise, only: [<<<: 2, |||: 2, &&&: 2] + field :id, 1, optional: true, type: :uint64, default: 0 field :tags, 2, repeated: true, type: :uint32, packed: true, deprecated: false field :type, 3, optional: true, type: VectorTile.GeomType, default: :UNKNOWN, enum: true field :geometry, 4, repeated: true, type: :uint32, packed: true, deprecated: false - @neg_max -1 * 2 ** 31 - @pos_max 2 ** 31 + @doc """ + Creates a Feature of type `:POINT` from a single point `[x, y]`. + + `x` and `y` must be integers in the tile's coordinate system (usually 0..4096). Use the `:project` option to provide a + projection function if your input coordinates are in a different system. + + ## Example + + iex> point([25, 17]) + %Feature{ + type: :POINT, + geometry: [9, 50, 34] + } + """ + def point([x, y] = point, opts \\ []) when is_number(x) and is_number(y), + do: multi_point([point], opts) + + @doc """ + Creates a Feature of type `:POINT` with multiple geometries from a list of points `[[x1, y1], [x2, y2], ...]`. + + `x` and `y` must be integers in the tile's coordinate system (usually 0..4096). Use the `:project` option to provide a + projection function if your input coordinates are in a different system. + + ## Example + + iex> multi_point([[5, 7], [3, 2]]) + %Feature{ + type: :POINT, + geometry: [17, 10, 14, 3, 9] + } + """ + def multi_point(points, opts \\ []) + + def multi_point([[x, y] | _] = points, opts) when is_number(x) and is_number(y) do + {geometry, _cursor} = + Enum.flat_map_reduce(points, {0, 0}, fn point, {last_x, last_y} -> + [x, y] = project(point, opts) + + coordinates = [ + zigzag(x - last_x), + zigzag(y - last_y) + ] + + {coordinates, {x, y}} + end) + + struct(__MODULE__, %{ + type: :POINT, + geometry: [ + command(:move_to, Enum.count(points)) | geometry + ] + }) + end + + @doc """ + Creates a Feature of type `:POLYGON` from a single polygon `[[x1, y1], [x2, y2], ...]`. + + `x` and `y` must be integers in the tile's coordinate system (usually 0..4096). Use the `:project` option to provide a + projection function if your input coordinates are in a different system. + + The polygon ring must be clockwise (to be an exterior ring) as per [Polygon Geometry + Type](https://github.com/mapbox/vector-tile-spec/tree/master/2.1#4344-polygon-geometry-type). This function does not + validate the winding order of the ring. + + The ring should **not** repeat the starting point at the end. + + ## Example + + iex> polygon([[3, 6], [8, 12], [20, 34]]) + %Feature{ + type: :POLYGON, + geometry: [9, 6, 12, 18, 10, 12, 24, 44, 15] + } + """ + def polygon([[x, y] | _] = polygon, opts \\ []) when is_number(x) and is_number(y), + do: multi_polygon([polygon], opts) + + @doc """ + Creates a Feature of type `:POLYGON` from multiple polygons `[[[x11, y11], [x12, y12], ...], [[x21, y21], [x22, y22], + ...], ...]`. + + `x` and `y` must be integers in the tile's coordinate system (usually 0..4096). Use the `:project` option to provide a + projection function if your input coordinates are in a different system. + + The first polygon ring must be clockwise (to be an exterior ring) as per [Polygon Geometry + Type](https://github.com/mapbox/vector-tile-spec/tree/master/2.1#4344-polygon-geometry-type). Following rings can also + be counter-clockwise (to be interior rings). This function does not validate the winding order of the rings. + + All rings should **not** repeat the starting point at the end. + + ## Example + + iex> multi_polygon([ + ...> [[11, 11], [20, 11], [20, 20], [11, 20]], + ...> [[13, 13], [13, 17], [17, 17], [17, 13]] + ...> ]) + %Feature{ + type: :POLYGON, + geometry: [9, 22, 22, 26, 18, 0, 0, 18, 17, 0, 15, 9, 4, 13, 26, 0, 8, 8, 0, 0, 7, 15] + } + """ + def multi_polygon(polygons, opts \\ []) + + def multi_polygon([[[x, y] | _] | _] = polygons, opts) when is_number(x) and is_number(y) do + {geometry, _cursor} = + Enum.flat_map_reduce(polygons, {0, 0}, fn ring, cursor -> + draw_ring(ring, cursor, opts) + end) + + struct(__MODULE__, %{ + type: :POLYGON, + geometry: geometry + }) + end + + defp draw_ring([point | other_points], {last_x, last_y}, opts) do + [x, y] = project(point, opts) + + move_to = [ + command(:move_to), + zigzag(x - last_x), + zigzag(y - last_y), + command(:line_to, Enum.count(other_points)) + ] + + {line_positions, cursor} = + Enum.flat_map_reduce(other_points, {x, y}, fn point, {last_x, last_y} -> + [x, y] = project(point, opts) + + coordinates = [ + zigzag(x - last_x), + zigzag(y - last_y) + ] + + {coordinates, {x, y}} + end) + + close_path = [command(:close_path)] + + {move_to ++ line_positions ++ close_path, cursor} + end + + # Command Integers: https://github.com/mapbox/vector-tile-spec/tree/master/2.1#431-command-integers + @cmd_move_to 1 + @cmd_line_to 2 + @cmd_close_path 7 + + @doc """ + Builds a [*CommandInteger*](https://github.com/mapbox/vector-tile-spec/tree/master/2.1#431-command-integers) for the + given command and parameter count. + + - The available commands are `:move_to`, `:line_to`, and `:close_path`. + - `count` must be a non-negative integer and defaults to `1`. The `:close_path` command only supports a count of `1`. + + ## Example + + iex> command(:move_to) + 9 + + iex> command(:line_to, 3) + 26 + + iex> command(:close_path) + 15 + """ + @spec command(:move_to | :line_to | :close_path, non_neg_integer()) :: integer() + def command(command, count \\ 1) + + def command(:move_to, count), do: count <<< 3 ||| (@cmd_move_to &&& 0x7) + def command(:line_to, count), do: count <<< 3 ||| (@cmd_line_to &&& 0x7) + def command(:close_path, 1), do: 1 <<< 3 ||| (@cmd_close_path &&& 0x7) + + defp project(point, opts) do + case Keyword.fetch(opts, :project) do + {:ok, projection_fn} -> + projection_fn.(point) + + :error -> + point + end + end @doc """ Zigzag encodes an [*ParameterInteger*](https://github.com/mapbox/vector-tile-spec/tree/master/2.1#432-parameter-integers). Supports values greater than -2^31 and less than 2^31. - """ - @spec zigzag(integer()) :: integer() - def zigzag(value) when is_integer(value) and @neg_max < value and value < @pos_max do - import Bitwise - Bitwise.bxor(value <<< 1, value >>> 31) - end + Deprecated: Use `VectorTile.Coordinates.zigzag/1` instead. + """ + @deprecated "Use VectorTile.Coordinates.zigzag/1 instead" + defdelegate zigzag(value), to: VectorTile.Coordinates end diff --git a/lib/vector_tile/tile.pb.ex b/lib/vector_tile/tile.pb.ex index 7044066..34c8446 100644 --- a/lib/vector_tile/tile.pb.ex +++ b/lib/vector_tile/tile.pb.ex @@ -22,4 +22,34 @@ defmodule VectorTile.Tile do field :layers, 3, repeated: true, type: VectorTile.Layer extensions [{16, 8192}] + + @doc """ + Creates a new `VectorTile.Tile` struct. + + The properties provided in `opts` are set on the struct. While they are not used for encoding the tile, they can be + useful for coordinate calculations with the `VectorTile.Coordinates` module. + + ## Options + + * `:bbox` - The bounding box of the tile as `[west, south, east, north]`. Optional. + * `:extent` - The extent of the tile. Optional, defaults to `4096`. + + """ + def new(opts \\ []) do + struct(__MODULE__, opts) + |> Map.put(:extent, Keyword.get(opts, :extent, 4096)) + |> Map.put(:bbox, Keyword.get(opts, :bbox, nil)) + end + + # Implement Access behaviour to allow accessing fields like a map. + @behaviour Access + + @impl Access + defdelegate fetch(v, key), to: Map + + @impl Access + defdelegate get_and_update(v, key, func), to: Map + + @impl Access + defdelegate pop(v, key), to: Map end diff --git a/mix.exs b/mix.exs index d6e7e3d..bc63a30 100644 --- a/mix.exs +++ b/mix.exs @@ -14,9 +14,6 @@ protobuf.", elixir: "~> 1.18", start_permanent: Mix.env() == :prod, deps: deps(), - preferred_cli_env: [ - "test.watch": :test - ], # Docs name: "VectorTile", @@ -26,6 +23,10 @@ protobuf.", ] end + def cli do + [preferred_envs: ["test.watch": :test]] + end + defp docs do [ main: "readme", diff --git a/test/vector_tile/coordinates_test.exs b/test/vector_tile/coordinates_test.exs new file mode 100644 index 0000000..e5bf3dd --- /dev/null +++ b/test/vector_tile/coordinates_test.exs @@ -0,0 +1,29 @@ +defmodule VectorTile.CoordinatesTest do + use ExUnit.Case, async: true + + alias VectorTile.Coordinates + + doctest(Coordinates, import: true) + + describe "zigzag/1" do + test "supports specified number range" do + assert Coordinates.zigzag(0) == 0 + assert Coordinates.zigzag(1) == 2 + assert Coordinates.zigzag(-1) == 1 + assert Coordinates.zigzag(2) == 4 + assert Coordinates.zigzag(-2) == 3 + assert Coordinates.zigzag(3) == 6 + assert Coordinates.zigzag(-3) == 5 + assert Coordinates.zigzag(2 ** 31 - 1) == 2 ** 32 - 2 + assert Coordinates.zigzag(-1 * (2 ** 31 - 1)) == 2 ** 32 - 3 + + assert_raise FunctionClauseError, fn -> + Coordinates.zigzag(2 ** 31) + end + + assert_raise FunctionClauseError, fn -> + Coordinates.zigzag(-1 * 2 ** 31) + end + end + end +end diff --git a/test/vector_tile/feature_test.exs b/test/vector_tile/feature_test.exs index 8b7ecd9..ff04221 100644 --- a/test/vector_tile/feature_test.exs +++ b/test/vector_tile/feature_test.exs @@ -3,25 +3,5 @@ defmodule VectorTile.FeatureTest do alias VectorTile.Feature - describe "zigzag/1" do - test "supports specified number range" do - assert Feature.zigzag(0) == 0 - assert Feature.zigzag(1) == 2 - assert Feature.zigzag(-1) == 1 - assert Feature.zigzag(2) == 4 - assert Feature.zigzag(-2) == 3 - assert Feature.zigzag(3) == 6 - assert Feature.zigzag(-3) == 5 - assert Feature.zigzag(2 ** 31 - 1) == 2 ** 32 - 2 - assert Feature.zigzag(-1 * (2 ** 31 - 1)) == 2 ** 32 - 3 - - assert_raise FunctionClauseError, fn -> - Feature.zigzag(2 ** 31) - end - - assert_raise FunctionClauseError, fn -> - Feature.zigzag(-1 * 2 ** 31) - end - end - end + doctest(Feature, import: true) end