• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

code-shoily / choreo / 17b14252751eb2cfad891ce2a504c01050e94f0e

26 Apr 2026 08:17PM UTC coverage: 88.482% (-4.0%) from 92.503%
17b14252751eb2cfad891ce2a504c01050e94f0e

push

github

code-shoily
Add customization on rendering params

88 of 168 new or added lines in 8 files covered. (52.38%)

7 existing lines in 7 files now uncovered.

1498 of 1693 relevant lines covered (88.48%)

18.67 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

75.64
/lib/choreo/decision_tree/render/dot.ex
1
defmodule Choreo.DecisionTree.Render.DOT do
2
  @moduledoc """
3
  DOT (Graphviz) rendering for `Choreo.DecisionTree`.
4

5
  Produces tree-oriented visualisation:
6

7
    * **Root** — diamond with double border
8
    * **Decisions** — diamonds
9
    * **Outcomes** — rounded rectangles with class colour coding
10

11
  Layout is top-down so decisions flow downward to outcomes.
12
  Edge labels show the branch condition.
13
  """
14

15
  alias Choreo.Theme
16

17
  @doc """
18
  Renders a decision tree to a DOT string.
19

20
  ## Options
21

22
    * `:theme` — `:default`, `:dark`, or a `Choreo.Theme` struct
23

24
  ## Examples
25

26
      iex> tree = Choreo.DecisionTree.new()
27
      iex> tree = tree
28
      ...>   |> Choreo.DecisionTree.set_root(:color, feature: "color")
29
      ...>   |> Choreo.DecisionTree.add_outcome(:stop, label: "Stop")
30
      ...>   |> Choreo.DecisionTree.add_outcome(:go, label: "Go")
31
      ...>   |> Choreo.DecisionTree.branch(:color, :stop, "red")
32
      ...>   |> Choreo.DecisionTree.branch(:color, :go, "green")
33
      iex> dot = Choreo.DecisionTree.Render.DOT.to_dot(tree)
34
      iex> String.contains?(dot, "digraph")
35
      true
36
      iex> String.contains?(dot, "red")
37
      true
38
      iex> String.contains?(dot, "green")
39
      true
40
  """
41
  @spec to_dot(Choreo.DecisionTree.t(), keyword()) :: String.t()
42
  def to_dot(%Choreo.DecisionTree{} = tree, opts \\ []) do
43
    theme = resolve_theme(Keyword.get(opts, :theme, :default))
4✔
44

45
    base_opts =
4✔
46
      Yog.Render.DOT.default_options()
47
      |> Map.put(:rankdir, :tb)
48
      |> Map.put(:splines, :spline)
49
      |> Map.put(:nodesep, 0.7)
50
      |> Map.put(:ranksep, 1.2)
51
      |> Map.put(:node_shape, :box)
52
      |> Map.put(:node_style, :filled)
53
      |> Map.put(:node_color, "white")
54
      |> Map.put(:node_fontname, theme.node_fontname)
4✔
55
      |> Map.put(:node_fontsize, theme.node_fontsize)
4✔
56
      |> Map.put(:node_fontcolor, theme.node_fontcolor)
4✔
57
      |> Map.put(:edge_color, theme.edge_color)
4✔
58
      |> Map.put(:edge_fontname, theme.edge_fontname)
4✔
59
      |> Map.put(:edge_fontsize, theme.edge_fontsize)
4✔
60
      |> Map.put(:edge_penwidth, theme.edge_penwidth)
4✔
61
      |> Map.put(:arrowhead, :normal)
62
      |> Map.put(:node_label, &node_label/2)
63
      |> Map.put(:edge_label, &edge_label/1)
64
      |> Map.put(:node_attributes, node_attributes_fn(theme))
65
      |> Map.put(:edge_attributes, edge_attributes_fn(tree, theme))
66
      |> Map.merge(theme_graph_overrides(theme))
67
      |> Map.merge(Map.new(opts))
68

69
    dot = Yog.Render.DOT.to_dot(tree.graph, base_opts)
4✔
70

71
    # Force same-rank alignment for siblings at each depth level
72
    rank_groups = build_rank_groups(tree)
4✔
73

74
    if rank_groups != "" do
4✔
75
      inject_before_closing(dot, rank_groups)
3✔
76
    else
77
      dot
1✔
78
    end
79
  end
80

81
  # ============================================================================
82
  # Theme helpers
83
  # ============================================================================
84

85
  defp resolve_theme(%Theme{} = theme), do: theme
