Skip to content

Plotly

dyce.viz.plotly builds Plotly figure specifications for H objects.

Each builder returns a PlotSpec containing plain mappings and lists. Neither Plotly nor Matplotlib is required. Callers can pass spec.data, spec.layout, and spec.config to Plotly.newPlot, or pass spec.figure_dict() to plotly.graph_objects.Figure.

PlotSpec dataclass

Portable structural description of a Plotly figure.

data and layout are accepted by both Plotly.py and Plotly.js. config contains renderer options used by Plotly.js and by HTML generated with Plotly.py. as_dict returns a plain, JSON-serializable representation suitable for crossing a worker boundary.

Source code in dyce/viz/plotly.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
@dataclass
class PlotSpec:
    r"""
    Portable structural description of a Plotly figure.

    *data* and *layout* are accepted by both Plotly.py and Plotly.js.
    *config* contains renderer options used by Plotly.js and by HTML generated with Plotly.py.
    [`as_dict`][dyce.viz.plotly.PlotSpec.as_dict] returns a plain, JSON-serializable representation suitable for crossing a worker boundary.
    """

    data: list[dict[str, Any]]
    layout: dict[str, Any]
    config: dict[str, Any] = field(
        default_factory=lambda: {
            "responsive": True,
            "displaylogo": False,
            "modeBarButtonsToRemove": ["lasso2d", "select2d"],
        }
    )

    def as_dict(self) -> dict[str, Any]:
        r"""Return the complete specification as plain mappings and lists."""
        return {"data": self.data, "layout": self.layout, "config": self.config}

    def figure_dict(self) -> dict[str, Any]:
        r"""Return the portion accepted by `plotly.graph_objects.Figure`."""
        return {"data": self.data, "layout": self.layout}

as_dict() -> dict[str, Any]

Return the complete specification as plain mappings and lists.

Source code in dyce/viz/plotly.py
71
72
73
def as_dict(self) -> dict[str, Any]:
    r"""Return the complete specification as plain mappings and lists."""
    return {"data": self.data, "layout": self.layout, "config": self.config}

figure_dict() -> dict[str, Any]

Return the portion accepted by plotly.graph_objects.Figure.

Source code in dyce/viz/plotly.py
75
76
77
def figure_dict(self) -> dict[str, Any]:
    r"""Return the portion accepted by `plotly.graph_objects.Figure`."""
    return {"data": self.data, "layout": self.layout}

bar_spec(*hs: H, colors: Sequence[str] = (), graph_type: GraphType = GraphType.NORMAL, horizontal: bool = False, labels: Sequence[str] = (), max_percent: float | None = None, precision: int = _DEFAULT_PRECISION) -> PlotSpec

Experimental

dyce.viz.plotly.bar_spec is experimental; its interface may change or it may be removed in a future release.

Return a portable Plotly figure specification for a grouped bar chart of one or more histograms.

Use labels to assign legend names to each histogram. Unmatched histograms receive an empty label.

colors assigns hues, cycling as needed.

graph_type controls which variant of the distribution is plotted (see GraphType).

When horizontal is True, outcomes appear on the y-axis and probabilities on the x-axis.

max_percent fixes the probability-axis maximum, which is useful for keeping several separately rendered figures comparable.

precision is the number of decimal places tooltips show.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from dyce import H
from dyce.viz.plotly import bar_spec

spec = bar_spec(
    2 @ H(10),
    H(8) + H(12),
    labels=["2d10", "d8 + d12"],
    colors=["#1f77b4", "#d62728"],
)
spec.layout.update(
    {
        "title": {"text": "2d10 vs. d8 + d12"},
        "margin": {"l": 55, "r": 20, "t": 50, "b": 45},
        "paper_bgcolor": "rgba(0,0,0,0)",
        "plot_bgcolor": "rgba(0,0,0,0)",
    }
)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from dyce import H
from dyce.viz.plotly import bar_spec

spec = bar_spec(
    2 @ H(10),
    H(8) + H(12),
    labels=["2d10", "d8 + d12"],
    colors=["#1f77b4", "#d62728"],
    horizontal=True,
)
spec.layout.update(
    {
        "title": {"text": "2d10 vs. d8 + d12"},
        "margin": {"l": 55, "r": 20, "t": 50, "b": 45},
        "paper_bgcolor": "rgba(0,0,0,0)",
        "plot_bgcolor": "rgba(0,0,0,0)",
    }
)

Source code in dyce/viz/plotly.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
@experimental
def bar_spec(
    *hs: H,
    colors: Sequence[str] = (),
    graph_type: GraphType = GraphType.NORMAL,
    horizontal: bool = False,
    labels: Sequence[str] = (),
    max_percent: float | None = None,
    precision: int = _DEFAULT_PRECISION,
) -> PlotSpec:
    r"""
    Return a portable Plotly figure specification for a grouped bar chart of one or more histograms.

    Use *labels* to assign legend names to each histogram.
    Unmatched histograms receive an empty label.

    *colors* assigns hues, cycling as needed.

    *graph_type* controls which variant of the distribution is plotted (see [`GraphType`][dyce.viz.GraphType]).

    When *horizontal* is `True`, outcomes appear on the y-axis and probabilities on the x-axis.

    *max_percent* fixes the probability-axis maximum, which is useful for keeping several separately rendered figures comparable.

    *precision* is the number of decimal places tooltips show.

    === "Vertical bars (default)"

            --8<-- "docs/assets/plotly_viz_plot_bar.py:viz"

        --8<-- "docs/snippets/plotly_viz_plot_bar.html"

    === "Horizontal bars (`horizontal=True`)"

            --8<-- "docs/assets/plotly_viz_plot_hbar.py:viz"

        --8<-- "docs/snippets/plotly_viz_plot_hbar.html"
    """
    data: list[dict[str, Any]] = []
    for i, h in enumerate(hs):
        label = labels[i] if i < len(labels) else ""
        outcomes, probabilities = values_for_graph_type(h, graph_type)
        outcomes_list = list(outcomes)
        percents = [probability * 100.0 for probability in probabilities]
        outcome_ref = "y" if horizontal else "x"
        trace: dict[str, Any] = {
            "type": "bar",
            "name": label,
            "orientation": "h" if horizontal else "v",
            "x": percents if horizontal else outcomes_list,
            "y": outcomes_list if horizontal else percents,
            "customdata": percents,
            "hovertemplate": f"{label}<br>%{{{outcome_ref}}}: %{{customdata:.{precision}f}}%<extra></extra>",
            "texttemplate": f"%{{customdata:.{precision}f}}%",
            "textposition": "auto",
            "meta": {"series": i, "role": "bar"},
        }
        if colors:
            trace["marker"] = {"color": colors[i % len(colors)]}
        data.append(trace)

    probability_axis: dict[str, Any] = {
        "title": {"text": "Probability (%)"},
        "rangemode": "tozero",
    }
    if max_percent is not None:
        probability_axis["range"] = [0.0, max_percent]
    outcome_axis = {"title": {"text": "Outcome"}, "zeroline": False}
    return PlotSpec(
        data=data,
        layout={
            "barmode": "group",
            "showlegend": len(hs) > 1,
            "xaxis": probability_axis if horizontal else outcome_axis,
            "yaxis": outcome_axis if horizontal else probability_axis,
        },
    )

line_spec(*hs: H, colors: Sequence[str] = (), graph_type: GraphType = GraphType.NORMAL, labels: Sequence[str] = (), precision: int = _DEFAULT_PRECISION) -> PlotSpec

Experimental

dyce.viz.plotly.line_spec is experimental; its interface may change or it may be removed in a future release.

Return a portable Plotly figure specification for a line graph of one or more histograms.

Use labels to assign legend names to each histogram. Unmatched histograms receive an empty label.

colors assigns hues, cycling as needed.

graph_type controls which variant of the distribution is plotted (see GraphType).

precision is the number of decimal places tooltips show.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from dyce import H
from dyce.viz.plotly import line_spec

spec = line_spec(
    2 @ H(10),
    H(8) + H(12),
    labels=["2d10", "d8 + d12"],
    colors=["#1f77b4", "#d62728"],
)
spec.layout.update(
    {
        "title": {"text": "2d10 vs. d8 + d12"},
        "margin": {"l": 55, "r": 20, "t": 50, "b": 45},
        "paper_bgcolor": "rgba(0,0,0,0)",
        "plot_bgcolor": "rgba(0,0,0,0)",
    }
)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
from dyce import H
from dyce.viz import GraphType
from dyce.viz.plotly import line_spec

