• 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

70.73
/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))
7✔
58
    subgraphs = Choreo.Internal.build_cluster_subgraphs(flow, theme)
7✔
59

60
    base_opts =
7✔
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)
7✔
70
      |> Map.put(:node_fontsize, theme.node_fontsize)
7✔
71
      |> Map.put(:node_fontcolor, theme.node_fontcolor)
7✔
72
      |> Map.put(:edge_color, theme.edge_color)
7✔
73
      |> Map.put(:edge_fontname, theme.edge_fontname)
7✔
74
      |> Map.put(:edge_fontsize, theme.edge_fontsize)
7✔
75
      |> Map.put(:edge_penwidth, theme.edge_penwidth)
7✔
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
7✔
85

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

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

93
  defp resolve_theme(%Theme{} = theme), do: theme
×
94
  defp resolve_theme(:default), do: default_dataflow_theme()
6✔
95
  defp resolve_theme(:dark), do: dark_dataflow_theme()
1✔
NEW
96
  defp resolve_theme(:warm), do: warm_dataflow_theme()
×
NEW
97
  defp resolve_theme(:forest), do: forest_dataflow_theme()
×
NEW
98
  defp resolve_theme(:ocean), do: ocean_dataflow_theme()
×
UNCOV
99
  defp resolve_theme(_), do: default_dataflow_theme()
×
100

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

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

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

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

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

226
  defp df_color(%Theme{colors: colors}, key) do
227
    Map.get(colors, key, "#64748b")
18✔
228
  end
229

230
  defp theme_graph_overrides(%Theme{graph_bgcolor: nil}), do: %{}
6✔
231
  defp theme_graph_overrides(%Theme{graph_bgcolor: bg}), do: %{bgcolor: bg}
1✔
232

233
  # ============================================================================
234
  # Node styling
235
  # ============================================================================
236

237
  defp node_attributes_fn(theme) do
238
    fn _id, data ->
7✔
239
      base =
18✔
240
        case Map.get(data, :node_type, :transform) do
241
          :source ->
6✔
242
            [
243
              {:shape, :house},
244
              {:fillcolor, df_color(theme, :source)},
245
              {:fontcolor, theme.node_fontcolor},
6✔
246
              {:style, "filled"}
247
            ]
248

249
          :sink ->
8✔
250
            [
251
              {:shape, :invhouse},
252
              {:fillcolor, df_color(theme, :sink)},
253
              {:fontcolor, theme.node_fontcolor},
8✔
254
              {:style, "filled"}
255
            ]
256

257
          :transform ->
4✔
258
            [
259
              {:shape, :box3d},
260
              {:fillcolor, df_color(theme, :transform)},
261
              {:fontcolor, theme.node_fontcolor},
4✔
262
              {:style, "filled"}
263
            ]
264

265
          :buffer ->
×
266
            [
267
              {:shape, :cylinder},
268
              {:fillcolor, df_color(theme, :buffer)},
NEW
269
              {:fontcolor, theme.node_fontcolor},
×
270
              {:style, "filled"}
271
            ]
272

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

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

289
          _ ->
×
290
            [
291
              {:shape, :box},
292
              {:fillcolor, df_color(theme, :transform)},
NEW
293
              {:fontcolor, theme.node_fontcolor},
×
294
              {:style, "filled"}
295
            ]
296
        end
297

298
      base =
18✔
299
        if shape = data[:shape], do: [{:shape, shape} | Keyword.delete(base, :shape)], else: base
18✔
300

301
      base =
18✔
NEW
302
        if color = data[:fillcolor],
×
303
          do: [{:fillcolor, color} | Keyword.delete(base, :fillcolor)],
304
          else: base
18✔
305

306
      base =
18✔
NEW
307
        if fontcolor = data[:fontcolor],
×
308
          do: [{:fontcolor, fontcolor} | Keyword.delete(base, :fontcolor)],
309
          else: base
18✔
310

311
      base =
18✔
312
        if style = data[:style], do: [{:style, style} | Keyword.delete(base, :style)], else: base
18✔
313

314
      base =
18✔
NEW
315
        if penwidth = data[:penwidth],
×
316
          do: [{:penwidth, penwidth} | Keyword.delete(base, :penwidth)],
317
          else: base
18✔
318

319
      if desc = data[:description] do
18✔
320
        [{:tooltip, desc} | base]
321
      else
322
        base
18✔
323
      end
324
    end
325
  end
326

327
  defp node_label(_id, data) do
328
    label = data[:label] || ""
18✔
329

330
    label =
18✔
331
      if rate = data[:rate] do
332
        "#{label}\n#{rate} evt/s"
1✔
333
      else
334
        label
17✔
335
      end
336

337
    if capacity = data[:capacity] do
18✔
338
      "#{label}\n(cap: #{capacity})"
×
339
    else
340
      label
18✔
341
    end
342
  end
343

344
  # ============================================================================
345
  # Edge styling
346
  # ============================================================================
347

348
  defp edge_attributes_fn(flow) do
349
    fn from, to, _weight ->
7✔
350
      meta = Map.get(flow.edge_meta, {from, to}, %{})
11✔
351

352
      base = path_type_attrs(meta[:path_type] || :normal)
11✔
353

354
      label = meta[:label]
11✔
355
      rate = meta[:rate]
11✔
356

357
      display_label =
11✔
358
        case {label, rate} do
359
          {nil, nil} -> nil
5✔
360
          {label, nil} -> label
6✔
361
          {nil, rate} -> "#{rate} evt/s"
×
362
          {label, rate} -> "#{label}\\n#{rate} evt/s"
×
363
        end
364

365
      if display_label do
11✔
366
        [{:label, display_label} | base]
367
      else
368
        base
5✔
369
      end
370
    end
371
  end
372

373
  defp path_type_attrs(:error) do
1✔
374
    [{:color, "#ef4444"}, {:penwidth, 1.5}, {:style, "dashed"}]
375
  end
376

377
  defp path_type_attrs(:retry) do
×
378
    [{:color, "#f97316"}, {:penwidth, 1.5}, {:style, "dotted"}]
379
  end
380

381
  defp path_type_attrs(:dead_letter) do
×
382
    [{:color, "#9ca3af"}, {:penwidth, 1.2}, {:style, "dashed"}]
383
  end
384

385
  defp path_type_attrs(_) do
10✔
386
    [{:color, "#64748b"}, {:penwidth, 1.0}, {:style, "solid"}]
387
  end
388

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