scistackplot 0.1.34__tar.gz → 0.1.35__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 (48) hide show
  1. {scistackplot-0.1.34 → scistackplot-0.1.35}/PKG-INFO +14 -2
  2. {scistackplot-0.1.34 → scistackplot-0.1.35}/README.md +12 -0
  3. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/codegen.py +82 -37
  4. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/presets.py +1 -0
  5. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/reduce.py +132 -29
  6. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/render/base.py +15 -0
  7. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/render/mpl.py +10 -83
  8. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/render/plotly_.py +22 -1
  9. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/resolved.py +7 -0
  10. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/spec.py +9 -0
  11. {scistackplot-0.1.34 → scistackplot-0.1.35}/.gitignore +0 -0
  12. {scistackplot-0.1.34 → scistackplot-0.1.35}/pyproject.toml +0 -0
  13. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/__init__.py +0 -0
  14. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/aliases.py +0 -0
  15. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/autosize.py +0 -0
  16. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/capability.py +0 -0
  17. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/cell.py +0 -0
  18. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/colors.py +0 -0
  19. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/compare.py +0 -0
  20. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/custom.py +0 -0
  21. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/dedup.py +0 -0
  22. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/diffbars.py +0 -0
  23. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/export.py +0 -0
  24. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/figsize.py +0 -0
  25. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/figure_file.py +0 -0
  26. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/framesize.py +0 -0
  27. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/groups.py +0 -0
  28. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/numeric.py +0 -0
  29. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/panels.py +0 -0
  30. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/paper.py +0 -0
  31. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/reducer.py +0 -0
  32. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/render/__init__.py +0 -0
  33. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/restore.py +0 -0
  34. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/roles.py +0 -0
  35. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/series_stats.py +0 -0
  36. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/shape.py +0 -0
  37. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/sources/__init__.py +0 -0
  38. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/sources/base.py +0 -0
  39. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/sources/csv.py +0 -0
  40. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/sources/frame.py +0 -0
  41. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/spaghetti.py +0 -0
  42. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/table.py +0 -0
  43. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/textsize.py +0 -0
  44. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/ticklabels.py +0 -0
  45. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/variants.py +0 -0
  46. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/weights.py +0 -0
  47. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/xaxis.py +0 -0
  48. {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/ylimits.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scistackplot
3
- Version: 0.1.34
3
+ Version: 0.1.35
4
4
  Summary: Spec-driven plotting for long-format scientific data — standalone, GUI-friendly
5
5
  Author: SciStack Contributors
6
6
  License-Expression: MIT
@@ -19,7 +19,7 @@ Classifier: Typing :: Typed
19
19
  Requires-Python: >=3.10
20
20
  Requires-Dist: numpy>=1.21
21
21
  Requires-Dist: pandas>=1.5
22
- Requires-Dist: scistacklog==0.1.34
22
+ Requires-Dist: scistacklog==0.1.35
23
23
  Provides-Extra: dev
24
24
  Requires-Dist: matplotlib>=3.6; extra == 'dev'
25
25
  Requires-Dist: plotly>=5.0; extra == 'dev'
@@ -262,6 +262,18 @@ that looks like a data problem. `LongTable` carries each factor's real level
262
262
  order; sources that know better (SciDB knows its declared `schema_key_types`)
263
263
  supply it explicitly, and everything else falls back to a natural sort.
264
264
 
265
+ ## Missing levels stay visible
266
+
267
+ A figure with no data for an x level that the rest of the plot has keeps an
268
+ empty tick for it: under `subject=ITERATE`, the figure of a subject who never
269
+ ran session 2 still shows a session 2 slot, so the gap reads as missing data.
270
+ `PlotSpec.show_missing_levels=False` leaves those ticks off, which makes the
271
+ remaining marks wider. "Missing" is measured against the plot's filtered
272
+ rows, so a level you filtered out never comes back as an empty tick. Leaves
273
+ of a nested axis work the same way, and no position is ever made for a
274
+ combination no figure has. Either way `ResolvedPlot.missing_x` lists the
275
+ levels, and the exported code draws the same ticks as the preview.
276
+
265
277
  ## Rendering
266
278
 
267
279
  Two backends translate the same reduced plot, so the interactive view and the
@@ -223,6 +223,18 @@ that looks like a data problem. `LongTable` carries each factor's real level
223
223
  order; sources that know better (SciDB knows its declared `schema_key_types`)
224
224
  supply it explicitly, and everything else falls back to a natural sort.
225
225
 
226
+ ## Missing levels stay visible
227
+
228
+ A figure with no data for an x level that the rest of the plot has keeps an
229
+ empty tick for it: under `subject=ITERATE`, the figure of a subject who never
230
+ ran session 2 still shows a session 2 slot, so the gap reads as missing data.
231
+ `PlotSpec.show_missing_levels=False` leaves those ticks off, which makes the
232
+ remaining marks wider. "Missing" is measured against the plot's filtered
233
+ rows, so a level you filtered out never comes back as an empty tick. Leaves
234
+ of a nested axis work the same way, and no position is ever made for a
235
+ combination no figure has. Either way `ResolvedPlot.missing_x` lists the
236
+ levels, and the exported code draws the same ticks as the preview.
237
+
226
238
  ## Rendering
227
239
 
228
240
  Two backends translate the same reduced plot, so the interactive view and the
@@ -30,7 +30,15 @@ from .cell import cell_collapses, effective_shape
30
30
  from .compare import comparison_title, plan_comparison
31
31
  from .groups import active_level_groups, apply_level_groups, effective_mapping
32
32
  from .panels import Y_TITLES_FIRST_COLUMN, overrides_by_key, panel_key_text
33
- from .reduce import plan_layout, planned_y_limits, x_axis_title
33
+ from .reduce import (
34
+ apply_filters,
35
+ axis_combinations,
36
+ axis_values,
37
+ nested_layer_orders,
38
+ plan_layout,
39
+ planned_y_limits,
40
+ x_axis_title,
41
+ )
34
42
  from .resolved import DASH_CYCLE
35
43
  from .roles import (
36
44
  collapse_steps,
@@ -847,15 +855,23 @@ def _spaghetti_position_lines(spec, table: LongTable, roles, shape) -> list[str]
847
855
  if layers:
848
856
  # The composed, spacer-free order the nested axis is exported with.
849
857
  declared = _nested_x_order(spec, table, roles, shape)
858
+ plot_levels = declared
850
859
  elif x == _X_CONSTANT:
851
860
  declared = [""]
861
+ plot_levels = []
852
862
  else:
853
863
  declared = [str(level) for level in table.factor(x).levels]
864
+ plot_levels = [str(v) for v in axis_values(_plot_frame(spec, table), x)]
865
+ # PlotSpec.show_missing_levels: a level another figure of the plot holds
866
+ # keeps its (empty) slot here too, as the preview's tick does.
867
+ present = f"set(df[{x!r}].astype(str))"
868
+ if spec.show_missing_levels and plot_levels:
869
+ present = f"{present} | set({plot_levels!r})"
854
870
  return [
855
871
  "# spaghetti: each level at its index, each series shifted a fixed",
856
872
  "# fraction of a tick so every line ends on its own markers",
857
873
  f"_declared = {declared!r}",
858
- f"_present = set(df[{x!r}].astype(str))",
874
+ f"_present = {present}",
859
875
  "_order = [v for v in _declared if v in _present]",
860
876
  "_natural = lambda s: [(0, int(c)) if c.isdigit() else (1, c) "
861
877
  'for c in re.split(r"(\\d+)", s) if c]',
@@ -1173,7 +1189,7 @@ def _sample_draw_lines(
1173
1189
  # (`_order`), the nested order catplot was given, the declared order
1174
1190
  # it was given (`_level_order_lines`), or — with neither — the order
1175
1191
  # of appearance seaborn infers.
1176
- "_x_levels = list(_order)" if spaghetti else f"_x_levels = {nested!r}" if nested else (
1192
+ "_x_levels = list(_order)" if spaghetti else "_x_levels = list(_x_order)" if nested else (
1177
1193
  "_x_levels = [str(v) for v in _x_order]"
1178
1194
  if _emits_x_order(spec, table, roles, shape, _seaborn_call(spec, table, roles, shape))
1179
1195
  else f"_x_levels = list(dict.fromkeys(df[{x!r}].astype(str)))"
@@ -1436,41 +1452,27 @@ def _facet_layout_args(spec, table: LongTable, facets: list[str]) -> list[str]:
1436
1452
  return args
1437
1453
 
1438
1454
 
1439
- def _nested_x_args(spec, table: LongTable, roles, shape) -> list[str]:
1440
- """``order=[...]`` reproducing the composed nested axis.
1441
-
1442
- The RESOLVED order, computed by the same :func:`~scistackplot.xaxis.plan_x_axis`
1443
- the preview used, rather than the nesting rules re-applied in generated
1444
- code. A second implementation is a second thing that can drift, and the
1445
- failure would be a re-ordered axis nobody notices.
1446
-
1447
- Spacers are dropped: seaborn has no concept of an empty category, so the
1448
- exported figure groups by ordering alone. The docstring says so — the
1449
- interactive view's gaps are the one thing the export cannot reproduce.
1450
- """
1451
- order = _nested_x_order(spec, table, roles, shape)
1452
- return [f"order={order!r}"] if order else []
1453
-
1454
-
1455
1455
  def _nested_x_order(spec, table: LongTable, roles, shape) -> list[str]:
1456
- """The composed nested-axis order, spacers dropped (see ``_nested_x_args``)."""
1456
+ """The composed nested-axis order over the PLOT's filtered rows: every
1457
+ leaf any figure of the fan-out holds, in the RESOLVED order the same
1458
+ :func:`~scistackplot.xaxis.plan_x_axis` gave the preview — never the
1459
+ nesting rules replayed in generated code, a second thing that can drift.
1460
+
1461
+ Spacers are dropped: seaborn has no empty category, so the export groups
1462
+ by ordering alone (the docstring says so). The generated ``_x_order``
1463
+ keeps every leaf under ``PlotSpec.show_missing_levels`` and the frame's
1464
+ own leaves otherwise (``_x_order_lines``) — the preview's
1465
+ ``reduce._x_axis_levels``."""
1457
1466
  layers = _nested_x_layers(spec, table, roles, shape)
1458
1467
  if not layers:
1459
1468
  return []
1460
1469
 
1461
1470
  from .xaxis import LEAF_SEPARATOR, is_spacer, plan_x_axis
1462
1471
 
1463
- frame = table.frame
1464
- present = [name for name in layers if name in frame.columns]
1465
- if len(present) != len(layers):
1472
+ combinations = axis_combinations(_plot_frame(spec, table), layers)
1473
+ if not combinations:
1466
1474
  return []
1467
- combinations = list(
1468
- frame[layers].astype(str).drop_duplicates().itertuples(index=False, name=None)
1469
- )
1470
- plan = plan_x_axis(
1471
- combinations,
1472
- [[str(level) for level in table.factor(name).levels] for name in layers],
1473
- )
1475
+ plan = plan_x_axis(combinations, nested_layer_orders(table, layers))
1474
1476
  return [
1475
1477
  key.replace(LEAF_SEPARATOR, _NESTED_JOIN)
1476
1478
  for key in plan.order
@@ -1478,6 +1480,49 @@ def _nested_x_order(spec, table: LongTable, roles, shape) -> list[str]:
1478
1480
  ]
1479
1481
 
1480
1482
 
1483
+ def _plot_frame(spec, table: LongTable):
1484
+ """The plot's rows after the spec's filters — every figure of the fan-out,
1485
+ which is what a "missing" x level is missing FROM (``_Plan.frame`` in the
1486
+ preview, cut by the same ``apply_filters``)."""
1487
+ return apply_filters(table.frame, spec)
1488
+
1489
+
1490
+ def _x_order_lines(spec, table: LongTable, roles, shape, x: str) -> list[str]:
1491
+ """``_x_order = …`` for a categorical x the call orders: the nested
1492
+ composed order, or a flat factor's levels through ``_in_order``.
1493
+
1494
+ ``PlotSpec.show_missing_levels`` on: the levels of the whole PLOT, baked
1495
+ in, so a figure of an ITERATE fan-out keeps an empty slot for a level
1496
+ another figure has (the preview's empty tick). Off: only what this
1497
+ frame holds. One variable, read by the call, the sample overlay and the
1498
+ difference bars alike.
1499
+ """
1500
+ nested = _nested_x_order(spec, table, roles, shape)
1501
+ Log.info(
1502
+ "generated code: x order over %s — %s",
1503
+ "the nested leaves" if nested else repr(x),
1504
+ "the plot's levels baked in (show_missing_levels: a figure keeps "
1505
+ "empty slots for the others')"
1506
+ if spec.show_missing_levels
1507
+ else "each frame's own levels (show_missing_levels off)",
1508
+ layer=LAYER,
1509
+ )
1510
+ if nested:
1511
+ if spec.show_missing_levels:
1512
+ return [f"_x_order = {nested!r}"]
1513
+ return [
1514
+ f"_x_present = set(df[{_X_NESTED!r}])",
1515
+ f"_x_order = [_k for _k in {nested!r} if _k in _x_present]",
1516
+ ]
1517
+ declared = _declared_levels(table, x)
1518
+ if spec.show_missing_levels:
1519
+ plot_levels = axis_values(_plot_frame(spec, table), x)
1520
+ return [
1521
+ f"_x_order = {_IN_ORDER}(pd.Series([*df[{x!r}], *{plot_levels!r}]), {declared!r})"
1522
+ ]
1523
+ return [f"_x_order = {_IN_ORDER}(df[{x!r}], {declared!r})"]
1524
+
1525
+
1481
1526
  def _seaborn_can_express_layout(spec, table: LongTable, roles) -> bool:
1482
1527
  """Whether ``_facet_layout_args`` reproduced the rules (see ``_docstring``)."""
1483
1528
  facets = [name for name, role in roles.items() if role is Role.FACET]
@@ -1681,9 +1726,6 @@ def _plot_call(
1681
1726
  if len(facets) > 1:
1682
1727
  args.append(f"row={facets[1]!r}")
1683
1728
  args.extend(_facet_layout_args(spec, table, facets))
1684
- if kind is not PlotKind.SPAGHETTI:
1685
- # relplot has no `order=`; the spaghetti preamble applies it itself.
1686
- args.extend(_nested_x_args(spec, table, roles, shape))
1687
1729
 
1688
1730
  estimator = (
1689
1731
  '"median"' if spec.aggregate.statistic is Statistic.MEDIAN else '"mean"'
@@ -2061,7 +2103,7 @@ def _seaborn_call(spec, table, roles, shape) -> str:
2061
2103
  def _emits_x_order(spec, table, roles, shape, call: str) -> bool:
2062
2104
  """Whether the call gets ``order=_x_order`` — a flat categorical x.
2063
2105
 
2064
- Nested x has its own composed ``order=`` (``_nested_x_args``) and the
2106
+ Nested x has its own composed order (``_x_order_lines``) and the
2065
2107
  constant x has one level; spaghetti places levels itself.
2066
2108
  """
2067
2109
  if call not in _ORDERED_X_CALLS or _nested_x_layers(spec, table, roles, shape):
@@ -2090,8 +2132,11 @@ def _level_order_lines(
2090
2132
  lines.append(f"{variable} = {_IN_ORDER}(df[{column!r}], {declared!r})")
2091
2133
  call_args.append(f"{arg}={variable}")
2092
2134
 
2093
- if _emits_x_order(spec, table, roles, shape, call):
2094
- order("_x_order", x, "order")
2135
+ if _emits_x_order(spec, table, roles, shape, call) or (
2136
+ call in _ORDERED_X_CALLS and _nested_x_order(spec, table, roles, shape)
2137
+ ):
2138
+ lines.extend(_x_order_lines(spec, table, roles, shape, x))
2139
+ call_args.append("order=_x_order")
2095
2140
  if color:
2096
2141
  order("_hue_order", color, "hue_order")
2097
2142
  # A RULED layout is `_facet_layout_args`' business: it emits its own
@@ -2383,7 +2428,7 @@ def _difference_bar_export(
2383
2428
  if spec.kind is PlotKind.SPAGHETTI:
2384
2429
  order_expr: str | None = "_order"
2385
2430
  elif nested:
2386
- order_expr = repr(_nested_x_order(spec, table, roles, shape))
2431
+ order_expr = "_x_order"
2387
2432
  elif _emits_x_order(spec, table, roles, shape, _seaborn_call(spec, table, roles, shape)):
2388
2433
  order_expr = "_x_order"
2389
2434
  else:
@@ -91,6 +91,7 @@ FIELD_CLASSES: dict[str, FieldClass] = {
91
91
  "join_sample": _T,
92
92
  "sample_color": _T,
93
93
  "sample_in_legend": _T,
94
+ "show_missing_levels": _T,
94
95
  "facet": _T,
95
96
  "y_axis.scope": _T,
96
97
  "y_axis.minimum": _V,
@@ -371,6 +371,7 @@ def resolve(
371
371
  y_limits=plan.y_limits,
372
372
  text=text,
373
373
  colors=colors,
374
+ axis_frame=plan.frame,
374
375
  )
375
376
  )
376
377
  for figure in figures:
@@ -807,6 +808,7 @@ def resolve_one(
807
808
  y_limits=plan.y_limits,
808
809
  text=text,
809
810
  colors=colors,
811
+ axis_frame=plan.frame,
810
812
  )
811
813
  figure.fanout_notes = plan.notes
812
814
  siblings = _difference_sibling_indices(plan, position)
@@ -827,6 +829,7 @@ def resolve_one(
827
829
  y_limits=plan.y_limits,
828
830
  text=text,
829
831
  colors=colors,
832
+ axis_frame=plan.frame,
830
833
  )
831
834
  for other in siblings
832
835
  ]
@@ -1236,6 +1239,7 @@ def _build_figure(
1236
1239
  y_limits: dict | None = None,
1237
1240
  text: DisplayText | None = None,
1238
1241
  colors: MarkColors | None = None,
1242
+ axis_frame: pd.DataFrame | None = None,
1239
1243
  ) -> ResolvedPlot:
1240
1244
  # ``narrate`` makes this function announce each phase as it starts. A
1241
1245
  # full-resolution figure is minutes of work (scidb.log 2026-09-11: 1543s for
@@ -1402,7 +1406,6 @@ def _build_figure(
1402
1406
  color,
1403
1407
  layer=LAYER,
1404
1408
  )
1405
- x_factor = x_layers[0] if x_layers else None
1406
1409
  # Several factors may be faceted at once; their combined levels are the
1407
1410
  # panels, and FacetOptions decides how those panels are arranged.
1408
1411
  facet_names = [
@@ -1557,11 +1560,25 @@ def _build_figure(
1557
1560
  # renderers draw the same brackets and codegen can replay the result
1558
1561
  # instead of re-deriving it (same bargain as plan_layout).
1559
1562
  # Its TEXT is aliased; its `order` (the keys marks are placed by) is not.
1560
- x_plan = (
1561
- figure_text.x_plan(_plan_nested_x(panels, table, x_layers))
1562
- if len(x_layers) > 1
1563
+ x_axis = (
1564
+ _x_axis_levels(
1565
+ panels, table, x_layers, axis_frame, spec.show_missing_levels
1566
+ )
1567
+ if x_layers
1563
1568
  else None
1564
1569
  )
1570
+ x_plan = figure_text.x_plan(x_axis.plan) if x_axis and x_axis.plan else None
1571
+ if x_axis is not None and x_axis.missing:
1572
+ Log.info(
1573
+ "x axis: %d level(s) of the plot have no data in this figure "
1574
+ "(%s): %s",
1575
+ len(x_axis.missing),
1576
+ "drawn as empty ticks"
1577
+ if spec.show_missing_levels
1578
+ else "left off the axis (show_missing_levels=False)",
1579
+ _describe_missing(x_axis.missing),
1580
+ layer=LAYER,
1581
+ )
1565
1582
 
1566
1583
  # The FIGURE's limits: the panels' own, when they all agree. Not a
1567
1584
  # second computation — every consumer that reads one number per figure
@@ -1642,12 +1659,16 @@ def _build_figure(
1642
1659
  list(x_plan.order)
1643
1660
  if x_plan
1644
1661
  else (
1645
- _level_order(table, x_factor, [p.frame for p in panels], X)
1646
- if x_factor
1662
+ x_axis.order
1663
+ if x_axis is not None
1647
1664
  else _unlabelled_x_order(panels)
1648
1665
  )
1649
1666
  ),
1650
1667
  x_plan=x_plan,
1668
+ missing_x=[
1669
+ MISSING_X_JOIN.join(values)
1670
+ for values in (x_axis.missing if x_axis is not None else [])
1671
+ ],
1651
1672
  x_layers=list(x_layers),
1652
1673
  color_factor=color,
1653
1674
  color_order=color_order,
@@ -1981,7 +2002,7 @@ def _panel_frame(
1981
2002
  # Carry the nested axis's LAYER columns through the summary. They are a
1982
2003
  # function of the composed key, so grouping by them as well changes no
1983
2004
  # group — but without them `_summarize`'s `reset_index` returns three
1984
- # columns and the layer values are gone, and `_plan_nested_x` (which
2005
+ # columns and the layer values are gone, and `_x_axis_levels` (which
1985
2006
  # requires every layer to be present in a panel) then skips every panel
1986
2007
  # and composes an EMPTY axis: no order, no ticks, no brackets.
1987
2008
  #
@@ -2120,7 +2141,7 @@ def _summarize(
2120
2141
  functionally determined by ``__x`` (the composed leaf key IS their
2121
2142
  combination), so grouping by them splits nothing that was not already
2122
2143
  split; it only keeps them from being dropped by the ``reset_index`` below,
2123
- which is what the renderers and ``_plan_nested_x`` read the group labels
2144
+ which is what the renderers and ``_x_axis_levels`` read the group labels
2124
2145
  from.
2125
2146
 
2126
2147
  ``__color`` is carried the same way, and for the same reason: the
@@ -2255,33 +2276,115 @@ def _level_rank(table: LongTable, column: str, value: Any) -> tuple:
2255
2276
  return (1,) + natural_sort_key(value)
2256
2277
 
2257
2278
 
2258
- def _plan_nested_x(
2259
- panels: list[Panel], table: LongTable, x_layers: list[str]
2260
- ) -> XPlan:
2261
- """Compose the nested axis from the combinations the panels actually hold.
2279
+ def axis_combinations(frame: pd.DataFrame, layers: list[str]) -> list[tuple[str, ...]]:
2280
+ """The distinct combinations of ``layers`` in ``frame``, as text, in
2281
+ order of appearance — ``[]`` when a layer is absent or the frame empty.
2282
+
2283
+ THE rule for which leaves a nested x axis can hold, read by the preview
2284
+ (over a figure's panels, and over the plot's filtered frame for the
2285
+ missing ones) and by ``codegen`` (over the same filtered frame). Observed
2286
+ combinations, never the Cartesian product: real designs are ragged — a
2287
+ sham group with no post session — and a position for a combination
2288
+ nobody ran anywhere in the plot is not "missing data", it is no data.
2289
+ """
2290
+ if not layers or frame.empty or any(name not in frame.columns for name in layers):
2291
+ return []
2292
+ return list(
2293
+ frame[layers].astype(str).drop_duplicates().itertuples(index=False, name=None)
2294
+ )
2295
+
2296
+
2297
+ def axis_values(frame: pd.DataFrame, column: str) -> list[Any]:
2298
+ """The distinct non-null values of a flat x factor in ``frame``, as the
2299
+ frame holds them (a numeric level stays a number). The flat-axis twin of
2300
+ :func:`axis_combinations`, with the same two readers."""
2301
+ if column not in frame.columns or frame.empty:
2302
+ return []
2303
+ return frame[column].dropna().unique().tolist()
2304
+
2305
+
2306
+ def nested_layer_orders(table: LongTable, x_layers: list[str]) -> list[list[str]]:
2307
+ """Each nested layer's declared level order, as text (``plan_x_axis``)."""
2308
+ return [[str(level) for level in _factor_levels(table, name)] for name in x_layers]
2309
+
2310
+
2311
+ @dataclass(frozen=True)
2312
+ class _XAxis:
2313
+ """What :func:`_x_axis_levels` decided for one figure."""
2314
+
2315
+ #: The flat axis' levels in order (natives), or None for a nested axis.
2316
+ order: list[Any] | None
2317
+ #: The composed nested axis, or None for a flat one.
2318
+ plan: XPlan | None
2319
+ #: Leaves of the plot this figure has no data for, as text, outermost
2320
+ #: layer first — listed whether or not they are drawn.
2321
+ missing: list[tuple[str, ...]]
2262
2322
 
2263
- Observed combinations, never the Cartesian product: real designs are ragged
2264
- — a sham group with no post session — and reserving a position for a
2265
- combination nobody ran leaves a hole that reads as missing data.
2266
2323
 
2267
- Taken across ALL panels so a faceted figure shares one axis; a panel missing
2268
- a combination gets a gap in the same place as its neighbours rather than a
2269
- differently-shaped axis.
2324
+ def _x_axis_levels(
2325
+ panels: list[Panel],
2326
+ table: LongTable,
2327
+ x_layers: list[str],
2328
+ axis_frame: pd.DataFrame | None,
2329
+ show_missing: bool,
2330
+ ) -> _XAxis:
2331
+ """The figure's x levels: its own, plus — under
2332
+ ``PlotSpec.show_missing_levels`` — the plot's levels it has no data for.
2333
+
2334
+ ``axis_frame`` is the plot's post-filter frame (``_Plan.frame``, every
2335
+ figure of the fan-out), so "missing" is relative to what the plot holds
2336
+ after the user's filters: a filtered-out level never returns as an empty
2337
+ tick. Taken across ALL panels so a faceted figure shares one axis; a
2338
+ panel missing a combination gets a gap in the same place as its
2339
+ neighbours rather than a differently-shaped axis.
2270
2340
  """
2271
- combinations: list[tuple] = []
2272
- for panel in panels:
2273
- present = [name for name in x_layers if name in panel.frame.columns]
2274
- if len(present) != len(x_layers) or panel.frame.empty:
2275
- continue
2276
- combinations.extend(
2277
- panel.frame[x_layers].astype(str).drop_duplicates().itertuples(
2278
- index=False, name=None
2341
+ plot_frame = axis_frame if axis_frame is not None else pd.DataFrame()
2342
+ if len(x_layers) > 1:
2343
+ own: list[tuple[str, ...]] = []
2344
+ for panel in panels:
2345
+ own.extend(axis_combinations(panel.frame, x_layers))
2346
+ held = set(own)
2347
+ missing = list(
2348
+ dict.fromkeys(
2349
+ combo
2350
+ for combo in axis_combinations(plot_frame, x_layers)
2351
+ if combo not in held
2279
2352
  )
2280
2353
  )
2281
- layer_orders = [
2282
- [str(level) for level in _factor_levels(table, name)] for name in x_layers
2354
+ combinations = own + missing if show_missing else own
2355
+ return _XAxis(
2356
+ order=None,
2357
+ plan=plan_x_axis(combinations, nested_layer_orders(table, x_layers)),
2358
+ missing=sorted(missing, key=lambda combo: _combo_rank(table, x_layers, combo)),
2359
+ )
2360
+
2361
+ x_factor = x_layers[0]
2362
+ present = _level_order(table, x_factor, [p.frame for p in panels], X) or []
2363
+ held_text = {str(value) for value in present}
2364
+ missing_values = [
2365
+ value for value in axis_values(plot_frame, x_factor) if str(value) not in held_text
2283
2366
  ]
2284
- return plan_x_axis(combinations, layer_orders)
2367
+ order = present + missing_values if show_missing else present
2368
+ order = sorted(order, key=lambda v: _level_rank(table, x_factor, v))
2369
+ missing_values = sorted(missing_values, key=lambda v: _level_rank(table, x_factor, v))
2370
+ return _XAxis(order=order, plan=None, missing=[(str(v),) for v in missing_values])
2371
+
2372
+
2373
+ #: Joins a missing nested leaf's layer values (outermost first) into the one
2374
+ #: string ``ResolvedPlot.missing_x`` lists it as.
2375
+ MISSING_X_JOIN = " / "
2376
+
2377
+
2378
+ def _describe_missing(missing: list[tuple[str, ...]], limit: int = 8) -> str:
2379
+ names = [MISSING_X_JOIN.join(values) for values in missing]
2380
+ more = f" … +{len(names) - limit} more" if len(names) > limit else ""
2381
+ return ", ".join(names[:limit]) + more
2382
+
2383
+
2384
+ def _combo_rank(table: LongTable, layers: list[str], combo: tuple[str, ...]) -> tuple:
2385
+ return tuple(
2386
+ _level_rank(table, name, value) for name, value in zip(layers, combo, strict=True)
2387
+ )
2285
2388
 
2286
2389
 
2287
2390
  def _factor_levels(table: LongTable, name: str) -> list[Any]:
@@ -436,6 +436,21 @@ def legend_levels(resolved: ResolvedPlot) -> list[Any]:
436
436
  return ordered
437
437
 
438
438
 
439
+ def legend_entry_labels(resolved: ResolvedPlot) -> list[str]:
440
+ """
441
+ The colour entries' TEXT, in legend order: :func:`legend_levels` as the
442
+ renderers label them.
443
+
444
+ The one place a legend's order comes from. Both renderers used to list
445
+ entries in the order they first met them while drawing panel after panel,
446
+ so a facet grid whose first panel lacked a level (no ``BL`` in the sham
447
+ group) listed that level after the others — the declared ``[schema_keys]``
448
+ order held in every panel and in the colours, but not in the legend
449
+ (2026-10-15).
450
+ """
451
+ return [resolved.text.color_level(level) for level in legend_levels(resolved)]
452
+
453
+
439
454
  def shares_y_axis(resolved: ResolvedPlot) -> bool:
440
455
  """Whether every panel draws the same y range, so one axis can serve them.
441
456
 
@@ -41,6 +41,7 @@ from .base import (
41
41
  dash_style,
42
42
  grid_shape,
43
43
  is_categorical_x,
44
+ legend_entry_labels,
44
45
  legend_levels,
45
46
  mark_width,
46
47
  palette_for,
@@ -1269,89 +1270,6 @@ def _verify_x_ticks(fig, ticks: list[_XTicks], fit: LabelFit) -> None:
1269
1270
  #: figure width.
1270
1271
  LEGEND_PAD = 0.02
1271
1272
 
1272
- #: Widest the legend strip may get, however long the level names are: past this
1273
- #: the labels have eaten the figure, and truncating the panels is worse than
1274
- #: truncating the legend.
1275
- MAX_LEGEND_FRACTION = 0.4
1276
-
1277
-
1278
- def _apply_legend(fig, resolved: ResolvedPlot) -> float:
1279
- """
1280
- Draw the legend to the right of the panels; return the width it claimed.
1281
-
1282
- The return value is a fraction of the figure width, for ``tight_layout``'s
1283
- ``rect`` — see the call site. Zero means no legend was drawn, which is the
1284
- answer for a single colour level (``base.shows_legend``) as well as for no
1285
- colour at all.
1286
- """
1287
- if not shows_legend(resolved):
1288
- Log.debug(
1289
- "legend omitted: %d colour level(s) drawn for %r",
1290
- len(legend_levels(resolved)),
1291
- resolved.labels.color,
1292
- layer=LAYER,
1293
- )
1294
- return 0.0
1295
- # Every VISIBLE axes, not just the first: with facets, a level can be
1296
- # absent from panel 1 and present in panel 5, and reading one panel's
1297
- # handles would drop it from the legend of a figure that draws it.
1298
- unique: dict[str, Any] = {}
1299
- for ax in fig.axes:
1300
- if not ax.get_visible():
1301
- continue
1302
- handles, labels = ax.get_legend_handles_labels()
1303
- for handle, label in zip(handles, labels, strict=False):
1304
- unique.setdefault(label, handle)
1305
- if not unique:
1306
- return 0.0
1307
- # The overlay's block goes LAST, in its levels' order. matplotlib lists
1308
- # an axes' Line2D handles before its bar containers, so gathered as-is
1309
- # the subjects would precede the bars they sit on.
1310
- sample_labels = [resolved.text.sample_level(level) for level in sample_legend_levels(resolved)]
1311
- if len(sample_labels) > 1:
1312
- unique = {
1313
- **{k: v for k, v in unique.items() if k not in sample_labels},
1314
- **{k: unique[k] for k in sample_labels if k in unique},
1315
- }
1316
-
1317
- # One title naming every block: the colours, the dash styles, and the
1318
- # overlay's own colour key when its levels are listed too.
1319
- blocks = (
1320
- resolved.labels.color,
1321
- resolved.labels.dash,
1322
- resolved.labels.sample if len(sample_legend_levels(resolved)) > 1 else None,
1323
- )
1324
- title = " / ".join(t for t in blocks if t) or None
1325
- legend = fig.legend(
1326
- unique.values(),
1327
- unique.keys(),
1328
- title=title,
1329
- loc="center right",
1330
- frameon=False,
1331
- )
1332
- return _legend_width_fraction(fig, legend, unique.keys(), title)
1333
-
1334
-
1335
- def _legend_width_fraction(fig, legend, labels, title) -> float:
1336
- """How much of the figure's width the legend needs, measured if possible."""
1337
- figure_width = fig.get_size_inches()[0] or 1.0
1338
- try:
1339
- inches = legend.get_window_extent(fig.canvas.get_renderer()).width / fig.dpi
1340
- except Exception: # a backend without a usable renderer
1341
- # Estimate rather than reserve nothing: a wrong-by-a-little strip still
1342
- # keeps the legend off the panels, an unmeasured one does not.
1343
- longest = max((len(str(text)) for text in [*labels, title or ""]), default=0)
1344
- inches = 0.55 + 0.085 * longest
1345
- Log.debug(
1346
- "legend width not measurable; estimating %.2fin from %d labels",
1347
- inches,
1348
- len(list(labels)),
1349
- layer=LAYER,
1350
- )
1351
- #: Breathing room between the panels and the legend strip, as a fraction of the
1352
- #: figure width.
1353
- LEGEND_PAD = 0.02
1354
-
1355
1273
  #: Share of the figure width a legend at the right may take. Past it the
1356
1274
  #: legend is narrowed (title wrapped, shorter line samples, smaller text) and,
1357
1275
  #: if still too wide, moved below the panels. The old cap was 0.4, and at it a
@@ -1431,6 +1349,15 @@ def _legend_entries(fig, resolved: ResolvedPlot):
1431
1349
  unique.setdefault(label, handle)
1432
1350
  if not unique:
1433
1351
  return None
1352
+ # The colour block FIRST, in legend order (`legend_entry_labels`): handles
1353
+ # are gathered axes by axes, so a level missing from the first panel
1354
+ # would otherwise be listed after levels declared later.
1355
+ colour_labels = [label for label in legend_entry_labels(resolved) if label in unique]
1356
+ unique = {
1357
+ **{label: unique[label] for label in colour_labels},
1358
+ **{k: v for k, v in unique.items() if k not in colour_labels},
1359
+ }
1360
+ Log.debug("export legend order: %s", list(unique), layer=LAYER)
1434
1361
  # The overlay's block goes LAST, in its levels' order. matplotlib lists
1435
1362
  # an axes' Line2D handles before its bar containers, so gathered as-is
1436
1363
  # the subjects would precede the bars they sit on.
@@ -55,6 +55,7 @@ from .base import (
55
55
  dash_style,
56
56
  grid_shape,
57
57
  is_categorical_x,
58
+ legend_entry_labels,
58
59
  legend_levels,
59
60
  colorable,
60
61
  palette_for,
@@ -174,6 +175,10 @@ def render(
174
175
  # from this, never from `spec.roles`.
175
176
  "panel_factors": list(resolved.panel_factors),
176
177
  "y_scope": list(resolved.y_scope),
178
+ # The plot's x levels this figure has no data for
179
+ # (ResolvedPlot.missing_x): the GUI offers "Show missing
180
+ # levels" only when this is non-empty, and says which.
181
+ "missing_x": list(resolved.missing_x),
177
182
  # The size the SAVED figure will have. The preview fills its
178
183
  # pane regardless, so this is how the panel states what the
179
184
  # export produces — the same numbers `render_mpl` logs.
@@ -236,6 +241,18 @@ def render(
236
241
  panel_position(resolved, index) for index in range(len(resolved.panels))
237
242
  ]
238
243
  seen_legend: set[str] = set()
244
+ # plotly lists legend entries in TRACE order, which is panel order —
245
+ # a level missing from the first panel would be listed late. A rank
246
+ # per entry pins the legend to `legend_entry_labels` instead. Below
247
+ # plotly's default 1000, so dash entries (unranked) follow the colours
248
+ # and the overlay (2000) stays last.
249
+ legend_ranks = (
250
+ {label: rank for rank, label in enumerate(legend_entry_labels(resolved))}
251
+ if legend_on
252
+ else {}
253
+ )
254
+ if legend_ranks:
255
+ Log.debug("preview legend order: %s", list(legend_ranks), layer=LAYER)
239
256
 
240
257
  for index, panel in enumerate(resolved.panels):
241
258
  row, col = positions[index]
@@ -245,7 +262,8 @@ def render(
245
262
 
246
263
  traces.extend(
247
264
  _panel_traces(
248
- panel.frame, resolved, x_axis, y_axis, seen_legend, legend_on
265
+ panel.frame, resolved, x_axis, y_axis, seen_legend, legend_on,
266
+ legend_ranks,
249
267
  )
250
268
  )
251
269
  traces.extend(_sample_traces(panel, resolved, x_axis, y_axis, seen_legend, legend_on))
@@ -472,6 +490,7 @@ def _panel_traces(
472
490
  y_axis: str,
473
491
  seen_legend: set[str],
474
492
  legend_on: bool = True,
493
+ legend_ranks: dict[str, int] | None = None,
475
494
  ) -> list[dict]:
476
495
  if frame.empty:
477
496
  return []
@@ -506,6 +525,8 @@ def _panel_traces(
506
525
  "xaxis": x_axis,
507
526
  "yaxis": y_axis,
508
527
  }
528
+ if legend_ranks and label in legend_ranks:
529
+ base["legendrank"] = legend_ranks[label]
509
530
  if _positional_x(resolved):
510
531
  # A linear axis of level indices (see `_positional_x`): every mark
511
532
  # is placed by index so the overlay can shift beside it, and the
@@ -194,6 +194,12 @@ class ResolvedPlot:
194
194
  #: so every existing consumer keeps working; renderers read this only to
195
195
  #: draw the group labels and brackets beneath the ticks.
196
196
  x_plan: Any = None
197
+ #: x levels of the plot (its filtered data, every figure of the fan-out)
198
+ #: this figure has no data for, in axis order — a nested leaf as its
199
+ #: layer values joined by ``reduce.MISSING_X_JOIN``. Listed whether or not
200
+ #: ``PlotSpec.show_missing_levels`` draws them, so the GUI can offer the
201
+ #: toggle exactly when it would change something.
202
+ missing_x: list[str] = field(default_factory=list)
197
203
  #: The factors sharing the x axis, outermost first: ``x_plan`` depth ``d``
198
204
  #: is ``x_layers[d]`` and the last entry is the tick labels' own layer.
199
205
  #: With ``color_factor``, how a renderer knows which labelled row repeats
@@ -353,6 +359,7 @@ class ResolvedPlot:
353
359
  "layout_notes": list(self.layout_notes),
354
360
  },
355
361
  "x_order": [_jsonable(v) for v in (self.x_order or [])] or None,
362
+ "missing_x": list(self.missing_x),
356
363
  "x_groups": [
357
364
  {
358
365
  "label": group.label,
@@ -1129,6 +1129,14 @@ class PlotSpec:
1129
1129
  #: shown — the points keep their colours. ``roles.overlay_in_legend`` is
1130
1130
  #: the one reader.
1131
1131
  sample_in_legend: bool = True
1132
+ #: Whether an x level this figure has no data for keeps its (empty) tick.
1133
+ #: "Missing" means present in the plot's FILTERED data but absent from
1134
+ #: this figure — a subject's figure without session 2 under a fan-out —
1135
+ #: never a level a filter removed. On (the default), the empty tick says
1136
+ #: the data is missing, at the cost of narrower marks; off, the axis holds
1137
+ #: only the figure's own levels. ``reduce._x_axis_levels`` is the one
1138
+ #: reader; ``ResolvedPlot.missing_x`` lists the levels either way.
1139
+ show_missing_levels: bool = True
1132
1140
  facet: FacetOptions = field(default_factory=FacetOptions)
1133
1141
  #: What the y axis spans, and what separates spans. See :class:`YAxis`.
1134
1142
  y_axis: YAxis = field(default_factory=YAxis)
@@ -1359,6 +1367,7 @@ class PlotSpec:
1359
1367
  join_sample=_optional_bool(raw.get("join_sample")),
1360
1368
  sample_color=raw.get("sample_color"),
1361
1369
  sample_in_legend=bool(raw.get("sample_in_legend", True)),
1370
+ show_missing_levels=bool(raw.get("show_missing_levels", True)),
1362
1371
  facet=_facet_from_dict(raw.get("facet") or {}),
1363
1372
  y_axis=YAxis.from_dict(raw.get("y_axis") or {}),
1364
1373
  style=StyleOptions.from_dict(raw.get("style")),
File without changes