defmodule Meridian.Geometry do
@moduledoc """
Pure-geometric helpers operating on `Geo` structs and `Yog.Graph` nodes.
These functions are CRS-agnostic: they work with whatever coordinates you
give them. When you need earth-aware distances, use `Meridian.CRS`.
"""
@doc """
Computes the bounding envelope of all node geometries in a graph.
Returns a `%Geo.Polygon{}` representing the bounding box, or `nil` if
no geometries are present.
## Examples
iex> g = Meridian.Graph.new()
iex> g = g
...> |> Meridian.Graph.add_node(:a, %{geometry: %Geo.Point{coordinates: {0.0, 0.0}}})
...> |> Meridian.Graph.add_node(:b, %{geometry: %Geo.Point{coordinates: {2.0, 3.0}}})
iex> %Geo.Polygon{} = Meridian.Geometry.envelope(Meridian.Graph.to_yog(g))
"""
@spec envelope(Yog.Graph.t()) :: Geo.Polygon.t() | nil
def envelope(%Yog.Graph{nodes: nodes}) when map_size(nodes) == 0, do: nil
def envelope(%Yog.Graph{nodes: nodes}) do
coords =
Enum.flat_map(nodes, fn
{_id, %{geometry: %Geo.Point{coordinates: {x, y}}}} -> [{x, y}]
{_id, %{geometry: %Geo.Polygon{coordinates: rings}}} -> List.flatten(rings)
{_id, %{geometry: %Geo.LineString{coordinates: cs}}} -> cs
_ -> []
end)
case coords do
[] ->
nil
_ ->
{xs, ys} = Enum.unzip(coords)
min_x = Enum.min(xs)
max_x = Enum.max(xs)
min_y = Enum.min(ys)
max_y = Enum.max(ys)
%Geo.Polygon{
coordinates: [
[
{min_x, min_y},
{max_x, min_y},
{max_x, max_y},
{min_x, max_y},
{min_x, min_y}
]
]
}
end
end
@doc """
Euclidean distance between two points.
## Examples
iex> a = %Geo.Point{coordinates: {0.0, 0.0}}
iex> b = %Geo.Point{coordinates: {3.0, 4.0}}
iex> Meridian.Geometry.euclidean(a, b)
5.0
"""
@spec euclidean(Geo.Point.t(), Geo.Point.t()) :: float()
def euclidean(%Geo.Point{coordinates: {x1, y1}}, %Geo.Point{coordinates: {x2, y2}}) do
:math.sqrt(:math.pow(x2 - x1, 2) + :math.pow(y2 - y1, 2))
end
@doc """
Approximate length of a `Geo.LineString` in meters using the haversine formula.
Sums the great-circle distance of each segment.
## Examples
iex> line = %Geo.LineString{coordinates: [{0.0, 0.0}, {0.0, 1.0}]}
iex> len = Meridian.Geometry.geo_length(line)
iex> len > 110_000 and len < 112_000
true
"""
@spec geo_length(Geo.LineString.t()) :: float()
def geo_length(%Geo.LineString{coordinates: coords}) when length(coords) < 2, do: 0.0
def geo_length(%Geo.LineString{coordinates: coords}) do
coords
|> Enum.chunk_every(2, 1, :discard)
|> Enum.reduce(0.0, fn [{lon1, lat1}, {lon2, lat2}], acc ->
acc + Geocalc.distance_between([lat1, lon1], [lat2, lon2])
end)
end
@doc """
Returns true if `point` lies inside `polygon`.
Uses a simple ray-casting algorithm. Works for simple polygons.
## Examples
iex> poly = %Geo.Polygon{coordinates: [[{0,0},{10,0},{10,10},{0,10},{0,0}]]}
iex> Meridian.Geometry.contains?(poly, %Geo.Point{coordinates: {5, 5}})
true
iex> Meridian.Geometry.contains?(poly, %Geo.Point{coordinates: {15, 5}})
false
"""
@spec contains?(Geo.Polygon.t(), Geo.Point.t()) :: boolean()
def contains?(%Geo.Polygon{coordinates: [ring | _]}, %Geo.Point{coordinates: {px, py}}) do
ring
|> Enum.chunk_every(2, 1, :discard)
|> Enum.reduce(false, fn [{x1, y1}, {x2, y2}], inside ->
if y1 > py != y2 > py and px < (x2 - x1) * (py - y1) / (y2 - y1) + x1 do
not inside
else
inside
end
end)
end
@doc """
Computes the centroid of a `Geo.Polygon` as a `Geo.Point`.
Uses the arithmetic mean of vertices (not area-weighted).
## Examples
iex> poly = %Geo.Polygon{coordinates: [[{0,0},{10,0},{10,10},{0,10},{0,0}]]}
iex> centroid = Meridian.Geometry.centroid(poly)
iex> centroid.coordinates
{5.0, 5.0}
"""
@spec centroid(Geo.Polygon.t()) :: Geo.Point.t()
def centroid(%Geo.Polygon{coordinates: [ring | _]}) do
# Drop the closing vertex (last point repeats first)
points = Enum.drop(ring, -1)
{xs, ys} = Enum.unzip(points)
count = length(xs)
%Geo.Point{
coordinates: {Enum.sum(xs) / count, Enum.sum(ys) / count}
}
end
end