×
86
  defp resolve_theme(:default), do: default_tree_theme()
3✔
87
  defp resolve_theme(:dark), do: dark_tree_theme()
1✔
NEW
88
  defp resolve_theme(:warm), do: warm_tree_theme()
×
NEW
89
  defp resolve_theme(:forest), do: forest_tree_theme()
×
NEW
90
  defp resolve_theme(:ocean), do: ocean_tree_theme()
×
UNCOV
91
  defp resolve_theme(_), do: default_tree_theme()
×
92

93
  defp warm_tree_theme do
NEW
94
    %Theme{
×
95
      name: :tree_warm,
96
      colors: %{
97
        root: "#ec4899",
98
        decision: "#f97316",
99
        outcome: "#10b981"
100
      },
101
      node_fontname: "Helvetica",
102
      node_fontsize: 12,
103
      node_fontcolor: "white",
104
      edge_color: "#78716c",
105
      edge_fontname: "Helvetica",
106
      edge_fontsize: 10,
107
      edge_penwidth: 1.0,
108
      graph_bgcolor: "#fef2f2"
109
    }
110
  end
111

112
  defp forest_tree_theme do
NEW
113
    %Theme{
×
114
      name: :tree_forest,
115
      colors: %{
116
        root: "#15803d",
117
        decision: "#166534",
118
        outcome: "#84cc16"
119
      },
120
      node_fontname: "Helvetica",
121
      node_fontsize: 12,
122
      node_fontcolor: "white",
123
      edge_color: "#4b5563",
124
      edge_fontname: "Helvetica",
125
      edge_fontsize: 10,
126
      edge_penwidth: 1.0,
127
      graph_bgcolor: "#f0fdf4"
128
    }
129
  end
130

131
  defp ocean_tree_theme do
NEW
132
    %Theme{
×
133
      name: :tree_ocean,
134
      colors: %{
135
        root: "#1d4ed8",
136
        decision: "#0284c7",
137
        outcome: "#0ea5e9"
138
      },
139
      node_fontname: "Helvetica",
140
      node_fontsize: 12,
141
      node_fontcolor: "white",
142
      edge_color: "#64748b",
143
      edge_fontname: "Helvetica",
144
      edge_fontsize: 10,
145
      edge_penwidth: 1.0,
146
      graph_bgcolor: "#f0f9ff"
147
    }
148
  end
149

150
  defp default_tree_theme do
151
    %Theme{
3✔
152
      name: :tree_default,
153
      colors: %{
154
        root: "#8b5cf6",
155
        decision: "#3b82f6",
156
        outcome: "#10b981"
157
      },
158
      node_fontname: "Helvetica",
159
      node_fontsize: 12,
160
      node_fontcolor: "white",
161
      edge_color: "#64748b",
162
      edge_fontname: "Helvetica",
163
      edge_fontsize: 10,
164
      edge_penwidth: 1.0,
165
      graph_bgcolor: nil
166
    }
167
  end
168

169
  defp dark_tree_theme do
170
    %Theme{
1✔
171
      name: :tree_dark,
172
      colors: %{
173
        root: "#7c3aed",
174
        decision: "#2563eb",
175
        outcome: "#059669"
176
      },
177
      node_fontname: "Helvetica",
178
      node_fontsize: 12,
179
      node_fontcolor: "#e2e8f0",
180
      edge_color: "#94a3b8",
181
      edge_fontname: "Helvetica",
182
      edge_fontsize: 10,
183
      edge_penwidth: 1.0,
184
      graph_bgcolor: "#0f172a"
185
    }
186
  end
187

188
  defp tree_color(%Theme{colors: colors}, key) do
189
    Map.get(colors, key, "#64748b")
11✔
190
  end
191

192
  defp theme_graph_overrides(%Theme{graph_bgcolor: nil}), do: %{}
3✔
193
  defp theme_graph_overrides(%Theme{graph_bgcolor: bg}), do: %{bgcolor: bg}
1✔
194

195
  # ============================================================================
196
  # Rank groups (same-level alignment)
197
  # ============================================================================
198

199
  defp build_rank_groups(tree) do
200
    tree
201
    |> nodes_by_depth()
202
    |> Enum.reject(fn {_depth, nodes} -> length(nodes) <= 1 end)
8✔
203
    |> Enum.map_join("\n", fn {_depth, nodes} ->
4✔
204
      node_list = Enum.map_join(nodes, "; ", &safe_id/1)
3✔
205
      "  { rank=same; #{node_list}; }"
3✔
206
    end)
