• 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

64.2
/lib/choreo/workflow/render/dot.ex
1
defmodule Choreo.Workflow.Render.DOT do
2
  @moduledoc """
3
  DOT (Graphviz) rendering for `Choreo.Workflow` orchestration diagrams.
4

5
  Produces workflow-oriented visualisation:
6

7
    * **Start** — green circle (entry point)
8
    * **End** — red double-circle (terminal)
9
    * **Task** — 3D boxes (automated steps)
10
    * **Decision** — diamond (conditional branch)
11
    * **Fork** — inverted house (parallel split)
12
    * **Join** — house (parallel merge)
13
    * **Compensation** — note shape with red dashed border (rollback)
14
    * **Event** — cloud (trigger / signal)
15

16
  Edge styles:
17
    * **Sequence** — solid grey
18
    * **Compensation** — dashed red
19
    * **Retry** — dotted orange
20
    * **Failure** — dashed grey
21
    * **Timeout** — dashdot
22

23
  Swimlanes are rendered as subgraph clusters with optional fill colours.
24

25
  ## Further reading
26

27
    * [BPMN 2.0 Specification](https://www.omg.org/spec/BPMN/2.0/)
28
    * [Workflow Patterns Initiative](http://www.workflowpatterns.com/)
29
  """
30

31
  alias Choreo.Theme
32

33
  @doc """
34
  Renders a workflow to a DOT string.
35

36
  ## Options
37

38
    * `:theme` — `:default`, `:dark`, or a `Choreo.Theme` struct
39

40
  ## Examples
41

42
      iex> workflow = Choreo.Workflow.new()
43
      iex> workflow = workflow
44
      ...>   |> Choreo.Workflow.add_start(:start)
45
      ...>   |> Choreo.Workflow.add_task(:process)
46
      ...>   |> Choreo.Workflow.add_end(:end)
47
      ...>   |> Choreo.Workflow.connect(:start, :process)
48
      ...>   |> Choreo.Workflow.connect(:process, :end)
49
      iex> dot = Choreo.Workflow.Render.DOT.to_dot(workflow)
50
      iex> String.contains?(dot, "digraph")
51
      true
52
      iex> String.contains?(dot, "process")
53
      true
54
  """
55
  @spec to_dot(Choreo.Workflow.t(), keyword()) :: String.t()
56
  def to_dot(%Choreo.Workflow{} = workflow, opts \\ []) do
57
    theme = resolve_theme(Keyword.get(opts, :theme, :default))
4✔
58
    subgraphs = Choreo.Internal.build_cluster_subgraphs(workflow, theme)
4✔
59

60
    base_opts =
4✔
61
      Yog.Render.DOT.default_options()
62
      |> Map.put(:rankdir, :tb)
63
      |> Map.put(:splines, :spline)
64
      |> Map.put(:nodesep, 0.6)
65
      |> Map.put(:ranksep, 1.2)
66
      |> Map.put(:node_shape, :box)
67
      |> Map.put(:node_style, :filled)
68
      |> Map.put(:node_color, "white")
69
      |> Map.put(:node_fontname, theme.node_fontname)
4✔
70
      |> Map.put(:node_fontsize, theme.node_fontsize)
4✔
71
      |> Map.put(:node_fontcolor, theme.node_fontcolor)
4✔
72
      |> Map.put(:edge_color, theme.edge_color)
4✔
73
      |> Map.put(:edge_fontname, theme.edge_fontname)
4✔
74
      |> Map.put(:edge_fontsize, theme.edge_fontsize)
4✔
75
      |> Map.put(:edge_penwidth, theme.edge_penwidth)
4✔
76
      |> Map.put(:arrowhead, :normal)
77
      |> Map.put(:node_label, &node_label/2)
78
      |> Map.put(:edge_label, fn _edge_id, weight -> edge_label(weight) end)
7✔
79
      |> Map.put(:node_attributes, node_attributes_fn(theme))
80
      |> Map.put(:edge_attributes, edge_attributes_fn(workflow))
