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.
Files changed (26) hide show
  1. {apmtools-0.2.45 → apmtools-0.2.47}/PKG-INFO +1 -1
  2. {apmtools-0.2.45 → apmtools-0.2.47}/pyproject.toml +1 -1
  3. {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/shiny/appearance.py +5 -6
  4. apmtools-0.2.47/src/apmtools/shiny/color_editor.py +64 -0
  5. {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/shiny/data.py +10 -6
  6. apmtools-0.2.47/src/apmtools/shiny/elements.py +96 -0
  7. apmtools-0.2.47/src/apmtools/shiny/histogram_multi.py +32 -0
  8. {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/shiny/matplotlib_renderer.py +190 -23
  9. apmtools-0.2.47/src/apmtools/shiny/multi_axis.py +150 -0
  10. apmtools-0.2.47/src/apmtools/shiny/multi_y.py +56 -0
  11. {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/shiny/plotly_renderer.py +111 -5
  12. {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/shiny/ui.py +1030 -132
  13. {apmtools-0.2.45 → apmtools-0.2.47}/.gitignore +0 -0
  14. {apmtools-0.2.45 → apmtools-0.2.47}/LICENSE +0 -0
  15. {apmtools-0.2.45 → apmtools-0.2.47}/README.md +0 -0
  16. {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/__init__.py +0 -0
  17. {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/classes.py +0 -0
  18. {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/data_processing.py +0 -0
  19. {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/functions.py +0 -0
  20. {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/legacy.py +0 -0
  21. {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/shiny/__init__.py +0 -0
  22. {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/shiny/columns.py +0 -0
  23. {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/shiny/config.py +0 -0
  24. {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/shiny/export.py +0 -0
  25. {apmtools-0.2.45 → apmtools-0.2.47}/src/apmtools/shiny/summary.py +0 -0
  26. {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.45
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>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "apmtools"
7
- version = "0.2.45"
7
+ version = "0.2.47"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -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[0]))
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
- # The values axis is shared by multiple series; facet headings carry
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 "").split(";"):
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((" | ".join(label(value) for value in row)
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
- raise ValueError("Select at least one categorical grouping variable for categorical-only aggregation.")
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
- raise ValueError(
146
- "Summary.group_stat() returned None. Check the selected variables, "
147
- "grouping columns, frequency, and completeness settings."
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["__plot_group"])]
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
- "Value" if kind == "line" and len(ys) > 1 else
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 restore V77-like breathing room
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=0.34, hspace=0.44)
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="", _axes=None,
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, 3.8*nrows), dpi=plot_dpi,
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
- "_hide_ungrouped_legend": not bool(other_groups)}
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; restore visible gutters
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=0.34, hspace=0.44)
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 = "__plot_group" if spec["groups"] and "__plot_group" in df.columns else None
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 kind == "histogram":
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
- ax.plot(part[x], part[y], color=colors.get(name, palette_values[0]),
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"}