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

code-shoily / choreo / 9ec1e0b7b277b203a6aa1b290d96bffbb1d2b560

03 May 2026 08:44PM UTC coverage: 91.281% (+0.04%) from 91.237%
9ec1e0b7b277b203a6aa1b290d96bffbb1d2b560

push

github

code-shoily
Add test cases

2073 of 2271 relevant lines covered (91.28%)

19.05 hits per line

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

79.31
/lib/choreo/dataflow/render/dot.ex
1
defmodule Choreo.Dataflow.Render.DOT do
2
  @moduledoc """
3
  DOT (Graphviz) rendering for `Choreo.Dataflow` pipeline diagrams.
4

5
  Produces dataflow-oriented visualisation:
6

7
    * **Sources** — house shape (data enters)
8
    * **Sinks** — inverted house (data exits)
9
    * **Transforms** — 3D boxes (processing)
10
    * **Buffers** — cylinders (queues / topics)
11
    * **Conditionals** — diamonds (branching decisions)
12
    * **Merges** — trapezium (joining streams)
13

14
  Edge styles:
15
    * **Normal** — solid grey
16
    * **Error** — dashed red
17
    * **Retry** — dotted orange
18
    * **Dead letter** — dashed grey
19

20
  Layout is left-to-right by default so data flows from left to right.
21
  Clusters are rendered as bounded rectangles with optional fill colours.
22

23
  ## Further reading
24

25
    * [Dataflow Programming (Wikipedia)](https://en.wikipedia.org/wiki/Dataflow_programming)
26
    * [Enterprise Integration Patterns](https://www.enterpriseintegrationpatterns.com/)
27
  """
28

29
  alias Choreo.Theme
30

31
  @doc """
32
  Renders a dataflow to a DOT string.
33

34
  ## Options
35

36
    * `:theme` — `:default`, `:dark`, or a `Choreo.Theme` struct
37

38
  ## Examples
39

40
      iex> flow = Choreo.Dataflow.new()
41
      iex> flow = flow
42
      ...>   |> Choreo.Dataflow.add_source(:in, label: "Input")
43
      ...>   |> Choreo.Dataflow.add_transform(:proc, label: "Process")
44
      ...>   |> Choreo.Dataflow.add_sink(:out, label: "Output")
45
      ...>   |> Choreo.Dataflow.connect(:in, :proc, data_type: "raw")
46
      ...>   |> Choreo.Dataflow.connect(:proc, :out, data_type: "result")
47
      iex> dot = Choreo.Dataflow.Render.DOT.to_dot(flow)
48
      iex> String.contains?(dot, "digraph")
49
      true
50
      iex> String.contains?(dot, "Input")
51
      true
52
      iex> String.contains?(dot, "Output")
53
      true
54
  """
55
  @spec to_dot(Choreo.Dataflow.t(), keyword()) :: String.t()
56
  def to_dot(%Choreo.Dataflow{} = flow, opts \\ []) do
57
    theme = resolve_theme(Keyword.get(opts, :theme, :default))
11✔
58
    subgraphs = Choreo.Internal.build_cluster_subgraphs(flow, theme)
11✔
59

60
    base_opts =
11✔
61
      Yog.Render.DOT.default_options()
62
      |> Map.put(:rankdir, :lr)
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)
11✔
70
      |> Map.put(:node_fontsize, theme.node_fontsize)
11✔
71
      |> Map.put(:node_fontcolor, theme.node_fontcolor)
11✔
72
      |> Map.put(:edge_color, theme.edge_color)
11✔
73
      |> Map.put(:edge_fontname, theme.edge_fontname)
11✔
74
      |> Map.put(:edge_fontsize, theme.edge_fontsize)
11✔
75
      |> Map.put(:edge_penwidth, theme.edge_penwidth)
11✔
76
      |> Map.put(:arrowhead, :normal)
77
      |> Map.put(:node_label, &node_label/2)
78
      |> Map.put(:edge_label, &edge_label/1)
79
      |> Map.put(:node_attributes, node_attributes_fn(theme))
80
      |> Map.put(:edge_attributes, edge_attributes_fn(flow))
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
11✔
85

86
    Yog.Render.DOT.to_dot(flow.graph, base_opts)
11✔
87
  end
88

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

93
  @doc false
94
  def theme(name \\ :default, overrides \\ []) do
95
    resolve_theme(name) |> Choreo.Theme.override(overrides)
1✔
96
  end
97

98
  defp resolve_theme(%Theme{} = theme), do: theme
×
99
  defp resolve_theme(:default), do: default_dataflow_theme()
8✔
100
  defp resolve_theme(:dark), do: dark_dataflow_theme()
1✔
101
  defp resolve_theme(:warm), do: warm_dataflow_theme()
1✔
102
  defp resolve_theme(:forest), do: forest_dataflow_theme()
1✔
103
  defp resolve_theme(:ocean), do: ocean_dataflow_theme()
1✔
104
  defp resolve_theme(_), do: default_dataflow_theme()
×
105

