yog/builder/grid

A builder for creating graphs from 2D grids.

This module provides convenient ways to convert 2D grids (like heightmaps, mazes, or game boards) into graphs for pathfinding and traversal algorithms.

Choosing the Right Distance Heuristic

For optimal A* pathfinding, use the heuristic that matches your topology:

Example

import yog/builder/grid
import yog/model.{Directed}
import yog/traversal.{BreadthFirst}

pub fn main() {
  // A simple heightmap where you can only climb up by 1
  let heightmap = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
  ]

  // Build a graph where edges exist only if height diff <= 1
  let grid = grid.from_2d_list(
    heightmap,
    Directed,
    can_move: fn(from_height, to_height) {
      to_height - from_height <= 1
    }
  )

  // Convert to graph and use with algorithms
  let graph = grid.to_graph(grid)
  let start = grid.coord_to_id(0, 0, grid.cols)
  let goal = grid.coord_to_id(2, 2, grid.cols)

  traversal.walk_until(
    from: start,
    in: graph,
    using: BreadthFirst,
    until: fn(node) { node == goal }
  )
}

Types

A grid builder that wraps a graph and maintains grid dimensions.

The grid uses row-major ordering: node_id = row * cols + col

pub type Grid(cell_data, edge_data) {
  Grid(
    graph: model.Graph(cell_data, edge_data),
    rows: Int,
    cols: Int,
  )
}

Constructors

  • Grid(
      graph: model.Graph(cell_data, edge_data),
      rows: Int,
      cols: Int,
    )

    Arguments

    graph

    The underlying graph structure

    rows

    Number of rows in the grid

    cols

    Number of columns in the grid

Values

pub fn always() -> fn(cell_data, cell_data) -> Bool

Always allows movement between adjacent cells.

Every 4-directional neighbor pair gets an edge regardless of cell data. Useful for fully connected grids or when the cell data is purely informational (e.g., storing coordinates or labels).

Example

let labels = [["A", "B"], ["C", "D"]]

let g = grid.from_2d_list(labels, model.Undirected, can_move: grid.always())
// All adjacent cells are connected
pub fn avoiding(
  wall_value: cell_data,
) -> fn(cell_data, cell_data) -> Bool

Allows movement between any cells except the specified wall value.

Useful for maze-style grids where "#" or similar marks a wall. Both the source and destination cells must not be the wall value.

Example

// Maze where "#" is impassable
let maze = [
  [".", "#", "."],
  [".", ".", "."],
  ["#", "#", "."],
]

