lib/fountainedge/graph.ex

defmodule Fountainedge.Graph do
  @moduledoc """
  Graphing functions.
  """

  alias Fountainedge.Schema
  alias Fountainedge.Workflow

  @doc """
  Graphs a schema as a [UML](https://www.omg.org/spec/UML/)
  Activity Diagram using [Graphviz](https://graphviz.org/).

  If given a workflow, the graph will be decorated with stateful
  information such as the current node (or nodes.)

  The graph can then be compiled by Graphviz:

      Fountainedge.Graph.graph(workflow)
      |> Graphvix.Graph.compile("workflow_graph", :svg)

  """
  @spec graph(Workflow.t() | Schema.t()) :: Graphvix.Graph.t()
  def graph(%Workflow{} = workflow) do
    graph = Graphvix.Graph.new()
    {graph, vertices} = vertices(graph, workflow, [], workflow.schema.nodes)
    edges(graph, vertices, workflow.schema.edges)
  end

  def graph(%Schema{} = schema) do
    graph(%Workflow{schema: schema, states: []})
  end

  # https://graphviz.org/doc/info/shapes.html
  # https://graphviz.org/docs/nodes/
  defp vertices(%Graphvix.Graph{} = graph, %Workflow{} = workflow, vertices, [node | nodes]) do
    # Number of active states at the current node.
    active = Enum.count(workflow.states, fn s -> s.id == node.id end)

    {label, attributes} = cond do
      node.type in [:fork, :join] ->
        # Fork or join node.
        {
          nil,
          [
            id: node.id,
            shape: "box",
            style: "filled",
            fillcolor: "black",
            height: 0.1,
            width: 2,
            fixedsize: "true",
            xlabel: (if active > 0, do: String.duplicate("#", active), else: nil),
            fontcolor: "red",
          ]
        }
      node.type in [:initial, :final] && !node.label ->
        # Initial or final node without a label.
        {
          nil,
          [
            id: node.id,
            shape: (if node.type == :initial, do: "circle", else: "doublecircle"),
            style: "filled",
            fillcolor: (if active > 0, do: "red", else: "black"),
            height: (if node.type == :initial, do: 0.3, else: 0.2),
          ]
        }
      true ->
        # Decision node has more than one out edge.
        decision_node = Enum.count(workflow.schema.edges, fn e -> e.id == node.id end) > 1

        # Normal node (including initial and final.)
        {
          node.label || Integer.to_string(node.id),
          [
            id: node.id,
            shape: (cond do
              node.type in [:initial, :final] -> "oval"
              decision_node -> "diamond"
              true -> "box"
            end),
            color: (if active > 0, do: "red", else: "black"),
          ]
        }
    end

    # Apply custom node attributes.
    attributes = attributes ++ node.attributes

    {graph, vertex_id} = Graphvix.Graph.add_vertex(graph, label, attributes)
    vertices(%Graphvix.Graph{} = graph, workflow, [{node.id, vertex_id}] ++ vertices, nodes)
  end

  defp vertices(graph, %Workflow{} = _workflow, vertices, []), do: {graph, vertices}

  # https://graphviz.org/docs/edges/
  defp edges(graph, vertices, [edge | edges]) do
    {_, current} = List.keyfind(vertices, edge.id, 0)
    {_, next} = List.keyfind(vertices, edge.next, 0)

    {graph, _edge_id} = Graphvix.Graph.add_edge(graph, current, next, edge.attributes)
    edges(graph, vertices, edges)
  end

  defp edges(graph, _vertices, []), do: graph

  @doc """
  Ranks all nodes in a given schema.

  Will set the `rank` field on each `Fountainedge.Node` within the schema.

  Requires the `filename` of the dot file given to `Graphvix.Graph.compile/3`.

  Useful for determining backward and forward directions between two nodes.
  If the rank of the out edge node is less than the current node, then the
  direction is backwards. Otherwise, if greater, then the direction is forwards.

  [`dot`](https://graphviz.org/docs/layouts/dot/) creates hierarchical
  or layered drawings of directed graphs. A ranking algorithmn is used
  to determine this heirarchy. It may be useful to use these ranks
  when determining direction in a workflow. Call this function to
  calculate ranks per each node.
  """
  # Security warning: ensure all inputs to :os:cmd are sanitised.
  # TODO Think about https://hexdocs.pm/elixir/1.14/Path.html
  @spec rank(Schema.t() | Workflow.t(), String.t()) :: Schema.t()
  def rank(%Schema{} = schema, filename) do
    #port_dot = Port.open({:spawn, "dot -Tdot #{filename}.dot "}, [:binary])
    # port_gvpr = Port.open({:spawn, "gvpr -f rank.gvpr"}, [:binary])

    filename_rank_gvpr = to_string(:code.priv_dir(:fountainedge)) <> "/rank.gvpr"

    ranking = :os.cmd(:"dot -Tdot #{filename}.dot | gvpr -f #{filename_rank_gvpr}")
              |> to_string
              |> String.split("\n")
              |> Enum.map(fn line -> String.split line end)
              |> Enum.filter(fn row -> !Enum.empty? row end)
              |> Enum.map(fn row ->
                {id, _} = Integer.parse Enum.at(row, 1)
                {rank, _} = Integer.parse Enum.at(row, 2)
                node = Fountainedge.Node.find(schema.nodes, id)
                %{node | rank: rank}
              end)
              |> Enum.sort(&(&1.id < &2.id))

    put_in(schema.nodes, ranking)
  end

  def rank(%Workflow{} = workflow, filename) do
    %{workflow | schema: rank(workflow.schema, filename)}
  end
end