106
  defp warm_dataflow_theme do
107
    %Theme{
1✔
108
      name: :dataflow_warm,
109
      colors: %{
110
        source: "#fbbf24",
111
        sink: "#f43f5e",
112
        transform: "#f97316",
113
        buffer: "#ea580c",
114
        conditional: "#ec4899",
115
        merge: "#db2777"
116
      },
117
      node_fontname: "Helvetica",
118
      node_fontsize: 12,
119
      node_fontcolor: "white",
120
      edge_color: "#78716c",
121
      edge_fontname: "Helvetica",
122
      edge_fontsize: 10,
123
      edge_penwidth: 1.0,
124
      graph_bgcolor: "#fef2f2",
125
      cluster_fillcolor: "#fee2e2",
126
      cluster_style: :rounded,
127
      cluster_color: "#fca5a5"
128
    }
129
  end
130

131
  defp forest_dataflow_theme do
132
    %Theme{
1✔
133
      name: :dataflow_forest,
134
      colors: %{
135
        source: "#84cc16",
136
        sink: "#15803d",
137
        transform: "#14b8a6",
138
        buffer: "#047857",
139
        conditional: "#65a30d",
140
        merge: "#0f766e"
141
      },
142
      node_fontname: "Helvetica",
143
      node_fontsize: 12,
144
      node_fontcolor: "white",
145
      edge_color: "#4b5563",
146
      edge_fontname: "Helvetica",
147
      edge_fontsize: 10,
148
      edge_penwidth: 1.0,
149
      graph_bgcolor: "#f0fdf4",
150
      cluster_fillcolor: "#dcfce7",
151
      cluster_style: :rounded,
152
      cluster_color: "#86efac"
153
    }
154
  end
155

156
  defp ocean_dataflow_theme do
157
    %Theme{
1✔
158
      name: :dataflow_ocean,
159
      colors: %{
160
        source: "#0ea5e9",
161
        sink: "#1d4ed8",
162
        transform: "#0891b2",
163
        buffer: "#0369a1",
164
        conditional: "#2563eb",
165
        merge: "#0e7490"
166
      },
167
      node_fontname: "Helvetica",
168
      node_fontsize: 12,
169
      node_fontcolor: "white",
170
      edge_color: "#64748b",
171
      edge_fontname: "Helvetica",
172
      edge_fontsize: 10,
173
      edge_penwidth: 1.0,
174
      graph_bgcolor: "#f0f9ff",
175
      cluster_fillcolor: "#e0f2fe",
176
      cluster_style: :rounded,
177
      cluster_color: "#7dd3fc"
178
    }
179
  end
180

181
  defp default_dataflow_theme do
182
    %Theme{
8✔
183
      name: :dataflow_default,
184
      colors: %{
185
        source: "#10b981",
186
        sink: "#f43f5e",
187
        transform: "#3b82f6",
188
        buffer: "#f59e0b",
189
        conditional: "#8b5cf6",
190
        merge: "#06b6d4"
191
      },
192
      node_fontname: "Helvetica",
193
      node_fontsize: 12,
194
      node_fontcolor: "white",
195
      edge_color: "#64748b",
196
      edge_fontname: "Helvetica",
197
      edge_fontsize: 10,
198
      edge_penwidth: 1.0,
199
      graph_bgcolor: nil,
200
      cluster_fillcolor: "#f8fafc",
201
      cluster_style: :rounded,
202
      cluster_color: "#cbd5e1"
203
    }
204
  end
205

206
  defp dark_dataflow_theme do
207
    %Theme{
1✔
208
      name: :dataflow_dark,
209
      colors: %{
210
        source: "#059669",
211
        sink: "#e11d48",
212
        transform: "#2563eb",
213
        buffer: "#d97706",
214
        conditional: "#7c3aed",
215
        merge: "#0891b2"
216
      },
217
      node_fontname: "Helvetica",
218
      node_fontsize: 12,
219
      node_fontcolor: "#e2e8f0",
220
      edge_color: "#94a3b8",
221
      edge_fontname: "Helvetica",
222
      edge_fontsize: 10,
223
      edge_penwidth: 1.0,
224
      graph_bgcolor: "#0f172a",
225
      cluster_fillcolor: "#1e293b",
226
      cluster_style: :rounded,
227
      cluster_color: "#475569"
228
    }
229
  end
230

231
  defp df_color(%Theme{colors: colors}, key) do
232
    Map.get(colors, key, "#64748b")
19✔
233
  end
234

235
  defp theme_graph_overrides(%Theme{} = theme) do
236
    base = if theme.graph_rankdir, do: %{rankdir: theme.graph_rankdir}, else: %{}
11✔
237
    if theme.graph_bgcolor, do: Map.put(base, :bgcolor, theme.graph_bgcolor), else: base
11✔
238
  end
239

240
  # ============================================================================
241
  # Node styling
242
  # ============================================================================
243

244
  defp node_attributes_fn(theme) do
245
    fn _id, data ->
11✔
246
      base =
19✔
247
        case Map.get(data, :node_type, :transform) do
