apmtools 0.2.46__tar.gz → 0.2.47__tar.gz
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.
- {apmtools-0.2.46 → apmtools-0.2.47}/PKG-INFO +1 -1
- {apmtools-0.2.46 → apmtools-0.2.47}/pyproject.toml +1 -1
- {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/shiny/appearance.py +3 -2
- apmtools-0.2.47/src/apmtools/shiny/color_editor.py +64 -0
- apmtools-0.2.47/src/apmtools/shiny/elements.py +96 -0
- apmtools-0.2.47/src/apmtools/shiny/histogram_multi.py +32 -0
- {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/shiny/matplotlib_renderer.py +147 -19
- apmtools-0.2.47/src/apmtools/shiny/multi_axis.py +150 -0
- {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/shiny/plotly_renderer.py +32 -5
- {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/shiny/ui.py +575 -95
- {apmtools-0.2.46 → apmtools-0.2.47}/.gitignore +0 -0
- {apmtools-0.2.46 → apmtools-0.2.47}/LICENSE +0 -0
- {apmtools-0.2.46 → apmtools-0.2.47}/README.md +0 -0
- {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/__init__.py +0 -0
- {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/classes.py +0 -0
- {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/data_processing.py +0 -0
- {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/functions.py +0 -0
- {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/legacy.py +0 -0
- {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/shiny/__init__.py +0 -0
- {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/shiny/columns.py +0 -0
- {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/shiny/config.py +0 -0
- {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/shiny/data.py +0 -0
- {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/shiny/export.py +0 -0
- {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/shiny/multi_y.py +0 -0
- {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/shiny/summary.py +0 -0
- {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/units.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: apmtools
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.47
|
|
4
4
|
Summary: A collection of tools for processing air pollution monitoring data
|
|
5
5
|
Project-URL: Homepage, https://github.com/federlorenz/apmtools
|
|
6
6
|
Author-email: Federico Lorenzetti <lorenzetti.federico@gmail.com>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Figure labels and appearance controls."""
|
|
2
2
|
from __future__ import annotations
|
|
3
|
+
import re
|
|
3
4
|
import threading
|
|
4
5
|
from datetime import date, datetime, time, timedelta
|
|
5
6
|
from collections.abc import Sequence
|
|
@@ -38,7 +39,7 @@ def _label_plot_axes(fig, plot_type, x_column, y_columns, *,
|
|
|
38
39
|
elif plot_type in {"box", "bar"}:
|
|
39
40
|
categories = category_columns or [x_column]
|
|
40
41
|
fig.update_xaxes(title_text=" | ".join(_axis_label(c) for c in categories))
|
|
41
|
-
fig.update_yaxes(title_text=_axis_label(y_columns
|
|
42
|
+
fig.update_yaxes(title_text="<br>".join(_axis_label(y) for y in y_columns))
|
|
42
43
|
elif plot_type == "line" and len(y_columns) > 1:
|
|
43
44
|
fig.update_xaxes(title_text=_axis_label(x_column))
|
|
44
45
|
fig.update_yaxes(title_text="<br>".join(_axis_label(y) for y in y_columns))
|
|
@@ -59,7 +60,7 @@ def _label_plot_axes(fig, plot_type, x_column, y_columns, *,
|
|
|
59
60
|
def _parse_label_map(value):
|
|
60
61
|
"""Parse semicolon-separated original=replacement entries."""
|
|
61
62
|
result = {}
|
|
62
|
-
for entry in (value or "")
|
|
63
|
+
for entry in re.split(r"[;\r\n]+", value or ""):
|
|
63
64
|
if "=" in entry:
|
|
64
65
|
key, replacement = entry.split("=", 1)
|
|
65
66
|
if key.strip() and replacement.strip():
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Stable colour-editor keys for the actual prepared plotting data."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import re
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
_HEX = re.compile(r"#[0-9a-fA-F]{6}\Z")
|
|
7
|
+
|
|
8
|
+
def valid_hex_color(value):
|
|
9
|
+
return isinstance(value, str) and _HEX.fullmatch(value.strip()) is not None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def active_color_keys(spec, *, mode="group", panel_by=()):
|
|
13
|
+
"""Return colour dimensions with nonempty plotted observations.
|
|
14
|
+
|
|
15
|
+
Faceting removes its grouping columns. For multi-Y plots, 'both' uses
|
|
16
|
+
combinations, while group/variable/none use the corresponding colour
|
|
17
|
+
dimension (the same keys accepted by multi_y_styles).
|
|
18
|
+
"""
|
|
19
|
+
if "error" in spec or spec["data"].empty:
|
|
20
|
+
return ()
|
|
21
|
+
if spec.get("hist_variables"):
|
|
22
|
+
return tuple(dict.fromkeys(spec["data"].loc[
|
|
23
|
+
spec["data"][spec["x"]].notna(), "__hist_series"].astype(str)))
|
|
24
|
+
from .matplotlib_renderer import _panel_partitions
|
|
25
|
+
from .multi_axis import records
|
|
26
|
+
parts = (_panel_partitions(spec, panel_by) if panel_by
|
|
27
|
+
else [(None, spec["data"], spec["groups"])])
|
|
28
|
+
keys = []
|
|
29
|
+
for _, frame, remaining in parts:
|
|
30
|
+
if frame.empty:
|
|
31
|
+
continue
|
|
32
|
+
sub = {**spec, "data": frame, "groups": remaining,
|
|
33
|
+
"_original_groups": spec["groups"]} if panel_by else {**spec, "data": frame, "groups": remaining}
|
|
34
|
+
if spec.get("multi_axis"):
|
|
35
|
+
pairs = [(str(y), str(g) if remaining else None)
|
|
36
|
+
for _, g, y, _, _, _ in records(sub)]
|
|
37
|
+
else:
|
|
38
|
+
ys = list(sub["ys"])
|
|
39
|
+
groups = (list(pd.unique(frame["__plot_group"].astype(str)))
|
|
40
|
+
if remaining and "__plot_group" in frame else [None])
|
|
41
|
+
pairs = []
|
|
42
|
+
for g in groups:
|
|
43
|
+
rows = (frame.loc[frame["__plot_group"].astype(str) == g]
|
|
44
|
+
if g is not None else frame)
|
|
45
|
+
for y in ys:
|
|
46
|
+
if y in rows and rows[y].notna().any():
|
|
47
|
+
pairs.append((str(y), g))
|
|
48
|
+
multi = bool(spec.get("multi_axis") or len(sub["ys"]) > 1)
|
|
49
|
+
for y, g in pairs:
|
|
50
|
+
if not multi or mode in ("group", "none"):
|
|
51
|
+
key = g if g is not None else y
|
|
52
|
+
elif mode == "variable":
|
|
53
|
+
key = y
|
|
54
|
+
else:
|
|
55
|
+
key = f"{g}, {y}" if g is not None else y
|
|
56
|
+
if key not in keys and key != "All observations":
|
|
57
|
+
keys.append(key)
|
|
58
|
+
return tuple(keys)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def color_defaults(spec, keys, palette="Default"):
|
|
62
|
+
from .config import _MPL_PALETTES
|
|
63
|
+
values = _MPL_PALETTES.get(palette, _MPL_PALETTES["Default"])
|
|
64
|
+
return {key: values[i % len(values)] for i, key in enumerate(keys)}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Selectable plotted elements, independent of the rendering backend."""
|
|
2
|
+
import pandas as pd
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _group_labels(spec):
|
|
6
|
+
frame = spec['data']
|
|
7
|
+
if spec.get('groups') and '__plot_group' in frame:
|
|
8
|
+
return frame['__plot_group'].astype(str)
|
|
9
|
+
return pd.Series('All observations', index=frame.index)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def element_keys(spec):
|
|
13
|
+
"""Selectable element identifiers with at least one valid plotted value.
|
|
14
|
+
|
|
15
|
+
Elements with exclusively missing observations are not selectable, rather
|
|
16
|
+
than merely being unchecked by default.
|
|
17
|
+
"""
|
|
18
|
+
return default_visible_keys(spec)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def filter_elements(spec, selected):
|
|
22
|
+
"""Remove hidden elements without changing the underlying Summary or filters.
|
|
23
|
+
|
|
24
|
+
For multi-Y lines, individual group/variable combinations are removed by
|
|
25
|
+
masking only their Y values. Panels with no remaining values disappear.
|
|
26
|
+
"""
|
|
27
|
+
if 'error' in spec or selected is None:
|
|
28
|
+
return spec
|
|
29
|
+
allowed = set(selected)
|
|
30
|
+
if spec.get('hist_variables'):
|
|
31
|
+
frame = spec['data'].loc[spec['data']['__hist_series'].isin(allowed)].copy()
|
|
32
|
+
return {**spec, 'data': frame, '_visible_elements': allowed}
|
|
33
|
+
if spec.get('multi_axis'):
|
|
34
|
+
from .multi_axis import records
|
|
35
|
+
filtered = {**spec, '_visible_elements': allowed}
|
|
36
|
+
keep = pd.Index([])
|
|
37
|
+
for _, _, _, _, _, rows in records(filtered):
|
|
38
|
+
keep = keep.union(rows.index, sort=False)
|
|
39
|
+
return {**filtered, 'data': spec['data'].loc[spec['data'].index.isin(keep)].copy()}
|
|
40
|
+
frame = spec['data'].copy()
|
|
41
|
+
kind = spec['kind']
|
|
42
|
+
groups = _group_labels(spec)
|
|
43
|
+
if kind == 'line':
|
|
44
|
+
keep_any = pd.Series(False, index=frame.index)
|
|
45
|
+
for y in spec['ys']:
|
|
46
|
+
keys = groups.map(lambda g: f'{g} | {y}' if len(spec['ys']) > 1 else g)
|
|
47
|
+
permitted = keys.isin(allowed)
|
|
48
|
+
frame.loc[~permitted, y] = float('nan')
|
|
49
|
+
keep_any |= permitted & frame[y].notna()
|
|
50
|
+
frame = frame.loc[keep_any].copy()
|
|
51
|
+
elif kind in ('box', 'bar'):
|
|
52
|
+
keys = [f'{c} | {g}' if spec['groups'] else c
|
|
53
|
+
for c, g in zip(frame[spec['x']].astype(str), groups)]
|
|
54
|
+
frame = frame.loc[pd.Series(keys, index=frame.index).isin(allowed)].copy()
|
|
55
|
+
else:
|
|
56
|
+
frame = frame.loc[groups.isin(allowed)].copy()
|
|
57
|
+
return {**spec, 'data': frame, '_visible_elements': allowed}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def default_visible_keys(spec):
|
|
61
|
+
"""Elements with at least one nonmissing plotted value (not merely rows)."""
|
|
62
|
+
if 'error' in spec:
|
|
63
|
+
return []
|
|
64
|
+
if spec.get('multi_axis'):
|
|
65
|
+
from .multi_axis import all_keys
|
|
66
|
+
return all_keys(spec, nonmissing=True)
|
|
67
|
+
frame, kind, x = spec['data'], spec['kind'], spec['x']
|
|
68
|
+
if spec.get('hist_variables'):
|
|
69
|
+
return list(dict.fromkeys(frame.loc[frame[x].notna(), '__hist_series'].astype(str)))
|
|
70
|
+
groups = _group_labels(spec)
|
|
71
|
+
if kind == 'line':
|
|
72
|
+
return list(dict.fromkeys(
|
|
73
|
+
f'{group} | {y}' if len(spec['ys']) > 1 else group
|
|
74
|
+
for y in spec['ys'] if y in frame
|
|
75
|
+
for group in groups[frame[y].notna()]
|
|
76
|
+
))
|
|
77
|
+
if kind in ('box', 'bar'):
|
|
78
|
+
y = next((y for y in spec['ys'] if y in frame), None)
|
|
79
|
+
if y is None:
|
|
80
|
+
return []
|
|
81
|
+
valid = frame[y].notna()
|
|
82
|
+
return list(dict.fromkeys(
|
|
83
|
+
f'{category} | {group}' if spec['groups'] else str(category)
|
|
84
|
+
for category, group in zip(frame.loc[valid, x].astype(str), groups.loc[valid])
|
|
85
|
+
))
|
|
86
|
+
# Scatter requires both coordinates; histogram requires its numeric x.
|
|
87
|
+
if kind == 'scatter':
|
|
88
|
+
valid = frame[x].notna()
|
|
89
|
+
for y in spec['ys']:
|
|
90
|
+
if y in frame:
|
|
91
|
+
valid &= frame[y].notna()
|
|
92
|
+
else:
|
|
93
|
+
valid = frame[x].notna()
|
|
94
|
+
return list(dict.fromkeys(groups.loc[valid]))
|
|
95
|
+
|
|
96
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Long-format data preparation for overlaid multi-variable histograms."""
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from .data import _composite_labels
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def histogram_long_data(frame, variables, *, group_columns=()):
|
|
7
|
+
"""Create one histogram series per variable/group using a shared numeric axis.
|
|
8
|
+
|
|
9
|
+
Non-histogram columns are retained so faceting, export, and filtering work.
|
|
10
|
+
"""
|
|
11
|
+
variables = list(dict.fromkeys(variables))
|
|
12
|
+
if not variables:
|
|
13
|
+
raise ValueError("At least one histogram variable is required")
|
|
14
|
+
missing = [name for name in variables if name not in frame]
|
|
15
|
+
if missing:
|
|
16
|
+
raise KeyError(f"Missing histogram variables: {missing}")
|
|
17
|
+
groups = list(group_columns)
|
|
18
|
+
for col in groups:
|
|
19
|
+
if col not in frame:
|
|
20
|
+
raise KeyError(col)
|
|
21
|
+
chunks = []
|
|
22
|
+
for name in variables:
|
|
23
|
+
part = frame.copy()
|
|
24
|
+
part["__hist_value"] = pd.to_numeric(part[name], errors="coerce")
|
|
25
|
+
part["__hist_variable"] = name
|
|
26
|
+
part["__hist_series"] = (name + " | " + _composite_labels(part, groups)
|
|
27
|
+
if groups else name)
|
|
28
|
+
part = part.loc[part["__hist_value"].notna()]
|
|
29
|
+
chunks.append(part)
|
|
30
|
+
if not chunks:
|
|
31
|
+
return frame.iloc[0:0].copy()
|
|
32
|
+
return pd.concat(chunks, ignore_index=True)
|
|
@@ -16,7 +16,9 @@ def _matplotlib_defaults(spec, *, palette="Default"):
|
|
|
16
16
|
"""Derive labels and series colours directly from prepared pandas data."""
|
|
17
17
|
kind, df = spec["kind"], spec["data"]
|
|
18
18
|
x, ys, groups = spec["x"], spec["ys"], spec["groups"]
|
|
19
|
-
names = ([str(v) for v in pd.unique(df["
|
|
19
|
+
names = ([str(v) for v in pd.unique(df["__hist_series"])]
|
|
20
|
+
if kind == "histogram" and spec.get("hist_variables") and "__hist_series" in df
|
|
21
|
+
else [str(v) for v in pd.unique(df["__plot_group"])]
|
|
20
22
|
if groups and "__plot_group" in df else [])
|
|
21
23
|
if not names and kind == "line" and len(ys) > 1:
|
|
22
24
|
names = [str(y) for y in ys]
|
|
@@ -27,8 +29,10 @@ def _matplotlib_defaults(spec, *, palette="Default"):
|
|
|
27
29
|
x_label = (" | ".join(_axis_label(c) for c in spec["categories"])
|
|
28
30
|
if kind in ("box", "bar") else _axis_label(x))
|
|
29
31
|
y_label = ("Probability density" if kind == "histogram" else
|
|
30
|
-
"\n".join(_axis_label(y) for y in ys) if
|
|
32
|
+
"\n".join(_axis_label(y) for y in ys) if len(ys) > 1 else
|
|
31
33
|
_axis_label(ys[0]) if ys else "Count")
|
|
34
|
+
if kind == "scatter" and len(spec.get("x_variables", [])) > 1:
|
|
35
|
+
x_label = "\n".join(_axis_label(c) for c in spec["x_variables"])
|
|
32
36
|
x_label = spec.get("x_label_override") or x_label
|
|
33
37
|
return dict(x_label=x_label, y_label=y_label, names=names, colors=colors)
|
|
34
38
|
|
|
@@ -43,6 +47,8 @@ def _axis_bounds_from_spec(spec, *, bins=30, density=False, stacked=False, cumul
|
|
|
43
47
|
if "error" in spec:
|
|
44
48
|
return {key: "" for key in ("x_min", "x_max", "y_min", "y_max")}
|
|
45
49
|
df, kind, x, ys = spec["data"], spec["kind"], spec["x"], spec["ys"]
|
|
50
|
+
if df.empty:
|
|
51
|
+
return {key: "" for key in ("x_min", "x_max", "y_min", "y_max")}
|
|
46
52
|
bounds = {key: "" for key in ("x_min", "x_max", "y_min", "y_max")}
|
|
47
53
|
|
|
48
54
|
def fill(axis, values):
|
|
@@ -147,6 +153,9 @@ def _panel_partitions(spec, panel_by):
|
|
|
147
153
|
for value, part in spec["data"].groupby(grouper, sort=False, dropna=False):
|
|
148
154
|
values = value if isinstance(value, tuple) else (value,)
|
|
149
155
|
title = " | ".join("None" if pd.isna(v) else str(v) for v in values)
|
|
156
|
+
if spec.get("multi_axis") and spec["groups"] and "__plot_group" in part:
|
|
157
|
+
part = part.copy()
|
|
158
|
+
part["__plot_global_group"] = part["__plot_group"]
|
|
150
159
|
if remaining:
|
|
151
160
|
part = part.copy(deep=False)
|
|
152
161
|
part["__plot_group"] = (_composite_labels(part, remaining))
|
|
@@ -262,7 +271,8 @@ def _fit_inside_legends(fig, axes, font_size=12, ncol=1):
|
|
|
262
271
|
pos.height * old_h / (old_h + extra_h) + extra_h / (old_h + extra_h)))
|
|
263
272
|
|
|
264
273
|
|
|
265
|
-
def _place_outside_legend(fig, axes, position="right", font_size=12, ncol=1
|
|
274
|
+
def _place_outside_legend(fig, axes, position="right", font_size=12, ncol=1,
|
|
275
|
+
panel_hspace=0.90, panel_wspace=0.38):
|
|
266
276
|
"""Use exactly the requested columns and reserve their measured footprint.
|
|
267
277
|
|
|
268
278
|
A single legend is shared across panels. Only one full canvas draw is used
|
|
@@ -314,9 +324,9 @@ def _place_outside_legend(fig, axes, position="right", font_size=12, ncol=1):
|
|
|
314
324
|
legend.set_bbox_to_anchor((.5, y), transform=fig.transFigure)
|
|
315
325
|
legend.set_in_layout(False)
|
|
316
326
|
if len(axes) > 1:
|
|
317
|
-
# Keep the legend reservation, but
|
|
327
|
+
# Keep the legend reservation, but reserve more breathing room
|
|
318
328
|
# between facet axes. This changes only the internal subplot gaps.
|
|
319
|
-
fig.subplots_adjust(wspace=
|
|
329
|
+
fig.subplots_adjust(wspace=panel_wspace, hspace=panel_hspace)
|
|
320
330
|
|
|
321
331
|
def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
322
332
|
legend_labels="", trace_colors="", palette="Default",
|
|
@@ -325,11 +335,12 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
325
335
|
legend_inside=False, legend_columns=1, multi_y_color_by="group", show_grid=True, font_size=12,
|
|
326
336
|
line_width=2, marker_size=7, line_alpha="", line_style="",
|
|
327
337
|
scatter_size="", scatter_marker="", scatter_alpha="", hist_alpha="",
|
|
328
|
-
box_showfliers=True, box_orientation="vertical",
|
|
338
|
+
box_showfliers=True, box_orientation="vertical", bar_box_width=1.0,
|
|
329
339
|
title_font_size=14, axis_font_size=12, tick_font_size=10,
|
|
330
340
|
legend_font_size=12, panel_font_size=14, plot_dpi=100, bins=30, density=False,
|
|
331
341
|
stacked=False, histtype="bar", cumulative=0,
|
|
332
|
-
panel_by=None, independent_panel_limits=True, panel_titles="",
|
|
342
|
+
panel_by=None, independent_panel_limits=True, panel_titles="",
|
|
343
|
+
panel_hspace=0.90, panel_wspace=0.38, _axes=None,
|
|
333
344
|
x_major_ticks=6, y_major_ticks=6, x_minor_ticks=False, y_minor_ticks=False):
|
|
334
345
|
"""Draw directly from the filtered/aggregated DataFrame, without Plotly."""
|
|
335
346
|
import matplotlib
|
|
@@ -364,11 +375,12 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
364
375
|
plt.close(fig)
|
|
365
376
|
ncols = (2 if len(partitions) == 4 else min(3, len(partitions)))
|
|
366
377
|
nrows = math.ceil(len(partitions) / ncols)
|
|
367
|
-
fig, axes = plt.subplots(nrows, ncols, figsize=(5*ncols,
|
|
378
|
+
fig, axes = plt.subplots(nrows, ncols, figsize=(5.4*ncols, 4.7*nrows), dpi=plot_dpi,
|
|
368
379
|
squeeze=False, sharex=False, sharey=False)
|
|
369
380
|
for idx, (panel_title, panel_df, other_groups) in enumerate(partitions):
|
|
370
381
|
panel_spec = {**spec, "data": panel_df, "groups": other_groups,
|
|
371
|
-
"
|
|
382
|
+
"_original_groups": spec["groups"],
|
|
383
|
+
"_hide_ungrouped_legend": not bool(other_groups) and not bool(spec.get("hist_variables"))}
|
|
372
384
|
panel_ax = axes.flat[idx]
|
|
373
385
|
_matplotlib_direct_figure(panel_spec, title=panel_title_map.get(panel_title, panel_title), x_label=x_label,
|
|
374
386
|
y_label=y_label, legend_labels=legend_labels, trace_colors=trace_colors,
|
|
@@ -380,6 +392,7 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
380
392
|
line_width=line_width, marker_size=marker_size, line_alpha=line_alpha, line_style=line_style,
|
|
381
393
|
scatter_size=scatter_size, scatter_marker=scatter_marker, scatter_alpha=scatter_alpha,
|
|
382
394
|
hist_alpha=hist_alpha, box_showfliers=box_showfliers, box_orientation=box_orientation,
|
|
395
|
+
bar_box_width=bar_box_width,
|
|
383
396
|
title_font_size=panel_font_size, axis_font_size=axis_font_size,
|
|
384
397
|
tick_font_size=tick_font_size, legend_font_size=legend_font_size,
|
|
385
398
|
panel_font_size=panel_font_size, plot_dpi=plot_dpi,
|
|
@@ -394,15 +407,17 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
394
407
|
if panel_ax.get_legend() is not None:
|
|
395
408
|
panel_ax.get_legend().set_in_layout(False)
|
|
396
409
|
fig.tight_layout()
|
|
397
|
-
# V77 used tight_layout for facets;
|
|
410
|
+
# V77 used tight_layout for facets; reserve generous gutters
|
|
398
411
|
# without changing V83's legend behaviour or column control.
|
|
399
|
-
fig.subplots_adjust(wspace=
|
|
412
|
+
fig.subplots_adjust(wspace=panel_wspace, hspace=panel_hspace)
|
|
400
413
|
_fit_inside_legends(fig, list(axes.flat)[:len(partitions)], legend_font_size, legend_columns)
|
|
401
414
|
else:
|
|
402
415
|
_place_outside_legend(fig, list(axes.flat)[:len(partitions)],
|
|
403
|
-
legend_position, legend_font_size, legend_columns
|
|
416
|
+
legend_position, legend_font_size, legend_columns,
|
|
417
|
+
panel_hspace=panel_hspace, panel_wspace=panel_wspace)
|
|
404
418
|
return fig
|
|
405
|
-
group_col = "
|
|
419
|
+
group_col = ("__hist_series" if kind == "histogram" and spec.get("hist_variables")
|
|
420
|
+
else "__plot_group" if spec["groups"] and "__plot_group" in df.columns else None)
|
|
406
421
|
defaults = _matplotlib_defaults(spec, palette=palette)
|
|
407
422
|
rename = _parse_label_map(legend_labels)
|
|
408
423
|
overrides = _parse_label_map(trace_colors)
|
|
@@ -425,11 +440,101 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
425
440
|
def series():
|
|
426
441
|
if group_col:
|
|
427
442
|
for name in labels:
|
|
428
|
-
yield name, df.loc[df[group_col] == name]
|
|
443
|
+
yield name, df.loc[df[group_col].astype(str) == str(name)]
|
|
429
444
|
else:
|
|
430
445
|
yield "All observations", df
|
|
431
446
|
|
|
432
|
-
if
|
|
447
|
+
if spec.get("multi_axis"):
|
|
448
|
+
from .multi_axis import records, style_maps
|
|
449
|
+
from matplotlib.lines import Line2D
|
|
450
|
+
from matplotlib.patches import Patch
|
|
451
|
+
entries = list(records(spec))
|
|
452
|
+
color_map, dash_map, style_groups = style_maps(spec, multi_y_color_by, palette, trace_colors)
|
|
453
|
+
variable_names = list(ys)
|
|
454
|
+
x_names = list(spec.get("x_variables", [x]))
|
|
455
|
+
group_names = style_groups if group_col else []
|
|
456
|
+
active = set()
|
|
457
|
+
if kind == "scatter":
|
|
458
|
+
scatter_markers = ['o', 's', 'D', 'x', '^', 'v', 'P', '*']
|
|
459
|
+
for key, group, y, xv, category, rows in entries:
|
|
460
|
+
g = group if group_col else None
|
|
461
|
+
active.add((y, g))
|
|
462
|
+
ax.scatter(rows[xv], rows[y], color=color_map[(y, g)],
|
|
463
|
+
marker=scatter_markers[x_names.index(xv) % len(scatter_markers)],
|
|
464
|
+
s=marker_size**2, alpha=.8)
|
|
465
|
+
else:
|
|
466
|
+
cats = list(pd.unique(df[x].astype(str)))
|
|
467
|
+
combinations = [(g, y) for g in (group_names or ['All observations']) for y in ys]
|
|
468
|
+
n = max(1, len(combinations))
|
|
469
|
+
width = .82 / n
|
|
470
|
+
from collections import defaultdict
|
|
471
|
+
buckets = defaultdict(list)
|
|
472
|
+
for key, group, y, xv, category, rows in entries:
|
|
473
|
+
g = group if group_col else None
|
|
474
|
+
active.add((y, g))
|
|
475
|
+
idx = combinations.index((group, y))
|
|
476
|
+
pos = cats.index(category) + (idx - (n - 1)/2)*width
|
|
477
|
+
values = pd.to_numeric(rows[y], errors='coerce').dropna().to_numpy(dtype=float)
|
|
478
|
+
buckets[(pos, y, g)].extend(values)
|
|
479
|
+
for (pos, y, g), values in buckets.items():
|
|
480
|
+
if not values:
|
|
481
|
+
continue
|
|
482
|
+
color = color_map[(y, g)]
|
|
483
|
+
hatch_options = ['', '/', '\\', 'x', '.', '+', '-']
|
|
484
|
+
hatch_index = ys.index(y) if multi_y_color_by == 'group' else (group_names.index(g) if g in group_names else 0)
|
|
485
|
+
hatch = '' if multi_y_color_by in ('none', 'both') else hatch_options[hatch_index % len(hatch_options)]
|
|
486
|
+
if kind == 'bar':
|
|
487
|
+
ax.bar(pos, sum(values), width=width*.92*bar_box_width, color=color, hatch=hatch)
|
|
488
|
+
else:
|
|
489
|
+
bp = ax.boxplot([values], positions=[pos], widths=width*.92*bar_box_width,
|
|
490
|
+
manage_ticks=False, patch_artist=True,
|
|
491
|
+
showfliers=bool(box_showfliers),
|
|
492
|
+
vert=(box_orientation != 'horizontal'))
|
|
493
|
+
bp['boxes'][0].set_facecolor(color)
|
|
494
|
+
bp['boxes'][0].set_hatch(hatch)
|
|
495
|
+
bp['boxes'][0].set_alpha(.7)
|
|
496
|
+
if kind == 'box' and box_orientation == 'horizontal':
|
|
497
|
+
ax.set_yticks(range(len(cats)), cats)
|
|
498
|
+
ax.set_ylim(-.5, max(.5, len(cats)-.5))
|
|
499
|
+
else:
|
|
500
|
+
ax.set_xticks(range(len(cats)), cats)
|
|
501
|
+
ax.set_xlim(-.5, max(.5, len(cats)-.5))
|
|
502
|
+
handles, legend_names = [], []
|
|
503
|
+
def legend_entry(label, color, marker=None):
|
|
504
|
+
handles.append(Line2D([], [], color=color, marker=marker or None,
|
|
505
|
+
linestyle='None' if marker else '-', linewidth=2))
|
|
506
|
+
legend_names.append(rename.get(label, label))
|
|
507
|
+
if multi_y_color_by == 'none':
|
|
508
|
+
for g in group_names:
|
|
509
|
+
if any((y, g) in active for y in ys):
|
|
510
|
+
legend_entry(g, color_map[(ys[0], g)])
|
|
511
|
+
elif multi_y_color_by == 'both':
|
|
512
|
+
for g in (group_names or [None]):
|
|
513
|
+
for y in ys:
|
|
514
|
+
if (y, g) in active:
|
|
515
|
+
legend_entry(f'{g}, {y}' if g is not None else y, color_map[(y, g)])
|
|
516
|
+
else:
|
|
517
|
+
for y in ys:
|
|
518
|
+
pairs = [(y, g) for g in (group_names or [None]) if (y, g) in active]
|
|
519
|
+
if pairs:
|
|
520
|
+
legend_entry(y, color_map[pairs[0]])
|
|
521
|
+
if group_names:
|
|
522
|
+
handles.append(Line2D([], [], color='none'))
|
|
523
|
+
legend_names.append(' ')
|
|
524
|
+
for g in group_names:
|
|
525
|
+
pairs = [(y, g) for y in ys if (y, g) in active]
|
|
526
|
+
if pairs:
|
|
527
|
+
legend_entry(g, color_map[pairs[0]])
|
|
528
|
+
if kind == 'scatter' and len(x_names) > 1:
|
|
529
|
+
if handles:
|
|
530
|
+
handles.append(Line2D([], [], color='none'))
|
|
531
|
+
legend_names.append(' ')
|
|
532
|
+
for i, xv in enumerate(x_names):
|
|
533
|
+
if any(entry[3] == xv for entry in entries):
|
|
534
|
+
legend_entry(xv, '#666666', ['o', 's', 'D', 'x', '^', 'v', 'P', '*'][i % 8])
|
|
535
|
+
if handles and not spec.get('_hide_ungrouped_legend', False):
|
|
536
|
+
ax.legend(handles, legend_names)
|
|
537
|
+
elif kind == "histogram":
|
|
433
538
|
arrays = []
|
|
434
539
|
for name, rows in series():
|
|
435
540
|
vals = pd.to_numeric(rows[x], errors="coerce").to_numpy(dtype=float)
|
|
@@ -481,6 +586,9 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
481
586
|
else:
|
|
482
587
|
sets = [(str(y) if len(ys) > 1 else "All observations", line_df)]
|
|
483
588
|
for name, rows in sets:
|
|
589
|
+
rows = rows.loc[rows[y].notna()]
|
|
590
|
+
if rows.empty:
|
|
591
|
+
continue
|
|
484
592
|
segment_col = section_col or id_col
|
|
485
593
|
parts = (rows.groupby(segment_col, dropna=False, sort=False) if segment_col else [(None, rows)])
|
|
486
594
|
for _, part in parts:
|
|
@@ -540,7 +648,7 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
540
648
|
for category_index, group_index, position in zip(ci[valid_array], names, positions):
|
|
541
649
|
palette_for_positions[float(position)] = colors.get(group_names[group_index], palette_values[0])
|
|
542
650
|
if xpos:
|
|
543
|
-
ax.bar(xpos, [totals[pos] for pos in xpos], width=width * .92,
|
|
651
|
+
ax.bar(xpos, [totals[pos] for pos in xpos], width=width * .92 * bar_box_width,
|
|
544
652
|
color=[palette_for_positions[pos] for pos in xpos])
|
|
545
653
|
else:
|
|
546
654
|
# Calculate group memberships once and make one boxplot call.
|
|
@@ -553,13 +661,25 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
553
661
|
if buckets:
|
|
554
662
|
box_positions = sorted(buckets)
|
|
555
663
|
bp = ax.boxplot([buckets[pos] for pos in box_positions],
|
|
556
|
-
positions=box_positions, widths=width * .92,
|
|
664
|
+
positions=box_positions, widths=width * .92 * bar_box_width,
|
|
557
665
|
manage_ticks=False, patch_artist=True,
|
|
558
666
|
showfliers=bool(box_showfliers),
|
|
559
667
|
vert=(box_orientation != "horizontal"))
|
|
560
668
|
for patch, position in zip(bp["boxes"], box_positions):
|
|
561
669
|
patch.set_facecolor(bucket_colors[position])
|
|
562
670
|
patch.set_alpha(.7)
|
|
671
|
+
if box_showfliers:
|
|
672
|
+
# Explicitly reset numeric limits using all observations;
|
|
673
|
+
# avoids retaining whisker-only limits after facet changes.
|
|
674
|
+
all_values = np.asarray(numeric, dtype=float)
|
|
675
|
+
all_values = all_values[np.isfinite(all_values)]
|
|
676
|
+
if all_values.size:
|
|
677
|
+
low, high = float(all_values.min()), float(all_values.max())
|
|
678
|
+
margin = .05 * (high - low) if high > low else max(abs(low) * .05, .5)
|
|
679
|
+
if box_orientation == "horizontal":
|
|
680
|
+
ax.set_xlim(low - margin, high + margin)
|
|
681
|
+
else:
|
|
682
|
+
ax.set_ylim(low - margin, high + margin)
|
|
563
683
|
if not box_showfliers:
|
|
564
684
|
# Autoscaling normally includes every observation in the
|
|
565
685
|
# data; instead use only the whisker endpoints.
|
|
@@ -594,7 +714,7 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
594
714
|
else:
|
|
595
715
|
raise ValueError(f"Unknown plot type: {kind}")
|
|
596
716
|
|
|
597
|
-
if kind not in ("box", "bar"):
|
|
717
|
+
if kind not in ("box", "bar") and not spec.get("multi_axis"):
|
|
598
718
|
handles, texts = ax.get_legend_handles_labels()
|
|
599
719
|
unique = dict(zip(texts, handles))
|
|
600
720
|
if unique and not spec.get("_hide_ungrouped_legend", not bool(spec["groups"])):
|
|
@@ -604,8 +724,13 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
604
724
|
# Independent legend keys for the two dimensions, with explicit headings.
|
|
605
725
|
legend_handles = []
|
|
606
726
|
legend_labels_out = []
|
|
727
|
+
active = {(str(y), str(g) if group_col else None)
|
|
728
|
+
for y in ys for g in (group_names if group_col else [None])
|
|
729
|
+
if not df.loc[(df[group_col] == g) if group_col else slice(None), y].dropna().empty}
|
|
607
730
|
if multi_y_color_by == "none":
|
|
608
731
|
for group in group_names if group_col else []:
|
|
732
|
+
if not any((v, group) in active for v in variable_names):
|
|
733
|
+
continue
|
|
609
734
|
key = (variable_names[0], group)
|
|
610
735
|
legend_handles.append(Line2D([], [], color=multi_colors[key], linestyle=multi_dashes[key], linewidth=line_width))
|
|
611
736
|
legend_labels_out.append(rename.get(group, group))
|
|
@@ -627,7 +752,10 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
627
752
|
legend_handles.append(Line2D([], [], color="none"))
|
|
628
753
|
legend_labels_out.append(heading)
|
|
629
754
|
for value in keys:
|
|
630
|
-
|
|
755
|
+
relevant = [pair for pair in active if (pair[0] == value if by_var else pair[1] == value)]
|
|
756
|
+
if not relevant:
|
|
757
|
+
continue
|
|
758
|
+
key = relevant[0]
|
|
631
759
|
legend_handles.append(Line2D([], [], color=multi_colors[key], linestyle=multi_dashes[key], linewidth=line_width))
|
|
632
760
|
legend_labels_out.append(rename.get(value, value))
|
|
633
761
|
if legend_handles:
|