81
      |> Map.merge(theme_graph_overrides(theme))
82
      |> Map.merge(Map.new(opts))
83

84
    base_opts = if subgraphs != [], do: Map.put(base_opts, :subgraphs, subgraphs), else: base_opts
4✔
85

86
    Yog.Multi.DOT.to_dot(workflow.graph, base_opts)
4✔
87
  end
88

89
  # ============================================================================
90
  # Theme helpers
91
  # ============================================================================
92

93
  defp resolve_theme(%Theme{} = theme), do: theme
×
94
  defp resolve_theme(:default), do: default_workflow_theme()
3✔
95
  defp resolve_theme(:dark), do: dark_workflow_theme()
1✔
NEW
96
  defp resolve_theme(:warm), do: warm_workflow_theme()
×
NEW
97
  defp resolve_theme(:forest), do: forest_workflow_theme()
×
NEW
98
  defp resolve_theme(:ocean), do: ocean_workflow_theme()
×
UNCOV
99
  defp resolve_theme(_), do: default_workflow_theme()
×
100

101
  defp warm_workflow_theme do
NEW
102
    %Theme{
×
103
      name: :workflow_warm,
104
      colors: %{
105
        start: "#10b981",
106
        end: "#ef4444",
107
        task: "#f97316",
108
        decision: "#fbbf24",
109
        fork: "#ea580c",
110
        join: "#ea580c",
111
        compensation: "#f43f5e",
112
        event: "#db2777"
113
      },
114
      node_fontname: "Helvetica",
115
      node_fontsize: 12,
116
      node_fontcolor: "white",
117
      edge_color: "#78716c",
118
      edge_fontname: "Helvetica",
119
      edge_fontsize: 10,
120
      edge_penwidth: 1.0,
121
      graph_bgcolor: "#fef2f2",
122
      cluster_fillcolor: "#fee2e2",
123
      cluster_style: :rounded,
124
      cluster_color: "#fca5a5"
125
    }
126
  end
127

128
  defp forest_workflow_theme do
NEW
129
    %Theme{
×
130
      name: :workflow_forest,
131
      colors: %{
132
        start: "#84cc16",
133
        end: "#ef4444",
134
        task: "#15803d",
135
        decision: "#65a30d",
136
        fork: "#166534",
137
        join: "#166534",
138
        compensation: "#047857",
139
        event: "#14b8a6"
140
      },
141
      node_fontname: "Helvetica",
142
      node_fontsize: 12,
143
      node_fontcolor: "white",
144
      edge_color: "#4b5563",
145
      edge_fontname: "Helvetica",
146
      edge_fontsize: 10,
147
      edge_penwidth: 1.0,
148
      graph_bgcolor: "#f0fdf4",
149
      cluster_fillcolor: "#dcfce7",
150
      cluster_style: :rounded,
151
      cluster_color: "#86efac"
152
    }
153
  end
154

155
  defp ocean_workflow_theme do
NEW
156
    %Theme{
×
157
      name: :workflow_ocean,
158
      colors: %{
159
        start: "#0ea5e9",
160
        end: "#ef4444",
161
        task: "#1d4ed8",
162
        decision: "#0891b2",
163
        fork: "#0369a1",
164
        join: "#0369a1",
165
        compensation: "#0e7490",
166
        event: "#2563eb"
167
      },
168
      node_fontname: "Helvetica",
169
      node_fontsize: 12,
170
      node_fontcolor: "white",
171
      edge_color: "#64748b",
172
      edge_fontname: "Helvetica",
173
      edge_fontsize: 10,
174
      edge_penwidth: 1.0,
175
      graph_bgcolor: "#f0f9ff",
176
      cluster_fillcolor: "#e0f2fe",
177
      cluster_style: :rounded,
178
      cluster_color: "#7dd3fc"
179
    }
180
  end
181

182
  defp default_workflow_theme do