248
          :source ->
7✔
249
            [
250
              {:shape, :house},
251
              {:fillcolor, df_color(theme, :source)},
252
              {:fontcolor, theme.node_fontcolor},
7✔
253
              {:style, "filled"}
254
            ]
255

256
          :sink ->
8✔
257
            [
258
              {:shape, :invhouse},
259
              {:fillcolor, df_color(theme, :sink)},
260
              {:fontcolor, theme.node_fontcolor},
8✔
261
              {:style, "filled"}
262
            ]
263

264
          :transform ->
4✔
265
            [
266
              {:shape, :box3d},
267
              {:fillcolor, df_color(theme, :transform)},
268
              {:fontcolor, theme.node_fontcolor},
4✔
269
              {:style, "filled"}
270
            ]
271

272
          :buffer ->
×
273
            [
274
              {:shape, :cylinder},
275
              {:fillcolor, df_color(theme, :buffer)},
276
              {:fontcolor, theme.node_fontcolor},
×
277
              {:style, "filled"}
278
            ]
279

280
          :conditional ->
×
281
            [
282
              {:shape, :diamond},
283
              {:fillcolor, df_color(theme, :conditional)},
284
              {:fontcolor, theme.node_fontcolor},
×
285
              {:style, "filled"}
286
            ]
287

288
          :merge ->
×
289
            [
290
              {:shape, :trapezium},
291
              {:fillcolor, df_color(theme, :merge)},
292
              {:fontcolor, theme.node_fontcolor},
×
293
              {:style, "filled"}
294
            ]
295

296
          _ ->
×
297
            [
298
              {:shape, :box},
299
              {:fillcolor, df_color(theme, :transform)},
300
              {:fontcolor, theme.node_fontcolor},
×
301
              {:style, "filled"}
302
            ]
303
        end
304

305
      base =
19✔
306
        if shape = data[:shape], do: [{:shape, shape} | Keyword.delete(base, :shape)], else: base
19✔
307

308
      base =
19✔
309
        if color = data[:fillcolor],
1✔
310
          do: [{:fillcolor, color} | Keyword.delete(base, :fillcolor)],
311
          else: base
18✔
312

313
      base =
19✔
314
        if fontcolor = data[:fontcolor],
×
315
          do: [{:fontcolor, fontcolor} | Keyword.delete(base, :fontcolor)],
316
          else: base
19✔
317

318
      base =
19✔
319
        if style = data[:style], do: [{:style, style} | Keyword.delete(base, :style)], else: base
19✔
320

321
      base =
19✔
322
        if penwidth = data[:penwidth],
1✔
323
          do: [{:penwidth, penwidth} | Keyword.delete(base, :penwidth)],
324
          else: base
18✔
325

326
      base =
19✔
327
        if image = data[:image],
×
328
          do: [{:image, image} | Keyword.delete(base, :image)],
329
          else: base
19✔
330

331
      if desc = data[:description] do
19✔
332
        [{:tooltip, desc} | base]
333
      else
334
        base
19✔
335
      end
336
    end
337
  end
338

339
  defp node_label(_id, data) do
340
    label = data[:label] || ""
19✔
341

342
    label =
19✔
343
      if rate = data[:rate] do
344
        "#{label}\n#{rate} evt/s"
1✔
345
      else
346
        label
18✔
347
      end
348

349
    if capacity = data[:capacity] do
19✔
350
      "#{label}\n(cap: #{capacity})"
×
351
    else
352
      label
19✔
353
    end
354
  end
355

356
  # ============================================================================
357
  # Edge styling
358
  # ============================================================================
359

360
  defp edge_attributes_fn(flow) do
361
    fn from, to, _weight ->
11✔
362
      meta = Map.get(flow.edge_meta, {from, to}, %{})
11✔
363

364
      base = path_type_attrs(meta[:path_type] || :normal)
11✔
365

366
      label = meta[:label]
11✔
367
      rate = meta[:rate]
11✔
368

369
      display_label =
11✔
370
        case {label, rate} do
371
          {nil, nil} -> nil
5✔
372
          {label, nil} -> label
6✔
373
          {nil, rate} -> "#{rate} evt/s"
×
374
          {label, rate} -> "#{label}\\n#{rate} evt/s"
×
375
        end
376

377
      if display_label do
11✔
378
        [{:label, display_label} | base]
379
      else
380
        base
5✔
381
      end
382
    end
383
  end
384

385
  defp path_type_attrs(:virtual) do
×
386
    [{:color, "#cbd5e1"}, {:penwidth, 0.8}, {:style, "dashed"}]
387
  end
388

389
  defp path_type_attrs(:error) do
1✔
390
    [{:color, "#ef4444"}, {:penwidth, 1.5}, {:style, "dashed"}]
391
  end
392

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

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

401
  defp path_type_attrs(_) do
10✔
402
    [{:color, "#64748b"}, {:penwidth, 1.0}, {:style, "solid"}]
403
  end
404

405
  defp edge_label(_), do: ""
11✔
406
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