Pathfinding — click to draw walls

BFS · 0 walls · 0 visited · idle

Code

Pathfinding module · lib/phx_demo/examples/pathfinding.ex
defmodule PhxDemo.Examples.Pathfinding do
  @cols 56
  @rows 34
  @cell 16

  def cols, do: @cols
  def rows, do: @rows
  def cell, do: @cell
  def width, do: @cols * @cell
  def height, do: @rows * @cell

  def init do
    start = {2, 2}
    goal = {@cols - 3, @rows - 3}

    %{
      walls: MapSet.new(),
      start: start,
      goal: goal,
      mode: :bfs,
      open: :queue.new(),
      visited: MapSet.new(),
      came: %{},
      g: %{},
      frontier: MapSet.new(),
      path: MapSet.new(),
      running: false,
      found: false
    }
    |> reset_search()
  end

  def set_mode(state, mode) when mode in [:bfs, :dfs, :astar, :greedy],
    do: %{state | mode: mode} |> reset_search()

  def toggle_wall(state, x, y) do
    cell = {x, y}

    cond do
      cell == state.start or cell == state.goal -> state
      MapSet.member?(state.walls, cell) -> %{state | walls: MapSet.delete(state.walls, cell)}
      true -> %{state | walls: MapSet.put(state.walls, cell)}
    end
  end

  def clear_walls(state) do
    mode = state[:mode] || :bfs
    %{state | walls: MapSet.new(), mode: mode} |> reset_search()
  end

  def random_walls(state) do
    walls =
      for y <- 0..(@rows - 1),
          x <- 0..(@cols - 1),
          :rand.uniform() < 0.23,
          {x, y} != state.start,
          {x, y} != state.goal,
          into: MapSet.new() do
        {x, y}
      end

    mode = state[:mode] || :bfs
    %{state | walls: walls, mode: mode} |> reset_search()
  end

  def start_search(state), do: %{reset_search(state) | running: true}

  def stop_search(state), do: %{state | running: false}

  def tick(%{running: false} = state), do: state
  def tick(%{found: true} = state), do: %{state | running: false}

  def tick(state) do
    Enum.reduce_while(1..40, state, fn _, acc ->
      step(acc)
      |> case do
        %{running: true} = next -> {:cont, next}
        next -> {:halt, next}
      end
    end)
  end

  def render_background do
    Easel.new(width(), height())
    |> Easel.set_fill_style("#0b1020")
    |> Easel.fill_rect(0, 0, width(), height())
    |> Easel.render()
  end

  def render(state) do
    wall_instances = Enum.map(state.walls, &to_inst(&1, "#334155"))
    visited_instances = Enum.map(state.visited, &to_inst(&1, "rgba(59,130,246,0.35)"))
    frontier_instances = Enum.map(state.frontier, &to_inst(&1, "#f59e0b"))
    path_instances = Enum.map(state.path, &to_inst(&1, "#22c55e"))

    points = [to_inst(state.start, "#22d3ee"), to_inst(state.goal, "#ef4444")]

    Easel.new(width(), height())
    |> Easel.template(:cell, fn c -> c |> Easel.fill_rect(0, 0, @cell - 1, @cell - 1) end)
    |> Easel.instances(:cell, visited_instances)
    |> Easel.instances(:cell, wall_instances)
    |> Easel.instances(:cell, frontier_instances)
    |> Easel.instances(:cell, path_instances)
    |> Easel.instances(:cell, points)
    |> Easel.render()
  end

  defp step(state) do
    case open_pop(state) do
      :empty ->
        %{state | running: false}

      {current, open2} ->
        frontier2 = MapSet.delete(state.frontier, current)

        cond do
          MapSet.member?(state.visited, current) ->
            %{state | open: open2, frontier: frontier2}

          current == state.goal ->
            %{
              state
              | open: open2,
                frontier: frontier2,
                running: false,
                found: true,
                path: build_path(state.came, state.goal)
            }

          true ->
            visited2 = MapSet.put(state.visited, current)
            current_g = Map.get(state.g, current, 0)

            {open3, came3, g3, frontier3} =
              neighbors(current)
              |> Enum.reject(&blocked?(state, &1))
              |> Enum.reduce({open2, state.came, state.g, frontier2}, fn n, {o, c, g, f} ->
                maybe_enqueue(state, n, current, current_g, visited2, o, c, g, f)
              end)

            %{state | open: open3, visited: visited2, came: came3, g: g3, frontier: frontier3}
        end
    end
  end

  defp maybe_enqueue(%{mode: mode}, n, current, current_g, visited, open, came, g, frontier)
       when mode in [:bfs, :dfs] do
    if MapSet.member?(visited, n) or MapSet.member?(frontier, n) do
      {open, came, g, frontier}
    else
      open2 = open_put(mode, open, n, 0)
      {open2, Map.put(came, n, current), Map.put(g, n, current_g + 1), MapSet.put(frontier, n)}
    end
  end

  defp maybe_enqueue(
         %{mode: :astar, goal: goal},
         n,
         current,
         current_g,
         visited,
         open,
         came,
         g,
         frontier
       ) do
    if MapSet.member?(visited, n) do
      {open, came, g, frontier}
    else
      tentative = current_g + 1

      if tentative < Map.get(g, n, 1_000_000) do
        f_score = tentative + heuristic(n, goal)

        {
          open_put(:astar, open, n, f_score),
          Map.put(came, n, current),
          Map.put(g, n, tentative),
          MapSet.put(frontier, n)
        }
      else
        {open, came, g, frontier}
      end
    end
  end

  defp maybe_enqueue(
         %{mode: :greedy, goal: goal},
         n,
         current,
         current_g,
         visited,
         open,
         came,
         g,
         frontier
       ) do
    if MapSet.member?(visited, n) or MapSet.member?(frontier, n) do
      {open, came, g, frontier}
    else
      {
        open_put(:greedy, open, n, heuristic(n, goal)),
        Map.put(came, n, current),
        Map.put(g, n, current_g + 1),
        MapSet.put(frontier, n)
      }
    end
  end

  defp reset_search(state) do
    start = state.start
    mode = state[:mode] || :bfs

    %{
      state
      | mode: mode,
        open: open_put(mode, open_empty(mode), start, 0),
        visited: MapSet.new(),
        came: %{},
        g: %{start => 0},
        frontier: MapSet.new([start]),
        path: MapSet.new(),
        found: false,
        running: false
    }
  end

  defp open_empty(:bfs), do: :queue.new()
  defp open_empty(:dfs), do: []
  defp open_empty(:astar), do: []
  defp open_empty(:greedy), do: []

  defp open_put(:bfs, open, node, _priority), do: :queue.in(node, open)
  defp open_put(:dfs, open, node, _priority), do: [node | open]
  defp open_put(:astar, open, node, priority), do: [{node, priority} | open]
  defp open_put(:greedy, open, node, priority), do: [{node, priority} | open]

  defp open_pop(%{mode: :bfs, open: open}) do
    case :queue.out(open) do
      {:empty, _} -> :empty
      {{:value, node}, open2} -> {node, open2}
    end
  end

  defp open_pop(%{mode: :dfs, open: []}), do: :empty
  defp open_pop(%{mode: :dfs, open: [node | rest]}), do: {node, rest}

  defp open_pop(%{mode: :astar, open: []}), do: :empty

  defp open_pop(%{mode: :astar, open: open}) do
    best = Enum.min_by(open, fn {_node, f} -> f end)
    {elem(best, 0), List.delete(open, best)}
  end

  defp open_pop(%{mode: :greedy, open: []}), do: :empty

  defp open_pop(%{mode: :greedy, open: open}) do
    best = Enum.min_by(open, fn {_node, h} -> h end)
    {elem(best, 0), List.delete(open, best)}
  end

  defp heuristic({x, y}, {gx, gy}), do: abs(x - gx) + abs(y - gy)

  defp build_path(came, goal) do
    Stream.unfold(goal, fn
      nil -> nil
      node -> {node, Map.get(came, node)}
    end)
    |> MapSet.new()
  end

  defp blocked?(state, {x, y}) do
    x < 0 or x >= @cols or y < 0 or y >= @rows or MapSet.member?(state.walls, {x, y})
  end

  defp neighbors({x, y}), do: [{x + 1, y}, {x - 1, y}, {x, y + 1}, {x, y - 1}]

  defp to_inst({x, y}, fill), do: %{x: x * @cell, y: y * @cell, fill: fill}