spec = line_spec(
    2 @ H(10),
    H(8) + H(12),
    labels=["2d10", "d8 + d12"],
    colors=["#1f77b4", "#d62728"],
    graph_type=GraphType.AT_MOST,
)
spec.layout.update(
    {
        "title": {"text": '2d10 vs. d8 + d12 ("at most")'},
        "margin": {"l": 55, "r": 20, "t": 50, "b": 45},
        "paper_bgcolor": "rgba(0,0,0,0)",
        "plot_bgcolor": "rgba(0,0,0,0)",
    }
)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
from dyce import H
from dyce.viz import GraphType
from dyce.viz.plotly import line_spec

spec = line_spec(
    2 @ H(10),
    H(8) + H(12),
    labels=["2d10", "d8 + d12"],
    colors=["#1f77b4", "#d62728"],
    graph_type=GraphType.AT_LEAST,
)
spec.layout.update(
    {
        "title": {"text": '2d10 vs. d8 + d12 ("at least")'},
        "margin": {"l": 55, "r": 20, "t": 50, "b": 45},
        "paper_bgcolor": "rgba(0,0,0,0)",
        "plot_bgcolor": "rgba(0,0,0,0)",
    }
)

Source code in dyce/viz/plotly.py
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
@experimental
def line_spec(
    *hs: H,
    colors: Sequence[str] = (),
    graph_type: GraphType = GraphType.NORMAL,
    labels: Sequence[str] = (),
    precision: int = _DEFAULT_PRECISION,
) -> PlotSpec:
    r"""
    Return a portable Plotly figure specification for a line graph of one or more histograms.

    Use *labels* to assign legend names to each histogram.
    Unmatched histograms receive an empty label.

    *colors* assigns hues, cycling as needed.

    *graph_type* controls which variant of the distribution is plotted (see [`GraphType`][dyce.viz.GraphType]).

    *precision* is the number of decimal places tooltips show.

    === "`graph_type=GraphType.NORMAL` (default)"

            --8<-- "docs/assets/plotly_viz_plot_line.py:viz"

        --8<-- "docs/snippets/plotly_viz_plot_line.html"

    === "`graph_type=GraphType.AT_MOST`"

            --8<-- "docs/assets/plotly_viz_plot_line_at_most.py:viz"

        --8<-- "docs/snippets/plotly_viz_plot_line_at_most.html"

    === "`graph_type=GraphType.AT_LEAST`"

            --8<-- "docs/assets/plotly_viz_plot_line_at_least.py:viz"

        --8<-- "docs/snippets/plotly_viz_plot_line_at_least.html"
    """
    data: list[dict[str, Any]] = []
    for i, h in enumerate(hs):
        label = labels[i] if i < len(labels) else ""
        outcomes, probabilities = values_for_graph_type(h, graph_type)
        color = colors[i % len(colors)] if colors else None
        marker: dict[str, Any] = {"size": 5}
        trace: dict[str, Any] = {
            "type": "scatter",
            "mode": "lines+markers",
            "name": label,
            "x": list(outcomes),
            "y": [probability * 100.0 for probability in probabilities],
            "hovertemplate": f"{label}<br>%{{x}}: %{{y:.{precision}f}}%<extra></extra>",
            "marker": marker,
            "meta": {"series": i, "role": "line"},
        }
        if color is not None:
            marker["color"] = color
            trace["line"] = {"color": color}
        data.append(trace)
    return PlotSpec(
        data=data,
        layout={
            "showlegend": len(hs) > 1,
            "hovermode": "x",
            "xaxis": {"title": {"text": "Outcome"}, "zeroline": False},
            "yaxis": {
                "title": {"text": "Probability (%)"},
                "rangemode": "tozero",
            },
        },
    )

ridge_spec(*hs: H, colors: Sequence[str] = (), graph_type: GraphType = GraphType.NORMAL, label_bgcolor: str | None = None, labels: Sequence[str] = (), overlap: float = _DEFAULT_RIDGE_OVERLAP, peak: float | None = None, precision: int = _DEFAULT_PRECISION) -> PlotSpec

