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

1"""Render a `FigureSpec` with Plotly. 

2 

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. 

7 

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""" 

14 

15from __future__ import annotations 

16 

17from typing import Any 

18 

19import plotly.graph_objects as go 

20import plotly.io as pio 

21from plotly.subplots import make_subplots 

22 

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) 

38 

39__all__ = ["render_plotly"] 

40 

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" 

46 

47 

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} 

58 

59# Semantic dash style -> Plotly's `line.dash` vocabulary. 

60_DASHES = {"solid": "solid", "dash": "dash"} 

61 

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} 

70 

71# Bars are drawn without an outline throughout. 

72_BAR_OUTLINE = {"width": 0} 

73 

74 

75def _hovertemplate(hover: HoverSpec) -> str: 

76 """Build a Plotly hovertemplate from a structural hover description. 

77 

78 Args: 

79 hover: What the tooltip should say. 

80 

81 Returns: 

82 str: A hovertemplate string. 

83 

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}" 

89 

90 

91def _scatter(line: LineSeries) -> go.Scatter: 

92 """Convert one line series into a Plotly scatter trace. 

93 

94 Args: 

95 line: The series to draw. 

96 

97 Returns: 

98 go.Scatter: The trace, styled per the series. 

99 

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] 

107 

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 

113 

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 

119 

120 mode = "lines+markers" if line.markers else "lines" 

121 if line.marker_size is not None: 

122 grouping["marker"] = {"size": line.marker_size} 

123 

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 

128 

129 

130def _bar(bar: BarSeries) -> go.Bar: 

131 """Convert one bar series into a Plotly bar trace. 

132 

133 Args: 

134 bar: The series to draw. 

135 

136 Returns: 

137 go.Bar: The trace, with one colour per bar. 

138 

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 

151 

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 

156 

157 

158def _histogram(hist: HistogramSeries) -> go.Histogram: 

159 """Convert one histogram series into a Plotly histogram trace. 

160 

161 Args: 

162 hist: The series to bin and draw. 

163 

164 Returns: 

165 go.Histogram: The trace. 

166 

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 

179 

180 

181def _box(box: BoxSeries) -> go.Box: 

182 """Convert one box-and-whisker series into a Plotly box trace. 

183 

184 Args: 

185 box: The series to summarise. 

186 

187 Returns: 

188 go.Box: The trace, showing outliers individually. 

189 

190 """ 

191 grouping: dict[str, object] = {"showlegend": box.show_legend} 

192 if box.legend_group is not None: 

193 grouping["legendgroup"] = box.legend_group 

194 

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 

199 

200 

201def _heatmap(grid: HeatmapGrid) -> go.Heatmap: 

202 """Convert a value matrix into a Plotly heatmap trace. 

203 

204 Args: 

205 grid: The matrix to draw. 

206 

207 Returns: 

208 go.Heatmap: The trace, labelled cell by cell. 

209 

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 

227 

228 

229def _add_ref_line(fig: go.Figure, ref: RefLine, at: dict[str, int]) -> None: 

230 """Draw a fixed-value marker line onto *fig*. 

231 

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. 

236 

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 } 

249 

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) 

254 

255 

256def _add_band(fig: go.Figure, band: Band, at: dict[str, int]) -> None: 

257 """Draw a shaded vertical span onto *fig*. 

258 

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. 

263 

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) 

273 

274 

275def _axis_kwargs(axis: Axis, *, vertical: bool = False) -> dict[str, object]: 

276 """Collect the axis properties a spec actually asked for. 

277 

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. 

281 

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. 

286 

287 Returns: 

288 dict[str, object]: Keyword arguments for ``update_xaxes`` / 

289 ``update_yaxes``. 

290 

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 

308 

309 

310def render_plotly(spec: FigureSpec) -> go.Figure: 

311 """Render *spec* as an interactive Plotly figure. 

312 

313 Args: 

314 spec: The chart to draw. Must describe exactly one panel; multi-panel 

315 charts arrive with the dashboards. 

316 

317 Returns: 

318 go.Figure: The rendered figure. 

319 

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) 

325 

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 

332 

333 

334def _position(spec: FigureSpec, index: int) -> dict[str, int]: 

335 """Locate the *index*-th panel within the figure's subplot grid. 

336 

337 Args: 

338 spec: The chart being rendered. 

339 index: One-based panel number. 

340 

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. 

344 

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} 

351 

352 

353def _blank_figure(spec: FigureSpec) -> go.Figure: 

354 """Create the figure a spec's panels will be drawn onto. 

355 

356 Args: 

357 spec: The chart being rendered. 

358 

359 Returns: 

360 go.Figure: An empty figure, with a subplot grid when the spec has one. 

361 

362 """ 

363 if spec.arrangement == "single": 

364 return go.Figure() 

365 

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) 

377 

378 

379def _draw_panel(fig: go.Figure, panel: Panel, at: dict[str, int]) -> None: 

380 """Draw one panel's marks onto *fig*. 

381 

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. 

386 

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) 

402 

403 

404def _apply_layout(fig: go.Figure, spec: FigureSpec) -> None: 

405 """Apply the figure-wide layout for *spec*'s chrome. 

406 

407 Args: 

408 fig: The figure to lay out. 

409 spec: The chart being rendered. 

410 

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") 

443 

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) 

449 

450 

451def _apply_panel_axes(fig: go.Figure, panel: Panel, at: dict[str, int]) -> None: 

452 """Apply one panel's axis configuration. 

453 

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. 

458 

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) 

466 

467 

468def _date_range_selector() -> dict[str, Any]: 

469 """Return a standard Plotly date range-selector configuration. 

470 

471 Returns: 

472 A dict suitable for ``xaxis.rangeselector``. 

473 

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 } 

484 

485 

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. 

493 

494 Sets white background, light-grey grid, horizontal legend, and an 

495 optional date range-selector on the primary x-axis. 

496 

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. 

504 

505 Returns: 

506 The same figure, mutated in-place and returned for chaining. 

507 

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 

526 

527 

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