let g = grid.from_2d_list(maze, model.Directed, can_move: grid.avoiding("#"))
// Edges only connect non-wall cells
pub fn bishop() -> List(#(Int, Int))

Diagonal (4-way) movement: the four diagonal directions.

Named after the bishop in chess, which moves along diagonals.

↖ . ↗
. · .
↙ . ↘
pub fn chebyshev_distance(
  from_id: Int,
  to_id: Int,
  cols: Int,
) -> Int

Calculates the Chebyshev distance between two node IDs.

This is the optimal heuristic for A* pathfinding on grids with 8-way (queen) movement, where diagonal moves have the same cost as orthogonal moves. Chebyshev distance is the maximum of absolute differences in coordinates: max(|x1 - x2|, |y1 - y2|)

Use this for: queen() topology, or any 8-directional movement

Example

let start = grid.coord_to_id(0, 0, 10)
let goal = grid.coord_to_id(3, 4, 10)
let distance = grid.chebyshev_distance(start, goal, 10)
// => 4 (max of 3 and 4)
pub fn coord_to_id(row: Int, col: Int, cols: Int) -> Int

Converts grid coordinates (row, col) to a node ID.

Uses row-major ordering: id = row * cols + col

Example

grid.coord_to_id(0, 0, 3)  // => 0
grid.coord_to_id(1, 2, 3)  // => 5
grid.coord_to_id(2, 1, 3)  // => 7
pub fn find_node(
  grid: Grid(cell_data, e),
  predicate: fn(cell_data) -> Bool,
) -> Result(Int, Nil)

Finds a node in the grid where the cell data matches a predicate.

Returns the node ID of the first matching cell, or Error(Nil) if not found.

Example

// Find the starting position marked with 'S'
case grid.find_node(grid, fn(cell) { cell == "S" }) {
  Ok(start_id) -> // Use start_id
  Error(_) -> // Not found
}
pub fn from_2d_list(
  grid_data: List(List(cell_data)),
  graph_type: model.GraphType,
  can_move can_move: fn(cell_data, cell_data) -> Bool,
) -> Grid(cell_data, Int)

Creates a graph from a 2D list using 4-directional (rook) movement.

Each cell becomes a node, and edges are added between adjacent cells (up/down/left/right) if the can_move predicate returns True. This is equivalent to from_2d_list_with_topology with rook().

Example

let heightmap = [[1, 2, 3], [2, 3, 4], [3, 4, 5]]

let g = grid.from_2d_list(
  heightmap,
  model.Directed,
  can_move: fn(from, to) { to - from <= 1 },
)

Time Complexity: O(rows × cols)

pub fn from_2d_list_with_topology(
  grid_data: List(List(cell_data)),
  graph_type: model.GraphType,
  topology: List(#(Int, Int)),
  can_move can_move: fn(cell_data, cell_data) -> Bool,
) -> Grid(cell_data, Int)

Creates a graph from a 2D list using a custom movement topology.

The topology parameter is a list of #(row_delta, col_delta) offsets that define which neighbors each cell can reach. Use the built-in presets — rook(), bishop(), queen(), knight() — or define your own.

Example

// 8-way movement (queen topology) on a maze
let maze = [[".", "#", "."], [".", ".", "."], ["#", ".", "."]]

let g = grid.from_2d_list_with_topology(
  maze,
  model.Directed,
  grid.queen(),
  can_move: grid.avoiding("#"),
)
// Knight jumps on a chessboard
let board = [
  [0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0],
]

let g = grid.from_2d_list_with_topology(
  board,
  model.Directed,
  grid.knight(),
  can_move: grid.always(),
)

Time Complexity: O(rows × cols × |topology|)

pub fn get_cell(
  grid: Grid(cell_data, e),
  row: Int,
  col: Int,
) -> Result(cell_data, Nil)

Gets the cell data at the specified grid coordinate.

Returns Ok(cell_data) if the coordinate is valid, Error(Nil) otherwise.

Example

case grid.get_cell(grid, 1, 2) {
  Ok(cell) -> // Use cell data
  Error(_) -> // Out of bounds
}
pub fn id_to_coord(id: Int, cols: Int) -> #(Int, Int)

Converts a node ID back to grid coordinates (row, col).

Example

grid.id_to_coord(0, 3)  // => #(0, 0)
grid.id_to_coord(5, 3)  // => #(1, 2)
grid.id_to_coord(7, 3)  // => #(2, 1)
pub fn including(
  valid_values: List(cell_data),
) -> fn(cell_data, cell_data) -> Bool

Allows movement only between cells matching any of the specified values.

A multi-value version of walkable. Both the source and destination cells must be included in the valid values list.

Example

// Grid where both "." and "P" are walkable
let terrain = [
  [".", "P", "#"],
  ["#", ".", "."],
]

let g = grid.from_2d_list(terrain, model.Directed, can_move: grid.including([".", "P"]))
// Edges exist between any combination of "." and "P"
pub fn knight() -> List(#(Int, Int))

L-shaped jumps in all 8 orientations.

Named after the knight in chess, which jumps in an L-shape (2 squares in one direction, 1 square perpendicular).

. ♞ . ♞ .
♞ . . . ♞
. . · . .
♞ . . . ♞
. ♞ . ♞ .
pub fn manhattan_distance(
  from_id: Int,
  to_id: Int,
  cols: Int,
) -> Int

Calculates the Manhattan distance between two node IDs.

This is useful as a heuristic for A* pathfinding on grids. Manhattan distance is the sum of absolute differences in coordinates: |x1 - x2| + |y1 - y2|

Example

let start = grid.coord_to_id(0, 0, 10)
let goal = grid.coord_to_id(3, 4, 10)
let distance = grid.manhattan_distance(start, goal, 10)
// => 7 (3 + 4)
pub fn octile_distance(
  from_id: Int,
  to_id: Int,
  cols: Int,
) -> Float

Calculates the Octile distance between two node IDs.

This is the optimal heuristic for A* pathfinding on grids with 8-way movement where diagonal moves cost √2 (approximately 1.414) and orthogonal moves cost 1. This represents true Euclidean-style movement on a grid.

The formula is: min(dx, dy) × √2 + |dx - dy|

Use this for: Weighted 8-directional movement with realistic diagonal costs

Example

let start = grid.coord_to_id(0, 0, 10)
let goal = grid.coord_to_id(3, 4, 10)
let distance = grid.octile_distance(start, goal, 10)
// => 5.242... (3 × √2 + 1)
pub fn queen() -> List(#(Int, Int))

All 8 surrounding directions: cardinal + diagonal.

Named after the queen in chess, which combines rook and bishop movement.

↖ ↑ ↗
← · →
↙ ↓ ↘
pub fn rook() -> List(#(Int, Int))

Cardinal (4-way) movement: up, down, left, right.

Named after the rook in chess, which moves along ranks and files. This is the default topology used by from_2d_list.

. ↑ .
← · →
. ↓ .
pub fn to_graph(
  grid: Grid(cell_data, e),
) -> model.Graph(cell_data, e)

Converts the grid to a standard Graph.

The resulting graph can be used with all yog algorithms.

Example

let graph = grid.to_graph(grid)
// Now use with pathfinding, traversal, etc.
pub fn walkable(
  valid_value: cell_data,
) -> fn(cell_data, cell_data) -> Bool

Allows movement only between cells matching the specified value.

The inverse of avoiding — instead of blacklisting one value, this whitelists exactly one value. Both the source and destination cells must match the valid value.

Example

// Grid with varied terrain — only "." is walkable
let terrain = [
  [".", "~", "^"],
  [".", ".", "^"],
  ["~", ".", "."],
]

let g = grid.from_2d_list(terrain, model.Directed, can_move: grid.walkable("."))
// Only "." → "." edges exist
Search Document