Current section

Files

Jump to
yog_ex lib yog render dot.ex
Raw

lib/yog/render/dot.ex

defmodule Yog.Render.DOT do
@moduledoc """
DOT (Graphviz) format export for visualizing graphs.
This module exports graphs to the [DOT language](https://graphviz.org/doc/info/lang.html),
which is the native format for [Graphviz](https://graphviz.org/) - a powerful open-source
graph visualization tool. The exported files can be rendered to PNG, SVG, PDF, and other
formats using the `dot`, `neato`, `circo`, or other Graphviz layout engines.
## Quick Start
# Export with default styling
dot_string = Yog.Render.DOT.to_dot(my_graph, Yog.Render.DOT.default_options())
# Write to file and render with Graphviz CLI
# $ dot -Tpng output.dot -o graph.png
## Customization
Use `t:options/0` to customize:
- Node labels and shapes
- Edge labels and styles
- Highlight specific nodes or paths
- Graph direction (LR, TB, etc.)
## Rendering Options
| Engine | Best For |
|--------|----------|
| `dot` | Hierarchical layouts (DAGs, trees) |
| `neato` | Spring-based layouts (undirected) |
| `circo` | Circular layouts |
| `fdp` | Force-directed layouts |
| `sfdp` | Large graphs |
## References
- [Graphviz Documentation](https://graphviz.org/documentation/)
- [DOT Language Guide](https://graphviz.org/doc/info/lang.html)
- [Node Shapes](https://graphviz.org/doc/info/shapes.html)
- [Arrow Styles](https://graphviz.org/doc/info/arrows.html)
"""
@typedoc "Graph direction (rank direction)"
@type rank_dir :: :tb | :lr | :bt | :rl
@typedoc "Node shapes"
@type node_shape ::
:box
| :circle
| :ellipse
| :diamond
| :hexagon
| :triangle
| :rectangle
@typedoc "Visual style"
@type style :: :solid | :dashed | :dotted | :bold | :filled | :rounded
@typedoc "Options for customizing DOT (Graphviz) diagram rendering"
@type options :: %{
node_label: (Yog.node_id(), any() -> String.t()),
edge_label: (any() -> String.t()),
highlighted_nodes: [Yog.node_id()] | nil,
highlighted_edges: [{Yog.node_id(), Yog.node_id()}] | nil,
graph_name: String.t(),
rankdir: rank_dir() | nil,
bgcolor: String.t() | nil,
node_shape: node_shape(),
node_color: String.t(),
node_style: style(),
node_fontname: String.t(),
node_fontsize: integer(),
edge_color: String.t(),
edge_style: style(),
highlight_color: String.t()
}
@doc """
Creates default DOT options with simple labeling and sensible styling.
Default configuration:
- Layout: Auto-detected by Graphviz
- Direction: Top-to-bottom
- Node shape: Ellipse
- Colors: Light blue nodes, black edges
- Font: Helvetica 12pt
## Examples
iex> opts = Yog.Render.DOT.default_options()
iex> opts.graph_name
"G"
iex> opts.node_shape
:ellipse
iex> opts.node_color
"lightblue"
"""
@spec default_options() :: options()
def default_options do
%{
node_label: fn id, _data -> Integer.to_string(id) end,
edge_label: fn weight -> to_string(weight) end,
highlighted_nodes: nil,
highlighted_edges: nil,
graph_name: "G",
rankdir: :tb,
bgcolor: nil,
node_shape: :ellipse,
node_color: "lightblue",
node_style: :filled,
node_fontname: "Helvetica",
node_fontsize: 12,
edge_color: "black",
edge_style: :solid,
highlight_color: "red"
}
end
@doc """
Converts a graph to DOT (Graphviz) syntax.
Works with any node data type and edge data type. Use `default_options/0`
to create appropriate options for your graph.
## Examples
iex> graph = Yog.directed()
...> |> Yog.add_node(1, "Start")
...> |> Yog.add_node(2, "End")
...> |> Yog.add_edge!(from: 1, to: 2, with: "5")
iex> dot = Yog.Render.DOT.to_dot(graph, Yog.Render.DOT.default_options())
iex> String.contains?(dot, "digraph G {")
true
iex> String.contains?(dot, "->")
true
# Undirected graph
iex> undirected = Yog.undirected()
...> |> Yog.add_node(1, "A")
...> |> Yog.add_node(2, "B")
...> |> Yog.add_edge!(from: 1, to: 2, with: "1")
iex> dot = Yog.Render.DOT.to_dot(undirected, Yog.Render.DOT.default_options())
iex> String.contains?(dot, "graph G {")
true
iex> String.contains?(dot, "--")
true
"""
@spec to_dot(Yog.graph(), options()) :: String.t()
def to_dot(graph, options) do
nodes = extract_nodes(graph)
edges = extract_edges(graph)
kind = extract_kind(graph)
{graph_type, arrow} =
if kind == :directed do
{"digraph", "->"}
else
{"graph", "--"}
end
header = "#{graph_type} #{options.graph_name} {"
graph_attrs = build_graph_attrs(options)
node_defaults = build_node_defaults(options)
edge_defaults = build_edge_defaults(options)
lines =
[header]
|> add_if_not_empty(graph_attrs)
|> List.insert_at(-1, node_defaults)
|> List.insert_at(-1, edge_defaults)
|> List.insert_at(-1, "")
node_lines = build_node_lines(nodes, options)
edge_lines = build_edge_lines(edges, options, arrow)
lines =
(lines ++ node_lines)
|> List.insert_at(-1, "")
|> Kernel.++(edge_lines)
|> List.insert_at(-1, "}")
Enum.join(lines, "\n")
end
defp extract_nodes(graph) do
case graph do
%{nodes: n} when is_map(n) -> n
{:graph, _, n, _, _} when is_map(n) -> n
_ -> %{}
end
end
defp extract_edges(graph) do
case graph do
%{out_edges: e} when is_map(e) -> e
{:graph, _, _, e, _} when is_map(e) -> e
_ -> %{}
end
end
defp extract_kind(graph) do
case graph do
%{kind: k} -> k
{:graph, k, _, _, _} -> k
_ -> :directed
end
end
defp build_graph_attrs(options) do
attrs =
[
options.rankdir && "rankdir=#{rankdir_to_string(options.rankdir)}",
options.bgcolor && "bgcolor=\"#{options.bgcolor}\""
]
|> Enum.reject(&is_nil/1)
if attrs != [] do
" graph [#{Enum.join(attrs, ", ")}];"
else
""
end
end
defp build_node_defaults(options) do
attrs = [
"shape=#{options.node_shape}",
"style=#{options.node_style}",
"fillcolor=\"#{options.node_color}\"",
"fontname=\"#{options.node_fontname}\"",
"fontsize=#{options.node_fontsize}"
]
" node [#{Enum.join(attrs, ", ")}];"
end
defp build_edge_defaults(options) do
attrs = [
"color=\"#{options.edge_color}\"",
"style=#{options.edge_style}"
]
" edge [#{Enum.join(attrs, ", ")}];"
end
defp build_node_lines(nodes, options) do
Enum.map(nodes, fn {id, data} ->
label = options.node_label.(id, data)
attrs = [{"label", label}]
attrs =
if options.highlighted_nodes && id in options.highlighted_nodes do
attrs ++ [{"color", options.highlight_color}, {"penwidth", "2"}]
else
attrs
end
attr_str = Enum.map_join(attrs, ", ", fn {k, v} -> "#{k}=\"#{v}\"" end)
" #{id} [#{attr_str}];"
end)
end
defp build_edge_lines(edges, options, arrow) do
Enum.flat_map(edges, fn {from, targets} ->
build_edge_lines_for_node(from, targets, options, arrow)
end)
end
defp build_edge_lines_for_node(from, targets, options, arrow) do
if is_map(targets) do
Enum.map(targets, fn {to, weight} ->
build_single_edge_line(from, to, weight, arrow, options)
end)
else
[]
end
end
defp build_single_edge_line(from, to, weight, arrow, options) do
label = options.edge_label.(weight)
attrs = if label != "" and label != "nil", do: [{"label", label}], else: []
attrs =
if options.highlighted_edges && {from, to} in options.highlighted_edges do
attrs ++ [{"color", options.highlight_color}, {"penwidth", "2"}]
else
attrs
end
if attrs == [] do
" #{from} #{arrow} #{to};"
else
attr_str = Enum.map_join(attrs, ", ", fn {k, v} -> "#{k}=\"#{v}\"" end)
" #{from} #{arrow} #{to} [#{attr_str}];"
end
end
defp add_if_not_empty(lines, ""), do: lines
defp add_if_not_empty(lines, line), do: lines ++ [line]
defp rankdir_to_string(:tb), do: "TB"
defp rankdir_to_string(:lr), do: "LR"
defp rankdir_to_string(:bt), do: "BT"
defp rankdir_to_string(:rl), do: "RL"
@doc """
Converts a shortest path result to highlighted DOT options.
Creates a copy of the base options with the path's nodes and edges
set to be highlighted. This is useful for visualizing algorithm results.
## Examples
iex> base_opts = Yog.Render.DOT.default_options()
iex> path = %{nodes: [1, 2, 3], weight: 10}
iex> highlighted_opts = Yog.Render.DOT.path_to_options(path, base_opts)
iex> highlighted_opts.highlighted_nodes
[1, 2, 3]
iex> highlighted_opts.highlighted_edges
[{1, 2}, {2, 3}]
"""
@spec path_to_options(map(), options()) :: options()
def path_to_options(path, base_options) do
nodes = path.nodes
edges = path_to_edges(nodes)
Map.merge(base_options, %{
highlighted_nodes: nodes,
highlighted_edges: edges
})
end
defp path_to_edges([]), do: []
defp path_to_edges([_]), do: []
defp path_to_edges([first, second | rest]) do
[{first, second} | path_to_edges([second | rest])]
end
end