Current section
Files
Jump to
Current section
Files
lib/yog/layout/geometry.ex
defmodule Yog.Layout.Geometry do
@moduledoc """
Geometry helpers for converting center-based node positions to bounding rectangles,
computing anchor points on rectangle edges, and deriving connector endpoints for edges.
These utilities are designed to support rendering pipelines that need to convert
abstract layout coordinates into concrete geometric primitives for drawing.
"""
@doc """
Converts a center-based position map to a map of bounding rectangles.
Each entry `node_id => {cx, cy}` becomes `node_id => {left_x, top_y, width, height}`,
where `left_x = cx - w/2` and `top_y = cy - h/2`.
## Options
* `:size` - Either a `{w, h}` tuple applied uniformly to all nodes, or an arity-2
function `fn node_id, {cx, cy} -> {w, h} end` for per-node sizes.
Defaults to `{1.0, 1.0}`.
## Examples
iex> Yog.Layout.Geometry.rects(%{a: {10.0, 20.0}}, size: {4.0, 2.0})
%{a: {8.0, 19.0, 4.0, 2.0}}
"""
@spec rects(%{any() => {float(), float()}}, keyword()) ::
%{any() => {float(), float(), float(), float()}}
def rects(positions, opts \\ []) do
size_opt = Keyword.get(opts, :size, {1.0, 1.0})
Map.new(positions, fn {node_id, {cx, cy}} ->
{w, h} =
case size_opt do
{w, h} -> {w * 1.0, h * 1.0}
fun when is_function(fun, 2) -> fun.(node_id, {cx, cy})
end
{node_id, {cx - w / 2.0, cy - h / 2.0, w, h}}
end)
end
@doc """
Returns a point on the edge of a bounding rectangle.
The rectangle is given as `{x, y, w, h}` where `x` and `y` are the top-left corner.
## Directions
* `:top` — top-center `{x + w/2, y}`
* `:bottom` — bottom-center `{x + w/2, y + h}`
* `:left` — left-center `{x, y + h/2}`
* `:right` — right-center `{x + w, y + h/2}`
* `:top_left` — `{x, y}`
* `:top_right` — `{x + w, y}`
* `:bottom_left` — `{x, y + h}`
* `:bottom_right` — `{x + w, y + h}`
* `:center` — `{x + w/2, y + h/2}`
## Examples
iex> Yog.Layout.Geometry.anchor({10.0, 20.0, 4.0, 2.0}, :right)
{14.0, 21.0}
iex> Yog.Layout.Geometry.anchor({10.0, 20.0, 4.0, 2.0}, :center)
{12.0, 21.0}
iex> Yog.Layout.Geometry.anchor({10.0, 20.0, 4.0, 2.0}, :top)
{12.0, 20.0}
iex> Yog.Layout.Geometry.anchor({10.0, 20.0, 4.0, 2.0}, :bottom)
{12.0, 22.0}
iex> Yog.Layout.Geometry.anchor({10.0, 20.0, 4.0, 2.0}, :left)
{10.0, 21.0}
"""
@spec anchor({float(), float(), float(), float()}, atom()) :: {float(), float()}
def anchor({x, y, w, h}, direction) do
case direction do
:top -> {x + w / 2.0, y}
:bottom -> {x + w / 2.0, y + h}
:left -> {x, y + h / 2.0}
:right -> {x + w, y + h / 2.0}
:top_left -> {x, y}
:top_right -> {x + w, y}
:bottom_left -> {x, y + h}
:bottom_right -> {x + w, y + h}
:center -> {x + w / 2.0, y + h / 2.0}
end
end
@doc """
Returns connector endpoints for a list of `{from_id, to_id}` edge pairs.
Without `:node_size`, endpoints are the node centers. With `:node_size`, endpoints
are clipped to the closest cardinal side midpoint of each node's bounding rectangle.
## Cardinal side selection
Given `dx = to_cx - from_cx` and `dy = to_cy - from_cy`:
* If `dx == 0` and `dy == 0`: returns centers (overlapping nodes degrade gracefully).
* If `abs(dx) >= abs(dy)`: horizontal dominance.
From-node uses `:right` if `dx > 0`, else `:left`.
To-node uses `:left` if `dx > 0`, else `:right`.
* Else: vertical dominance.
From-node uses `:bottom` if `dy > 0`, else `:top`.
To-node uses `:top` if `dy > 0`, else `:bottom`.
## Options
* `:node_size` — Either a `{w, h}` tuple or an arity-2 function
`fn node_id, {cx, cy} -> {w, h} end`. When provided, endpoints are clipped
to rect edges; otherwise raw centers are returned.
## Examples
iex> Yog.Layout.Geometry.edge_endpoints(
...> %{a: {0.0, 0.0}, b: {10.0, 0.0}},
...> [{:a, :b}],
...> node_size: {2.0, 2.0}
...> )
[{{1.0, 0.0}, {9.0, 0.0}}]
"""
@spec edge_endpoints(
%{any() => {float(), float()}},
[{any(), any()}],
keyword()
) :: [{{float(), float()}, {float(), float()}}]
def edge_endpoints(positions, edges, opts \\ []) do
node_size = Keyword.get(opts, :node_size)
rects_map =
if node_size do
rects(positions, size: node_size)
else
nil
end
Enum.map(edges, fn {from_id, to_id} ->
{from_cx, from_cy} = Map.fetch!(positions, from_id)
{to_cx, to_cy} = Map.fetch!(positions, to_id)
dx = to_cx - from_cx
dy = to_cy - from_cy
if rects_map do
from_rect = Map.fetch!(rects_map, from_id)
to_rect = Map.fetch!(rects_map, to_id)
{from_dir, to_dir} = cardinal_directions(dx, dy)
from_point = anchor(from_rect, from_dir)
to_point = anchor(to_rect, to_dir)
{from_point, to_point}
else
{{from_cx, from_cy}, {to_cx, to_cy}}
end
end)
end
defp cardinal_directions(dx, dy) when dx == 0 and dy == 0, do: {:center, :center}
defp cardinal_directions(dx, dy) when abs(dx) >= abs(dy) do
if dx > 0 do
{:right, :left}
else
{:left, :right}
end
end
defp cardinal_directions(_dx, dy) do
if dy > 0 do
{:bottom, :top}
else
{:top, :bottom}
end
end
end