scistackplot 0.1.38__tar.gz → 0.1.40__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.38 → scistackplot-0.1.40}/PKG-INFO +2 -2
  2. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/codegen.py +5 -4
  3. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/diffbars.py +93 -16
  4. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/render/mpl.py +98 -34
  5. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/render/plotly_.py +2 -2
  6. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/weights.py +17 -0
  7. {scistackplot-0.1.38 → scistackplot-0.1.40}/.gitignore +0 -0
  8. {scistackplot-0.1.38 → scistackplot-0.1.40}/README.md +0 -0
  9. {scistackplot-0.1.38 → scistackplot-0.1.40}/pyproject.toml +0 -0
  10. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/__init__.py +0 -0
  11. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/aliases.py +0 -0
  12. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/autosize.py +0 -0
  13. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/capability.py +0 -0
  14. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/cell.py +0 -0
  15. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/colors.py +0 -0
  16. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/compare.py +0 -0
  17. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/custom.py +0 -0
  18. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/dedup.py +0 -0
  19. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/export.py +0 -0
  20. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/figsize.py +0 -0
  21. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/figure_file.py +0 -0
  22. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/framesize.py +0 -0
  23. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/groups.py +0 -0
  24. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/numeric.py +0 -0
  25. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/panels.py +0 -0
  26. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/paper.py +0 -0
  27. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/presets.py +0 -0
  28. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/reduce.py +0 -0
  29. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/reducer.py +0 -0
  30. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/render/__init__.py +0 -0
  31. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/render/base.py +0 -0
  32. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/resolved.py +0 -0
  33. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/restore.py +0 -0
  34. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/roles.py +0 -0
  35. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/series_stats.py +0 -0
  36. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/shape.py +0 -0
  37. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/sources/__init__.py +0 -0
  38. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/sources/base.py +0 -0
  39. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/sources/csv.py +0 -0
  40. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/sources/frame.py +0 -0
  41. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/spaghetti.py +0 -0
  42. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/spec.py +0 -0
  43. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/table.py +0 -0
  44. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/textsize.py +0 -0
  45. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/ticklabels.py +0 -0
  46. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/variants.py +0 -0
  47. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/xaxis.py +0 -0
  48. {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/ylimits.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scistackplot
3
- Version: 0.1.38
3
+ Version: 0.1.40
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.38
22
+ Requires-Dist: scistacklog==0.1.40
23
23
  Provides-Extra: dev
24
24
  Requires-Dist: matplotlib>=3.6; extra == 'dev'
25
25
  Requires-Dist: plotly>=5.0; extra == 'dev'
@@ -64,7 +64,7 @@ from .spec import (
64
64
  from .table import MISSING_LEVEL, LongTable
65
65
  from .paper import PAPER, figure_rc_params, paper_axes_code, seaborn_err_kws
66
66
  from .textsize import ResolvedSizes, resolve_sizes
67
- from .weights import sample_weight, spaghetti_weight
67
+ from .weights import SAMPLE_EDGE_WIDTH, sample_weight, spaghetti_weight
68
68
  from .ylimits import GLOBAL_KEY, pinned_ends
69
69
  from .variants import (
70
70
  LATEST,
@@ -1309,7 +1309,7 @@ def _sample_draw_lines(
1309
1309
  ),
1310
1310
  f" _ax.scatter(_part[{_X_POSITION!r}], _part[{y!r}], "
1311
1311
  f"s={weight.marker_area:.3f}, color=_point_colors, alpha={SAMPLE_ALPHA}, "
1312
- f"edgecolors={SAMPLE_EDGE_COLOR!r}, linewidths=0.5, "
1312
+ f"edgecolors={SAMPLE_EDGE_COLOR!r}, linewidths={SAMPLE_EDGE_WIDTH:g}, "
1313
1313
  + (
1314
1314
  # Over the line, and marked as part of its run
1315
1315
  # (render.base.SAMPLE_LINE_POINTS_GID), as the preview.
@@ -1325,7 +1325,8 @@ def _sample_draw_lines(
1325
1325
  " _ax.plot(",
1326
1326
  f" _part[{_X_POSITION!r}], _part[{y!r}],",
1327
1327
  f" linestyle={linestyle!r}, marker='o', markersize={weight.marker_pt:.3f},",
1328
- f" markeredgecolor={SAMPLE_EDGE_COLOR!r}, markeredgewidth=0.5,",
1328
+ f" markeredgecolor={SAMPLE_EDGE_COLOR!r}, "
1329
+ f"markeredgewidth={SAMPLE_EDGE_WIDTH:g},",
1329
1330
  f" color={paint}, alpha={SAMPLE_ALPHA}, "
1330
1331
  f"linewidth={weight.line_pt:.3f}, zorder=3,",
1331
1332
  " )",
@@ -1363,7 +1364,7 @@ def _sample_legend_lines(
1363
1364
  " for _lvl in _sample_levels:",
1364
1365
  " _entries[_lvl] = Line2D([], [], color=_sample_palette[_lvl], "
1365
1366
  f"linestyle={linestyle!r}, marker='o', markeredgecolor={SAMPLE_EDGE_COLOR!r}, "
1366
- "markeredgewidth=0.5)",
1367
+ f"markeredgewidth={SAMPLE_EDGE_WIDTH:g})",
1367
1368
  f" g.add_legend(legend_data=_entries, title={title!r})",
1368
1369
  ]
1369
1370
 
@@ -428,8 +428,15 @@ def log_figure(resolved: "ResolvedPlot") -> None:
428
428
  # bars need depends on ``u``, which depends on the top, so the top is found
429
429
  # by iterating to a fixed point (:func:`place_group`).
430
430
 
431
- #: Clearance between whatever is below and a bar's line (or a leg's foot).
431
+ #: Clearance between whatever is below and a bar's line.
432
432
  GAP_PT = 3.0
433
+ #: Clearance between the data under an end and that leg's foot (2026-10-16:
434
+ #: at GAP_PT the leg read as touching the data). Larger than GAP_PT: the line
435
+ #: only spans, a leg points AT a mark.
436
+ FOOT_GAP_PT = 6.0
437
+ #: The shortest a leg is drawn, from its foot to the line (2026-10-16:
438
+ #: before, the leg over the taller end was half a line width — no leg).
439
+ MIN_LEG_PT = 5.0
433
440
  #: A bar's line width.
434
441
  LINE_PT = 1.0
435
442
  #: Between the line and the bottom of its label's box.
@@ -560,6 +567,8 @@ class BarSizes:
560
567
  #: The label's font size (``TextSizes.differences``).
561
568
  label_font_pt: float
562
569
  gap_pt: float = GAP_PT
570
+ foot_gap_pt: float = FOOT_GAP_PT
571
+ min_leg_pt: float = MIN_LEG_PT
563
572
  line_pt: float = LINE_PT
564
573
  label_gap_pt: float = LABEL_GAP_PT
565
574
  pad_pt: float = PAD_PT
@@ -703,19 +712,31 @@ def _place_once(
703
712
  floors.append(under_label - above_line)
704
713
  floor = max(floors) if floors else bottom
705
714
  y = floor + (sizes.gap_pt + half_line) / per_y
715
+
716
+ # Each leg stops ``foot_gap_pt`` above what is under its end, so it
717
+ # never reads as touching the data. The line rises until every leg is
718
+ # at least ``min_leg_pt`` long (the end under the span's highest mark,
719
+ # or a lower bar's level, would otherwise get no visible leg). Both in
720
+ # points, so neither changes with the y range.
721
+ min_leg = sizes.min_leg_pt / per_y
722
+ foot_gap = sizes.foot_gap_pt / per_y
723
+ # A leg is a line: what it must clear is under its whole width, not
724
+ # its centre (a marker 3.01 pt off a leg of half-width 0.5 pt with a
725
+ # 3.5 pt reach was missed at the centre and touched, 2026-10-16).
726
+ half_leg = half_line / per_x
727
+ unders = [height(x - half_leg, x + half_leg) for x in (left, right)]
728
+ raised = [under + foot_gap + min_leg for under in unders if under is not None]
729
+ if raised:
730
+ y = max(y, max(raised))
731
+ feet = [
732
+ under + foot_gap
733
+ if under is not None
734
+ else max(bottom, y - max(2 * sizes.gap_pt / per_y, min_leg))
735
+ for under in unders
736
+ ]
706
737
  label_bottom = y + above_line
707
738
  label_top = label_bottom + height_pt / per_y
708
739
 
709
- feet = []
710
- for x in (left, right):
711
- under = height(x, x)
712
- foot = (
713
- min(y, under + sizes.gap_pt / per_y)
714
- if under is not None
715
- else max(bottom, y - 2 * sizes.gap_pt / per_y)
716
- )
717
- feet.append(foot)
718
-
719
740
  if pinned and label_top > top:
720
741
  unfit.append(item)
721
742
  continue
@@ -842,6 +863,59 @@ def place_group(
842
863
  )
843
864
 
844
865
 
866
+ def leg_lengths_pt(
867
+ result: GroupPlacement, frames: list[PanelFrame], log: bool = False
868
+ ) -> list[float]:
869
+ """Every placed leg's length in points on the final axis — what
870
+ ``MIN_LEG_PT`` guarantees (unless a leg reaches the axis bottom)."""
871
+ axis = (lambda v: math.log10(v)) if log else (lambda v: v)
872
+ bottom, top = axis(result.limits[0]), axis(result.limits[1])
873
+ lengths = []
874
+ for placed, frame in zip(result.placed, frames):
875
+ per_y = frame.height_pt / (top - bottom) if top > bottom else 0.0
876
+ for bar in placed:
877
+ for foot in (bar.left_foot, bar.right_foot):
878
+ lengths.append((axis(bar.y) - axis(foot)) * per_y)
879
+ return lengths
880
+
881
+
882
+ def foot_clearances_pt(
883
+ result: GroupPlacement, problems: list[PanelProblem], log: bool = False
884
+ ) -> list[float]:
885
+ """Every placed foot's height above the data under it, in points on the
886
+ final axis — what ``FOOT_GAP_PT`` guarantees. *problems* hold the data's
887
+ obstacles only (no bars), so a foot resting over a lower bar reports
888
+ its height above the data, which is more than the gap. A foot with no
889
+ data under it is left out."""
890
+ axis = (lambda v: math.log10(v)) if log else (lambda v: v)
891
+ bottom, top = axis(result.limits[0]), axis(result.limits[1])
892
+ clearances = []
893
+ for placed, problem in zip(result.placed, problems):
894
+ if top <= bottom:
895
+ continue
896
+ per_y = problem.frame.height_pt / (top - bottom)
897
+ per_x = problem.frame.per_unit_x
898
+ obstacles = problem.obstacles.to_axis(log)
899
+ half_leg = (LINE_PT / 2.0) / per_x
900
+ for bar in placed:
901
+ for x, foot in ((bar.left, bar.left_foot), (bar.right, bar.right_foot)):
902
+ under = obstacles.top_over(
903
+ x - half_leg, x + half_leg, per_unit_y=per_y, per_unit_x=per_x
904
+ )
905
+ if under is not None:
906
+ clearances.append((axis(foot) - under) * per_y)
907
+ return clearances
908
+
909
+
910
+ def _legs_note(result: GroupPlacement, problems: list[PanelProblem], log: bool) -> str:
911
+ lengths = leg_lengths_pt(result, [p.frame for p in problems], log)
912
+ clearances = foot_clearances_pt(result, problems, log)
913
+ note = f", shortest leg {min(lengths):.1f} pt" if lengths else ""
914
+ if clearances:
915
+ note += f", lowest foot {min(clearances):.1f} pt above the data"
916
+ return note
917
+
918
+
845
919
  # ---------------------------------------------------------------------------
846
920
  # A resolved figure -> problems
847
921
 
@@ -859,9 +933,10 @@ def panel_obstacles(resolved: "ResolvedPlot", panel: "Panel") -> Obstacles:
859
933
  ``spanmode: "hard"`` stop at the data); a box's flier adds its radius;
860
934
  - strip: every point, jittered ``±STRIP_JITTER``, plus the marker radius;
861
935
  - scatter: every point plus the marker radius;
862
- - spaghetti: every point at its line's offset plus the marker radius,
936
+ - spaghetti: every point at its line's offset plus the marker's reach
937
+ (radius + half its edge stroke, ``weights.ResolvedWeight.marker_reach_pt``),
863
938
  and every segment of every run plus half the line width;
864
- - overlay: every point at ``sample_positions`` plus its radius, and the
939
+ - overlay: every point at ``sample_positions`` plus its reach, and the
865
940
  joining segments when joined.
866
941
  """
867
942
  from .render.base import (
@@ -918,7 +993,7 @@ def panel_obstacles(resolved: "ResolvedPlot", panel: "Panel") -> Obstacles:
918
993
  xs, _ = x_positions(run[encoding.x], resolved)
919
994
  xs = xs + offsets.get(str(series_id), 0.0)
920
995
  ys = pd.to_numeric(run[encoding.y], errors="coerce").to_numpy(dtype=float)
921
- rows.extend(_polyline(xs, ys, weight.marker_pt / 2.0, weight.line_pt / 2.0, True))
996
+ rows.extend(_polyline(xs, ys, weight.marker_reach_pt, weight.line_pt / 2.0, True))
922
997
  else: # scatter, and anything else drawn as points
923
998
  rows.extend((p, p, v, v, marker_radius) for p, v in zip(positions, values))
924
999
 
@@ -929,7 +1004,7 @@ def panel_obstacles(resolved: "ResolvedPlot", panel: "Panel") -> Obstacles:
929
1004
  xs = sample_positions(run.rows, resolved, run.identity)
930
1005
  ys = pd.to_numeric(run.rows[encoding.y], errors="coerce").to_numpy(dtype=float)
931
1006
  joined = resolved.sample_join and len(run.rows) > 1
932
- rows.extend(_polyline(xs, ys, weight.marker_pt / 2.0, weight.line_pt / 2.0, joined))
1007
+ rows.extend(_polyline(xs, ys, weight.marker_reach_pt, weight.line_pt / 2.0, joined))
933
1008
  return Obstacles.build(rows)
934
1009
 
935
1010
 
@@ -1106,7 +1181,9 @@ def place_figure(
1106
1181
  notes.append(
1107
1182
  f"{names}: {sum(len(p) for p in result.placed)} placed, {n_unfit} unfit, "
1108
1183
  f"top {shared[1]:.6g} -> {result.limits[1]:.6g} "
1109
- f"({result.iterations} iteration(s), bars {result.bar_fraction:.0%} of the height)"
1184
+ f"({result.iterations} iteration(s), bars {result.bar_fraction:.0%} of the height"
1185
+ + _legs_note(result, problems, log)
1186
+ + ")"
1110
1187
  + (f", raised to at least {top_floor:.6g} for other figures" if top_floor is not None else "")
1111
1188
  )
1112
1189
  Log.debug(
@@ -25,7 +25,7 @@ from ..spec import PlotKind
25
25
  from ..table import natural_sort_key
26
26
  from ..paper import PAPER, apply_paper_axes, describe_paper, figure_rc_params
27
27
  from ..textsize import ResolvedSizes, sizes_for
28
- from ..weights import describe_weights, sample_weight, spaghetti_weight
28
+ from ..weights import SAMPLE_EDGE_WIDTH, describe_weights, sample_weight, spaghetti_weight
29
29
  from ..ticklabels import (
30
30
  BRACKET_POLICY,
31
31
  TICK_POLICY,
@@ -396,6 +396,7 @@ def _draw_spaghetti(ax, frame, resolved) -> None:
396
396
  # A diameter in pt (weights.spaghetti_weight): at 1x the same
397
397
  # visual size as the scatter kinds' `s` area.
398
398
  markersize=weight.marker_pt,
399
+ markeredgewidth=weight.marker_edge_pt,
399
400
  label=(
400
401
  resolved.text.color_level(level)
401
402
  if (level is not None and position == 0)
@@ -629,7 +630,7 @@ def _draw_sample(ax, panel, resolved: ResolvedPlot) -> tuple[int, int]:
629
630
  marker="o",
630
631
  markersize=weight.marker_pt,
631
632
  markeredgecolor=SAMPLE_EDGE_COLOR,
632
- markeredgewidth=0.5,
633
+ markeredgewidth=weight.marker_edge_pt,
633
634
  zorder=3,
634
635
  )
635
636
  continue
@@ -651,7 +652,7 @@ def _draw_sample(ax, panel, resolved: ResolvedPlot) -> tuple[int, int]:
651
652
  color=[run.point_colors[i] for i in order],
652
653
  alpha=SAMPLE_ALPHA,
653
654
  edgecolors=SAMPLE_EDGE_COLOR,
654
- linewidths=0.5,
655
+ linewidths=weight.marker_edge_pt,
655
656
  zorder=3.1,
656
657
  gid=SAMPLE_LINE_POINTS_GID,
657
658
  )
@@ -663,7 +664,7 @@ def _draw_sample(ax, panel, resolved: ResolvedPlot) -> tuple[int, int]:
663
664
  color=color,
664
665
  alpha=SAMPLE_ALPHA,
665
666
  edgecolors=SAMPLE_EDGE_COLOR,
666
- linewidths=0.5,
667
+ linewidths=weight.marker_edge_pt,
667
668
  zorder=3,
668
669
  )
669
670
  artists += len(lone)
@@ -687,7 +688,7 @@ def _sample_legend_handles(ax, resolved: ResolvedPlot) -> None:
687
688
  linestyle="-" if resolved.sample_join else "none",
688
689
  marker="o",
689
690
  markeredgecolor=SAMPLE_EDGE_COLOR,
690
- markeredgewidth=0.5,
691
+ markeredgewidth=SAMPLE_EDGE_WIDTH,
691
692
  label=resolved.text.sample_level(level),
692
693
  )
693
694
 
@@ -1900,6 +1901,14 @@ def layout_decisions(
1900
1901
  "width_in", "height_in"}``. The plotly preview
1901
1902
  applies these rather than estimating text of its own, so what the preview
1902
1903
  shows and what the file gets cannot come from two different rules.
1904
+
1905
+ One layout, not two: the auto text size search already lays the figure
1906
+ out at every reference it tries, and a layout depends on the auto result
1907
+ only through its ``reference`` (``textsize.sizes_for``). So the trial at
1908
+ the chosen reference IS the final layout, and its decisions are kept
1909
+ rather than drawn again (scidb.log 2026-10-16: the redraw was ~0.4 s of
1910
+ every ~0.85 s next/previous click). A custom kind's trial skips the
1911
+ user's function (``custom.measuring``), so it is always drawn again.
1903
1912
  """
1904
1913
  import matplotlib.pyplot as plt
1905
1914
 
@@ -1911,37 +1920,87 @@ def layout_decisions(
1911
1920
  with _one_figure_at_a_time("layout_decisions"), Log.timer(
1912
1921
  "layout_decisions", layer=LAYER, extra=f"{width:.2f}x{height:.2f}in"
1913
1922
  ):
1923
+ # {reference: decisions} of each auto-size trial laid out here.
1924
+ trials: dict[float, dict] = {}
1925
+
1926
+ def measure(candidate: ResolvedPlot):
1927
+ decisions: dict = {}
1928
+ report = _layout_report(candidate, decisions_out=decisions)
1929
+ trials[candidate.auto_text.reference] = decisions
1930
+ return report
1931
+
1932
+ reusable = resolved.kind is not PlotKind.CUSTOM
1914
1933
  # Auto text is chosen at the size being decided (the pane's, in Fit
1915
1934
  # pane), keyed on the caller's object so the preview's later
1916
1935
  # render_plotly and a Save of the same resolve reuse it.
1917
- target = at_size(settle(resolved, width_in=width, height_in=height), width, height)
1918
- fig = render(target)
1919
- try:
1920
- fits = getattr(fig, LABEL_FIT_ATTR, None) or {}
1921
- axis_text = getattr(fig, AXIS_TEXT_ATTR, None) or ()
1922
- return {
1923
- "ticks": fits.get("ticks"),
1924
- "brackets": fits.get("brackets"),
1925
- "bracket_geometry": fits.get("bracket_geometry"),
1926
- "grid_reach": getattr(fig, GRID_REACH_ATTR, None),
1927
- # tuple[base.AxisText, ...]: the tick values/labels and title
1928
- # distance the export drew, which the preview copies so the
1929
- # y text it puts in a column gap is the text measured for it,
1930
- # and a numeric x axis shows the ticks the auto size judged.
1931
- "y_axes": tuple(a for a in axis_text if a.axis == "y"),
1932
- "x_axes": tuple(a for a in axis_text if a.axis == "x"),
1933
- "legend": getattr(fig, LEGEND_ATTR, None),
1934
- # diffbars.FigurePlacement, or None: the preview draws these
1935
- # bars and ranges rather than placing its own.
1936
- "difference_bars": getattr(fig, DIFF_BARS_ATTR, None),
1937
- # autosize.AutoTextSize, or None when the size is fixed: the
1938
- # preview draws at these sizes rather than searching again.
1939
- "auto_text": target.auto_text,
1940
- "width_in": width,
1941
- "height_in": height,
1942
- }
1943
- finally:
1944
- plt.close(fig)
1936
+ target = at_size(
1937
+ settle(
1938
+ resolved,
1939
+ width_in=width,
1940
+ height_in=height,
1941
+ measure=measure if reusable else None,
1942
+ ),
1943
+ width,
1944
+ height,
1945
+ )
1946
+ auto = target.auto_text
1947
+ decisions = trials.get(auto.reference) if auto is not None else None
1948
+ if decisions is not None:
1949
+ Log.info(
1950
+ "layout_decisions%s: kept the %gpt auto-size trial's layout "
1951
+ "(%d trial(s)); no second matplotlib layout",
1952
+ f" ({resolved.figure_label})" if resolved.figure_label else "",
1953
+ auto.reference,
1954
+ len(trials),
1955
+ layer=LAYER,
1956
+ )
1957
+ else:
1958
+ # Why a full draw: a fixed text size, a memo hit (the search did
1959
+ # not run here), or a custom kind.
1960
+ Log.debug(
1961
+ "layout_decisions: drawing (%s)",
1962
+ "custom kind" if not reusable
1963
+ else "fixed text size" if auto is None
1964
+ else "auto size reused, no trial here",
1965
+ layer=LAYER,
1966
+ )
1967
+ fig = render(target)
1968
+ try:
1969
+ decisions = _figure_decisions(fig)
1970
+ finally:
1971
+ plt.close(fig)
1972
+ return {
1973
+ **decisions,
1974
+ # autosize.AutoTextSize, or None when the size is fixed: the
1975
+ # preview draws at these sizes rather than searching again.
1976
+ "auto_text": auto,
1977
+ "width_in": width,
1978
+ "height_in": height,
1979
+ }
1980
+
1981
+
1982
+ def _figure_decisions(fig) -> dict:
1983
+ """What a drawn export decided, as ``layout_decisions`` returns it (less
1984
+ the size and ``auto_text``). The one reader of the figure's records, for
1985
+ both the final draw and a kept auto-size trial."""
1986
+ fits = getattr(fig, LABEL_FIT_ATTR, None) or {}
1987
+ axis_text = getattr(fig, AXIS_TEXT_ATTR, None) or ()
1988
+ return {
1989
+ "ticks": fits.get("ticks"),
1990
+ "brackets": fits.get("brackets"),
1991
+ "bracket_geometry": fits.get("bracket_geometry"),
1992
+ "grid_reach": getattr(fig, GRID_REACH_ATTR, None),
1993
+ # tuple[base.AxisText, ...]: the tick values/labels and title
1994
+ # distance the export drew, which the preview copies so the
1995
+ # y text it puts in a column gap is the text measured for it,
1996
+ # and a numeric x axis shows the ticks the auto size judged.
1997
+ "y_axes": tuple(a for a in axis_text if a.axis == "y"),
1998
+ "x_axes": tuple(a for a in axis_text if a.axis == "x"),
1999
+ "legend": getattr(fig, LEGEND_ATTR, None),
2000
+ # diffbars.FigurePlacement, or None: the preview draws these
2001
+ # bars and ranges rather than placing its own.
2002
+ "difference_bars": getattr(fig, DIFF_BARS_ATTR, None),
2003
+ }
1945
2004
 
1946
2005
 
1947
2006
  def layout_report(resolved: ResolvedPlot):
@@ -1953,7 +2012,10 @@ def layout_report(resolved: ResolvedPlot):
1953
2012
  return _layout_report(resolved)
1954
2013
 
1955
2014
 
1956
- def _layout_report(resolved: ResolvedPlot):
2015
+ def _layout_report(resolved: ResolvedPlot, *, decisions_out: dict | None = None):
2016
+ """``layout_report``'s body. ``decisions_out``, when given, is filled
2017
+ with the figure's ``_figure_decisions`` before it closes, so a trial at
2018
+ the chosen reference serves as the final layout too."""
1957
2019
  import matplotlib.pyplot as plt
1958
2020
 
1959
2021
  from ..autosize import LayoutReport
@@ -1964,6 +2026,8 @@ def _layout_report(resolved: ResolvedPlot):
1964
2026
  with measuring():
1965
2027
  fig = render(resolved)
1966
2028
  try:
2029
+ if decisions_out is not None:
2030
+ decisions_out.update(_figure_decisions(fig))
1967
2031
  fits = getattr(fig, LABEL_FIT_ATTR, None) or {}
1968
2032
  ticks, brackets = fits.get("ticks"), fits.get("brackets")
1969
2033
  legend = getattr(fig, LEGEND_ATTR, None) or {}
@@ -43,7 +43,7 @@ from ..diffbars import (
43
43
  sibling_floors,
44
44
  )
45
45
  from ..textsize import sizes_for
46
- from ..weights import mark_weights_meta, sample_weight, spaghetti_weight
46
+ from ..weights import SAMPLE_EDGE_WIDTH, mark_weights_meta, sample_weight, spaghetti_weight
47
47
  from ..xaxis import LEAF_SEPARATOR
48
48
  from .base import (
49
49
  MARK_OFFSET_GROUP,
@@ -758,7 +758,7 @@ def _sample_traces(
758
758
  if run.uniform
759
759
  else [run.point_colors[i] for i in order],
760
760
  "size": weight.marker_px,
761
- "line": {"color": SAMPLE_EDGE_COLOR, "width": 0.5},
761
+ "line": {"color": SAMPLE_EDGE_COLOR, "width": SAMPLE_EDGE_WIDTH},
762
762
  },
763
763
  "line": {"color": run.line_color, "width": weight.line_pt},
764
764
  "opacity": SAMPLE_ALPHA,
@@ -29,6 +29,14 @@ SAMPLE_MARKER_FRACTION = 0.45
29
29
  SAMPLE_LINE_WIDTH = 1.0
30
30
  #: A spaghetti's own polylines at 1x.
31
31
  SPAGHETTI_LINE_WIDTH = 1.2
32
+ #: The edge stroke round a marker, in points, at every weight (it outlines;
33
+ #: it does not scale). It widens the marker by half its width on each side,
34
+ #: which ``diffbars.panel_obstacles`` must count (2026-10-16: a leg touched a
35
+ #: spaghetti marker the obstacle model thought it cleared).
36
+ SAMPLE_EDGE_WIDTH = 0.5
37
+ #: matplotlib's ``lines.markeredgewidth`` default, which the spaghetti drew
38
+ #: before it was stated here.
39
+ SPAGHETTI_EDGE_WIDTH = 1.0
32
40
  #: The plotly preview draws a marker of ``d`` pt at ``d x 4/3`` px — the
33
41
  #: 96/72 pt-to-px ratio. Pinned so 1x reproduces the preview as it was (8 px
34
42
  #: markers where matplotlib draws 6 pt). Lines are 1 pt = 1 px, as before.
@@ -54,6 +62,13 @@ class ResolvedWeight:
54
62
  marker_pt: float
55
63
  #: Line width in points (and px in the preview).
56
64
  line_pt: float
65
+ #: The marker's edge stroke in points (``markeredgewidth``).
66
+ marker_edge_pt: float
67
+
68
+ @property
69
+ def marker_reach_pt(self) -> float:
70
+ """How far a marker reaches from its centre, edge included."""
71
+ return self.marker_pt / 2.0 + self.marker_edge_pt / 2.0
57
72
 
58
73
  @property
59
74
  def marker_area(self) -> float:
@@ -83,6 +98,7 @@ def sample_weight(style: StyleOptions) -> ResolvedWeight:
83
98
  weight=w,
84
99
  marker_pt=math.sqrt(style.marker_size * SAMPLE_MARKER_FRACTION) * w,
85
100
  line_pt=SAMPLE_LINE_WIDTH * w,
101
+ marker_edge_pt=SAMPLE_EDGE_WIDTH,
86
102
  )
87
103
 
88
104
 
@@ -94,6 +110,7 @@ def spaghetti_weight(style: StyleOptions) -> ResolvedWeight:
94
110
  weight=w,
95
111
  marker_pt=math.sqrt(style.marker_size) * w,
96
112
  line_pt=SPAGHETTI_LINE_WIDTH * w,
113
+ marker_edge_pt=SPAGHETTI_EDGE_WIDTH,
97
114
  )
98
115
 
99
116
 
File without changes
File without changes