end
Pathfinding LiveView · lib/phx_demo_web/live/pathfinding_live.ex
defmodule PhxDemoWeb.PathfindingLive do
  use PhxDemoWeb, :live_view

  def mount(_params, _session, socket) do
    state = PhxDemo.Examples.Pathfinding.init()

    socket =
      socket
      |> assign(:state, state)
      |> assign(:background, PhxDemo.Examples.Pathfinding.render_background())
      |> assign(:canvas, PhxDemo.Examples.Pathfinding.render(state))
      |> Easel.LiveView.animate(
        "fg",
        :state,
        fn state ->
          next = PhxDemo.Examples.Pathfinding.tick(state)
          {PhxDemo.Examples.Pathfinding.render(next), next}
        end,
        interval: 33,
        canvas_assign: :canvas
      )

    {:ok, socket}
  end

  def handle_info({:easel_tick, id}, socket), do: {:noreply, Easel.LiveView.tick(socket, id)}

  def handle_event("fg:click", %{"x" => x, "y" => y}, socket) do
    cx = div(round(x), PhxDemo.Examples.Pathfinding.cell())
    cy = div(round(y), PhxDemo.Examples.Pathfinding.cell())
    state = PhxDemo.Examples.Pathfinding.toggle_wall(socket.assigns.state, cx, cy)
    {:noreply, assign(socket, :state, state)}
  end

  def handle_event("start", _, socket),
    do: {:noreply, update(socket, :state, &PhxDemo.Examples.Pathfinding.start_search/1)}

  def handle_event("stop", _, socket),
    do: {:noreply, update(socket, :state, &PhxDemo.Examples.Pathfinding.stop_search/1)}

  def handle_event("clear", _, socket),
    do: {:noreply, update(socket, :state, &PhxDemo.Examples.Pathfinding.clear_walls/1)}

  def handle_event("random", _, socket),
    do: {:noreply, update(socket, :state, &PhxDemo.Examples.Pathfinding.random_walls/1)}

  def handle_event("mode", %{"mode" => mode}, socket) do
    mode =
      case mode do
        "bfs" -> :bfs
        "dfs" -> :dfs
        "astar" -> :astar
        "greedy" -> :greedy
        _ -> :bfs
      end

    {:noreply, update(socket, :state, &PhxDemo.Examples.Pathfinding.set_mode(&1, mode))}
  end

  def render(assigns) do
    ~H"""
    <.demo title="Pathfinding — click to draw walls" code_id="pathfinding">
      <div class="flex flex-wrap gap-2 mb-3">
        <button phx-click="start" class="px-3 py-1 border rounded text-sm">Start</button>
        <button phx-click="stop" class="px-3 py-1 border rounded text-sm">Stop</button>
        <button phx-click="random" class="px-3 py-1 border rounded text-sm">Random walls</button>
        <button phx-click="clear" class="px-3 py-1 border rounded text-sm">Clear</button>

        <button
          phx-click="mode"
          phx-value-mode="bfs"
          class={[
            "px-3 py-1 border rounded text-sm",
            @state.mode == :bfs && "bg-blue-100 border-blue-400"
          ]}
        >
          BFS
        </button>
        <button
          phx-click="mode"
          phx-value-mode="dfs"
          class={[
            "px-3 py-1 border rounded text-sm",
            @state.mode == :dfs && "bg-blue-100 border-blue-400"
          ]}
        >
          DFS
        </button>
        <button
          phx-click="mode"
          phx-value-mode="astar"
          class={[
            "px-3 py-1 border rounded text-sm",
            @state.mode == :astar && "bg-blue-100 border-blue-400"
          ]}
        >
          A*
        </button>
        <button
          phx-click="mode"
          phx-value-mode="greedy"
          class={[
            "px-3 py-1 border rounded text-sm",
            @state.mode == :greedy && "bg-blue-100 border-blue-400"
          ]}
        >
          Greedy
        </button>
      </div>

      <Easel.LiveView.canvas_stack id="path" width={@background.width} height={@background.height}>
        <:layer id="bg" ops={@background.ops} />
        <:layer id="fg" ops={@canvas.ops} templates={@canvas.templates} on_click />
      </Easel.LiveView.canvas_stack>

      <p class="text-sm text-gray-500 mt-2">
        {String.upcase(to_string(@state.mode))} · {MapSet.size(@state.walls)} walls · {MapSet.size(
          @state.visited
        )} visited · {if @state.found,
          do: "path found",
          else: if(@state.running, do: "searching", else: "idle")}
      </p>
    </.demo>
    """
  end
end