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.
- {scistackplot-0.1.34 → scistackplot-0.1.35}/PKG-INFO +14 -2
- {scistackplot-0.1.34 → scistackplot-0.1.35}/README.md +12 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/codegen.py +82 -37
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/presets.py +1 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/reduce.py +132 -29
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/render/base.py +15 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/render/mpl.py +10 -83
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/render/plotly_.py +22 -1
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/resolved.py +7 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/spec.py +9 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/.gitignore +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/pyproject.toml +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/__init__.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/aliases.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/autosize.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/capability.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/cell.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/colors.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/compare.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/custom.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/dedup.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/diffbars.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/export.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/figsize.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/figure_file.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/framesize.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/groups.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/numeric.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/panels.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/paper.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/reducer.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/render/__init__.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/restore.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/roles.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/series_stats.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/shape.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/sources/__init__.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/sources/base.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/sources/csv.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/sources/frame.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/spaghetti.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/table.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/textsize.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/ticklabels.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/variants.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/weights.py +0 -0
- {scistackplot-0.1.34 → scistackplot-0.1.35}/src/scistackplot/xaxis.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
1464
|
-
|
|
1465
|
-
if len(present) != len(layers):
|
|
1472
|
+
combinations = axis_combinations(_plot_frame(spec, table), layers)
|
|
1473
|
+
if not combinations:
|
|
1466
1474
|
return []
|
|
1467
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
@@ -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
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
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
|
-
|
|
1646
|
-
if
|
|
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 `
|
|
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 ``
|
|
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
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
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
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
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
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
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
|
-
|
|
2282
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|