183
    %Theme{
3✔
184
      name: :workflow_default,
185
      colors: %{
186
        start: "#10b981",
187
        end: "#ef4444",
188
        task: "#3b82f6",
189
        decision: "#8b5cf6",
190
        fork: "#f59e0b",
191
        join: "#f59e0b",
192
        compensation: "#f87171",
193
        event: "#64748b"
194
      },
195
      node_fontname: "Helvetica",
196
      node_fontsize: 12,
197
      node_fontcolor: "white",
198
      edge_color: "#64748b",
199
      edge_fontname: "Helvetica",
200
      edge_fontsize: 10,
201
      edge_penwidth: 1.0,
202
      graph_bgcolor: nil,
203
      cluster_fillcolor: "#f8fafc",
204
      cluster_style: :rounded,
205
      cluster_color: "#cbd5e1"
206
    }
207
  end
208

209
  defp dark_workflow_theme do
210
    %Theme{
1✔
211
      name: :workflow_dark,
212
      colors: %{
213
        start: "#059669",
214
        end: "#dc2626",
215
        task: "#2563eb",
216
        decision: "#7c3aed",
217
        fork: "#d97706",
218
        join: "#d97706",
219
        compensation: "#ef4444",
220
        event: "#475569"
221
      },
222
      node_fontname: "Helvetica",
223
      node_fontsize: 12,
224
      node_fontcolor: "#e2e8f0",
225
      edge_color: "#94a3b8",
226
      edge_fontname: "Helvetica",
227
      edge_fontsize: 10,
228
      edge_penwidth: 1.0,
229
      graph_bgcolor: "#0f172a",
230
      cluster_fillcolor: "#1e293b",
231
      cluster_style: :rounded,
232
      cluster_color: "#475569"
233
    }
234
  end
235

236
  defp wf_color(%Theme{colors: colors}, key) do
237
    Map.get(colors, key, "#64748b")
11✔
238
  end
239

240
  defp theme_graph_overrides(%Theme{graph_bgcolor: nil}), do: %{}
3✔
241
  defp theme_graph_overrides(%Theme{graph_bgcolor: bg}), do: %{bgcolor: bg}
1✔
242

243
  # ============================================================================
244
  # Node styling
245
  # ============================================================================
246

247
  defp node_attributes_fn(theme) do
248
    fn _id, data ->
4✔
249
      base =
11✔
250
        case Map.get(data, :node_type, :task) do
251
          :start ->
4✔
252
            [
253
              {:shape, :circle},
254
              {:fillcolor, wf_color(theme, :start)},
255
              {:fontcolor, theme.node_fontcolor},
4✔
256
              {:style, "filled"},
257
              {:penwidth, 2.0}
258
            ]
259

260
          :end ->
4✔
261
            [
262
              {:shape, :doublecircle},
263
              {:fillcolor, wf_color(theme, :end)},
264
              {:fontcolor, theme.node_fontcolor},
4✔
265
              {:style, "filled"},
266
              {:penwidth, 2.0}
267
            ]
268

269
          :task ->
3✔
270
            [
271
              {:shape, :box3d},
272
              {:fillcolor, wf_color(theme, :task)},
273
              {:fontcolor, theme.node_fontcolor},
3✔
274
              {:style, "filled"}
275
            ]
276

277
          :decision ->
×
278
            [
279
              {:shape, :diamond},
280
              {:fillcolor, wf_color(theme, :decision)},
NEW
281
              {:fontcolor, theme.node_fontcolor},
×
282
              {:style, "filled"}
283
            ]
284

285
          :fork ->
×
286
            [
287
              {:shape, :invhouse},
288
              {:fillcolor, wf_color(theme, :fork)},
NEW
289
              {:fontcolor, theme.node_fontcolor},
×
290
              {:style, "filled"}
291
            ]
292

293
          :join ->
×
294
            [
295
              {:shape, :house},
296
              {:fillcolor, wf_color(theme, :join)},
NEW
297
              {:fontcolor, theme.node_fontcolor},
×
298
              {:style, "filled"}
299
            ]
300

301
          :compensation ->
