apmtools 0.2.45__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.45 → apmtools-0.2.47}/PKG-INFO +1 -1
- {apmtools-0.2.45 → apmtools-0.2.47}/pyproject.toml +1 -1
- {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/shiny/appearance.py +5 -6
- apmtools-0.2.47/src/apmtools/shiny/color_editor.py +64 -0
- {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/shiny/data.py +10 -6
- 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.45 → apmtools-0.2.47}/src/apmtools/shiny/matplotlib_renderer.py +190 -23
- apmtools-0.2.47/src/apmtools/shiny/multi_axis.py +150 -0
- apmtools-0.2.47/src/apmtools/shiny/multi_y.py +56 -0
- {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/shiny/plotly_renderer.py +111 -5
- {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/shiny/ui.py +1030 -132
- {apmtools-0.2.45 → apmtools-0.2.47}/.gitignore +0 -0
- {apmtools-0.2.45 → apmtools-0.2.47}/LICENSE +0 -0
- {apmtools-0.2.45 → apmtools-0.2.47}/README.md +0 -0
- {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/__init__.py +0 -0
- {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/classes.py +0 -0
- {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/data_processing.py +0 -0
- {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/functions.py +0 -0
- {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/legacy.py +0 -0
- {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/shiny/__init__.py +0 -0
- {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/shiny/columns.py +0 -0
- {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/shiny/config.py +0 -0
- {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/shiny/export.py +0 -0
- {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/shiny/summary.py +0 -0
- {apmtools-0.2.45 → 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,12 +39,10 @@ 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
|
-
# the variable-specific unit rather than misleadingly naming one unit.
|
|
46
|
-
fig.update_yaxes(title_text="Value")
|
|
45
|
+
fig.update_yaxes(title_text="<br>".join(_axis_label(y) for y in y_columns))
|
|
47
46
|
for annotation in fig.layout.annotations:
|
|
48
47
|
label = str(annotation.text)
|
|
49
48
|
if label.startswith("variable="):
|
|
@@ -61,7 +60,7 @@ def _label_plot_axes(fig, plot_type, x_column, y_columns, *,
|
|
|
61
60
|
def _parse_label_map(value):
|
|
62
61
|
"""Parse semicolon-separated original=replacement entries."""
|
|
63
62
|
result = {}
|
|
64
|
-
for entry in (value or "")
|
|
63
|
+
for entry in re.split(r"[;\r\n]+", value or ""):
|
|
65
64
|
if "=" in entry:
|
|
66
65
|
key, replacement = entry.split("=", 1)
|
|
67
66
|
if key.strip() and replacement.strip():
|
|
@@ -125,7 +124,7 @@ def _apply_plot_appearance(fig, *, title, x_label="", y_label="",
|
|
|
125
124
|
if x_label.strip():
|
|
126
125
|
fig.update_xaxes(title_text=x_label.strip())
|
|
127
126
|
if y_label.strip():
|
|
128
|
-
fig.update_yaxes(title_text=y_label.strip())
|
|
127
|
+
fig.update_yaxes(title_text=y_label.strip().replace("\n", "<br>"))
|
|
129
128
|
fig.update_xaxes(showgrid=show_grid)
|
|
130
129
|
fig.update_yaxes(showgrid=show_grid)
|
|
131
130
|
use_x, use_y = _numeric_axis_controls(plot_kind, box_orientation)
|
|
@@ -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)}
|
|
@@ -23,7 +23,8 @@ def _composite_labels(data: pd.DataFrame, columns: list[str]) -> pd.Series:
|
|
|
23
23
|
return "None" if pd.isna(value) else str(value)
|
|
24
24
|
if len(columns) == 1:
|
|
25
25
|
return data[columns[0]].map(label)
|
|
26
|
-
return pd.Series(
|
|
26
|
+
return pd.Series(
|
|
27
|
+
(" | ".join(label(value) for value in row)
|
|
27
28
|
for row in data[columns].itertuples(index=False, name=None)),
|
|
28
29
|
index=data.index,
|
|
29
30
|
dtype="str",
|
|
@@ -130,8 +131,12 @@ def _aggregate(data, value_columns, frequency, grouping_by, aggregate, *,
|
|
|
130
131
|
is selected, matching Summary.group_stat(frequency=None, grouping_by=...).
|
|
131
132
|
This function is called only when aggregation is explicitly requested.
|
|
132
133
|
"""
|
|
134
|
+
if data is None or data.empty:
|
|
135
|
+
return pd.DataFrame()
|
|
133
136
|
if frequency is None and not grouping_by:
|
|
134
|
-
|
|
137
|
+
# Invalid/incomplete UI selection: no aggregation groups exist.
|
|
138
|
+
# Return empty rather than breaking Shiny's reactive graph.
|
|
139
|
+
return pd.DataFrame()
|
|
135
140
|
|
|
136
141
|
result = data.group_stat(
|
|
137
142
|
value_cols=value_columns,
|
|
@@ -142,10 +147,9 @@ def _aggregate(data, value_columns, frequency, grouping_by, aggregate, *,
|
|
|
142
147
|
if frequency is not None else {}),
|
|
143
148
|
)
|
|
144
149
|
if result is None:
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
)
|
|
150
|
+
# group_stat() may return None when no rows survive completeness
|
|
151
|
+
# requirements or other filtering. Treat this as an empty plot.
|
|
152
|
+
return pd.DataFrame()
|
|
149
153
|
return result
|
|
150
154
|
|
|
151
155
|
|
|
@@ -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
|
-
"
|
|
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,22 +324,23 @@ 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",
|
|
323
333
|
x_min="", x_max="", y_min="", y_max="",
|
|
324
334
|
template="plotly", legend_position="right",
|
|
325
|
-
legend_inside=False, legend_columns=1, show_grid=True, font_size=12,
|
|
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,
|
|
@@ -376,10 +388,11 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
376
388
|
x_min=_panel_limit_value(x_min, panel_title), x_max=_panel_limit_value(x_max, panel_title),
|
|
377
389
|
y_min=_panel_limit_value(y_min, panel_title), y_max=_panel_limit_value(y_max, panel_title),
|
|
378
390
|
template=template, legend_position=legend_position,
|
|
379
|
-
legend_inside=legend_inside, legend_columns=legend_columns, show_grid=show_grid, font_size=font_size,
|
|
391
|
+
legend_inside=legend_inside, legend_columns=legend_columns, multi_y_color_by=multi_y_color_by, show_grid=show_grid, font_size=font_size,
|
|
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)
|
|
@@ -413,6 +428,11 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
413
428
|
labels = defaults["names"] if group_col else ([str(y) for y in ys] if len(ys) > 1 else ["All observations"])
|
|
414
429
|
colors = {name: overrides.get(name, defaults["colors"].get(name, palette_values[i % len(palette_values)]))
|
|
415
430
|
for i, name in enumerate(labels)}
|
|
431
|
+
multi_y = kind == "line" and len(ys) > 1
|
|
432
|
+
if multi_y:
|
|
433
|
+
from .multi_y import multi_y_styles
|
|
434
|
+
multi_colors, multi_dashes, variable_names, group_names = multi_y_styles(
|
|
435
|
+
ys, labels if group_col else [], palette_values, multi_y_color_by, overrides, styles)
|
|
416
436
|
for name, color in list(colors.items()):
|
|
417
437
|
if not mcolors.is_color_like(color):
|
|
418
438
|
colors[name] = defaults["colors"].get(name, palette_values[0])
|
|
@@ -420,11 +440,101 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
420
440
|
def series():
|
|
421
441
|
if group_col:
|
|
422
442
|
for name in labels:
|
|
423
|
-
yield name, df.loc[df[group_col] == name]
|
|
443
|
+
yield name, df.loc[df[group_col].astype(str) == str(name)]
|
|
424
444
|
else:
|
|
425
445
|
yield "All observations", df
|
|
426
446
|
|
|
427
|
-
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":
|
|
428
538
|
arrays = []
|
|
429
539
|
for name, rows in series():
|
|
430
540
|
vals = pd.to_numeric(rows[x], errors="coerce").to_numpy(dtype=float)
|
|
@@ -476,15 +586,19 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
476
586
|
else:
|
|
477
587
|
sets = [(str(y) if len(ys) > 1 else "All observations", line_df)]
|
|
478
588
|
for name, rows in sets:
|
|
589
|
+
rows = rows.loc[rows[y].notna()]
|
|
590
|
+
if rows.empty:
|
|
591
|
+
continue
|
|
479
592
|
segment_col = section_col or id_col
|
|
480
593
|
parts = (rows.groupby(segment_col, dropna=False, sort=False) if segment_col else [(None, rows)])
|
|
481
594
|
for _, part in parts:
|
|
482
595
|
part = part.sort_values(x)
|
|
483
|
-
|
|
596
|
+
key = (str(y), str(name) if group_col else None)
|
|
597
|
+
ax.plot(part[x], part[y], color=(multi_colors[key] if multi_y else colors.get(name, palette_values[0])),
|
|
484
598
|
linewidth=float(line_width),
|
|
485
|
-
linestyle=_line_style(styles, name),
|
|
599
|
+
linestyle=(multi_dashes[key] if multi_y else _line_style(styles, name)),
|
|
486
600
|
alpha=_bounded_float(alphas, name, 1, 0, 1),
|
|
487
|
-
label=rename.get(name, name))
|
|
601
|
+
label=("_nolegend_" if multi_y else rename.get(name, name)))
|
|
488
602
|
elif kind in ("box", "bar"):
|
|
489
603
|
# Build categorical groups in a single pass. The former implementation
|
|
490
604
|
# repeatedly scanned the full DataFrame for every category × group,
|
|
@@ -534,7 +648,7 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
534
648
|
for category_index, group_index, position in zip(ci[valid_array], names, positions):
|
|
535
649
|
palette_for_positions[float(position)] = colors.get(group_names[group_index], palette_values[0])
|
|
536
650
|
if xpos:
|
|
537
|
-
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,
|
|
538
652
|
color=[palette_for_positions[pos] for pos in xpos])
|
|
539
653
|
else:
|
|
540
654
|
# Calculate group memberships once and make one boxplot call.
|
|
@@ -547,13 +661,25 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
547
661
|
if buckets:
|
|
548
662
|
box_positions = sorted(buckets)
|
|
549
663
|
bp = ax.boxplot([buckets[pos] for pos in box_positions],
|
|
550
|
-
positions=box_positions, widths=width * .92,
|
|
664
|
+
positions=box_positions, widths=width * .92 * bar_box_width,
|
|
551
665
|
manage_ticks=False, patch_artist=True,
|
|
552
666
|
showfliers=bool(box_showfliers),
|
|
553
667
|
vert=(box_orientation != "horizontal"))
|
|
554
668
|
for patch, position in zip(bp["boxes"], box_positions):
|
|
555
669
|
patch.set_facecolor(bucket_colors[position])
|
|
556
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)
|
|
557
683
|
if not box_showfliers:
|
|
558
684
|
# Autoscaling normally includes every observation in the
|
|
559
685
|
# data; instead use only the whisker endpoints.
|
|
@@ -588,11 +714,52 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
588
714
|
else:
|
|
589
715
|
raise ValueError(f"Unknown plot type: {kind}")
|
|
590
716
|
|
|
591
|
-
if kind not in ("box", "bar"):
|
|
717
|
+
if kind not in ("box", "bar") and not spec.get("multi_axis"):
|
|
592
718
|
handles, texts = ax.get_legend_handles_labels()
|
|
593
719
|
unique = dict(zip(texts, handles))
|
|
594
720
|
if unique and not spec.get("_hide_ungrouped_legend", not bool(spec["groups"])):
|
|
595
721
|
ax.legend(unique.values(), unique.keys(), title="Legend")
|
|
722
|
+
if multi_y:
|
|
723
|
+
from matplotlib.lines import Line2D
|
|
724
|
+
# Independent legend keys for the two dimensions, with explicit headings.
|
|
725
|
+
legend_handles = []
|
|
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}
|
|
730
|
+
if multi_y_color_by == "none":
|
|
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
|
|
734
|
+
key = (variable_names[0], group)
|
|
735
|
+
legend_handles.append(Line2D([], [], color=multi_colors[key], linestyle=multi_dashes[key], linewidth=line_width))
|
|
736
|
+
legend_labels_out.append(rename.get(group, group))
|
|
737
|
+
if multi_y_color_by == "both":
|
|
738
|
+
for group in (group_names or [None]):
|
|
739
|
+
for variable in variable_names:
|
|
740
|
+
key = (variable, group)
|
|
741
|
+
label = f"{group}, {variable}" if group is not None else variable
|
|
742
|
+
legend_handles.append(Line2D([], [], color=multi_colors[key], linestyle=multi_dashes[key], linewidth=line_width))
|
|
743
|
+
legend_labels_out.append(rename.get(label, label))
|
|
744
|
+
added_section = False
|
|
745
|
+
for heading, keys, by_var in (() if multi_y_color_by in ("both", "none") else (("Y variable", variable_names, True), ("Grouping", group_names, False))):
|
|
746
|
+
if not keys or (not by_var and not group_col):
|
|
747
|
+
continue
|
|
748
|
+
if added_section:
|
|
749
|
+
legend_handles.append(Line2D([], [], color="none"))
|
|
750
|
+
legend_labels_out.append(" ")
|
|
751
|
+
added_section = True
|
|
752
|
+
legend_handles.append(Line2D([], [], color="none"))
|
|
753
|
+
legend_labels_out.append(heading)
|
|
754
|
+
for value in keys:
|
|
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]
|
|
759
|
+
legend_handles.append(Line2D([], [], color=multi_colors[key], linestyle=multi_dashes[key], linewidth=line_width))
|
|
760
|
+
legend_labels_out.append(rename.get(value, value))
|
|
761
|
+
if legend_handles:
|
|
762
|
+
ax.legend(legend_handles, legend_labels_out, title=None)
|
|
596
763
|
legend = ax.get_legend()
|
|
597
764
|
if legend is not None:
|
|
598
765
|
locs = {"right":"upper right", "left":"upper left", "top":"upper center", "bottom":"lower center"}
|