scistackplot 0.1.56__tar.gz → 0.1.58__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 (53) hide show
  1. {scistackplot-0.1.56 → scistackplot-0.1.58}/PKG-INFO +21 -4
  2. {scistackplot-0.1.56 → scistackplot-0.1.58}/README.md +18 -1
  3. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/__init__.py +6 -0
  4. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/capability.py +6 -4
  5. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/codegen.py +91 -37
  6. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/diffbars.py +11 -0
  7. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/reduce.py +6 -5
  8. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/render/base.py +35 -3
  9. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/render/mpl.py +335 -26
  10. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/render/plotly_.py +247 -16
  11. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/roles.py +2 -2
  12. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/spec.py +30 -0
  13. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/ticklabels.py +13 -0
  14. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/ylimits.py +3 -3
  15. {scistackplot-0.1.56 → scistackplot-0.1.58}/.gitignore +0 -0
  16. {scistackplot-0.1.56 → scistackplot-0.1.58}/pyproject.toml +0 -0
  17. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/aliases.py +0 -0
  18. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/autosize.py +0 -0
  19. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/bins.py +0 -0
  20. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/cell.py +0 -0
  21. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/colors.py +0 -0
  22. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/compare.py +0 -0
  23. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/custom.py +0 -0
  24. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/dedup.py +0 -0
  25. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/design.py +0 -0
  26. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/export.py +0 -0
  27. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/figsize.py +0 -0
  28. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/figure_file.py +0 -0
  29. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/fits.py +0 -0
  30. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/framesize.py +0 -0
  31. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/groups.py +0 -0
  32. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/numeric.py +0 -0
  33. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/panels.py +0 -0
  34. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/paper.py +0 -0
  35. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/presets.py +0 -0
  36. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/reducer.py +0 -0
  37. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/render/__init__.py +0 -0
  38. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/resolved.py +0 -0
  39. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/restore.py +0 -0
  40. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/series_stats.py +0 -0
  41. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/shape.py +0 -0
  42. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/sources/__init__.py +0 -0
  43. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/sources/base.py +0 -0
  44. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/sources/csv.py +0 -0
  45. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/sources/frame.py +0 -0
  46. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/spaghetti.py +0 -0
  47. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/spread.py +0 -0
  48. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/table.py +0 -0
  49. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/temporal.py +0 -0
  50. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/textsize.py +0 -0
  51. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/variants.py +0 -0
  52. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/weights.py +0 -0
  53. {scistackplot-0.1.56 → scistackplot-0.1.58}/src/scistackplot/xaxis.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scistackplot
3
- Version: 0.1.56
3
+ Version: 0.1.58
4
4
  Summary: Spec-driven plotting for long-format scientific data — standalone, GUI-friendly
5
5
  Author: SciStack Contributors
6
6
  License-Expression: MIT
@@ -20,8 +20,8 @@ Requires-Python: >=3.10
20
20
  Requires-Dist: csv-stats>=0.5.0
21
21
  Requires-Dist: numpy>=1.21
22
22
  Requires-Dist: pandas>=1.5
23
- Requires-Dist: scicanonicalhash==0.1.56
24
- Requires-Dist: scistacklog==0.1.56
23
+ Requires-Dist: scicanonicalhash==0.1.58
24
+ Requires-Dist: scistacklog==0.1.58
25
25
  Provides-Extra: dev
26
26
  Requires-Dist: matplotlib>=3.6; extra == 'dev'
27
27
  Requires-Dist: plotly>=5.0; extra == 'dev'
@@ -101,7 +101,7 @@ shape, through one pure function:
101
101
  ```python
102
102
  from scistackplot import available_plots, default_plot, Shape
103
103
 
104
- available_plots(Shape.SCALAR, {"session": Role.GROUP}) # scatter, strip, bar
104
+ available_plots(Shape.SCALAR, {"session": Role.GROUP}) # scatter, strip, bar, hbar
105
105
  available_plots(Shape.SCALAR, {"session": Role.GROUP, "trial": Role.COLLAPSE}) # + box, violin
106
106
  ```
107
107
 
@@ -209,6 +209,23 @@ datetime `x_measure` is drawn on a date axis. Log axes are ignored on both
209
209
  kinds. "Save data" adds the bars or the fits as a table. See
210
210
  `docs/claude/histogram-and-regression.md`.
211
211
 
212
+ ## Horizontal bars
213
+
214
+ `PlotKind.HBAR` is the bar plot turned: the same bars, error bars, colours,
215
+ grouping, Show sample, comparison and limits, with the categories down the
216
+ left (first level at the top) and the values along the bottom.
217
+
218
+ ```python
219
+ spec = PlotSpec(measures=["StepLength"], roles=..., groups=["limb", "session"],
220
+ kind=PlotKind.HBAR)
221
+ ```
222
+
223
+ Nothing upstream of drawing changes — "x" is still the category axis and
224
+ "y" the value axis, so `y_axis`, `style.y_label` and `style.log_y` apply to
225
+ the bottom axis. Outer grouping layers become brackets at the left, and a
226
+ faceted panel's name is the title under it. Difference bars are not drawn on
227
+ it. See `docs/claude/horizontal-bar.md`.
228
+
212
229
  ## Save the data behind a figure
213
230
 
214
231
  The rows a plot is drawn from are available as a long table, so the
@@ -60,7 +60,7 @@ shape, through one pure function:
60
60
  ```python
61
61
  from scistackplot import available_plots, default_plot, Shape
62
62
 
63
- available_plots(Shape.SCALAR, {"session": Role.GROUP}) # scatter, strip, bar
63
+ available_plots(Shape.SCALAR, {"session": Role.GROUP}) # scatter, strip, bar, hbar
64
64
  available_plots(Shape.SCALAR, {"session": Role.GROUP, "trial": Role.COLLAPSE}) # + box, violin
65
65
  ```
66
66
 
@@ -168,6 +168,23 @@ datetime `x_measure` is drawn on a date axis. Log axes are ignored on both
168
168
  kinds. "Save data" adds the bars or the fits as a table. See
169
169
  `docs/claude/histogram-and-regression.md`.
170
170
 
171
+ ## Horizontal bars
172
+
173
+ `PlotKind.HBAR` is the bar plot turned: the same bars, error bars, colours,
174
+ grouping, Show sample, comparison and limits, with the categories down the
175
+ left (first level at the top) and the values along the bottom.
176
+
177
+ ```python
178
+ spec = PlotSpec(measures=["StepLength"], roles=..., groups=["limb", "session"],
179
+ kind=PlotKind.HBAR)
180
+ ```
181
+
182
+ Nothing upstream of drawing changes — "x" is still the category axis and
183
+ "y" the value axis, so `y_axis`, `style.y_label` and `style.log_y` apply to
184
+ the bottom axis. Outer grouping layers become brackets at the left, and a
185
+ faceted panel's name is the title under it. Difference bars are not drawn on
186
+ it. See `docs/claude/horizontal-bar.md`.
187
+
171
188
  ## Save the data behind a figure
172
189
 
173
190
  The rows a plot is drawn from are available as a long table, so the
@@ -137,6 +137,8 @@ from .spec import (
137
137
  PlotKind,
138
138
  PlotSpec,
139
139
  Role,
140
+ BAR_KINDS,
141
+ HORIZONTAL_KINDS,
140
142
  OVERLAY_KINDS,
141
143
  SCALAR_KINDS,
142
144
  Statistic,
@@ -148,6 +150,7 @@ from .spec import (
148
150
  VariantSet,
149
151
  YAxis,
150
152
  grid_shape_for,
153
+ is_horizontal,
151
154
  )
152
155
  from .bins import BinEdges, BinError, bin_edges
153
156
  from .fits import PanelFit, annotation_text
@@ -315,6 +318,9 @@ __all__ = [
315
318
  "VariantSet",
316
319
  "SCALAR_KINDS",
317
320
  "OVERLAY_KINDS",
321
+ "BAR_KINDS",
322
+ "HORIZONTAL_KINDS",
323
+ "is_horizontal",
318
324
  # 1-D -> scalar collapse
319
325
  "cell_collapses",
320
326
  "effective_shape",
@@ -38,12 +38,12 @@ from .roles import (
38
38
  sample_key,
39
39
  )
40
40
  from .shape import Shape
41
- from .spec import BUILTIN_KINDS, MAX_X_LAYERS, SCALAR_KINDS, PlotKind, PlotSpec, Role
41
+ from .spec import BAR_KINDS, BUILTIN_KINDS, MAX_X_LAYERS, SCALAR_KINDS, PlotKind, PlotSpec, Role
42
42
  from .table import CODE_FACTOR_PREFIX, RUN_FACTOR_PREFIX, LongTable
43
43
  from .temporal import BAR_REASON, time_zone_summary
44
44
 
45
45
  #: Kinds that summarize several rows per x position into one mark.
46
- DISTRIBUTION_KINDS = (PlotKind.BOX, PlotKind.VIOLIN, PlotKind.BAR, PlotKind.BAND)
46
+ DISTRIBUTION_KINDS = (PlotKind.BOX, PlotKind.VIOLIN, *BAR_KINDS, PlotKind.BAND)
47
47
 
48
48
  #: Kinds that cannot be drawn without a sample — ``roles.SAMPLE_NEEDING_KINDS``,
49
49
  #: read by ``roles.kind_requirement``, the one statement of what each kind
@@ -96,6 +96,7 @@ _ROLE_HINTS_BY_SHAPE: dict[Shape, dict[Role, str]] = {
96
96
  #: kind's (``roles.grouping_layers``); this is the reading, said out loud.
97
97
  _GROUPING_HINTS: dict[PlotKind, str] = {
98
98
  PlotKind.BAR: "One bar per combination — first entry innermost",
99
+ PlotKind.HBAR: "One bar per combination, top to bottom — first entry innermost",
99
100
  PlotKind.BOX: "One box per combination — first entry innermost",
100
101
  PlotKind.VIOLIN: "One violin per combination — first entry innermost",
101
102
  PlotKind.STRIP: "One point per combination — first entry innermost",
@@ -245,13 +246,14 @@ def available_plots(
245
246
  PlotKind.SCATTER,
246
247
  PlotKind.STRIP,
247
248
  PlotKind.BAR,
249
+ PlotKind.HBAR,
248
250
  PlotKind.SPAGHETTI,
249
251
  PlotKind.BOX,
250
252
  PlotKind.VIOLIN,
251
253
  PlotKind.HISTOGRAM,
252
254
  )
253
255
  if kind_requirement(kind, Shape.SCALAR, roles, n_groups) is None
254
- and not (temporal and kind is PlotKind.BAR)
256
+ and not (temporal and kind in BAR_KINDS)
255
257
  ]
256
258
 
257
259
  if shape is Shape.SERIES_1D:
@@ -334,7 +336,7 @@ def why_unavailable(
334
336
  if kind is PlotKind.HISTOGRAM:
335
337
  return HISTOGRAM_NO_X
336
338
  return "With an X variable, the kinds are scatter, line and regression; clear it for the others."
337
- if temporal and kind is PlotKind.BAR:
339
+ if temporal and kind in BAR_KINDS:
338
340
  return BAR_REASON
339
341
  if shape is Shape.MATRIX_2D:
340
342
  return "2-D measures render as a heatmap."
@@ -56,13 +56,16 @@ from .roles import (
56
56
  )
57
57
  from .shape import Shape
58
58
  from .spec import (
59
+ BAR_KINDS,
59
60
  CONTINUOUS_X_KINDS,
61
+ SUMMARY_KINDS,
60
62
  ErrorBand,
61
63
  FactorVariable,
62
64
  PlotKind,
63
65
  PlotSpec,
64
66
  Role,
65
67
  Statistic,
68
+ is_horizontal,
66
69
  value_spellings,
67
70
  )
68
71
  from .table import MISSING_LEVEL, LongTable
@@ -535,7 +538,7 @@ def _docstring(spec: PlotSpec, table: LongTable, roles: dict, y_plan: _YLimitPla
535
538
  steps = collapse_steps(spec, roles, table)
536
539
  if steps.all:
537
540
  chain = " -> ".join([*steps.pre, " x ".join(steps.sample)])
538
- if spec.kind in (PlotKind.BAR, PlotKind.BAND):
541
+ if spec.kind in SUMMARY_KINDS:
539
542
  what = (
540
543
  f"error bars: {spec.aggregate.error}"
541
544
  f"{' (t-based)' if spec.aggregate.error is ErrorBand.CI95 else ''} across "
@@ -851,7 +854,7 @@ def _preamble(spec, table, roles, shape) -> list[str]:
851
854
  )
852
855
  if comparison is not None and not compared:
853
856
  lines.extend(_compare_lines(comparison, spec, kept, shape, index_column, overlay_drawn))
854
- if steps.sample and spec.kind in (PlotKind.BAR, PlotKind.BAND):
857
+ if steps.sample and spec.kind in SUMMARY_KINDS:
855
858
  lines.append(
856
859
  f"# the sample: {' x '.join(steps.sample)} — seaborn's estimator and "
857
860
  f"errorbar run over its levels{' (pooled)' if len(steps.sample) > 1 else ''}"
@@ -1425,6 +1428,13 @@ def _sample_draw_lines(
1425
1428
  nested = _nested_x_order(spec, table, roles, shape)
1426
1429
  weight = sample_weight(spec.style)
1427
1430
  linestyle = "-" if join.join else "none"
1431
+ # The screen (x, y) of a sample point: (position, value), or (value,
1432
+ # position) on a horizontal kind (spec.is_horizontal).
1433
+ xy = (
1434
+ f"_part[{y!r}], _part[{_X_POSITION!r}]"
1435
+ if is_horizontal(spec.kind)
1436
+ else f"_part[{_X_POSITION!r}], _part[{y!r}]"
1437
+ )
1428
1438
 
1429
1439
  spaghetti = spec.kind is PlotKind.SPAGHETTI
1430
1440
  line_layers = _line_layers(spec, table, roles, shape) if spaghetti else []
@@ -1546,14 +1556,14 @@ def _sample_draw_lines(
1546
1556
  *(
1547
1557
  [
1548
1558
  " if len(_part) > 1:",
1549
- f" _ax.plot(_part[{_X_POSITION!r}], _part[{y!r}], "
1559
+ f" _ax.plot({xy}, "
1550
1560
  f"color=_line_color, alpha={SAMPLE_ALPHA}, "
1551
1561
  f"linewidth={weight.line_pt:.3f}, zorder=3)",
1552
1562
  ]
1553
1563
  if join.join
1554
1564
  else []
1555
1565
  ),
1556
- f" _ax.scatter(_part[{_X_POSITION!r}], _part[{y!r}], "
1566
+ f" _ax.scatter({xy}, "
1557
1567
  f"s={weight.marker_area:.3f}, color=_point_colors, alpha={SAMPLE_ALPHA}, "
1558
1568
  f"edgecolors={SAMPLE_EDGE_COLOR!r}, linewidths={SAMPLE_EDGE_WIDTH:g}, "
1559
1569
  + (
@@ -1569,7 +1579,7 @@ def _sample_draw_lines(
1569
1579
  else []
1570
1580
  ),
1571
1581
  " _ax.plot(",
1572
- f" _part[{_X_POSITION!r}], _part[{y!r}],",
1582
+ f" {xy},",
1573
1583
  f" linestyle={linestyle!r}, marker='o', markersize={weight.marker_pt:.3f},",
1574
1584
  f" markeredgecolor={SAMPLE_EDGE_COLOR!r}, "
1575
1585
  f"markeredgewidth={SAMPLE_EDGE_WIDTH:g},",
@@ -1868,8 +1878,11 @@ def _alias_definition_lines(aliases: _ExportAliases) -> list[str]:
1868
1878
  ]
1869
1879
 
1870
1880
 
1871
- def _alias_relabel_lines(aliases: _ExportAliases, x_categorical: bool) -> list[str]:
1872
- """Relabel the drawn x ticks and legend entries through ``_alias``.
1881
+ def _alias_relabel_lines(
1882
+ aliases: _ExportAliases, x_categorical: bool, cat: str = "x"
1883
+ ) -> list[str]:
1884
+ """Relabel the drawn category ticks (on ``cat``, the screen axis they are
1885
+ drawn on: y for a horizontal kind) and legend entries through ``_alias``.
1873
1886
 
1874
1887
  Runs after everything that draws or rebuilds text (the overlay rebuilds
1875
1888
  the legend) and before the fitted-tick replay, which then works on the
@@ -1891,8 +1904,8 @@ def _alias_relabel_lines(aliases: _ExportAliases, x_categorical: bool) -> list[s
1891
1904
  f"_x_text = {text}",
1892
1905
  "g.figure.canvas.draw() # categorical tick labels exist once drawn",
1893
1906
  "for _ax in g.axes.flat:",
1894
- " _ax.set_xticks(_ax.get_xticks())",
1895
- " _ax.set_xticklabels([_x_text(t.get_text()) for t in _ax.get_xticklabels()])",
1907
+ f" _ax.set_{cat}ticks(_ax.get_{cat}ticks())",
1908
+ f" _ax.set_{cat}ticklabels([_x_text(t.get_text()) for t in _ax.get_{cat}ticklabels()])",
1896
1909
  ]
1897
1910
  )
1898
1911
  dashes = aliases.aliased(*aliases.dash_layers)
@@ -1945,7 +1958,7 @@ def _uses_ci95(spec, shape) -> bool:
1945
1958
  """True when the plot call passes `errorbar=_ci95` (bar and band only)."""
1946
1959
  return (
1947
1960
  shape is not Shape.MATRIX_2D
1948
- and spec.kind in (PlotKind.BAR, PlotKind.BAND)
1961
+ and spec.kind in SUMMARY_KINDS
1949
1962
  and spec.aggregate.error is ErrorBand.CI95
1950
1963
  )
1951
1964
 
@@ -1990,6 +2003,13 @@ def _plot_call(
1990
2003
  if kind is PlotKind.HISTOGRAM
1991
2004
  else [f"data=df", f"x={x_column!r}", f"y={spec.y_measure!r}"]
1992
2005
  )
2006
+ if is_horizontal(kind):
2007
+ # The same bars turned (spec.is_horizontal): the categories on y,
2008
+ # the measure along x, and the orientation stated, not inferred.
2009
+ args = [f"data=df", f"x={spec.y_measure!r}", f"y={x_column!r}", 'orient="h"']
2010
+ # The screen axis each logical axis is drawn on: the CATEGORY axis and
2011
+ # the VALUE axis (y limits, y scale, the y title) — swapped when horizontal.
2012
+ cat, val = _screen_axes(spec)
1993
2013
  if color:
1994
2014
  args.append(f"hue={color!r}")
1995
2015
  if _color_level_count(spec, table, color) < 2:
@@ -2011,11 +2031,12 @@ def _plot_call(
2011
2031
  )
2012
2032
  errorbar = _SEABORN_ERRORBAR[spec.aggregate.error]
2013
2033
 
2014
- if kind in (PlotKind.BOX, PlotKind.VIOLIN, PlotKind.BAR, PlotKind.STRIP):
2034
+ if kind in (PlotKind.BOX, PlotKind.VIOLIN, *BAR_KINDS, PlotKind.STRIP):
2015
2035
  seaborn_kind = {
2016
2036
  PlotKind.BOX: "box",
2017
2037
  PlotKind.VIOLIN: "violin",
2018
2038
  PlotKind.BAR: "bar",
2039
+ PlotKind.HBAR: "bar",
2019
2040
  PlotKind.STRIP: "strip",
2020
2041
  }[kind]
2021
2042
  args.append(f'kind="{seaborn_kind}"')
@@ -2027,7 +2048,7 @@ def _plot_call(
2027
2048
  # mark there narrow and off-centre — the preview draws it on its
2028
2049
  # tick, full width (`render.mpl._draw_bars`).
2029
2050
  args.append("dodge=False")
2030
- if kind is PlotKind.BAR:
2051
+ if kind in BAR_KINDS:
2031
2052
  args.append(f"estimator={estimator}")
2032
2053
  args.append(f"errorbar={errorbar}")
2033
2054
  # The export's error-bar ink and caps (paper.seaborn_err_kws);
@@ -2160,7 +2181,7 @@ def _plot_call(
2160
2181
  # argument" (found 2026-09-24 — every faceted per-panel catplot export
2161
2182
  # raised). relplot has no `sharey` and takes it through facet_kws.
2162
2183
  args.append(
2163
- "sharey=False" if call == "sns.catplot" else 'facet_kws={"sharey": False}'
2184
+ f"share{val}=False" if call == "sns.catplot" else 'facet_kws={"sharey": False}'
2164
2185
  )
2165
2186
 
2166
2187
  aliases = _export_aliases(spec, table, roles, shape)
@@ -2185,9 +2206,9 @@ def _plot_call(
2185
2206
  if kind is PlotKind.REGRESSION:
2186
2207
  lines.extend(_fit_lines(spec, table, color, facets, aliases))
2187
2208
  if ylim is not None:
2188
- lines.append(f"g.set(ylim={(float(ylim[0]), float(ylim[1]))!r})")
2209
+ lines.append(f"g.set({val}lim={(float(ylim[0]), float(ylim[1]))!r})")
2189
2210
  if y_plan.by_panel is not None:
2190
- lines.extend(_panel_ylim_lines(y_plan, facets))
2211
+ lines.extend(_panel_ylim_lines(y_plan, facets, val))
2191
2212
  # The one owner (reduce.x_axis_title): no title on a nested axis, none
2192
2213
  # for the constant-x scaffolding column, the factor's display name
2193
2214
  # otherwise — exactly what the preview labels the axis.
@@ -2227,7 +2248,10 @@ def _plot_call(
2227
2248
  and table.measure(spec.x_measure).temporal
2228
2249
  ):
2229
2250
  x_label += zone_suffix
2230
- lines.append(f"g.set_axis_labels({x_label!r}, {y_label!r})")
2251
+ # Positional (x title, y title) on the SCREEN: a horizontal kind's value
2252
+ # title is the one along x.
2253
+ titles = (y_label, x_label) if val == "x" else (x_label, y_label)
2254
+ lines.append(f"g.set_axis_labels({titles[0]!r}, {titles[1]!r})")
2231
2255
  if facets:
2232
2256
  # Same rule as the preview (render.base.panel_y_title): the facet values
2233
2257
  # ARE the panel's y-axis title, and no caption sits above it. seaborn
@@ -2243,14 +2267,14 @@ def _plot_call(
2243
2267
  else "str(v) for v in reversed(_values)"
2244
2268
  )
2245
2269
  if overrides.titles or overrides.first_column:
2246
- lines.extend(['g.set_titles("")', *_panel_title_lines(overrides, shown)])
2270
+ lines.extend(['g.set_titles("")', *_panel_title_lines(overrides, shown, val)])
2247
2271
  else:
2248
2272
  lines.extend(
2249
2273
  [
2250
2274
  'g.set_titles("")',
2251
2275
  "for _key, _ax in g.axes_dict.items():",
2252
2276
  " _values = _key if isinstance(_key, tuple) else (_key,)",
2253
- f' _ax.set_ylabel(" · ".join({shown}))',
2277
+ f' _ax.set_{val}label(" · ".join({shown}))',
2254
2278
  ]
2255
2279
  )
2256
2280
  sizes = _export_sizes(style)
@@ -2273,14 +2297,16 @@ def _plot_call(
2273
2297
  and spec.x_measure in table.measure_names
2274
2298
  and table.measure(spec.x_measure).temporal
2275
2299
  )
2276
- if style.log_x and not log_inert and not temporal_x_measure:
2300
+ # A horizontal kind's category axis is y and takes no log scale (as the
2301
+ # preview, mpl._apply_horizontal_cosmetics); its value axis is x.
2302
+ if style.log_x and not log_inert and not temporal_x_measure and cat == "x":
2277
2303
  lines.append('g.set(xscale="log")')
2278
2304
  if style.log_y and not log_inert:
2279
- lines.append('g.set(yscale="log")')
2280
- if y_plan.autoscaled and kind is PlotKind.BAR and not style.log_y:
2281
- lines.extend(_bar_autoscale_lines())
2305
+ lines.append(f'g.set({val}scale="log")')
2306
+ if y_plan.autoscaled and kind in BAR_KINDS and not style.log_y:
2307
+ lines.extend(_bar_autoscale_lines(val))
2282
2308
  # Last of the y-range lines, so nothing after it rescales a pinned panel.
2283
- lines.extend(_panel_override_ylim_lines(overrides, facets))
2309
+ lines.extend(_panel_override_ylim_lines(overrides, facets, val))
2284
2310
  # Difference bars set the final range of every panel they sit in (and
2285
2311
  # of every panel sharing that range), so they come after the pins.
2286
2312
  lines.extend(
@@ -2290,7 +2316,9 @@ def _plot_call(
2290
2316
  )
2291
2317
  if style.title:
2292
2318
  lines.append(f"g.figure.suptitle({style.title!r})")
2293
- lines.extend(_alias_relabel_lines(aliases, _x_is_categorical(spec, table, roles, shape)))
2319
+ lines.extend(
2320
+ _alias_relabel_lines(aliases, _x_is_categorical(spec, table, roles, shape), cat)
2321
+ )
2294
2322
  # The frame, ticks and background the preview and render_matplotlib draw
2295
2323
  # (paper.py). Also undoes seaborn's default despine, which would export
2296
2324
  # an open frame beside a boxed preview.
@@ -2341,13 +2369,16 @@ def _fitted_tick_lines(spec, table: LongTable, roles, shape) -> list[str]:
2341
2369
  wrapped[original[len(fit.prefix):]] = text
2342
2370
  rotated = fit.rotation != 0
2343
2371
  style = spec.style
2372
+ # The category axis: y on a horizontal kind, whose labels are stacked
2373
+ # and never rotated (ticklabels.STACKED_TICK_POLICY).
2374
+ cat, _val = _screen_axes(spec)
2344
2375
  lines = [
2345
- f"# x tick labels as scistackplot fitted them at {style.width} x {style.height} in:"
2376
+ f"# {cat} tick labels as scistackplot fitted them at {style.width} x {style.height} in:"
2346
2377
  f" {fit.describe()}"
2347
2378
  + (" (nested axis: rotation, font and every-k only)" if nested else ""),
2348
2379
  "g.figure.canvas.draw() # categorical tick labels exist once drawn",
2349
2380
  "for _ax in g.axes.flat:",
2350
- " _labels = [t.get_text() for t in _ax.get_xticklabels()]",
2381
+ f" _labels = [t.get_text() for t in _ax.get_{cat}ticklabels()]",
2351
2382
  ]
2352
2383
  if fit.prefix and not nested:
2353
2384
  lines.append(
@@ -2361,6 +2392,14 @@ def _fitted_tick_lines(spec, table: LongTable, roles, shape) -> list[str]:
2361
2392
  f" _labels = [s if i % {fit.every} == 0 or i == len(_labels) - 1 else '' "
2362
2393
  "for i, s in enumerate(_labels)]"
2363
2394
  )
2395
+ if cat == "y":
2396
+ lines.extend(
2397
+ [
2398
+ " _ax.set_yticks(_ax.get_yticks())",
2399
+ f" _ax.set_yticklabels(_labels, fontsize={fit.font_pt})",
2400
+ ]
2401
+ )
2402
+ return lines
2364
2403
  lines.extend(
2365
2404
  [
2366
2405
  " _ax.set_xticks(_ax.get_xticks())",
@@ -2429,7 +2468,7 @@ def _declared_levels(table: LongTable, name: str) -> list[str]:
2429
2468
 
2430
2469
  def _seaborn_call(spec, table, roles, shape) -> str:
2431
2470
  """Which seaborn function ``_plot_call`` draws with (heatmaps aside)."""
2432
- if spec.kind in (PlotKind.BOX, PlotKind.VIOLIN, PlotKind.BAR, PlotKind.STRIP):
2471
+ if spec.kind in (PlotKind.BOX, PlotKind.VIOLIN, *BAR_KINDS, PlotKind.STRIP):
2433
2472
  return "sns.catplot"
2434
2473
  if spec.kind is PlotKind.SCATTER and _x_is_categorical(spec, table, roles, shape):
2435
2474
  return "sns.catplot"
@@ -2504,7 +2543,7 @@ def _heatmap_call(spec, table: LongTable) -> list[str]:
2504
2543
  ]
2505
2544
 
2506
2545
 
2507
- def _bar_autoscale_lines() -> list[str]:
2546
+ def _bar_autoscale_lines(val: str = "y") -> list[str]:
2508
2547
  """Let an autoscaled bar axis pad below zero, as the preview's does.
2509
2548
 
2510
2549
  The preview's bar limits are ``min(low, 0)`` .. ``max(high, 0)`` padded by
@@ -2523,7 +2562,7 @@ def _bar_autoscale_lines() -> list[str]:
2523
2562
  "# otherwise pins an autoscaled bar axis to exactly 0).",
2524
2563
  "for _ax in g.axes.flat:",
2525
2564
  " _ax.use_sticky_edges = False",
2526
- " _ax.autoscale_view(scalex=False)",
2565
+ f" _ax.autoscale_view(scale{'y' if val == 'x' else 'x'}=False)",
2527
2566
  ]
2528
2567
 
2529
2568
 
@@ -2608,7 +2647,7 @@ def _panel_override_export(
2608
2647
 
2609
2648
 
2610
2649
  def _panel_override_ylim_lines(
2611
- overrides: _PanelOverrideExport, facets: list[str]
2650
+ overrides: _PanelOverrideExport, facets: list[str], val: str = "y"
2612
2651
  ) -> list[str]:
2613
2652
  """Each overridden panel's own range, after every other y-range line.
2614
2653
 
@@ -2623,11 +2662,17 @@ def _panel_override_ylim_lines(
2623
2662
  " _values = _key if isinstance(_key, tuple) else (_key,)",
2624
2663
  " _pin = _panel_ylims.get(tuple(str(_v) for _v in reversed(_values)))",
2625
2664
  " if _pin is not None:",
2626
- " _ax.set_ylim(bottom=_pin[0], top=_pin[1])",
2665
+ (
2666
+ " _ax.set_ylim(bottom=_pin[0], top=_pin[1])"
2667
+ if val == "y"
2668
+ else " _ax.set_xlim(left=_pin[0], right=_pin[1])"
2669
+ ),
2627
2670
  ]
2628
2671
 
2629
2672
 
2630
- def _panel_title_lines(overrides: _PanelOverrideExport, shown: str) -> list[str]:
2673
+ def _panel_title_lines(
2674
+ overrides: _PanelOverrideExport, shown: str, val: str = "y"
2675
+ ) -> list[str]:
2631
2676
  """Each panel's y title with its override and the ``y_titles`` toggle.
2632
2677
 
2633
2678
  The text and the hide flag belong to the panel's facet values, so they are
@@ -2650,7 +2695,7 @@ def _panel_title_lines(overrides: _PanelOverrideExport, shown: str) -> list[str]
2650
2695
  f" _hidden = {unset_hidden}",
2651
2696
  " if _text is None:",
2652
2697
  f' _text = " · ".join({shown})',
2653
- ' _ax.set_ylabel("" if _hidden else _text)',
2698
+ f' _ax.set_{val}label("" if _hidden else _text)',
2654
2699
  ]
2655
2700
 
2656
2701
 
@@ -2689,7 +2734,8 @@ def _difference_bar_export(
2689
2734
  would move every bar anyway: the code says the positions are fitted to
2690
2735
  this data.
2691
2736
  """
2692
- if not spec.difference_bars or shape is not Shape.SCALAR:
2737
+ if not spec.difference_bars or shape is not Shape.SCALAR or is_horizontal(spec.kind):
2738
+ # A horizontal kind draws none (diffbars.HORIZONTAL_REASON), as the preview.
2693
2739
  return None
2694
2740
  import matplotlib.pyplot as plt
2695
2741
 
@@ -2827,8 +2873,9 @@ def _difference_bar_lines(export: _DifferenceExport | None, style) -> list[str]:
2827
2873
  ]
2828
2874
 
2829
2875
 
2830
- def _panel_ylim_lines(y_plan: _YLimitPlan, facets: list[str]) -> list[str]:
2831
- """Set each panel's y limits from the preview's per-facet table.
2876
+ def _panel_ylim_lines(y_plan: _YLimitPlan, facets: list[str], val: str = "y") -> list[str]:
2877
+ """Set each panel's y limits from the preview's per-facet table (on
2878
+ ``val``, the screen axis the values run along: x for a horizontal kind).
2832
2879
 
2833
2880
  ``axes_dict`` is keyed by the ``col`` value, or by ``(row, col)`` for two
2834
2881
  facets — the reverse of ``facets``' order, hence the ``reversed()`` (the
@@ -2848,7 +2895,7 @@ def _panel_ylim_lines(y_plan: _YLimitPlan, facets: list[str]) -> list[str]:
2848
2895
  " _values = dict(",
2849
2896
  f" zip({facets!r}, reversed(_key if isinstance(_key, tuple) else (_key,)))",
2850
2897
  " )",
2851
- " _ax.set_ylim(",
2898
+ f" _ax.set_{val}lim(",
2852
2899
  f" _ylims.get(tuple(str(_values[_f]) for _f in {list(y_plan.panel_scope)!r}), "
2853
2900
  f"{fallback!r})",
2854
2901
  " )",
@@ -3137,6 +3184,13 @@ def _x_expression(spec, table, roles, shape) -> str:
3137
3184
  return ticks[0] if ticks else _X_CONSTANT
3138
3185
 
3139
3186
 
3187
+ def _screen_axes(spec) -> tuple[str, str]:
3188
+ """``(category axis, value axis)`` as the screen letters the generated
3189
+ code addresses: ``("x", "y")``, or ``("y", "x")`` for a horizontal kind
3190
+ (``spec.is_horizontal``, the one owner of orientation)."""
3191
+ return ("y", "x") if is_horizontal(spec.kind) else ("x", "y")
3192
+
3193
+
3140
3194
  def _x_is_categorical(spec, table, roles, shape) -> bool:
3141
3195
  if spec.x_measure or shape is Shape.SERIES_1D or spec.kind in CONTINUOUS_X_KINDS:
3142
3196
  return False
@@ -168,6 +168,14 @@ DIFFERENCE_BAR_KINDS: tuple[str, ...] = (
168
168
  "spaghetti",
169
169
  )
170
170
 
171
+ #: Why a horizontal kind (``spec.HORIZONTAL_KINDS``) carries no difference
172
+ #: bars: they are stacked ABOVE the marks in points, a geometry with no
173
+ #: horizontal counterpart yet (user, 2026-10-26: omit them for HBAR).
174
+ HORIZONTAL_REASON = (
175
+ "Difference bars are drawn above vertical marks; use the vertical bar "
176
+ "plot for them."
177
+ )
178
+
171
179
 
172
180
  def unavailable(spec: "PlotSpec", shape: "Shape", *, temporal: bool = False) -> str | None:
173
181
  """Why this figure cannot carry difference bars, or None.
@@ -178,7 +186,10 @@ def unavailable(spec: "PlotSpec", shape: "Shape", *, temporal: bool = False) ->
178
186
  answers it.
179
187
  """
180
188
  from .shape import Shape
189
+ from .spec import is_horizontal
181
190
 
191
+ if is_horizontal(spec.kind):
192
+ return HORIZONTAL_REASON
182
193
  if str(spec.kind) not in DIFFERENCE_BAR_KINDS:
183
194
  return (
184
195
  f"A {spec.kind} plot has no tick-by-tick marks to join; difference "
@@ -79,6 +79,7 @@ from .spec import (
79
79
  PlotKind,
80
80
  PlotSpec,
81
81
  Role,
82
+ SUMMARY_KINDS,
82
83
  Statistic,
83
84
  grid_shape_for,
84
85
  value_spellings,
@@ -1354,14 +1355,14 @@ def _build_figure(
1354
1355
  overlay_frame: pd.DataFrame | None = None
1355
1356
  summarize_nested = (
1356
1357
  explode
1357
- and spec.kind in (PlotKind.BAND, PlotKind.BAR)
1358
+ and spec.kind in SUMMARY_KINDS
1358
1359
  # A compared 1-D band/bar takes the exploded route: the baseline
1359
1360
  # is `compare`'s, over long rows, never restated over cells. The
1360
1361
  # transport stride then runs after the summary is built from the
1361
1362
  # compared rows (`downsample`), not inside the explode.
1362
1363
  and comparison is None
1363
1364
  )
1364
- if comparison is not None and explode and spec.kind in (PlotKind.BAND, PlotKind.BAR):
1365
+ if comparison is not None and explode and spec.kind in SUMMARY_KINDS:
1365
1366
  Log.info(
1366
1367
  "compare: 1-D %s summarised from exploded rows (the nested-cell "
1367
1368
  "summary does not compare)",
@@ -2231,7 +2232,7 @@ def _panel_frame(
2231
2232
  out = out.dropna(subset=[Y])
2232
2233
 
2233
2234
  # --- summarize the sample into centre + error ------------------------
2234
- if spec.kind in (PlotKind.BAR, PlotKind.BAND):
2235
+ if spec.kind in SUMMARY_KINDS:
2235
2236
  # Carry the nested axis's LAYER columns through the summary. They are a
2236
2237
  # function of the composed key, so grouping by them as well changes no
2237
2238
  # group — but without them `_summarize`'s `reset_index` returns three
@@ -2944,8 +2945,8 @@ def _encoding_for(
2944
2945
  x=X,
2945
2946
  y=Y,
2946
2947
  color=COLOR if color else None,
2947
- y_low=Y_LOW if kind in (PlotKind.BAR, PlotKind.BAND) else None,
2948
- y_high=Y_HIGH if kind in (PlotKind.BAR, PlotKind.BAND) else None,
2948
+ y_low=Y_LOW if kind in SUMMARY_KINDS else None,
2949
+ y_high=Y_HIGH if kind in SUMMARY_KINDS else None,
2949
2950
  series=SERIES if series else None,
2950
2951
  dash=DASH if has_dash else None,
2951
2952
  )
@@ -27,7 +27,7 @@ from ..panels import (
27
27
  )
28
28
  from ..resolved import DASH_CYCLE, SAMPLE_COLOR, SAMPLE_LINE, RUN, SERIES, ResolvedPlot
29
29
  from ..roles import overlay_in_legend
30
- from ..spec import PlotKind
30
+ from ..spec import BAR_KINDS, PlotKind, is_horizontal
31
31
 
32
32
  LAYER = "scistackplot"
33
33
 
@@ -227,6 +227,38 @@ def shows_y_labels(resolved: ResolvedPlot, row: int, col: int) -> bool:
227
227
  return is_leftmost(resolved, row, col)
228
228
 
229
229
 
230
+ def shows_category_labels(resolved: ResolvedPlot, row: int, col: int) -> bool:
231
+ """Whether this cell labels the CATEGORY axis (logical x: its ticks, its
232
+ brackets, its title) — the one rule both renderers read, whichever way
233
+ the kind points. Vertical, that is the axis at the bottom
234
+ (:func:`shows_x_labels`); horizontal (``spec.is_horizontal``) it runs
235
+ down the left, labelled where nothing sits to the left
236
+ (:func:`is_leftmost`)."""
237
+ if is_horizontal(resolved.kind):
238
+ return is_leftmost(resolved, row, col)
239
+ return shows_x_labels(resolved, row, col)
240
+
241
+
242
+ def axis_sides(resolved: ResolvedPlot) -> dict[str, str]:
243
+ """Which side of each panel the logical axes are drawn on —
244
+ ``{"x": "bottom", "y": "left"}``, or swapped for a horizontal kind. The
245
+ GUI says where its "X label" / "Y axis" controls land from this
246
+ (``layout.meta.axis_sides``), never from the kind."""
247
+ if is_horizontal(resolved.kind):
248
+ return {"x": "left", "y": "bottom"}
249
+ return {"x": "bottom", "y": "left"}
250
+
251
+
252
+ def shows_value_labels(resolved: ResolvedPlot, row: int, col: int) -> bool:
253
+ """Whether this cell numbers the VALUE axis (logical y). Vertical:
254
+ :func:`shows_y_labels`. Horizontal: the same rule turned — nothing
255
+ directly below, unless the panels are on different scales, when every
256
+ panel keeps its numbers."""
257
+ if is_horizontal(resolved.kind):
258
+ return not shares_y_axis(resolved) or shows_x_labels(resolved, row, col)
259
+ return shows_y_labels(resolved, row, col)
260
+
261
+
230
262
  def is_leftmost(resolved: ResolvedPlot, row: int, col: int) -> bool:
231
263
  """Whether nothing sits directly to the left of this cell: the first
232
264
  column, including a panel in a partial wrapped row whose left cell is
@@ -295,7 +327,7 @@ def panel_override_meta(resolved: ResolvedPlot) -> dict:
295
327
  ``unmatched`` lists overrides for panels not in this figure (inert).
296
328
  """
297
329
  at_left = {
298
- id(panel): shows_y_labels(resolved, panel.grid_row, panel.grid_col)
330
+ id(panel): shows_value_labels(resolved, panel.grid_row, panel.grid_col)
299
331
  for panel in resolved.panels
300
332
  }
301
333
  panels = []
@@ -693,7 +725,7 @@ def fill_alpha(kind: PlotKind, style) -> float | None:
693
725
  and its own half-transparent box fill, and the export drew every fill
694
726
  opaque.
695
727
  """
696
- if kind is PlotKind.BAR:
728
+ if kind in BAR_KINDS:
697
729
  return style.alpha
698
730
  if kind is PlotKind.BOX:
699
731
  return BOX_FILL_ALPHA