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.
Files changed (26) hide show
  1. {apmtools-0.2.46 → apmtools-0.2.47}/PKG-INFO +1 -1
  2. {apmtools-0.2.46 → apmtools-0.2.47}/pyproject.toml +1 -1
  3. {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/shiny/appearance.py +3 -2
  4. apmtools-0.2.47/src/apmtools/shiny/color_editor.py +64 -0
  5. apmtools-0.2.47/src/apmtools/shiny/elements.py +96 -0
  6. apmtools-0.2.47/src/apmtools/shiny/histogram_multi.py +32 -0
  7. {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/shiny/matplotlib_renderer.py +147 -19
  8. apmtools-0.2.47/src/apmtools/shiny/multi_axis.py +150 -0
  9. {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/shiny/plotly_renderer.py +32 -5
  10. {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/shiny/ui.py +575 -95
  11. {apmtools-0.2.46 → apmtools-0.2.47}/.gitignore +0 -0
  12. {apmtools-0.2.46 → apmtools-0.2.47}/LICENSE +0 -0
  13. {apmtools-0.2.46 → apmtools-0.2.47}/README.md +0 -0
  14. {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/__init__.py +0 -0
  15. {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/classes.py +0 -0
  16. {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/data_processing.py +0 -0
  17. {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/functions.py +0 -0
  18. {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/legacy.py +0 -0
  19. {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/shiny/__init__.py +0 -0
  20. {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/shiny/columns.py +0 -0
  21. {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/shiny/config.py +0 -0
  22. {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/shiny/data.py +0 -0
  23. {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/shiny/export.py +0 -0
  24. {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/shiny/multi_y.py +0 -0
  25. {apmtools-0.2.46 → apmtools-0.2.47}/src/apmtools/shiny/summary.py +0 -0
  26. {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.46
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.46"
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,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[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
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 "").split(";"):
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["__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
- "\n".join(_axis_label(y) for y in ys) 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,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 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",
@@ -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="", _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,
@@ -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; 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)
@@ -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 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":
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
- key = (value, group_names[0] if group_col else None) if by_var else (variable_names[0], value)
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: