Coverage for src/jquantstats/_plots/_render/_plotly.py: 100%
180 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 04:11 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 04:11 +0000
1"""Render a `FigureSpec` with Plotly.
3This module owns every Plotly-specific decision: how a semantic
4`~jquantstats._plots._spec.TickFormat` becomes a d3 format string, how a
5`~jquantstats._plots._spec.HoverSpec` becomes a hovertemplate, and how the
6shared layout is applied.
8It deliberately reuses `_apply_base_layout` and `_apply_figsize` rather than
9restating what they do. Those helpers already produce the layout every chart in
10the package has, so routing through them is what lets a chart move onto the
11spec/renderer split without altering a single byte of its rendered output — the
12property the full-fidelity snapshot tests exist to pin.
13"""
15from __future__ import annotations
17from typing import Any
19import plotly.graph_objects as go
20import plotly.io as pio
21from plotly.subplots import make_subplots
23from .._spec import (
24 Axis,
25 Band,
26 BarSeries,
27 BoxSeries,
28 ColorScale,
29 FigureSpec,
30 HeatmapGrid,
31 HistogramSeries,
32 HoverSpec,
33 LineSeries,
34 Panel,
35 RefLine,
36 TickFormat,
37)
39__all__ = ["render_plotly"]
41# Marimo renders a figure through its mimetype rather than by writing HTML, and
42# will not display one otherwise. Set here rather than at package import so that
43# `import jquantstats` has no side effect on Plotly's global configuration; the
44# first Plotly render is the earliest point it can possibly matter.
45pio.renderers.default = "plotly_mimetype"
48# Semantic tick format -> d3 format string, the vocabulary Plotly speaks.
49_D3_FORMATS: dict[TickFormat, str] = {
50 "float2": ".2f",
51 "float4": ".4f",
52 "percent0": ".0%",
53 "percent1": ".1%",
54 "percent2": ".2%",
55 "currency0": ",.0f",
56 "si2": ".2s",
57}
59# Semantic dash style -> Plotly's `line.dash` vocabulary.
60_DASHES = {"solid": "solid", "dash": "dash"}
62# Named colour ramp -> Plotly's colorscale stops.
63# Named colour ramp -> what Plotly expects: explicit stops for the custom one,
64# a built-in scale name for the rest.
65_COLORSCALES: dict[ColorScale, list[list[float | str]] | str] = {
66 "red_white_green": [[0, "#d62728"], [0.5, "#ffffff"], [1, "#2ca02c"]],
67 "rdylgn": "RdYlGn",
68 "rdbu_r": "RdBu_r",
69}
71# Bars are drawn without an outline throughout.
72_BAR_OUTLINE = {"width": 0}
75def _hovertemplate(hover: HoverSpec) -> str:
76 """Build a Plotly hovertemplate from a structural hover description.
78 Args:
79 hover: What the tooltip should say.
81 Returns:
82 str: A hovertemplate string.
84 """
85 header = "<b>%{x|%b %Y}</b><br>" if hover.date_header else ""
86 value = f"%{{{hover.axis}:{_D3_FORMATS[hover.value_format]}}}"
87 extra = "<extra></extra>" if hover.hide_extra else ""
88 return f"{header}{hover.label}: {hover.prefix}{value}{hover.suffix}{extra}"
91def _scatter(line: LineSeries) -> go.Scatter:
92 """Convert one line series into a Plotly scatter trace.
94 Args:
95 line: The series to draw.
97 Returns:
98 go.Scatter: The trace, styled per the series.
100 """
101 style: dict[str, object] = {}
102 if line.color is not None:
103 style["color"] = line.color
104 style["width"] = line.width
105 if line.dash is not None:
106 style["dash"] = _DASHES[line.dash]
108 fill: dict[str, object] = {}
109 if line.fill:
110 fill["fill"] = "tozeroy"
111 if line.fill_color is not None:
112 fill["fillcolor"] = line.fill_color
114 grouping: dict[str, object] = {}
115 if line.legend_group is not None:
116 grouping["legendgroup"] = line.legend_group
117 if line.show_legend is not None:
118 grouping["showlegend"] = line.show_legend
120 mode = "lines+markers" if line.markers else "lines"
121 if line.marker_size is not None:
122 grouping["marker"] = {"size": line.marker_size}
124 trace = go.Scatter(x=line.x, y=line.y, mode=mode, name=line.name, line=style, **fill, **grouping)
125 if line.hover is not None:
126 trace.update(hovertemplate=_hovertemplate(line.hover))
127 return trace
130def _bar(bar: BarSeries) -> go.Bar:
131 """Convert one bar series into a Plotly bar trace.
133 Args:
134 bar: The series to draw.
136 Returns:
137 go.Bar: The trace, with one colour per bar.
139 """
140 # A chart that names no colours takes Plotly's palette, and says nothing
141 # about markers or opacity at all.
142 styling: dict[str, object] = {}
143 if bar.colors is not None:
144 styling["marker"] = {"color": list(bar.colors), "line": _BAR_OUTLINE}
145 if bar.opacity is not None:
146 styling["opacity"] = bar.opacity
147 if bar.legend_group is not None:
148 styling["legendgroup"] = bar.legend_group
149 if bar.show_legend is not None:
150 styling["showlegend"] = bar.show_legend
152 trace = go.Bar(x=bar.x, y=bar.y, name=bar.name, **styling)
153 if bar.hover is not None:
154 trace.update(hovertemplate=_hovertemplate(bar.hover))
155 return trace
158def _histogram(hist: HistogramSeries) -> go.Histogram:
159 """Convert one histogram series into a Plotly histogram trace.
161 Args:
162 hist: The series to bin and draw.
164 Returns:
165 go.Histogram: The trace.
167 """
168 binning: dict[str, object] = {} if hist.bins is None else {"nbinsx": hist.bins}
169 trace = go.Histogram(
170 x=hist.values,
171 name=hist.name,
172 marker_color=hist.color,
173 opacity=hist.opacity,
174 **binning,
175 )
176 if hist.hover is not None:
177 trace.update(hovertemplate=_hovertemplate(hist.hover))
178 return trace
181def _box(box: BoxSeries) -> go.Box:
182 """Convert one box-and-whisker series into a Plotly box trace.
184 Args:
185 box: The series to summarise.
187 Returns:
188 go.Box: The trace, showing outliers individually.
190 """
191 grouping: dict[str, object] = {"showlegend": box.show_legend}
192 if box.legend_group is not None:
193 grouping["legendgroup"] = box.legend_group
195 trace = go.Box(y=box.values, name=box.name, marker_color=box.color, boxpoints="outliers", **grouping)
196 if box.hover is not None:
197 trace.update(hovertemplate=_hovertemplate(box.hover))
198 return trace
201def _heatmap(grid: HeatmapGrid) -> go.Heatmap:
202 """Convert a value matrix into a Plotly heatmap trace.
204 Args:
205 grid: The matrix to draw.
207 Returns:
208 go.Heatmap: The trace, labelled cell by cell.
210 """
211 trace = go.Heatmap(
212 x=list(grid.x_labels),
213 y=list(grid.y_labels),
214 z=[list(row) for row in grid.z],
215 text=[list(row) for row in grid.text],
216 texttemplate="%{text}",
217 colorscale=_COLORSCALES[grid.colorscale],
218 zmid=grid.zmid,
219 zmin=grid.zmin,
220 zmax=grid.zmax,
221 showscale=True,
222 colorbar={"title": grid.colorbar_title},
223 )
224 if grid.hover_label is not None:
225 trace.update(hovertemplate=f"<b>%{{y}} %{{x}}</b><br>{grid.hover_label}: %{{text}}<extra></extra>")
226 return trace
229def _add_ref_line(fig: go.Figure, ref: RefLine, at: dict[str, int]) -> None:
230 """Draw a fixed-value marker line onto *fig*.
232 Args:
233 fig: The figure to draw on.
234 ref: The line to draw.
235 at: Subplot position, empty for a single-panel figure.
237 """
238 # An unset dash is left unstated rather than sent as "solid": that would
239 # add a property to the serialised shape which the chart never had.
240 style: dict[str, object] = {"line_width": ref.width, "line_color": ref.color}
241 if ref.dash is not None:
242 style["line_dash"] = _DASHES[ref.dash]
243 if ref.label is not None:
244 style |= {
245 "annotation_text": ref.label,
246 "annotation_position": "top right",
247 "annotation_font_size": ref.label_size,
248 }
250 if ref.orientation == "h":
251 fig.add_hline(y=ref.value, **style, **at)
252 else:
253 fig.add_vline(x=ref.value, **style, **at)
256def _add_band(fig: go.Figure, band: Band, at: dict[str, int]) -> None:
257 """Draw a shaded vertical span onto *fig*.
259 Args:
260 fig: The figure to draw on.
261 band: The span to draw.
262 at: Subplot position, empty for a single-panel figure.
264 """
265 kwargs: dict[str, object] = {}
266 if band.label is not None:
267 kwargs = {
268 "annotation_text": band.label,
269 "annotation_position": "top left",
270 "annotation_font_size": band.label_size,
271 }
272 fig.add_vrect(x0=band.x0, x1=band.x1, fillcolor=band.color, line_width=0, **kwargs, **at)
275def _axis_kwargs(axis: Axis, *, vertical: bool = False) -> dict[str, object]:
276 """Collect the axis properties a spec actually asked for.
278 Only requested properties are returned. Emitting a default for an untouched
279 property would change the serialised figure, which the fidelity snapshots
280 would (correctly) flag.
282 Args:
283 axis: The axis configuration.
284 vertical: Whether this is the y-axis, which decides what
285 `~jquantstats._plots._spec.Axis.opposite_side` resolves to.
287 Returns:
288 dict[str, object]: Keyword arguments for ``update_xaxes`` /
289 ``update_yaxes``.
291 """
292 kwargs: dict[str, object] = {}
293 if axis.title is not None:
294 kwargs["title_text"] = axis.title
295 if axis.tick_prefix:
296 kwargs["tickprefix"] = axis.tick_prefix
297 if axis.tick_format is not None:
298 kwargs["tickformat"] = _D3_FORMATS[axis.tick_format]
299 if axis.log:
300 kwargs["type"] = "log"
301 if axis.kind is not None:
302 kwargs["type"] = axis.kind
303 if axis.dtick is not None:
304 kwargs["dtick"] = axis.dtick
305 if axis.opposite_side:
306 kwargs["side"] = "right" if vertical else "top"
307 return kwargs
310def render_plotly(spec: FigureSpec) -> go.Figure:
311 """Render *spec* as an interactive Plotly figure.
313 Args:
314 spec: The chart to draw. Must describe exactly one panel; multi-panel
315 charts arrive with the dashboards.
317 Returns:
318 go.Figure: The rendered figure.
320 """
321 fig = _blank_figure(spec)
322 positions = [_position(spec, index) for index in range(1, len(spec.panels) + 1)]
323 for panel, at in zip(spec.panels, positions, strict=True):
324 _draw_panel(fig, panel, at)
326 # Layout first, then per-panel axes: the shared layout sets grid and axis
327 # defaults across the whole figure, and a panel's own settings refine them.
328 _apply_layout(fig, spec)
329 for panel, at in zip(spec.panels, positions, strict=True):
330 _apply_panel_axes(fig, panel, at)
331 return fig
334def _position(spec: FigureSpec, index: int) -> dict[str, int]:
335 """Locate the *index*-th panel within the figure's subplot grid.
337 Args:
338 spec: The chart being rendered.
339 index: One-based panel number.
341 Returns:
342 dict[str, int]: Row and column keyword arguments, empty for a
343 single-panel figure, which has no grid to place anything in.
345 """
346 if spec.arrangement == "single":
347 return {}
348 if spec.arrangement == "stacked":
349 return {"row": index, "col": 1}
350 return {"row": 1, "col": index}
353def _blank_figure(spec: FigureSpec) -> go.Figure:
354 """Create the figure a spec's panels will be drawn onto.
356 Args:
357 spec: The chart being rendered.
359 Returns:
360 go.Figure: An empty figure, with a subplot grid when the spec has one.
362 """
363 if spec.arrangement == "single":
364 return go.Figure()
366 titles = [panel.title for panel in spec.panels]
367 if spec.arrangement == "stacked":
368 return make_subplots(
369 rows=len(spec.panels),
370 cols=1,
371 shared_xaxes=spec.shared_x,
372 row_heights=[panel.height_ratio for panel in spec.panels],
373 subplot_titles=titles,
374 vertical_spacing=spec.vertical_spacing,
375 )
376 return make_subplots(rows=1, cols=len(spec.panels), subplot_titles=titles, shared_yaxes=spec.shared_y)
379def _draw_panel(fig: go.Figure, panel: Panel, at: dict[str, int]) -> None:
380 """Draw one panel's marks onto *fig*.
382 Args:
383 fig: The figure to draw on.
384 panel: The panel to draw.
385 at: Subplot position, empty for a single-panel figure.
387 """
388 for line in panel.lines:
389 fig.add_trace(_scatter(line), **at)
390 for bar in panel.bars:
391 fig.add_trace(_bar(bar), **at)
392 for hist in panel.histograms:
393 fig.add_trace(_histogram(hist), **at)
394 for box in panel.boxes:
395 fig.add_trace(_box(box), **at)
396 if panel.heatmap is not None:
397 fig.add_trace(_heatmap(panel.heatmap), **at)
398 for ref in panel.ref_lines:
399 _add_ref_line(fig, ref, at)
400 for band in panel.bands:
401 _add_band(fig, band, at)
404def _apply_layout(fig: go.Figure, spec: FigureSpec) -> None:
405 """Apply the figure-wide layout for *spec*'s chrome.
407 Args:
408 fig: The figure to lay out.
409 spec: The chart being rendered.
411 """
412 if spec.chrome == "timeseries":
413 _apply_base_layout(fig, spec.title, height=spec.height, with_range_selector=spec.date_range_selector)
414 elif spec.chrome == "panels":
415 # Small multiples: the legend names the categories repeated in every
416 # panel, so it sits a little clear of the subplot titles.
417 fig.update_layout(
418 title=spec.title,
419 height=spec.height,
420 plot_bgcolor="white",
421 legend={"orientation": "h", "yanchor": "bottom", "y": 1.05, "xanchor": "right", "x": 1},
422 )
423 # Horizontal rules help compare box heights across panels; vertical
424 # ones would only clutter what is a categorical axis.
425 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
426 fig.update_xaxes(showgrid=False)
427 elif spec.chrome == "plain":
428 # One series that names itself in the title, so no legend; the grid
429 # still helps read a value off the axes.
430 fig.update_layout(
431 title=spec.title,
432 height=spec.height,
433 plot_bgcolor="white",
434 hovermode=spec.hover_mode,
435 )
436 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
437 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
438 else:
439 # A matrix chart: colour encodes the value, so a legend would name
440 # nothing and a shared-x hover has nothing to align. Only the title,
441 # height and background are set.
442 fig.update_layout(title=spec.title, height=spec.height, plot_bgcolor="white")
444 if spec.width is not None:
445 fig.update_layout(width=spec.width)
446 _apply_figsize(fig, spec.figsize)
447 if spec.bar_mode is not None:
448 fig.update_layout(barmode=spec.bar_mode)
451def _apply_panel_axes(fig: go.Figure, panel: Panel, at: dict[str, int]) -> None:
452 """Apply one panel's axis configuration.
454 Args:
455 fig: The figure to configure.
456 panel: The panel whose axes to configure.
457 at: Subplot position, empty for a single-panel figure.
459 """
460 x_kwargs = _axis_kwargs(panel.xaxis)
461 if x_kwargs:
462 fig.update_xaxes(**x_kwargs, **at)
463 y_kwargs = _axis_kwargs(panel.yaxis, vertical=True)
464 if y_kwargs:
465 fig.update_yaxes(**y_kwargs, **at)
468def _date_range_selector() -> dict[str, Any]:
469 """Return a standard Plotly date range-selector configuration.
471 Returns:
472 A dict suitable for ``xaxis.rangeselector``.
474 """
475 return {
476 "buttons": [
477 {"count": 6, "label": "6m", "step": "month", "stepmode": "backward"},
478 {"count": 1, "label": "1y", "step": "year", "stepmode": "backward"},
479 {"count": 3, "label": "3y", "step": "year", "stepmode": "backward"},
480 {"step": "year", "stepmode": "todate", "label": "YTD"},
481 {"step": "all", "label": "All"},
482 ]
483 }
486def _apply_base_layout(
487 fig: go.Figure,
488 title: str,
489 height: int | None = 600,
490 with_range_selector: bool = True,
491) -> go.Figure:
492 """Apply the standard jquantstats Plotly layout to a figure.
494 Sets white background, light-grey grid, horizontal legend, and an
495 optional date range-selector on the primary x-axis.
497 Args:
498 fig: The Plotly figure to style in-place.
499 title: Chart title.
500 height: Figure height in pixels. Defaults to 600. None leaves the
501 height unset, which Plotly treats as "size to the container".
502 with_range_selector: Attach a date range-selector to ``xaxis``.
503 Defaults to True.
505 Returns:
506 The same figure, mutated in-place and returned for chaining.
508 """
509 layout_kw: dict[str, Any] = {
510 "title": title,
511 "height": height,
512 "hovermode": "x unified",
513 "plot_bgcolor": "white",
514 "legend": {"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1},
515 }
516 if with_range_selector:
517 layout_kw["xaxis"] = {
518 "rangeselector": _date_range_selector(),
519 "rangeslider": {"visible": False},
520 "type": "date",
521 }
522 fig.update_layout(**layout_kw)
523 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
524 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
525 return fig
528def _apply_figsize(fig: go.Figure, figsize: tuple[int, int] | None) -> go.Figure:
529 """Apply optional ``(width, height)`` figure size to Plotly layout."""
530 if figsize is not None:
531 fig.update_layout(width=figsize[0], height=figsize[1])
532 return fig