207
  end
208

209
  defp nodes_by_depth(tree) do
210
    do_nodes_by_depth(tree, tree.root, 0, %{})
4✔
211
  end
212

213
  defp do_nodes_by_depth(_tree, nil, _depth, acc), do: acc
×
214

215
  defp do_nodes_by_depth(tree, id, depth, acc) do
216
    acc = Map.update(acc, depth, [id], &[id | &1])
11✔
217

218
    children = Yog.successor_ids(tree.graph, id)
11✔
219

220
    Enum.reduce(children, acc, fn child, acc ->
11✔
221
      do_nodes_by_depth(tree, child, depth + 1, acc)
7✔
222
    end)
223
  end
224

225
  defp inject_before_closing(dot, extra) do
226
    String.replace(dot, ~r/\n\}\z/, "\n#{extra}\n}")
3✔
227
  end
228

229
  # ============================================================================
230
  # Node styling
231
  # ============================================================================
232

233
  defp node_attributes_fn(theme) do
234
    fn _id, data ->
4✔
235
      base =
11✔
236
        case Map.get(data, :node_type, :decision) do
237
          :root ->
4✔
238
            [
239
              {:shape, :diamond},
240
              {:fillcolor, tree_color(theme, :root)},
241
              {:fontcolor, theme.node_fontcolor},
4✔
242
              {:style, "filled"},
243
              {:penwidth, 2.0}
244
            ]
245

246
          :decision ->
×
247
            [
248
              {:shape, :diamond},
249
              {:fillcolor, tree_color(theme, :decision)},
NEW
250
              {:fontcolor, theme.node_fontcolor},
×
251
              {:style, "filled"}
252
            ]
253

254
          :outcome ->
7✔
255
            [
256
              {:shape, :box},
257
              {:style, "rounded,filled"},
258
              {:fontcolor, theme.node_fontcolor},
7✔
259
              {:fillcolor, tree_color(theme, :outcome)}
260
            ]
261

262
          _ ->
×
263
            [
264
              {:shape, :ellipse},
265
              {:fillcolor, tree_color(theme, :decision)},
NEW
266
              {:fontcolor, theme.node_fontcolor},
×
267
              {:style, "filled"}
268
            ]
269
        end
270

271
      base =
11✔
272
        if shape = data[:shape], do: [{:shape, shape} | Keyword.delete(base, :shape)], else: base
11✔
273

274
      base =
11✔
NEW
275
        if color = data[:fillcolor],
×
276
          do: [{:fillcolor, color} | Keyword.delete(base, :fillcolor)],
277
          else: base
11✔
278

279
      base =
11✔
NEW
280
        if fontcolor = data[:fontcolor],
×
281
          do: [{:fontcolor, fontcolor} | Keyword.delete(base, :fontcolor)],
282
          else: base
11✔
283

284
      base =
11✔
285
        if style = data[:style], do: [{:style, style} | Keyword.delete(base, :style)], else: base
11✔
286

287
      base =
11✔
NEW
288
        if penwidth = data[:penwidth],
×
289
          do: [{:penwidth, penwidth} | Keyword.delete(base, :penwidth)],
290
          else: base
11✔
291

292
      if desc = data[:description] do
11✔
293
        [{:tooltip, desc} | base]
294
      else
295
        base
11✔
296
      end
297
    end
298
  end
299

300
  defp node_label(_id, data) do
301
    label = data[:label] || ""
11✔
302

303
    if prob = data[:probability] do
11✔
304
      "#{label}\n(#{:erlang.float_to_binary(prob, decimals: 2)})"
×
305
    else
306
      label
11✔
307
    end
308
  end
309

310
  # ============================================================================
311
  # Edge styling
312
  # ============================================================================
313

314
  defp edge_attributes_fn(tree, theme) do
315
    fn from, to, _weight ->
4✔
316
      meta = Map.get(tree.edge_meta, {from, to}, %{})
7✔
317

318
      base = [{:color, theme.edge_color}, {:fontcolor, theme.edge_color}]
7✔
319

320
      base = if label = meta[:label], do: [{:label, label} | base], else: base
7✔
321

322
      base
7✔
323
    end
324
  end
325

326
  defp edge_label(_), do: ""
7✔
327

328
  defp safe_id(id) when is_atom(id), do: Atom.to_string(id)
6✔
329
  defp safe_id(id) when is_binary(id), do: id
×
330
  defp safe_id(id), do: inspect(id)
×
331
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc