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.
- {scistackplot-0.1.38 → scistackplot-0.1.40}/PKG-INFO +2 -2
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/codegen.py +5 -4
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/diffbars.py +93 -16
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/render/mpl.py +98 -34
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/render/plotly_.py +2 -2
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/weights.py +17 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/.gitignore +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/README.md +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/pyproject.toml +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/__init__.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/aliases.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/autosize.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/capability.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/cell.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/colors.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/compare.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/custom.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/dedup.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/export.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/figsize.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/figure_file.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/framesize.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/groups.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/numeric.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/panels.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/paper.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/presets.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/reduce.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/reducer.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/render/__init__.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/render/base.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/resolved.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/restore.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/roles.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/series_stats.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/shape.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/sources/__init__.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/sources/base.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/sources/csv.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/sources/frame.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/spaghetti.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/spec.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/table.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/textsize.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/ticklabels.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/variants.py +0 -0
- {scistackplot-0.1.38 → scistackplot-0.1.40}/src/scistackplot/xaxis.py +0 -0
- {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.
|
|
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.
|
|
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=
|
|
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},
|
|
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=
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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(
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
"
|
|
1932
|
-
"
|
|
1933
|
-
"
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
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":
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|