Experimental

dyce.viz.plotly.ridge_spec is experimental; its interface may change or it may be removed in a future release.

Return a portable Plotly figure specification for a ridgeline (“joyplot”) of one or more histograms.

Each histogram becomes its own filled ridge, stacked vertically and offset so that neighbors overlap. Ridges appear top-to-bottom in argument order, and lower ridges are drawn in front of higher ones.

Each ridge covers only its own outcomes. Where a neighbor has an outcome this histogram lacks, the line bridges the gap rather than dipping to zero, since the histogram says nothing there rather than saying zero.

Every ridge contributes two traces, a translucent fill and an opaque crest line carrying the markers and the tooltip. Each trace’s meta records its ridge and which of the two it is, so a caller can restyle without counting.

Use labels to name each histogram. Names are drawn as “pills” inside the plot at their ridge’s baseline, pinned to the left edge, so a long one grows rightward over its own ridge rather than clipping into the margin. Unmatched histograms get a blank label. Set label_bgcolor to a translucent color appropriate for the rendering context.

colors assigns a hue per ridge, cycled if there are fewer colors than histograms. Each ridge’s fill takes that hue translucently and its crest line takes it at full strength. Without colors, the traces carry none and Plotly’s own sequence gives a ridge’s fill and line different hues, so either supply colors or restyle by meta afterward.

graph_type controls which variant of the distribution is plotted (see GraphType).

overlap is how many rows tall a ridge at peak stands. At 1.0, such a ridge just reaches the next row's baseline.

peak overrides the percentage drawn at full height, which is otherwise the largest among hs. Pass the largest across several figures to put them all on one scale, so ridges stay comparable between subplots.

precision is the number of decimal places tooltips show.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from dyce import H
from dyce.viz.plotly import ridge_spec