×
302
            [
303
              {:shape, :note},
304
              {:fillcolor, wf_color(theme, :compensation)},
NEW
305
              {:fontcolor, theme.node_fontcolor},
×
306
              {:style, "filled,dashed"},
307
              {:color, "#ef4444"}
308
            ]
309

310
          :event ->
×
311
            [
312
              {:shape, :cloud},
313
              {:fillcolor, wf_color(theme, :event)},
NEW
314
              {:fontcolor, theme.node_fontcolor},
×
315
              {:style, "filled"}
316
            ]
317

318
          _ ->
×
319
            [
320
              {:shape, :box},
321
              {:fillcolor, wf_color(theme, :task)},
NEW
322
              {:fontcolor, theme.node_fontcolor},
×
323
              {:style, "filled"}
324
            ]
325
        end
326

327
      base =
11✔
328
        if shape = data[:shape], do: [{:shape, shape} | Keyword.delete(base, :shape)], else: base
11✔
329

330
      base =
11✔
NEW
331
        if color = data[:fillcolor],
×
332
          do: [{:fillcolor, color} | Keyword.delete(base, :fillcolor)],
333
          else: base
11✔
334

335
      base =
11✔
NEW
336
        if fontcolor = data[:fontcolor],
×
337
          do: [{:fontcolor, fontcolor} | Keyword.delete(base, :fontcolor)],
338
          else: base
11✔
339

340
      base =
11✔
341
        if style = data[:style], do: [{:style, style} | Keyword.delete(base, :style)], else: base
11✔
342

343
      base =
11✔
NEW
344
        if penwidth = data[:penwidth],
×
345
          do: [{:penwidth, penwidth} | Keyword.delete(base, :penwidth)],
346
          else: base
11✔
347

348
      if desc = data[:description] do
11✔
349
        [{:tooltip, desc} | base]
350
      else
351
        base
11✔
352
      end
353
    end
354
  end
355

356
  defp node_label(_id, data) do
357
    label = data[:label] || ""
11✔
358

359
    label =
11✔
360
      if timeout = data[:timeout_ms] do
361
        "#{label}\n(#{timeout}ms)"
×
362
      else
363
        label
11✔
364
      end
365

366
    if retry = data[:retry] do
11✔
367
      "#{label}\nretry: #{retry}"
×
368
    else
369
      label
11✔
370
    end
371
  end
372

373
  # ============================================================================
374
  # Edge styling
375
  # ============================================================================
376

377
  defp edge_attributes_fn(workflow) do
378
    fn _from, _to, edge_id, _weight ->
4✔
379
      meta = Map.get(workflow.edge_meta, edge_id, %{})
7✔
380

381
      base = edge_type_attrs(meta[:edge_type] || :sequence)
7✔
382

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

385
      base
7✔
386
    end
387
  end
388

389
  defp edge_type_attrs(:compensation) do
×
390
    [{:color, "#ef4444"}, {:fontcolor, "#ef4444"}, {:penwidth, 1.5}, {:style, "dashed"}]
391
  end
392

393
  defp edge_type_attrs(:retry) do
×
394
    [{:color, "#f97316"}, {:fontcolor, "#f97316"}, {:penwidth, 1.5}, {:style, "dotted"}]
395
  end
396

397
  defp edge_type_attrs(:failure) do
×
398
    [{:color, "#9ca3af"}, {:fontcolor, "#9ca3af"}, {:penwidth, 1.2}, {:style, "dashed"}]
399
  end
400

401
  defp edge_type_attrs(:timeout) do
×
402
    [{:color, "#eab308"}, {:fontcolor, "#eab308"}, {:penwidth, 1.2}, {:style, "dashed"}]
403
  end
404

405
  defp edge_type_attrs(_) do
7✔
406
    [{:color, "#64748b"}, {:fontcolor, "#64748b"}, {:penwidth, 1.0}, {:style, "solid"}]
407
  end
408

409
  defp edge_label(_), do: ""
7✔
410
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