plotaviz 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,790 @@
1
+ """Figure construction — Plotly for the interactive view, matplotlib for static export.
2
+
3
+ Both renderers consume the same :class:`~plotaviz.core.spec.ChartSpec` and, crucially, the same
4
+ *prepared* data. :func:`prepare` does the aggregating, sorting, category capping, and
5
+ downsampling once, so the interactive chart and the exported PNG cannot disagree with each other.
6
+
7
+ The performance guardrails live here rather than in the UI because they have to apply to CLI
8
+ exports too. Above roughly 100k points a scatter or line will lock up a WebEngine view, so the
9
+ data is downsampled — LTTB for time series, which preserves visual shape far better than random
10
+ sampling — and the caller is handed a note saying exactly what was shown. A chart that quietly
11
+ drops 90% of its data is worse than no chart.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+
22
+ from .errors import PlotError
23
+ from .spec import MATRIX_CHARTS, ChartSpec
24
+
25
+ #: Above this many points a scatter/line becomes unresponsive in a browser view.
26
+ MAX_POINTS = 100_000
27
+
28
+ #: Default cap on distinct categories drawn on a categorical axis.
29
+ MAX_CATEGORIES = 30
30
+
31
+ #: Label used for the bucket holding categories beyond the cap.
32
+ OTHER_LABEL = "Other"
33
+
34
+ #: Charts whose point count scales with row count, so they need downsampling.
35
+ _DENSE_CHARTS = frozenset({"scatter", "line", "area"})
36
+
37
+
38
+ @dataclass
39
+ class PreparedData:
40
+ """Plot-ready data plus everything the user needs told about it.
41
+
42
+ Attributes:
43
+ df: The frame to render.
44
+ spec: The **effective** spec — the caller's spec after aggregation renames the measure
45
+ column. Renderers use this one; it describes :attr:`df`, not the source frame.
46
+ source_spec: The spec as the caller supplied it, untouched.
47
+ notes: User-facing remarks — sampling, category bucketing, dropped nulls.
48
+ sampled: Whether rows were dropped for performance.
49
+ rows_original: Row count before preparation.
50
+ """
51
+
52
+ df: pd.DataFrame
53
+ spec: ChartSpec
54
+ source_spec: ChartSpec | None = None
55
+ notes: list[str] = field(default_factory=list)
56
+ sampled: bool = False
57
+ rows_original: int = 0
58
+
59
+ @property
60
+ def sampling_notice(self) -> str:
61
+ """The "showing N of M points" line, or an empty string when nothing was dropped."""
62
+ if not self.sampled:
63
+ return ""
64
+ return f"Showing a sample of {len(self.df):,} of {self.rows_original:,} points."
65
+
66
+
67
+ # ---------------------------------------------------------------------------- preparation
68
+
69
+
70
+ #: Name given to the measure column produced by a counting aggregation.
71
+ COUNT_COLUMN = "count"
72
+
73
+
74
+ def effective_spec(spec: ChartSpec) -> ChartSpec:
75
+ """The spec as it describes the data *after* aggregation.
76
+
77
+ Aggregating changes the frame's shape: ``count`` invents a measure column that does not exist
78
+ in the source data. Renderers need a spec that matches what they are handed, but the caller's
79
+ spec must keep matching the source frame — it still has to validate, serialize into a session,
80
+ and generate code. So aggregation produces a separate spec instead of editing one in place.
81
+
82
+ The title is resolved here too, before the measure is renamed, so a counted bar chart is
83
+ titled "Count by department" rather than "Count of count by department".
84
+ """
85
+ out = spec.copy(title=spec.display_title())
86
+ if not spec.agg:
87
+ return out
88
+
89
+ if spec.chart == "heatmap":
90
+ # For a heatmap x and y are both axes, so the measure lives in `color`.
91
+ if not out.color:
92
+ out.color = COUNT_COLUMN
93
+ out.agg = "count"
94
+ elif spec.agg == "count" or not spec.y:
95
+ out.y = COUNT_COLUMN
96
+ return out
97
+
98
+
99
+ def prepare(
100
+ df: pd.DataFrame,
101
+ spec: ChartSpec,
102
+ *,
103
+ max_points: int = MAX_POINTS,
104
+ max_categories: int | None = None,
105
+ ) -> PreparedData:
106
+ """Aggregate, cap, and downsample a frame for one chart spec.
107
+
108
+ Args:
109
+ df: The cleaned, filtered data.
110
+ spec: What to draw. Never modified.
111
+ max_points: Downsampling threshold for dense charts.
112
+ max_categories: Category cap. Defaults to ``spec.options['top_n']`` or
113
+ :data:`MAX_CATEGORIES`.
114
+
115
+ Returns:
116
+ A :class:`PreparedData` whose ``spec`` is the effective spec describing ``df``.
117
+
118
+ Raises:
119
+ PlotError: If the spec references missing columns or nothing plottable survives.
120
+ """
121
+ spec.validate(list(df.columns))
122
+ eff = effective_spec(spec)
123
+ notes: list[str] = []
124
+ rows_original = len(df)
125
+ cap = int(max_categories or spec.options.get("top_n") or MAX_CATEGORIES)
126
+
127
+ if spec.chart in MATRIX_CHARTS:
128
+ numeric = df.select_dtypes(include="number")
129
+ # Outlier flag columns are booleans-as-numbers; they pollute a correlation matrix.
130
+ numeric = numeric[[c for c in numeric.columns if not str(c).endswith("__outlier")]]
131
+ if numeric.shape[1] < 2:
132
+ raise PlotError(
133
+ "This chart needs at least two numeric columns.",
134
+ hint="Use the type override panel if a numeric column was read as text.",
135
+ )
136
+ if numeric.shape[1] > 12:
137
+ keep = list(numeric.columns[:12])
138
+ notes.append(f"Showing the first 12 of {numeric.shape[1]} numeric columns.")
139
+ numeric = numeric[keep]
140
+ return PreparedData(numeric, eff, spec, notes, False, rows_original)
141
+
142
+ work = df[[c for c in spec.columns_used if c in df.columns]].copy()
143
+
144
+ # Drop rows that cannot be plotted, and say how many.
145
+ before = len(work)
146
+ work = work.dropna(subset=[c for c in (spec.x, spec.y) if c])
147
+ if len(work) < before:
148
+ notes.append(
149
+ f"Skipped {before - len(work):,} row(s) with missing values in the plotted columns."
150
+ )
151
+
152
+ if work.empty:
153
+ raise PlotError(
154
+ "No rows are left to plot once missing values are excluded.",
155
+ hint="Loosen the filters, or choose a different missing-value strategy.",
156
+ )
157
+
158
+ # Cap categories on the x axis (and on the colour split) before aggregating.
159
+ for column, is_axis in ((spec.x, True), (spec.color, False)):
160
+ if not column or column not in work.columns:
161
+ continue
162
+ if pd.api.types.is_numeric_dtype(work[column]) or pd.api.types.is_datetime64_any_dtype(
163
+ work[column]
164
+ ):
165
+ continue
166
+ n_unique = work[column].nunique()
167
+ limit = cap if is_axis else min(cap, 12)
168
+ if n_unique > limit:
169
+ work, dropped = _cap_categories(work, column, limit, spec)
170
+ notes.append(
171
+ f"{column!r} has {n_unique:,} categories; showing the top {limit} and grouping "
172
+ f"the remaining {dropped:,} as {OTHER_LABEL!r}."
173
+ )
174
+
175
+ # Aggregate if the spec asks for it.
176
+ if spec.agg:
177
+ work = _aggregate(work, spec, eff)
178
+
179
+ # Time series must be ordered, or the line zig-zags.
180
+ if eff.x and eff.x in work.columns and pd.api.types.is_datetime64_any_dtype(work[eff.x]):
181
+ work = work.sort_values(eff.x)
182
+ elif eff.chart in {"bar", "treemap", "pie"} and eff.y and eff.y in work.columns:
183
+ ascending = bool(eff.options.get("sort_ascending", False))
184
+ work = work.sort_values(eff.y, ascending=ascending)
185
+
186
+ # Downsample dense charts.
187
+ sampled = False
188
+ if eff.chart in _DENSE_CHARTS and len(work) > max_points:
189
+ work = _downsample(work, eff, max_points)
190
+ sampled = True
191
+ notes.append(
192
+ f"Showing a sample of {len(work):,} of {rows_original:,} points to keep the chart "
193
+ "responsive. The exported code uses the full dataset."
194
+ )
195
+
196
+ return PreparedData(work, eff, spec, notes, sampled, rows_original)
197
+
198
+
199
+ def _cap_categories(
200
+ df: pd.DataFrame, column: str, limit: int, spec: ChartSpec
201
+ ) -> tuple[pd.DataFrame, int]:
202
+ """Keep the ``limit`` largest categories in ``column``; bucket the rest as "Other"."""
203
+ if spec.y and spec.y in df.columns and pd.api.types.is_numeric_dtype(df[spec.y]):
204
+ ranking = df.groupby(column, observed=True)[spec.y].sum().nlargest(limit)
205
+ else:
206
+ ranking = df[column].value_counts().nlargest(limit)
207
+ keep = set(ranking.index)
208
+ out = df.copy()
209
+ mask = ~out[column].isin(keep)
210
+ dropped = int(out.loc[mask, column].nunique())
211
+ out[column] = out[column].where(~mask, OTHER_LABEL)
212
+ return out, dropped
213
+
214
+
215
+ def group_keys(*columns: str | None, available: list[str] | None = None) -> list[str]:
216
+ """Group-by keys from a spec's mapping — present, ordered, and deduplicated.
217
+
218
+ A spec may legitimately map the same column to two roles (a bar chart coloured by its own x
219
+ axis is a common way to get per-category colours). Passing that column to ``groupby`` twice
220
+ makes ``reset_index`` fail with "cannot insert region, already exists", so the duplicate is
221
+ dropped here rather than surfacing as a crash mid-render.
222
+ """
223
+ seen: list[str] = []
224
+ for column in columns:
225
+ if not column or column in seen:
226
+ continue
227
+ if available is not None and column not in available:
228
+ continue
229
+ seen.append(column)
230
+ return seen
231
+
232
+
233
+ def _group_keys(df: pd.DataFrame, *columns: str | None) -> list[str]:
234
+ """:func:`group_keys` restricted to the columns a frame actually has."""
235
+ return group_keys(*columns, available=list(df.columns))
236
+
237
+
238
+ def _aggregate(df: pd.DataFrame, spec: ChartSpec, eff: ChartSpec) -> pd.DataFrame:
239
+ """Group by the spec's keys and collapse the measure.
240
+
241
+ Args:
242
+ df: Frame holding the source columns.
243
+ spec: The caller's spec, which names real source columns.
244
+ eff: The effective spec, which names the measure column to produce.
245
+ """
246
+ keys = _group_keys(df, spec.x, spec.color)
247
+ measure = eff.y
248
+ if spec.chart == "heatmap":
249
+ keys = _group_keys(df, spec.x, spec.y)
250
+ measure = eff.color
251
+ if not keys or not measure:
252
+ return df
253
+
254
+ counting = spec.agg == "count" or not spec.y or spec.y not in df.columns
255
+ if spec.chart == "heatmap":
256
+ counting = eff.agg == "count" or not spec.color or spec.color not in df.columns
257
+
258
+ if counting:
259
+ return df.groupby(keys, observed=True, dropna=False).size().reset_index(name=measure)
260
+
261
+ source_measure = spec.color if spec.chart == "heatmap" else spec.y
262
+ if not source_measure:
263
+ return df
264
+ try:
265
+ grouped = df.groupby(keys, observed=True, dropna=False)[source_measure].agg(spec.agg)
266
+ except (TypeError, ValueError, KeyError) as exc:
267
+ raise PlotError(
268
+ f"Cannot compute the {spec.agg} of {source_measure!r} grouped by {', '.join(keys)}.",
269
+ hint="The measure column is probably not numeric. Check the type override panel.",
270
+ ) from exc
271
+ out = grouped.reset_index()
272
+ if source_measure != measure:
273
+ out = out.rename(columns={source_measure: measure})
274
+ return out
275
+
276
+
277
+ def _downsample(df: pd.DataFrame, spec: ChartSpec, max_points: int) -> pd.DataFrame:
278
+ """Reduce a dense frame, preserving shape.
279
+
280
+ Time-ordered data uses LTTB (largest-triangle-three-buckets), which keeps peaks and troughs
281
+ a random sample would erase. Everything else takes a reproducible random sample.
282
+ """
283
+ is_time_series = (
284
+ spec.x is not None
285
+ and spec.x in df.columns
286
+ and pd.api.types.is_datetime64_any_dtype(df[spec.x])
287
+ and spec.y is not None
288
+ and spec.y in df.columns
289
+ )
290
+ if is_time_series and spec.color is None:
291
+ return _lttb(df, str(spec.x), str(spec.y), max_points)
292
+ return df.sample(max_points, random_state=0).sort_index()
293
+
294
+
295
+ def _lttb(df: pd.DataFrame, x: str, y: str, threshold: int) -> pd.DataFrame:
296
+ """Largest-triangle-three-buckets downsampling for time series.
297
+
298
+ Keeps the first and last points, then picks from each bucket the point forming the largest
299
+ triangle with its neighbours — the standard way to shrink a series without flattening spikes.
300
+ """
301
+ n = len(df)
302
+ if threshold >= n or threshold < 3:
303
+ return df
304
+
305
+ ordered = df.sort_values(x)
306
+ xs = pd.to_datetime(ordered[x]).astype("int64").to_numpy(dtype=float)
307
+ ys = pd.to_numeric(ordered[y], errors="coerce").to_numpy(dtype=float)
308
+
309
+ bucket_size = (n - 2) / (threshold - 2)
310
+ picked = [0]
311
+ a = 0
312
+ for i in range(threshold - 2):
313
+ start = int(np.floor((i + 1) * bucket_size)) + 1
314
+ end = min(int(np.floor((i + 2) * bucket_size)) + 1, n)
315
+ next_start = end
316
+ next_end = min(int(np.floor((i + 3) * bucket_size)) + 1, n)
317
+ if next_start >= next_end:
318
+ next_start, next_end = max(n - 1, start), n
319
+
320
+ avg_x = float(np.nanmean(xs[next_start:next_end])) if next_end > next_start else xs[-1]
321
+ avg_y = float(np.nanmean(ys[next_start:next_end])) if next_end > next_start else ys[-1]
322
+
323
+ if start >= end:
324
+ continue
325
+ areas = np.abs(
326
+ (xs[a] - avg_x) * (ys[start:end] - ys[a]) - (xs[a] - xs[start:end]) * (avg_y - ys[a])
327
+ )
328
+ offset = int(np.nanargmax(areas)) if np.isfinite(areas).any() else 0
329
+ a = start + offset
330
+ picked.append(a)
331
+
332
+ picked.append(n - 1)
333
+ return ordered.iloc[sorted(set(picked))]
334
+
335
+
336
+ def kde_curve(values: pd.Series, *, points: int = 200) -> tuple[np.ndarray, np.ndarray]:
337
+ """Gaussian kernel density estimate on a fixed grid.
338
+
339
+ Implemented directly on numpy rather than pulling in scipy — the dependency is not worth one
340
+ curve, and Silverman's rule is a perfectly good default bandwidth here.
341
+
342
+ Returns:
343
+ ``(grid, density)`` arrays suitable for a line plot.
344
+ """
345
+ data = pd.to_numeric(values, errors="coerce").dropna().to_numpy(dtype=float)
346
+ if data.size < 2:
347
+ raise PlotError("A density curve needs at least two numeric values.")
348
+
349
+ std = float(np.std(data, ddof=1)) or 1.0
350
+ iqr = float(np.subtract(*np.percentile(data, [75, 25])))
351
+ spread = min(std, iqr / 1.349) if iqr > 0 else std
352
+ bandwidth = 0.9 * spread * data.size ** (-1 / 5) or 1.0
353
+
354
+ grid = np.linspace(data.min() - 3 * bandwidth, data.max() + 3 * bandwidth, points)
355
+ # (grid, data) distance matrix; fine for the sample sizes a KDE is meaningful on.
356
+ if data.size > 20_000:
357
+ data = np.random.default_rng(0).choice(data, 20_000, replace=False)
358
+ z = (grid[:, None] - data[None, :]) / bandwidth
359
+ density = np.exp(-0.5 * z**2).sum(axis=1) / (data.size * bandwidth * np.sqrt(2 * np.pi))
360
+ return grid, density
361
+
362
+
363
+ # ---------------------------------------------------------------------------- Plotly
364
+
365
+
366
+ def build_plotly(df: pd.DataFrame, spec: ChartSpec, *, prepared: PreparedData | None = None) -> Any:
367
+ """Build an interactive Plotly figure.
368
+
369
+ Args:
370
+ df: Cleaned, filtered data.
371
+ spec: What to draw.
372
+ prepared: Reuse an existing :func:`prepare` result instead of recomputing it.
373
+
374
+ Returns:
375
+ A ``plotly.graph_objects.Figure``.
376
+
377
+ Raises:
378
+ PlotError: If Plotly is unavailable or the chart cannot be built.
379
+ """
380
+ try:
381
+ import plotly.express as px
382
+ import plotly.graph_objects as go
383
+ except ImportError as exc: # pragma: no cover - dependency is declared
384
+ raise PlotError(
385
+ "Interactive charts need the plotly package.",
386
+ hint="Install it with: pip install plotly",
387
+ ) from exc
388
+
389
+ data = prepared or prepare(df, spec)
390
+ frame, spec = data.df, data.spec
391
+ title = spec.display_title()
392
+ opts = spec.options
393
+
394
+ try:
395
+ chart = spec.chart
396
+ if chart == "histogram":
397
+ fig = px.histogram(
398
+ frame, x=spec.x, color=spec.color, nbins=opts.get("bins"), title=title
399
+ )
400
+ elif chart == "kde":
401
+ grid, density = kde_curve(frame[spec.x])
402
+ fig = go.Figure(go.Scatter(x=grid, y=density, fill="tozeroy", mode="lines"))
403
+ fig.update_layout(title=title, xaxis_title=spec.x, yaxis_title="density")
404
+ elif chart in {"bar", "grouped_bar", "stacked_bar"}:
405
+ fig = px.bar(
406
+ frame,
407
+ x=spec.x,
408
+ y=spec.y,
409
+ color=spec.color,
410
+ barmode="stack" if chart == "stacked_bar" or opts.get("stacked") else "group",
411
+ orientation=opts.get("orientation", "v"),
412
+ title=title,
413
+ )
414
+ elif chart == "pie":
415
+ fig = px.pie(frame, names=spec.x, values=spec.y, title=title)
416
+ elif chart == "treemap":
417
+ path = [spec.x] if not spec.color else [spec.color, spec.x]
418
+ fig = px.treemap(frame, path=path, values=spec.y, title=title)
419
+ elif chart == "scatter":
420
+ fig = px.scatter(
421
+ frame,
422
+ x=spec.x,
423
+ y=spec.y,
424
+ color=spec.color,
425
+ opacity=opts.get("opacity", 0.75),
426
+ trendline="ols" if opts.get("trendline") and _has_statsmodels() else None,
427
+ title=title,
428
+ )
429
+ elif chart == "line":
430
+ fig = px.line(
431
+ frame, x=spec.x, y=spec.y, color=spec.color, markers=len(frame) < 200, title=title
432
+ )
433
+ elif chart == "area":
434
+ fig = px.area(frame, x=spec.x, y=spec.y, color=spec.color, title=title)
435
+ elif chart == "box":
436
+ fig = px.box(
437
+ frame, x=spec.x, y=spec.y, color=spec.color, points="outliers", title=title
438
+ )
439
+ elif chart == "violin":
440
+ fig = px.violin(frame, x=spec.x, y=spec.y, color=spec.color, box=True, title=title)
441
+ elif chart == "heatmap":
442
+ value = spec.color or "count"
443
+ value = value if value in frame.columns else frame.columns[-1]
444
+ matrix = frame.pivot_table(index=spec.y, columns=spec.x, values=value, aggfunc="mean")
445
+ fig = px.imshow(matrix, aspect="auto", text_auto=matrix.size <= 100, title=title)
446
+ elif chart == "correlation_heatmap":
447
+ corr = frame.corr(numeric_only=True)
448
+ fig = px.imshow(
449
+ corr,
450
+ zmin=-1,
451
+ zmax=1,
452
+ color_continuous_scale="RdBu_r",
453
+ text_auto=".2f" if corr.size <= 100 else False,
454
+ aspect="auto",
455
+ title=title,
456
+ )
457
+ elif chart == "pair_plot":
458
+ fig = px.scatter_matrix(frame, dimensions=list(frame.columns)[:6], title=title)
459
+ fig.update_traces(diagonal_visible=False, showupperhalf=False)
460
+ else: # pragma: no cover - validate() rejects unknown types first
461
+ raise PlotError(f"No Plotly renderer for chart type {chart!r}.")
462
+ except PlotError:
463
+ raise
464
+ except Exception as exc:
465
+ raise PlotError(f"Could not draw the {spec.chart} chart.", hint=str(exc)) from exc
466
+
467
+ if opts.get("log_y"):
468
+ fig.update_yaxes(type="log")
469
+ if opts.get("log_x"):
470
+ fig.update_xaxes(type="log")
471
+
472
+ fig.update_layout(
473
+ template=opts.get("template", "plotly_white"),
474
+ margin={"l": 60, "r": 30, "t": 60, "b": 60},
475
+ legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "x": 0},
476
+ )
477
+ if data.sampled:
478
+ fig.add_annotation(
479
+ text=data.sampling_notice,
480
+ xref="paper",
481
+ yref="paper",
482
+ x=0,
483
+ y=-0.16,
484
+ showarrow=False,
485
+ font={"size": 11, "color": "#666"},
486
+ )
487
+ return fig
488
+
489
+
490
+ def _has_statsmodels() -> bool:
491
+ """Plotly's OLS trendline needs statsmodels; degrade quietly when it is absent."""
492
+ try:
493
+ import statsmodels.api # noqa: F401
494
+ except ImportError:
495
+ return False
496
+ return True
497
+
498
+
499
+ # ---------------------------------------------------------------------------- matplotlib
500
+
501
+
502
+ def build_matplotlib(
503
+ df: pd.DataFrame,
504
+ spec: ChartSpec,
505
+ *,
506
+ prepared: PreparedData | None = None,
507
+ figsize: tuple[float, float] = (10.0, 6.0),
508
+ dpi: int = 300,
509
+ ) -> Any:
510
+ """Build a static matplotlib figure for export.
511
+
512
+ Uses the ``Agg`` backend explicitly so this works headless — CLI mode and CI both rely on it.
513
+
514
+ Args:
515
+ df: Cleaned, filtered data.
516
+ spec: What to draw.
517
+ prepared: Reuse an existing :func:`prepare` result.
518
+ figsize: Figure size in inches.
519
+ dpi: Dots per inch. 300 is print quality and the export default.
520
+
521
+ Returns:
522
+ A ``matplotlib.figure.Figure``.
523
+
524
+ Raises:
525
+ PlotError: If the chart cannot be built.
526
+ """
527
+ import matplotlib
528
+
529
+ matplotlib.use("Agg", force=False)
530
+ import matplotlib.pyplot as plt
531
+
532
+ data = prepared or prepare(df, spec)
533
+ frame, spec = data.df, data.spec
534
+ opts = spec.options
535
+ chart = spec.chart
536
+
537
+ try:
538
+ if chart == "pair_plot":
539
+ fig = _mpl_pair_plot(frame, figsize=figsize, dpi=dpi)
540
+ fig.suptitle(spec.display_title())
541
+ return fig
542
+
543
+ fig, ax = plt.subplots(figsize=figsize, dpi=dpi)
544
+
545
+ if chart == "histogram":
546
+ ax.hist(
547
+ pd.to_numeric(frame[spec.x], errors="coerce").dropna(),
548
+ bins=opts.get("bins", "auto"),
549
+ color="#4C78A8",
550
+ edgecolor="white",
551
+ )
552
+ ax.set_xlabel(str(spec.x))
553
+ ax.set_ylabel("count")
554
+ elif chart == "kde":
555
+ grid, density = kde_curve(frame[spec.x])
556
+ ax.plot(grid, density, color="#4C78A8")
557
+ ax.fill_between(grid, density, alpha=0.3, color="#4C78A8")
558
+ ax.set_xlabel(str(spec.x))
559
+ ax.set_ylabel("density")
560
+ elif chart in {"bar", "grouped_bar", "stacked_bar"}:
561
+ _mpl_bar(ax, frame, spec, stacked=chart == "stacked_bar" or bool(opts.get("stacked")))
562
+ elif chart == "pie":
563
+ values = frame[spec.y] if spec.y and spec.y in frame else frame.iloc[:, -1]
564
+ ax.pie(values, labels=frame[spec.x].astype(str).tolist(), autopct="%1.1f%%")
565
+ ax.set_ylabel("")
566
+ elif chart == "treemap":
567
+ _mpl_treemap(ax, frame, spec)
568
+ elif chart == "scatter":
569
+ _mpl_scatter(ax, frame, spec)
570
+ elif chart in {"line", "area"}:
571
+ _mpl_line(ax, frame, spec, area=chart == "area")
572
+ elif chart in {"box", "violin"}:
573
+ _mpl_distribution(ax, frame, spec, violin=chart == "violin")
574
+ elif chart == "heatmap":
575
+ value = spec.color if spec.color in frame.columns else frame.columns[-1]
576
+ matrix = frame.pivot_table(index=spec.y, columns=spec.x, values=value, aggfunc="mean")
577
+ image = ax.imshow(matrix.to_numpy(dtype=float), aspect="auto", cmap="viridis")
578
+ ax.set_xticks(
579
+ range(len(matrix.columns)),
580
+ [str(c) for c in matrix.columns],
581
+ rotation=45,
582
+ ha="right",
583
+ )
584
+ ax.set_yticks(range(len(matrix.index)), [str(i) for i in matrix.index])
585
+ fig.colorbar(image, ax=ax, label=str(value))
586
+ elif chart == "correlation_heatmap":
587
+ corr = frame.corr(numeric_only=True)
588
+ image = ax.imshow(corr.to_numpy(dtype=float), vmin=-1, vmax=1, cmap="RdBu_r")
589
+ ax.set_xticks(range(len(corr.columns)), list(corr.columns), rotation=45, ha="right")
590
+ ax.set_yticks(range(len(corr.index)), list(corr.index))
591
+ if corr.size <= 100:
592
+ for i in range(len(corr.index)):
593
+ for j in range(len(corr.columns)):
594
+ ax.text(j, i, f"{corr.iat[i, j]:.2f}", ha="center", va="center", fontsize=8)
595
+ fig.colorbar(image, ax=ax, label="Pearson r")
596
+ else: # pragma: no cover
597
+ raise PlotError(f"No matplotlib renderer for chart type {chart!r}.")
598
+
599
+ if opts.get("log_y"):
600
+ ax.set_yscale("log")
601
+ if opts.get("log_x"):
602
+ ax.set_xscale("log")
603
+
604
+ ax.set_title(spec.display_title())
605
+ if chart not in {"pie", "treemap", "heatmap", "correlation_heatmap"}:
606
+ if spec.x:
607
+ ax.set_xlabel(str(spec.x))
608
+ if spec.y and chart not in {"histogram", "kde"}:
609
+ ax.set_ylabel(str(spec.y))
610
+ ax.grid(True, alpha=0.25, linestyle="--")
611
+ ax.set_axisbelow(True)
612
+
613
+ if data.sampled:
614
+ fig.text(0.01, 0.01, data.sampling_notice, fontsize=8, color="#666")
615
+
616
+ fig.tight_layout()
617
+ return fig
618
+ except PlotError:
619
+ raise
620
+ except Exception as exc:
621
+ raise PlotError(f"Could not draw the {spec.chart} chart.", hint=str(exc)) from exc
622
+
623
+
624
+ def _mpl_bar(ax: Any, frame: pd.DataFrame, spec: ChartSpec, *, stacked: bool) -> None:
625
+ """Draw a plain, grouped, or stacked bar chart on ``ax``."""
626
+ x, y = str(spec.x), str(spec.y) if spec.y else None
627
+ if y is None or y not in frame.columns:
628
+ counts = frame[x].value_counts()
629
+ ax.bar([str(i) for i in counts.index], counts.to_numpy(), color="#4C78A8")
630
+ ax.set_ylabel("count")
631
+ elif spec.color and spec.color in frame.columns:
632
+ pivot = frame.pivot_table(index=x, columns=spec.color, values=y, aggfunc="mean")
633
+ pivot.plot(kind="bar", stacked=stacked, ax=ax, width=0.8)
634
+ ax.legend(title=str(spec.color), fontsize=8)
635
+ else:
636
+ ax.bar(frame[x].astype(str), pd.to_numeric(frame[y], errors="coerce"), color="#4C78A8")
637
+ if len(ax.get_xticklabels()) > 6:
638
+ ax.tick_params(axis="x", rotation=45)
639
+ for label in ax.get_xticklabels():
640
+ label.set_horizontalalignment("right")
641
+
642
+
643
+ def _mpl_scatter(ax: Any, frame: pd.DataFrame, spec: ChartSpec) -> None:
644
+ """Draw a scatter, optionally split by colour, with an optional least-squares trendline."""
645
+ x, y = str(spec.x), str(spec.y)
646
+ if spec.color and spec.color in frame.columns:
647
+ for name, group in frame.groupby(spec.color, observed=True):
648
+ ax.scatter(group[x], group[y], s=14, alpha=0.7, label=str(name))
649
+ ax.legend(title=str(spec.color), fontsize=8)
650
+ else:
651
+ ax.scatter(frame[x], frame[y], s=14, alpha=0.7, color="#4C78A8")
652
+
653
+ if spec.options.get("trendline"):
654
+ xs = pd.to_numeric(frame[x], errors="coerce")
655
+ ys = pd.to_numeric(frame[y], errors="coerce")
656
+ mask = xs.notna() & ys.notna()
657
+ if int(mask.sum()) > 2:
658
+ slope, intercept = np.polyfit(xs[mask], ys[mask], 1)
659
+ line = np.linspace(xs[mask].min(), xs[mask].max(), 100)
660
+ ax.plot(line, slope * line + intercept, color="#E45756", linewidth=1.5, label="trend")
661
+
662
+
663
+ def _mpl_line(ax: Any, frame: pd.DataFrame, spec: ChartSpec, *, area: bool) -> None:
664
+ """Draw a line or filled area, one series per colour value."""
665
+ x, y = str(spec.x), str(spec.y)
666
+ if spec.color and spec.color in frame.columns:
667
+ for name, group in frame.groupby(spec.color, observed=True):
668
+ group = group.sort_values(x)
669
+ ax.plot(group[x], group[y], label=str(name), linewidth=1.6)
670
+ if area:
671
+ ax.fill_between(group[x], group[y], alpha=0.25)
672
+ ax.legend(title=str(spec.color), fontsize=8)
673
+ else:
674
+ ordered = frame.sort_values(x)
675
+ ax.plot(ordered[x], ordered[y], color="#4C78A8", linewidth=1.6)
676
+ if area:
677
+ ax.fill_between(ordered[x], ordered[y], alpha=0.3, color="#4C78A8")
678
+ if pd.api.types.is_datetime64_any_dtype(frame[x]):
679
+ ax.figure.autofmt_xdate()
680
+
681
+
682
+ def _mpl_distribution(ax: Any, frame: pd.DataFrame, spec: ChartSpec, *, violin: bool) -> None:
683
+ """Draw a box or violin plot, one body per category on the x axis."""
684
+ y = str(spec.y)
685
+ if spec.x and spec.x in frame.columns:
686
+ groups = [
687
+ (str(name), pd.to_numeric(group[y], errors="coerce").dropna().to_numpy())
688
+ for name, group in frame.groupby(spec.x, observed=True)
689
+ ]
690
+ groups = [(name, values) for name, values in groups if values.size]
691
+ else:
692
+ groups = [(y, pd.to_numeric(frame[y], errors="coerce").dropna().to_numpy())]
693
+
694
+ if not groups:
695
+ raise PlotError("No numeric values are available to summarize.")
696
+
697
+ labels = [name for name, _ in groups]
698
+ values = [vals for _, vals in groups]
699
+ if violin:
700
+ ax.violinplot(values, showmedians=True)
701
+ ax.set_xticks(range(1, len(labels) + 1), labels)
702
+ else:
703
+ ax.boxplot(values, tick_labels=labels)
704
+ if len(labels) > 6:
705
+ ax.tick_params(axis="x", rotation=45)
706
+
707
+
708
+ def _mpl_treemap(ax: Any, frame: pd.DataFrame, spec: ChartSpec) -> None:
709
+ """Draw a simple slice-and-dice treemap without pulling in a dedicated dependency."""
710
+ import matplotlib.pyplot as plt
711
+ from matplotlib.patches import Rectangle
712
+
713
+ labels = frame[str(spec.x)].astype(str).tolist()
714
+ if spec.y and spec.y in frame.columns:
715
+ sizes = pd.to_numeric(frame[spec.y], errors="coerce").fillna(0).tolist()
716
+ else:
717
+ counts = frame[str(spec.x)].value_counts()
718
+ labels, sizes = [str(i) for i in counts.index], counts.tolist()
719
+
720
+ pairs = sorted(zip(labels, sizes, strict=True), key=lambda p: p[1], reverse=True)
721
+ pairs = [(label, size) for label, size in pairs if size > 0][:40]
722
+ if not pairs:
723
+ raise PlotError("The treemap has no positive values to lay out.")
724
+
725
+ total = sum(size for _, size in pairs)
726
+ cmap = plt.get_cmap("tab20")
727
+ x = y = 0.0
728
+ width = height = 1.0
729
+ horizontal = True
730
+ for i, (label, size) in enumerate(pairs):
731
+ fraction = size / total if total else 0
732
+ if horizontal:
733
+ rect_w, rect_h = width * fraction / (fraction + _remaining(pairs, i, total)), height
734
+ else:
735
+ rect_w, rect_h = width, height * fraction / (fraction + _remaining(pairs, i, total))
736
+ ax.add_patch(
737
+ Rectangle(
738
+ (x, y), rect_w, rect_h, facecolor=cmap(i % 20), edgecolor="white", linewidth=1.5
739
+ )
740
+ )
741
+ if rect_w > 0.06 and rect_h > 0.05:
742
+ ax.text(
743
+ x + rect_w / 2,
744
+ y + rect_h / 2,
745
+ label,
746
+ ha="center",
747
+ va="center",
748
+ fontsize=8,
749
+ color="white",
750
+ weight="bold",
751
+ )
752
+ if horizontal:
753
+ x += rect_w
754
+ width -= rect_w
755
+ else:
756
+ y += rect_h
757
+ height -= rect_h
758
+ horizontal = not horizontal
759
+ ax.set_xlim(0, 1)
760
+ ax.set_ylim(0, 1)
761
+ ax.axis("off")
762
+
763
+
764
+ def _remaining(pairs: list[tuple[str, float]], index: int, total: float) -> float:
765
+ """Fraction of the total still to be laid out after ``index``."""
766
+ rest = sum(size for _, size in pairs[index + 1 :])
767
+ return rest / total if total else 0.0
768
+
769
+
770
+ def _mpl_pair_plot(frame: pd.DataFrame, *, figsize: tuple[float, float], dpi: int) -> Any:
771
+ """Scatter-matrix of up to five numeric columns, histograms on the diagonal."""
772
+ import matplotlib.pyplot as plt
773
+
774
+ cols = list(frame.columns)[:5]
775
+ n = len(cols)
776
+ fig, axes = plt.subplots(n, n, figsize=figsize, dpi=dpi, squeeze=False)
777
+ for i, row_col in enumerate(cols):
778
+ for j, col_col in enumerate(cols):
779
+ ax = axes[i][j]
780
+ if i == j:
781
+ ax.hist(frame[row_col].dropna(), bins=20, color="#4C78A8", edgecolor="white")
782
+ else:
783
+ ax.scatter(frame[col_col], frame[row_col], s=6, alpha=0.5, color="#4C78A8")
784
+ if i == n - 1:
785
+ ax.set_xlabel(str(col_col), fontsize=8)
786
+ if j == 0:
787
+ ax.set_ylabel(str(row_col), fontsize=8)
788
+ ax.tick_params(labelsize=6)
789
+ fig.tight_layout()
790
+ return fig