spec = ridge_spec(
    2 @ H(10),
    H(8) + H(12),
    labels=["2d10", "d8 + d12"],
    colors=["#1f77b4", "#d62728"],
    label_bgcolor="rgba(255,255,255,0.72)",
)
spec.layout.update(
    {
        "title": {"text": "2d10 vs. d8 + d12"},
        "margin": {"l": 40, "r": 20, "t": 50, "b": 40},
        "paper_bgcolor": "rgba(0,0,0,0)",
        "plot_bgcolor": "rgba(0,0,0,0)",
    }
)
Source code in dyce/viz/plotly.py
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
@experimental
def ridge_spec(
    *hs: H,
    colors: Sequence[str] = (),
    graph_type: GraphType = GraphType.NORMAL,
    label_bgcolor: str | None = None,
    labels: Sequence[str] = (),
    overlap: float = _DEFAULT_RIDGE_OVERLAP,
    peak: float | None = None,
    precision: int = _DEFAULT_PRECISION,
) -> PlotSpec:
    r"""
    Return a portable Plotly figure specification for a ridgeline (“joyplot”) of one or more histograms.

    Each histogram becomes its own filled ridge, stacked vertically and offset so that neighbors overlap.
    Ridges appear top-to-bottom in argument order, and lower ridges are drawn in front of higher ones.

    Each ridge covers only its own outcomes.
    Where a neighbor has an outcome this histogram lacks, the line bridges the gap rather than dipping to zero, since the histogram says nothing there rather than saying zero.

    Every ridge contributes two traces, a translucent fill and an opaque crest line carrying the markers and the tooltip.
    Each trace’s `meta` records its ridge and which of the two it is, so a caller can restyle without counting.

    Use *labels* to name each histogram.
    Names are drawn as “pills” inside the plot at their ridge’s baseline, pinned to the left edge, so a long one grows rightward over its own ridge rather than clipping into the margin.
    Unmatched histograms get a blank label.
    Set *label_bgcolor* to a translucent color appropriate for the rendering context.

    *colors* assigns a hue per ridge, cycled if there are fewer colors than histograms.
    Each ridge’s fill takes that hue translucently and its crest line takes it at full strength.
    Without *colors*, the traces carry none and Plotly’s own sequence gives a ridge’s fill and line different hues, so either supply colors or restyle by `meta` afterward.

    *graph_type* controls which variant of the distribution is plotted (see [`GraphType`][dyce.viz.GraphType]).

    *overlap* is how many rows tall a ridge at *peak* stands.
    At `1.0`, such a ridge just reaches the next row's baseline.

    *peak* overrides the percentage drawn at full height, which is otherwise the largest among *hs*.
    Pass the largest across several figures to put them all on one scale, so ridges stay comparable between subplots.

    *precision* is the number of decimal places tooltips show.

        --8<-- "docs/assets/plotly_viz_plot_ridge.py:viz"

    --8<-- "docs/snippets/plotly_viz_plot_ridge.html"
    """
    rows = []
    for i, h in enumerate(hs):
        outcomes, probabilities = values_for_graph_type(h, graph_type)
        rows.append(
            (
                labels[i] if i < len(labels) else "",
                list(outcomes),
                [probability * 100.0 for probability in probabilities],
            )
        )
    num_rows = len(rows)
    peak_height = overlap * _RIDGE_ROW_STEP
    if peak is None:
        peak = max((max(percents, default=0.0) for _, _, percents in rows), default=0.0)
    data: list[dict[str, Any]] = []
    annotations: list[dict[str, Any]] = []

    for i, (label, row_outcomes, percents) in enumerate(rows):
        baseline = float(num_rows - 1 - i) * _RIDGE_ROW_STEP
        scale = peak_height / peak if peak else 0.0
        crests = [baseline + percent * scale for percent in percents]
        color = colors[i % len(colors)] if colors else None
        fill: dict[str, Any] = {
            "type": "scatter",
            "mode": "lines",
            "x": [
                row_outcomes[0] - _RIDGE_FILL_FOOT,
                *row_outcomes,
                row_outcomes[-1] + _RIDGE_FILL_FOOT,
            ]
            if row_outcomes
            else [],
            "y": [baseline, *crests, baseline] if row_outcomes else [],
            "fill": "toself",
            "line": {"width": 0},
            "hoverinfo": "skip",
            "showlegend": False,
            "meta": {"ridge": i, "role": "fill"},
        }
        if color is not None:
            fill["fillcolor"] = _with_alpha(color, _RIDGE_FILL_ALPHA)
        data.append(fill)
        marker: dict[str, Any] = {"size": 4}
        # The plotted y is offset and scaled, so tooltips read the true
        # percentages out of customdata instead.
        line: dict[str, Any] = {
            "type": "scatter",
            "mode": "lines+markers",
            "x": list(row_outcomes),
            "y": crests,
            "name": label,
            "customdata": percents,
            "hovertemplate": f"{label}<br>%{{x}}: %{{customdata:.{precision}f}}%<extra></extra>",
            "marker": marker,
            "showlegend": False,
            "meta": {"ridge": i, "role": "line"},
        }
        if color is not None:
            marker["color"] = color
            line["line"] = {"color": color, "width": 1.5}
        data.append(line)
        annotation: dict[str, Any] = {
            "xref": "paper",
            "x": 0,
            "xanchor": "left",
            "xshift": 4,
            "yref": "y",
            "y": baseline,
            "yanchor": "bottom",
            "yshift": 2,
            "text": label,
            "showarrow": False,
            "align": "left",
            "borderpad": 2,
        }
        if label_bgcolor is not None:
            annotation["bgcolor"] = label_bgcolor
        annotations.append(annotation)

    return PlotSpec(
        data=data,
        layout={
            "showlegend": False,
            # Not "x unified": one tooltip per ridge at the outcome nearest the
            # cursor, all at once. The fills skip hover, so that is exactly one
            # apiece.
            "hovermode": "x",
            "annotations": annotations,
            "xaxis": {"title": {"text": "Outcome"}, "zeroline": False},
            "yaxis": {
                "showticklabels": False,
                "showgrid": False,
                "zeroline": False,
                "range": [
                    -0.5 * _RIDGE_ROW_STEP,
                    (num_rows - 1) * _RIDGE_ROW_STEP
                    + peak_height
                    + 0.5 * _RIDGE_ROW_STEP,
                ],
            },
        },
    )