plot-io 0.12.0__py3-none-win_arm64.whl

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 (130) hide show
  1. plot_io/__init__.py +73 -0
  2. plot_io/_methods.py +1691 -0
  3. plot_io/_native/__init__.py +199 -0
  4. plot_io/_native/__main__.py +197 -0
  5. plot_io/_native/_api.py +457 -0
  6. plot_io/_native/bin/windows-arm64.dll +0 -0
  7. plot_io/_native/kernels.py +335 -0
  8. plot_io/_native/manifest.json +49 -0
  9. plot_io/axis.py +1347 -0
  10. plot_io/boxes.py +1156 -0
  11. plot_io/canvas.py +389 -0
  12. plot_io/color/__init__.py +3 -0
  13. plot_io/color/data.py +1646 -0
  14. plot_io/color/maps.py +922 -0
  15. plot_io/color/values.py +295 -0
  16. plot_io/color/variants.py +1246 -0
  17. plot_io/colorbar.py +1426 -0
  18. plot_io/columns.py +614 -0
  19. plot_io/decorations.py +920 -0
  20. plot_io/defaults.py +809 -0
  21. plot_io/density.py +113 -0
  22. plot_io/ext.py +68 -0
  23. plot_io/facets.py +240 -0
  24. plot_io/figure.py +788 -0
  25. plot_io/formatters.py +97 -0
  26. plot_io/genome/__init__.py +17 -0
  27. plot_io/genome/adapters.py +356 -0
  28. plot_io/genome/combine.py +123 -0
  29. plot_io/genome/contacts.py +508 -0
  30. plot_io/genome/intervals.py +750 -0
  31. plot_io/genome/lanes.py +191 -0
  32. plot_io/genome/layout.py +184 -0
  33. plot_io/genome/links.py +364 -0
  34. plot_io/genome/pack.py +27 -0
  35. plot_io/genome/readers.py +145 -0
  36. plot_io/genome/regions.py +228 -0
  37. plot_io/genome/scale.py +349 -0
  38. plot_io/genome/series.py +344 -0
  39. plot_io/genome/signal.py +621 -0
  40. plot_io/genome/sites.py +782 -0
  41. plot_io/genome/sources.py +459 -0
  42. plot_io/genome/testing.py +161 -0
  43. plot_io/geometry.py +289 -0
  44. plot_io/groups.py +308 -0
  45. plot_io/guides.py +503 -0
  46. plot_io/helpers/__init__.py +17 -0
  47. plot_io/helpers/clustermap.py +202 -0
  48. plot_io/helpers/dendrogram.py +172 -0
  49. plot_io/helpers/grids.py +304 -0
  50. plot_io/helpers/qq.py +334 -0
  51. plot_io/helpers/raincloud.py +249 -0
  52. plot_io/helpers/tree.py +214 -0
  53. plot_io/helpers/volcano.py +505 -0
  54. plot_io/hints.py +125 -0
  55. plot_io/kernels.py +740 -0
  56. plot_io/labels.py +566 -0
  57. plot_io/layers/__init__.py +100 -0
  58. plot_io/layers/base.py +630 -0
  59. plot_io/layers/context.py +87 -0
  60. plot_io/layers/core.py +446 -0
  61. plot_io/layers/geoms.py +1362 -0
  62. plot_io/layers/grammar.py +586 -0
  63. plot_io/layers/paint.py +614 -0
  64. plot_io/layers/pipeline.py +170 -0
  65. plot_io/layers/positions.py +431 -0
  66. plot_io/layers/stats.py +1088 -0
  67. plot_io/layout.py +1381 -0
  68. plot_io/legend.py +605 -0
  69. plot_io/levels.py +509 -0
  70. plot_io/limits.py +124 -0
  71. plot_io/lines.py +42 -0
  72. plot_io/mapped.py +522 -0
  73. plot_io/markers.py +110 -0
  74. plot_io/markup.py +128 -0
  75. plot_io/numbers.py +354 -0
  76. plot_io/occupancy.py +295 -0
  77. plot_io/plot.py +597 -0
  78. plot_io/presets/__init__.py +51 -0
  79. plot_io/presets/_util.py +153 -0
  80. plot_io/presets/area.py +145 -0
  81. plot_io/presets/bar.py +261 -0
  82. plot_io/presets/box.py +400 -0
  83. plot_io/presets/catpoints.py +177 -0
  84. plot_io/presets/contour.py +353 -0
  85. plot_io/presets/curves.py +30 -0
  86. plot_io/presets/dots.py +355 -0
  87. plot_io/presets/ecdf.py +97 -0
  88. plot_io/presets/errorbar.py +106 -0
  89. plot_io/presets/errors.py +296 -0
  90. plot_io/presets/fill.py +74 -0
  91. plot_io/presets/heatmap.py +322 -0
  92. plot_io/presets/hexbin.py +198 -0
  93. plot_io/presets/hist.py +391 -0
  94. plot_io/presets/hist2d.py +87 -0
  95. plot_io/presets/kde.py +162 -0
  96. plot_io/presets/kde2d.py +147 -0
  97. plot_io/presets/line.py +143 -0
  98. plot_io/presets/pie.py +502 -0
  99. plot_io/presets/regression.py +115 -0
  100. plot_io/presets/scatter.py +194 -0
  101. plot_io/presets/stem.py +100 -0
  102. plot_io/presets/violin.py +323 -0
  103. plot_io/py.typed +0 -0
  104. plot_io/registry.py +82 -0
  105. plot_io/render.py +480 -0
  106. plot_io/rows.py +491 -0
  107. plot_io/scales.py +103 -0
  108. plot_io/seams.py +138 -0
  109. plot_io/settings.py +807 -0
  110. plot_io/shapes.py +66 -0
  111. plot_io/sharing.py +232 -0
  112. plot_io/sizemaps.py +97 -0
  113. plot_io/spec.py +240 -0
  114. plot_io/states.py +216 -0
  115. plot_io/styles/__init__.py +307 -0
  116. plot_io/styles/library.py +397 -0
  117. plot_io/tables.py +138 -0
  118. plot_io/text.py +412 -0
  119. plot_io/ticks.py +400 -0
  120. plot_io/times.py +287 -0
  121. plot_io/titles.py +225 -0
  122. plot_io/transform.py +114 -0
  123. plot_io/types.py +13604 -0
  124. plot_io/units.py +768 -0
  125. plot_io/validators.py +1029 -0
  126. plot_io/values.py +190 -0
  127. plot_io-0.12.0.dist-info/METADATA +215 -0
  128. plot_io-0.12.0.dist-info/RECORD +130 -0
  129. plot_io-0.12.0.dist-info/WHEEL +4 -0
  130. plot_io-0.12.0.dist-info/licenses/LICENSE +28 -0
plot_io/boxes.py ADDED
@@ -0,0 +1,1156 @@
1
+ """The box tree of a plot: what surrounds its axes, side by side, as ordered nodes.
2
+
3
+ Each side of a plot's axes is a stack of nodes, from the axes outward, in the order
4
+ :func:`nodes` declares once: the axis (tick marks, tick labels, the label row; on a genome
5
+ axis with ``scale_bar``, the scale bar in place of the ticks and their labels), the
6
+ mirrored ticks, the twin axes (the first on a side drawn at the axes edge, over the
7
+ mirrored ticks, a further one past it), the ×10ᵏ
8
+ offsets of the y axes (on top, the plot's then its twins'), labels drawn past the axes
9
+ (pies), the room text decorations take past the axes, the outside legend, the lines of
10
+ attached colorbars, the titles and, on top, the plot's ``space_above``. :func:`measure` asks every node for its band (``measure``: a
11
+ thickness, how far it passes the ends of its side, its pad and extent) and stacks them:
12
+ a band's offset is the sum of the sizes before it, except a band at the edge (offset 0).
13
+ :func:`place_plot` places every node from the offset its band was measured at
14
+ (``place``); it keeps no running sums of its own, so what is placed is what was measured.
15
+ The row label and group bands (:func:`plot_io.rows.bands`) are added outermost on the
16
+ left per grid column.
17
+ """
18
+
19
+ import dataclasses
20
+ import math
21
+ from typing import Any
22
+
23
+ from . import rows
24
+ from .geometry import AxisGeometry, Band, Bands, PlotGeometry, Rect, Segment, TitleGeometry
25
+ from .layers.context import DrawContext
26
+ from .guides import place_inside
27
+ from .spec import set_boxes, warn
28
+ from .states import label_content, offset_content
29
+ from .labels import run_quad
30
+ from .text import box, lines_box, lines_run, run, wrap
31
+ from .titles import Block, along, block, rotation_of, rule_extent, rule_inside, rule_rect
32
+
33
+ __all__ = ["Node", "nodes", "prepare", "measure", "place_plot", "plot_titles", "title_font",
34
+ "bar_lines", "line_size", "AXIS_KINDS", "tick_label_extent", "tick_label_runs"]
35
+
36
+ SIDES = ("left", "right", "top", "bottom")
37
+ #: The bands of the axes themselves: what a title spanning the plot (``over="plot"``)
38
+ #: covers beside the axes.
39
+ AXIS_KINDS = ("x_ticks", "x_tick_labels", "x_scale_bar", "x_label", "x_mirror", "y_ticks",
40
+ "y_tick_labels", "y_label", "y_mirror", "twin", "y_offset", "twin_offset")
41
+ _OPPOSITE = {"left": "right", "right": "left", "top": "bottom", "bottom": "top"}
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Measuring and placing contexts
46
+ # ---------------------------------------------------------------------------
47
+
48
+ class Measuring:
49
+ """A plot measured for axes ``w`` × ``h``: the bands so far per side and how far the
50
+ content passes the ends of each side (``over``: left, right, top, bottom)."""
51
+
52
+ def __init__(self, ps: Any, fonts: Any, w: float, h: float) -> None:
53
+ self.ps, self.fonts, self.w, self.h = ps, fonts, w, h
54
+ self.sides: dict[str, list[Band]] = {s: [] for s in SIDES}
55
+ self.over = {s: 0.0 for s in SIDES}
56
+ self.twins: list[Any] = [] # the twins' measures, in order
57
+
58
+ def overhang(self, side: str, lo: float, hi: float) -> None:
59
+ """Content of a band on ``side`` passing the start (``lo``) and end (``hi``) of the
60
+ side: left and right of a top or bottom side, above and below a left or right one."""
61
+ a, b = ("left", "right") if side in ("top", "bottom") else ("top", "bottom")
62
+ self.over[a] = max(self.over[a], lo)
63
+ self.over[b] = max(self.over[b], hi)
64
+
65
+ def stack(self, side: str) -> float:
66
+ """The size of the bands on ``side`` so far."""
67
+ return sum((b.size for b in self.sides[side]), 0.0)
68
+
69
+ def axis_reach(self, side: str) -> float:
70
+ return sum((b.size for b in self.sides[side] if b.kind in AXIS_KINDS), 0.0)
71
+
72
+ def bands(self) -> Bands:
73
+ s = self.sides
74
+ left, right, top, bottom = (self.stack(k) for k in SIDES)
75
+ gap = float(self.ps.plot.resolve("space_above"))
76
+ top = max(top, self.over["top"])
77
+ if gap:
78
+ s["top"].append(Band("space_above", top, gap))
79
+ return Bands((self.w, self.h), tuple(s["left"]), tuple(s["right"]), tuple(s["top"]),
80
+ tuple(s["bottom"]), (max(left, self.over["left"]),
81
+ max(right, self.over["right"]), top + gap,
82
+ max(bottom, self.over["bottom"])),
83
+ tuple(t.bands() for t in self.twins))
84
+
85
+
86
+ class Placing:
87
+ """A plot placed with its axes at ``rect``: the parts its nodes make."""
88
+
89
+ def __init__(self, ps: Any, fonts: Any, rect: Rect, bands: Bands) -> None:
90
+ self.ps, self.fonts, self.rect, self.bands = ps, fonts, rect, bands
91
+ self.ctx = DrawContext(None, ps.plot, rect, ps.x, ps.y, ps.cycle)
92
+ self.offsets: dict[int, Any] = {} # id(plot state) → its y offset run
93
+ self.legend_at: dict[str, float] = {}
94
+ self.bars: list[Any] = []
95
+ self.titles: list[tuple[int, TitleGeometry]] = []
96
+ self.main_title: TitleGeometry | None = None
97
+ self.lane_names: tuple[Any, ...] = ()
98
+
99
+
100
+ class Node:
101
+ """A band of a side stack: :meth:`measure` gives its band (its offset is set by the
102
+ stack: the sizes before it, or 0 at the edge), :meth:`place` makes its part of the
103
+ plot's geometry from the band as measured. ``None``: nothing on that side."""
104
+
105
+ kind = ""
106
+ edge = False
107
+
108
+ def measure(self, m: Measuring, side: str) -> Band | None:
109
+ raise NotImplementedError
110
+
111
+ def place(self, p: Placing, side: str, band: Band) -> None:
112
+ """Most bands are placed with the axis (or plot) they belong to."""
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Axes
117
+ # ---------------------------------------------------------------------------
118
+
119
+ def prepare(ps: Any, fonts: Any, w: float, h: float,
120
+ span: tuple[float, float] | None = None) -> None:
121
+ """The ticks of a plot (and its twins) for axes ``w`` × ``h``: the axis lengths fix the
122
+ tick count, ``snap`` and px padding, hence the labels the bands measure. The y axes
123
+ run over the value lane (``span``: a twin's, its plot's)."""
124
+ w, h = max(w, 1.0), max(h, 1.0)
125
+ set_boxes(ps.plot, axes_width=w, axes_height=h)
126
+ if span is None:
127
+ span = _y_span(ps, h)
128
+ ps.y.span = span
129
+ ps.x.compute_ticks(fonts, w, True)
130
+ ps.y.compute_ticks(fonts, max(h - span[0] - span[1], 1.0), False)
131
+ for t in ps.twins:
132
+ prepare(t, fonts, w, h, span)
133
+
134
+
135
+ def _y_span(ps: Any, h: float) -> tuple[float, float]:
136
+ """The px a plot's y axis leaves below and above its value lane (a plot with interval
137
+ lanes, :mod:`plot_io.genome.lanes`)."""
138
+ if not any(getattr(c, "lane", None) is not None for c in ps.calls):
139
+ return 0.0, 0.0
140
+ from .genome.lanes import y_span
141
+ return y_span(ps.plot, h)
142
+
143
+
144
+ def _labels(state: Any, hidden: bool = False) -> list[tuple[Any, Any]]:
145
+ """The labelled major ticks (none when the labels are hidden, unless ``hidden``)."""
146
+ return [(t, t.label) for t in state.ticks if t.major and t.label is not None
147
+ and (hidden or not state.hide_labels)]
148
+
149
+
150
+ def _anchor(state: Any, far: bool) -> tuple[str, str]:
151
+ """How an x axis' tick label is anchored at its tick: rotated labels by the corner
152
+ nearest the axis, so that they extend away from it (down-left for a positive angle
153
+ under the axes, down-right for a negative one; mirrored above)."""
154
+ if not state.rotation:
155
+ return "center", "bottom" if far else "top"
156
+ up = state.rotation > 0
157
+ if far:
158
+ return ("left" if up else "right"), "bottom"
159
+ return ("right" if up else "left"), "top"
160
+
161
+
162
+ class Ticks(Node):
163
+ def __init__(self, state: Any) -> None:
164
+ self.state, self.kind = state, f"{state.axis.role}_ticks"
165
+
166
+ def measure(self, m: Measuring, side: str) -> Band:
167
+ out, _ = self.state.tick_out()
168
+ off = self.state.axis.resolve("spine_offset")
169
+ return Band(self.kind, 0.0, out + off, pad=off, extent=out)
170
+
171
+
172
+ class TickLabels(Node):
173
+ def __init__(self, state: Any) -> None:
174
+ self.state, self.kind = state, f"{state.axis.role}_tick_labels"
175
+
176
+ def measure(self, m: Measuring, side: str) -> Band | None:
177
+ state = self.state
178
+ horizontal = state.axis.role == "x"
179
+ got = tick_label_extent(state, m.fonts, m.w if horizontal else m.h, horizontal,
180
+ state.on_far_side())
181
+ if got is None:
182
+ return None
183
+ across, lo, hi = got
184
+ if state.crowded is not None and not state.hide_labels:
185
+ warn(f"{state.axis.role} axis: its tick labels do not fit along it, even at "
186
+ f"{state.crowded:g} px; one is drawn (lengthen the axis or set fewer ticks)",
187
+ key=("tick labels", state.axis.role))
188
+ pad = state.axis.resolve("tick_pad")
189
+ return Band(self.kind, 0.0, pad + across, lo, hi, pad, across)
190
+
191
+
192
+ def tick_label_extent(state: Any, fonts: Any, length: float, horizontal: bool, far: bool
193
+ ) -> tuple[float, float, float] | None:
194
+ """The tick labels of an axis (or colorbar) ``length`` px long, measured as placed
195
+ (:func:`tick_label_runs` at the axis' edge): their size across it and how far they pass
196
+ its start and end; ``None`` without labels. ``horizontal``: labels side by side;
197
+ ``far``: on the top (right) side."""
198
+ mapper = state.mapper(0.0, length) if horizontal else state.mapper(length, 0.0)
199
+ quads = [q for q in (run_quad(fonts, r) for r in
200
+ tick_label_runs(state, fonts, mapper, 0.0, horizontal, far))
201
+ if q is not None]
202
+ if not quads:
203
+ return None
204
+ along = [p[0 if horizontal else 1] for q in quads for p in q]
205
+ sign = -1.0 if far == horizontal else 1.0 # away from the axes: up, or left
206
+ away = [sign * p[1 if horizontal else 0] for q in quads for p in q]
207
+ return max(max(away), 0.0), max(0.0, -min(along)), max(0.0, max(along) - length)
208
+
209
+
210
+ def _moved(r: Any, dx: float, dy: float) -> Any:
211
+ """A run (or tuple of runs) moved by ``(dx, dy)``."""
212
+ if isinstance(r, tuple):
213
+ return tuple(_moved(x, dx, dy) for x in r)
214
+ return None if r is None else dataclasses.replace(r, x=r.x + dx, y=r.y + dy)
215
+
216
+
217
+ def _bounds(fonts: Any, r: Any) -> tuple[float, float, float, float] | None:
218
+ q = run_quad(fonts, r)
219
+ if q is None:
220
+ return None
221
+ xs, ys = [p[0] for p in q], [p[1] for p in q]
222
+ return min(xs), min(ys), max(xs), max(ys)
223
+
224
+
225
+ def tick_label_runs(state: Any, fonts: Any, mapper: Any, at: float, horizontal: bool,
226
+ far: bool, hidden: bool = False) -> list[Any]:
227
+ """The tick labels of an axis (or colorbar) placed ``at`` across it (the edge of their
228
+ box nearest it), each at its tick along it (``mapper``): an x label anchored at its
229
+ tick (a rotated one by its corner nearest the axis), a y label by the middle of its
230
+ box's edge nearest the axis (unrotated: of its cap height; rotated: moved along the
231
+ axis to stay within its ends when it fits). ``hidden``: hidden labels too (an inner
232
+ plot's, fitted for the plots sharing its ticks)."""
233
+ font = state.tick_font()
234
+ ha, va = _anchor(state, far)
235
+ runs = []
236
+ for t, lab in _labels(state, hidden):
237
+ p = mapper(t.value)
238
+ if horizontal:
239
+ runs.append(lines_run(fonts, lab, p, at, font, ha, va, state.rotation))
240
+ elif not state.rotation:
241
+ runs.append(lines_run(fonts, lab, at, p, font, "left" if far else "right",
242
+ "center"))
243
+ else:
244
+ r = lines_run(fonts, lab, 0.0, 0.0, font, "center", "center", state.rotation)
245
+ b = _bounds(fonts, r)
246
+ if b is None:
247
+ continue
248
+ lo, hi = sorted((mapper.position(mapper.flo), mapper.position(mapper.fhi)))
249
+ half = (b[3] - b[1]) / 2 # kept within the axes' length when it fits
250
+ mid = min(max(p, lo + half), hi - half) if 2 * half <= hi - lo else p
251
+ dx = at - b[0] if far else at - b[2]
252
+ runs.append(_moved(r, dx, mid - (b[1] + b[3]) / 2))
253
+ return runs
254
+
255
+
256
+ def scale_bar_on(state: Any) -> bool:
257
+ """Whether an axis state draws scale bars (:func:`plot_io.genome.scale.scale_bar_on`)."""
258
+ if not state.genome:
259
+ return False
260
+ from .genome.scale import scale_bar_on as on
261
+ return on(state)
262
+
263
+
264
+ class ScaleBar(Node):
265
+ """The scale bars of a genome x axis (:func:`plot_io.genome.scale.scale_bars`), in
266
+ place of its ticks and tick labels: each bar with its length written left of it, the
267
+ bar across the middle of the text, ``tick_pad`` from the axes (past the spine)."""
268
+
269
+ def __init__(self, state: Any) -> None:
270
+ self.state, self.kind = state, f"{state.axis.role}_scale_bar"
271
+
272
+ def measure(self, m: Measuring, side: str) -> Band | None:
273
+ st = self.state
274
+ bars = scale_bar_runs(st, m.fonts, st.mapper(0.0, m.w), 0.0)
275
+ if not bars:
276
+ return None
277
+ across = max(max(r[4] for r in bars), float(st.axis.resolve("tick_width")))
278
+ lo = max(0.0, max(-r[3] for r in bars)) # texts past the axes' left end
279
+ pad = st.axis.resolve("tick_pad") + st.axis.resolve("spine_offset")
280
+ return Band(self.kind, 0.0, pad + across, lo, 0.0, pad, across)
281
+
282
+
283
+ def scale_bar_runs(state: Any, fonts: Any, mapper: Any, mid: float
284
+ ) -> list[tuple[Segment, Any, float, float, float]]:
285
+ """The scale bars of an axis state with the bar across ``mid``: ``(bar, text run,
286
+ bar left, text left, text height)`` (none for an inner plot of a shared group, whose
287
+ labels are hidden)."""
288
+ from .genome.scale import scale_bars
289
+ if state.hide_labels:
290
+ return []
291
+ font = state.tick_font()
292
+ gap = 0.4 * font.size
293
+ out = []
294
+ room = lambda text: gap + lines_box(fonts, text, font)[0] # noqa: E731
295
+ for a, b, text in scale_bars(state, mapper, room):
296
+ w, h = lines_box(fonts, text, font)
297
+ r = lines_run(fonts, text, a - gap, mid, font, "right", "center")
298
+ out.append(((a, mid, b, mid), r, a, a - gap - w, h))
299
+ return out
300
+
301
+
302
+ class AxisLabel(Node):
303
+ """The axis label's row (an x axis: the ×10ᵏ offset at its end too, or in a row of
304
+ its own between the tick labels and the label when the label would reach it)."""
305
+
306
+ def __init__(self, state: Any) -> None:
307
+ self.state, self.kind = state, f"{state.axis.role}_label"
308
+
309
+ def measure(self, m: Measuring, side: str) -> Band | None:
310
+ st = self.state
311
+ horizontal = st.axis.role == "x"
312
+ length = m.w if horizontal else m.h
313
+ other = "y" if horizontal else "x" # the other axis' ticks and labels beside
314
+ reach = sum(b.size for s in SIDES for b in m.sides[s]
315
+ if b.kind in (f"{other}_ticks", f"{other}_tick_labels", f"{other}_mirror"))
316
+ b = st.title_block = _label_block(st, m.fonts, length + reach)
317
+ row = b.height if b is not None else 0.0
318
+ st.offset_row = 0.0
319
+ if horizontal:
320
+ offset = None if st.hide_labels else offset_content(st)
321
+ if offset is not None:
322
+ ow, oh = box(m.fonts, offset, st.tick_font(), 0.0)
323
+ gap = st.axis.resolve("tick_label_gap")
324
+ if b is not None and (length + b.width) / 2 + gap > length - ow:
325
+ st.offset_row = oh # beside the label it would meet: its
326
+ row += oh # own row, nearer the axes
327
+ else:
328
+ row = max(row, oh)
329
+ if b is not None and b.width > length: # a label longer than the axes
330
+ m.overhang(side, (b.width - length) / 2, (b.width - length) / 2)
331
+ if not row:
332
+ return None
333
+ pad = st.axis.resolve("label_pad")
334
+ return Band(self.kind, 0.0, pad + row, pad=pad, extent=row)
335
+
336
+
337
+ class Mirror(Node):
338
+ """An axis' ticks mirrored on the other side of the axes."""
339
+
340
+ edge = True
341
+
342
+ def __init__(self, state: Any) -> None:
343
+ self.state, self.kind = state, f"{state.axis.role}_mirror"
344
+
345
+ def measure(self, m: Measuring, side: str) -> Band | None:
346
+ st = self.state
347
+ if not (st.axis.resolve("mirror") and st.ticks) or scale_bar_on(st):
348
+ return None
349
+ reach = st.tick_out()[0] + st.axis.resolve("spine_offset")
350
+ return Band(self.kind, 0.0, reach) if reach else None
351
+
352
+
353
+ def _label_block(state: Any, fonts: Any, room: float) -> Block | None:
354
+ """The axis label's lines (a y label: only beside the axis, ``label_position="axis"``),
355
+ a string wrapped at spaces to ``room`` px: the plot's box along the axis, without the
356
+ axis labels. Over several regions, each region's label is wrapped to its share; one
357
+ wider than its share is the chromosome alone, and labels that would meet are thinned
358
+ as tick labels are."""
359
+ if state.hide_labels:
360
+ return None
361
+ if state.axis.role == "y" and state.axis.resolve("label_position") != "axis":
362
+ return None
363
+ font = state.label_font()
364
+ if state.segments is not None: # a label per region, wrapped to its share
365
+ from .genome.scale import segment_labels
366
+ total = state.segments.total or 1.0
367
+ gap = state.axis.resolve("tick_label_gap")
368
+ state.segment_titles = []
369
+ end = -math.inf
370
+ for c, seg in segment_labels(state):
371
+ share = room * (seg.u1 - seg.u0) / total
372
+ b = block(fonts, wrap(fonts, c, font, share), font)
373
+ if b.width > share: # the chromosome alone
374
+ b = block(fonts, seg.region.chr, font)
375
+ mid = room * (seg.u0 + seg.u1) / 2 / total
376
+ if mid - b.width / 2 < max(0.0, end + gap) or mid + b.width / 2 > room:
377
+ continue # thinned, as tick labels
378
+ end = mid + b.width / 2
379
+ state.segment_titles.append((b, seg))
380
+ return max((b for b, _ in state.segment_titles), key=lambda b: b.height,
381
+ default=None)
382
+ content = label_content(state)
383
+ if content is None:
384
+ return None
385
+ return block(fonts, wrap(fonts, content, font, room), font)
386
+
387
+
388
+ class LaneNames(Node):
389
+ """The names of a plot's interval lanes (:func:`plot_io.genome.lanes.names`) left of
390
+ the axes, each right-aligned and centred on its lane, in the y axis' tick font."""
391
+
392
+ kind = "lane_names"
393
+
394
+ def __init__(self, ps: Any) -> None:
395
+ self.ps = ps
396
+
397
+ def _names(self, fonts: Any, h: float) -> list[tuple[Any, float, float, float]]:
398
+ from .genome.lanes import names
399
+ font = self.ps.y.tick_font()
400
+ out = []
401
+ for name, top, bottom in names(self.ps.plot, h):
402
+ bw, bh = lines_box(fonts, name, font)
403
+ out.append((name, (top + bottom) / 2, bw, bh))
404
+ return out
405
+
406
+ def measure(self, m: Measuring, side: str) -> Band | None:
407
+ got = self._names(m.fonts, m.h)
408
+ if not got:
409
+ return None
410
+ across = max(bw for _, _, bw, _ in got)
411
+ lo = max(max(0.0, bh / 2 - mid) for _, mid, _, bh in got) # past the top
412
+ hi = max(max(0.0, mid + bh / 2 - m.h) for _, mid, _, bh in got) # past the bottom
413
+ pad = self.ps.y.axis.resolve("tick_pad")
414
+ return Band(self.kind, 0.0, pad + across, lo, hi, pad, across)
415
+
416
+ def place(self, p: Placing, side: str, band: Band) -> None:
417
+ x0, y0, _, y1 = p.rect
418
+ font = self.ps.y.tick_font()
419
+ right = x0 - band.offset - band.pad
420
+ p.lane_names = tuple(lines_run(p.fonts, name, right, y0 + mid, font, "right", "center")
421
+ for name, mid, _, _ in self._names(p.fonts, y1 - y0))
422
+
423
+
424
+ class Twin(Node):
425
+ """A twin plot's axes, drawn at the axes edge: on each side, the twin's own stack; its
426
+ content passing the side's ends counts as overhang only."""
427
+
428
+ kind = "twin"
429
+ edge = True
430
+
431
+ def __init__(self, twin: Any) -> None:
432
+ self.twin = twin
433
+ self.m: Measuring | None = None
434
+ twin.shift = {} # per side: the earlier twins' bands it is past
435
+
436
+ def measure(self, m: Measuring, side: str) -> Band:
437
+ if self.m is None:
438
+ self.m = _measure(self.twin, m.fonts, m.w, m.h, twin=True)
439
+ m.twins.append(self.m)
440
+ t = self.m
441
+ size = t.stack(side)
442
+ lo = hi = 0.0
443
+ if side == ("right" if self.twin.plot.twin_of[1] == "y" else "bottom"):
444
+ a, b = ("top", "bottom") if side == "right" else ("left", "right")
445
+ lo, hi = t.over[a], t.over[b]
446
+ return Band("twin", 0.0, size, lo, hi)
447
+
448
+
449
+ class Offset(Node):
450
+ """The ×10ᵏ offset of a y axis (the plot's, then each twin's), above the axes and what
451
+ is on top of them, beside its axis' side."""
452
+
453
+ def __init__(self, state: Any, own: bool) -> None:
454
+ self.state, self.own = state, own
455
+ self.kind = "y_offset" if own else "twin_offset"
456
+
457
+ def content(self) -> Any:
458
+ y = self.state.y
459
+ if not (self.state.axes_on and self.state.shows("y")) or y.hide_labels:
460
+ return None
461
+ return offset_content(y)
462
+
463
+ def measure(self, m: Measuring, side: str) -> Band | None:
464
+ oc = self.content()
465
+ if oc is None:
466
+ return None
467
+ y = self.state.y
468
+ ow, oh = box(m.fonts, oc, y.tick_font(), 0.0)
469
+ pad = self.state.plot.y_axis.resolve("tick_pad")
470
+ far = y.on_far_side()
471
+ bands = m.sides if self.own else _twin_sides(m, self.state)
472
+ yw = sum(b.size for b in bands["right" if far else "left"] if b.kind.startswith("y_"))
473
+ return Band(self.kind, 0.0, pad + oh, 0.0 if far else ow - yw,
474
+ ow - yw if far else 0.0, pad, oh)
475
+
476
+ def place(self, p: Placing, side: str, band: Band) -> None:
477
+ x0, y0, x1, _ = p.rect
478
+ y = self.state.y
479
+ right = y.on_far_side()
480
+ off = self.state.plot.y_axis.resolve("spine_offset") + getattr(
481
+ self.state, "shift", {}).get("right" if right else "left", 0.0)
482
+ base = x1 + off if right else x0 - off
483
+ p.offsets[id(self.state)] = run(p.fonts, self.content(), base,
484
+ y0 - band.offset - band.pad, y.tick_font(),
485
+ "right" if right else "left", "bottom")
486
+
487
+
488
+ def _twin_sides(m: Measuring, twin: Any) -> dict[str, list[Band]]:
489
+ for t in m.twins:
490
+ if t.ps is twin:
491
+ return t.sides
492
+ return {s: [] for s in SIDES}
493
+
494
+
495
+ class Labels(Node):
496
+ """Labels a call draws past the axes (pie names): no band, only their overhang."""
497
+
498
+ kind = "labels"
499
+ edge = True
500
+
501
+ def __init__(self, call: Any) -> None:
502
+ self.call = call
503
+
504
+ def measure(self, m: Measuring, side: str) -> Band:
505
+ lo, hi = self.call.overhang(m.fonts, m.w, m.h)
506
+ return Band("labels", 0.0, 0.0, lo, hi)
507
+
508
+
509
+ class Reach(Node):
510
+ """Text decorations drawn past the axes (``text``, ``annotate``): the margin grows to
511
+ hold them beyond what is already on the side; the text itself is never moved."""
512
+
513
+ kind = "text"
514
+
515
+ def __init__(self, texts: list[Any]) -> None:
516
+ self.texts = texts
517
+
518
+ def measure(self, m: Measuring, side: str) -> Band | None:
519
+ ctx = DrawContext(None, m.ps.plot, (0.0, 0.0, m.w, m.h), m.ps.x, m.ps.y,
520
+ m.ps.cycle)
521
+ k = SIDES.index(side)
522
+ need = max((d.reach(m.fonts, ctx)[k] for d in self.texts), default=0.0)
523
+ size = need - m.stack(side)
524
+ return Band("text", 0.0, size) if size > 0 else None
525
+
526
+
527
+ # ---------------------------------------------------------------------------
528
+ # Legend and colorbars
529
+ # ---------------------------------------------------------------------------
530
+
531
+ class Legend(Node):
532
+ """The plot's legend outside its axes (measured on every side: an inside legend takes
533
+ no band)."""
534
+
535
+ kind = "legend"
536
+
537
+ def __init__(self) -> None:
538
+ self.size: tuple[str, float, float] | None = None
539
+
540
+ def measure(self, m: Measuring, side: str) -> Band | None:
541
+ ps = m.ps
542
+ if self.size is None:
543
+ leg = ps.legend
544
+ size = leg.measure(m.fonts, m.w, m.h) # inside: wrapped within the axes, or moved
545
+ self.size = (leg.position(), *size)
546
+ pos, lw, lh = self.size
547
+ lg = ps.plot.legend
548
+ if pos != "outside_" + side:
549
+ return None
550
+ if side in ("right", "left") and lw:
551
+ pad = lg.resolve("pad", of=m.w)
552
+ lo, hi = _overhang(lh, m.h, ps.legend.align())
553
+ return Band("legend", 0.0, pad + lw, lo, hi, pad, lw)
554
+ if side in ("top", "bottom") and lh:
555
+ pad = lg.resolve("pad", of=m.h)
556
+ lo, hi = _overhang(lw, m.w, ps.legend.align()) # longer than the axes
557
+ return Band("legend", 0.0, pad + lh, lo, hi, pad, lh)
558
+ return None
559
+
560
+ def place(self, p: Placing, side: str, band: Band) -> None:
561
+ p.legend_at[side] = band.offset
562
+
563
+
564
+ def _overhang(length: float, axes: float, align: float) -> tuple[float, float]:
565
+ """How far a legend ``length`` long, aligned at ``align`` (0 start, 0.5 centre, 1 end)
566
+ along axes ``axes`` long, reaches past their start and their end."""
567
+ if length <= axes:
568
+ return 0.0, 0.0
569
+ lo = 0.0 if align == 0.0 else length - axes if align == 1.0 else (length - axes) / 2
570
+ return lo, length - axes - lo
571
+
572
+
573
+ def _line_gap(line: list[Any]) -> float:
574
+ """Gap between keys on one line: twice the first one's ``label_gap``."""
575
+ return 2.0 * line[0].cb.resolve("label_gap")
576
+
577
+
578
+ def line_size(fonts: Any, line: list[Any], w: float, h: float) -> tuple[float, float]:
579
+ """A line of attached colorbars: how far its keys' common middle line is from where
580
+ it starts, and its size across (a single colorbar: its own band)."""
581
+ if len(line) == 1:
582
+ return 0.0, line[0].measure(fonts, w, h)
583
+ reach = [b.key_reach(fonts, w, h) for b in line]
584
+ mid = max(n for n, _ in reach)
585
+ return mid, mid + max(f for _, f in reach)
586
+
587
+
588
+ def bar_lines(fonts: Any, bars: list[Any], w: float, h: float) -> list[list[Any]]:
589
+ """The attached colorbars in lines, in order: a horizontal key on top or bottom joins
590
+ the line of the keys before it on its side when the line still fits the axes width
591
+ (a color key and a size key side by side); every other colorbar is a line of its
592
+ own."""
593
+ lines: list[list[Any]] = []
594
+ last: dict[str, list[Any]] = {}
595
+ for b in bars:
596
+ line = last.get(b.side)
597
+ if line is not None and b.key and not b.vertical and b.side in ("top", "bottom") \
598
+ and all(x.key and not x.vertical for x in line):
599
+ widths = [x.key_width(fonts, w, h) for x in [*line, b]]
600
+ if sum(widths) + _line_gap(line) * len(line) <= w:
601
+ line.append(b)
602
+ continue
603
+ lines.append([b])
604
+ last[b.side] = lines[-1]
605
+ return lines
606
+
607
+
608
+ class Colorbars(Node):
609
+ """A line of attached colorbars (one bar, or keys side by side)."""
610
+
611
+ kind = "colorbar"
612
+
613
+ def __init__(self, line: list[Any]) -> None:
614
+ self.line = line
615
+
616
+ def measure(self, m: Measuring, side: str) -> Band:
617
+ bar = self.line[0]
618
+ extent = line_size(m.fonts, self.line, m.w, m.h)[1]
619
+ lo, hi = bar.over if len(self.line) == 1 else (0.0, 0.0)
620
+ return Band("colorbar", 0.0, extent, lo, hi, bar.pad, extent - bar.pad)
621
+
622
+ def place(self, p: Placing, side: str, band: Band) -> None:
623
+ fonts, rect, line = p.fonts, p.rect, self.line
624
+ if len(line) == 1:
625
+ p.bars.append(line[0].place(fonts, rect, offsets={side: band.offset}))
626
+ return
627
+ w, h = rect[2] - rect[0], rect[3] - rect[1]
628
+ widths = [b.key_width(fonts, w, h) for b in line]
629
+ gap = _line_gap(line)
630
+ mid = line_size(fonts, line, w, h)[0]
631
+ at = line[0]._start(rect[0], rect[2], sum(widths) + gap * (len(line) - 1))
632
+ for b, bw in zip(line, widths):
633
+ p.bars.append(b.place(fonts, rect, offsets={side: band.offset}, span=(at, at + bw),
634
+ line=mid))
635
+ at += bw + gap
636
+
637
+
638
+ # ---------------------------------------------------------------------------
639
+ # Titles
640
+ # ---------------------------------------------------------------------------
641
+
642
+ def plot_titles(plot: Any) -> dict[str, list[Any]]:
643
+ """The plot's titles with text, by side, each side's from the axes outward: by
644
+ ``order`` (unset: the main title, then the others in creation order)."""
645
+ out: dict[str, list[Any]] = {}
646
+ ranked = []
647
+ for k, t in enumerate(plot.titles):
648
+ if t.resolve("text") is None:
649
+ continue
650
+ order = t.resolve("order")
651
+ ranked.append((k if order is None else order, k, t))
652
+ for _, _, t in sorted(ranked, key=lambda r: (r[0], r[1])):
653
+ out.setdefault(t.resolve("side"), []).append(t)
654
+ return out
655
+
656
+
657
+ def title_font(t: Any) -> Any:
658
+ """The font of title ``t``."""
659
+ from .text import font_of
660
+ return font_of(t, t.resolve("font_size"), t.resolve("font_weight"),
661
+ t.resolve("font_style"), t.resolve("color"))
662
+
663
+
664
+ def _across(side: str, rotation: float) -> bool:
665
+ """Whether the lines of a title on ``side`` run along it (their height across it)."""
666
+ return (rotation == 0) == (side in ("top", "bottom"))
667
+
668
+
669
+ def title_need(t: Any, b: Any, side: str) -> tuple[float, float]:
670
+ """The band a title takes on its side (its pad, its text across the side, its rule)
671
+ and the text's extent across the side."""
672
+ pad = t.resolve("pad")
673
+ if _across(side, rotation_of(t)):
674
+ n = len(b.lines)
675
+ need = pad + b.ascents[0] + (n - 1) * b.pitch + b.descents[-1]
676
+ extent = b.height
677
+ else:
678
+ need = pad + b.width
679
+ extent = b.width
680
+ return need + rule_extent(t), extent
681
+
682
+
683
+ def _starts_high(side: str, rotation: float) -> bool:
684
+ """Whether ``start`` is the high end of the side's coordinate (the bottom of a left
685
+ or right side, for text reading upward)."""
686
+ return side in ("left", "right") and rotation == 90
687
+
688
+
689
+ def _title_overhang(t: Any, b: Any, side: str, w: float, h: float,
690
+ used: tuple[float, float, float, float]) -> tuple[float, float]:
691
+ """How far a title passes the start and the end of the axes side (left and right of a
692
+ top or bottom side, above and below a left or right side): an end of its span it is
693
+ aligned to lies within the plot's bands, so it passes only the other one."""
694
+ rotation = rotation_of(t)
695
+ size = b.width if _across(side, rotation) else b.height
696
+ plot_span = t.resolve("over") == "plot"
697
+ if side in ("top", "bottom"):
698
+ length, s0, s1 = w, (-used[0] if plot_span else 0.0), w
699
+ else:
700
+ length, s0, s1 = h, 0.0, h + (used[3] if plot_span else 0.0)
701
+ high = _starts_high(side, rotation)
702
+ if high: # measured from the bottom up, then swapped
703
+ s0, s1 = length - s1, length - s0
704
+ a = along(t) # aligned to an end of its span (within the
705
+ if a == "start": # plot's bands), it passes only the other one
706
+ lo, hi = 0.0, size - (length - s0)
707
+ elif a == "end":
708
+ lo, hi = size - s1, 0.0
709
+ else:
710
+ lo, hi = (size - (s0 + s1)) / 2, (size - (2 * length - s0 - s1)) / 2
711
+ return (hi, lo) if high else (lo, hi)
712
+
713
+
714
+ class Title(Node):
715
+ kind = "title"
716
+
717
+ def __init__(self, title: Any) -> None:
718
+ self.title = title
719
+
720
+ def _block(self, fonts: Any) -> tuple[Block, Any]:
721
+ font = title_font(self.title)
722
+ return block(fonts, self.title.resolve("text"), font), font
723
+
724
+ def measure(self, m: Measuring, side: str) -> Band:
725
+ t = self.title
726
+ b, _ = self._block(m.fonts)
727
+ need, across = title_need(t, b, side)
728
+ left, right, top, bottom = (m.axis_reach(s) for s in SIDES)
729
+ lo, hi = _title_overhang(t, b, side, m.w, m.h, (left, right, top, bottom))
730
+ pad = t.resolve("pad")
731
+ if lo > 0 or hi > 0: # past the side's ends: past what overhangs them too
732
+ pad += max(0.0, m.over[side] - m.stack(side))
733
+ return Band("title", 0.0, need - t.resolve("pad") + pad, lo, hi, pad, across)
734
+
735
+ def place(self, p: Placing, side: str, band: Band) -> None:
736
+ t = self.title
737
+ x0, y0, x1, y1 = p.rect
738
+ b, font = self._block(p.fonts)
739
+ need = title_need(t, b, side)[0]
740
+ pad = t.resolve("pad")
741
+ inner = rule_inside(t)
742
+ span = t.resolve("over") == "plot"
743
+ if side in ("top", "bottom"):
744
+ s0, s1 = (x0 - p.bands.extent("left", AXIS_KINDS) if span else x0), x1
745
+ else:
746
+ s0, s1 = y0, (y1 + p.bands.extent("bottom", AXIS_KINDS) if span else y1)
747
+ reach = band.offset + band.pad + inner
748
+ near = {"top": y0 - reach, "bottom": y1 + reach, "left": x0 - reach,
749
+ "right": x1 + reach}[side]
750
+ runs, box_ = title_runs(p.fonts, b, font, side, rotation_of(t), along(t), near, s0, s1)
751
+ rule = rule_rect(t, side, near, need - pad - rule_extent(t), s0, s1)
752
+ lane = sum(1 for k in p.bands.side(side) if k.kind == "title" and k.offset < band.offset)
753
+ g = TitleGeometry(t, side, lane, box_, runs, rule)
754
+ if t is p.ps.plot.title:
755
+ p.main_title = g
756
+ else:
757
+ p.titles.append((p.ps.plot.titles.index(t), g))
758
+
759
+
760
+ _HA = {"start": "left", "center": "center", "end": "right"}
761
+
762
+
763
+ def title_runs(fonts: Any, b: Block, font: Any, side: str, rotation: float, align: str,
764
+ near: float, s0: float, s1: float) -> tuple[tuple[Any, ...], Rect]:
765
+ """The runs of a title's lines and its box: the block's edge facing what the title
766
+ covers at ``near`` (across the side), aligned within ``s0``–``s1`` along it."""
767
+ out = []
768
+ if _across(side, rotation) and side in ("top", "bottom"): # lines along x
769
+ ha = _HA[align]
770
+ x = {"start": s0, "center": (s0 + s1) / 2, "end": s1}[align]
771
+ for k, ln in enumerate(b.lines):
772
+ if side == "top":
773
+ out.append(run(fonts, ln, x, near - b.bottom(k), font, ha, "bottom"))
774
+ else:
775
+ out.append(run(fonts, ln, x, near + b.top(k), font, ha, "top"))
776
+ bx = x - {"left": 0.0, "center": b.width / 2, "right": b.width}[ha]
777
+ by = near - b.height if side == "top" else near
778
+ return tuple(out), (bx, by, b.width, b.height)
779
+ if _across(side, rotation): # lines along y
780
+ high = _starts_high(side, rotation)
781
+ ha = _HA[align]
782
+ start, end = (s1, s0) if high else (s0, s1)
783
+ y = {"start": start, "center": (s0 + s1) / 2, "end": end}[align]
784
+ bottom_faces = (side == "left") == (rotation == 90)
785
+ sign = -1.0 if side == "left" else 1.0
786
+ for k, ln in enumerate(b.lines):
787
+ if bottom_faces:
788
+ out.append(run(fonts, ln, near + sign * b.bottom(k), y, font, ha, "bottom",
789
+ rotation))
790
+ else:
791
+ out.append(run(fonts, ln, near + sign * b.top(k), y, font, ha, "top",
792
+ rotation))
793
+ reach = {"left": 0.0, "center": b.width / 2, "right": b.width}[ha]
794
+ by = (y - b.width + reach) if high else (y - reach)
795
+ bx = near - b.height if side == "left" else near
796
+ return tuple(out), (bx, by, b.height, b.width)
797
+ if side in ("left", "right"): # horizontal lines beside the side
798
+ by = {"start": s0, "center": (s0 + s1 - b.height) / 2, "end": s1 - b.height}[align]
799
+ ha = "right" if side == "left" else "left"
800
+ for k, ln in enumerate(b.lines):
801
+ out.append(run(fonts, ln, near, by + b.top(k), font, ha, "top"))
802
+ bx = near - b.width if side == "left" else near
803
+ return tuple(out), (bx, by, b.width, b.height)
804
+ # vertical lines on top or bottom: each line reads across the side, from ``near``
805
+ bx = {"start": s0, "center": (s0 + s1 - b.height) / 2, "end": s1 - b.height}[align]
806
+ reads_away = (side == "top") == (rotation == 90)
807
+ ha = "left" if reads_away else "right"
808
+ for k, ln in enumerate(b.lines):
809
+ depth = b.height - b.bottom(k) # line k's bottom from the block's top
810
+ x = bx + depth if rotation == 90 else bx + b.height - depth
811
+ out.append(run(fonts, ln, x, near, font, ha, "bottom", rotation))
812
+ by = near - b.width if side == "top" else near
813
+ return tuple(out), (bx, by, b.height, b.width)
814
+
815
+
816
+ # ---------------------------------------------------------------------------
817
+ # The tree: declared once, measured, placed
818
+ # ---------------------------------------------------------------------------
819
+
820
+ def nodes(ps: Any, fonts: Any, w: float, h: float, twin: bool = False
821
+ ) -> dict[str, list[Node]]:
822
+ """The nodes of each side of plot ``ps``'s axes ``w`` × ``h``, from the axes outward.
823
+ A twin's y offset is a node of the plot it is a twin of (``twin``)."""
824
+ sides: dict[str, list[Node]] = {s: [] for s in SIDES}
825
+ for role, st in (("x", ps.x), ("y", ps.y)):
826
+ if not (ps.axes_on and ps.shows(role)):
827
+ continue
828
+ near, far = ("bottom", "top") if role == "x" else ("left", "right")
829
+ side = far if st.on_far_side() else near
830
+ sides[side] += ([ScaleBar(st)] if scale_bar_on(st) else [Ticks(st), TickLabels(st)]) \
831
+ + [AxisLabel(st)]
832
+ sides[_OPPOSITE[side]].append(Mirror(st))
833
+ if any(getattr(c, "lane", None) is not None for c in ps.calls):
834
+ sides["left"].append(LaneNames(ps))
835
+ for t in ps.twins:
836
+ node = Twin(t)
837
+ for s in (("left", "right") if t.plot.twin_of[1] == "y" else ("top", "bottom")):
838
+ sides[s].append(node)
839
+ if not twin:
840
+ sides["top"] += [Offset(ps, True), *(Offset(t, False) for t in ps.twins)]
841
+ for call in ps.calls:
842
+ if call.overhang is not None:
843
+ sides["bottom"].append(Labels(call))
844
+ texts = [d for d in ps.decorations if hasattr(d, "reach") and d.counts()]
845
+ if texts:
846
+ reach = Reach(texts)
847
+ for s in SIDES:
848
+ sides[s].append(reach)
849
+ legend = Legend()
850
+ for s in SIDES:
851
+ sides[s].append(legend)
852
+ for line in bar_lines(fonts, [b for b in ps.bars if b.inside is None], w, h):
853
+ sides[line[0].side].append(Colorbars(line))
854
+ for side, on_side in plot_titles(ps.plot).items():
855
+ sides[side] += [Title(t) for t in on_side]
856
+ return sides
857
+
858
+
859
+ #: The order in which node kinds are measured across the sides: the axes of every side
860
+ #: before what is past them (a title spanning the plot covers the axes' bands beside it).
861
+ _RANK = (Ticks, TickLabels, ScaleBar, Mirror, AxisLabel, LaneNames, Twin, Offset, Labels, Reach, Legend,
862
+ Colorbars, Title)
863
+
864
+
865
+ def _measure(ps: Any, fonts: Any, w: float, h: float, twin: bool = False) -> Measuring:
866
+ """Every node of the plot's tree measured and stacked (on one side the rank order is
867
+ the declared order); the nodes with their bands are kept on the state (``tree``) for
868
+ :func:`place_plot`."""
869
+ m = Measuring(ps, fonts, max(w, 1.0), max(h, 1.0))
870
+ tree = nodes(ps, fonts, m.w, m.h, twin)
871
+ placed = []
872
+ for cls in _RANK:
873
+ for side in SIDES:
874
+ for node in tree[side]:
875
+ if type(node) is not cls:
876
+ continue
877
+ b = node.measure(m, side)
878
+ if b is None:
879
+ continue
880
+ if isinstance(node, Twin): # past the earlier twins on that side
881
+ at = sum((t.size for t in m.sides[side] if t.kind == "twin"), 0.0)
882
+ node.twin.shift[side] = at
883
+ b = dataclasses.replace(b, offset=at)
884
+ else:
885
+ b = dataclasses.replace(b, offset=0.0 if node.edge else m.stack(side))
886
+ m.sides[side].append(b)
887
+ m.overhang(side, b.lo, b.hi)
888
+ placed.append((side, node, b))
889
+ ps.tree = placed
890
+ return m
891
+
892
+
893
+ def measure(ps: Any, fonts: Any, w: float, h: float) -> Bands:
894
+ """The bands around the axes ``w`` × ``h`` of a plot whose ticks are prepared for that
895
+ size (:func:`prepare`): its axes', its twins', its title's, its legend's and its
896
+ colorbars' (which keep their measured sizes on their states)."""
897
+ return _measure(ps, fonts, w, h).bands()
898
+
899
+
900
+ # ---------------------------------------------------------------------------
901
+ # Placing: geometry records from the bands as measured
902
+ # ---------------------------------------------------------------------------
903
+
904
+ def _tick_marks(state: Any, mapper: Any, horizontal: bool, base: float,
905
+ flip: bool = False) -> tuple[Segment, ...]:
906
+ """Tick mark segments; ``base`` is the axis line coordinate (y for x axes, x for y
907
+ axes); ``flip``: on the opposite side of the axes (``mirror``)."""
908
+ axis = state.axis
909
+ out, inside = state.tick_out(marks=True)
910
+ if not state.ticks or not (out or inside):
911
+ return ()
912
+ if flip:
913
+ out, inside = -out, -inside
914
+ ml = axis.resolve("minor_tick_length") * (-1.0 if flip else 1.0)
915
+ m_out, m_in = (ml if out else 0.0), (ml if inside else 0.0)
916
+ marks = []
917
+ for t in state.ticks:
918
+ p = mapper(t.value)
919
+ o, i = (out, inside) if t.major else (m_out, m_in)
920
+ if horizontal:
921
+ marks.append((p, base - i, p, base + o))
922
+ else:
923
+ marks.append((base + i, p, base - o, p))
924
+ return tuple(marks)
925
+
926
+
927
+ def _band(bands: Bands, side: str, kind: str) -> Band | None:
928
+ return next((b for b in bands.side(side) if b.kind == kind), None)
929
+
930
+
931
+ def _axis(ps: Any, fonts: Any, ctx: DrawContext, bands: Bands, role: str,
932
+ shift: float = 0.0) -> AxisGeometry:
933
+ """Axis ``role`` placed from its bands: tick marks (and the mirrored ones), tick
934
+ labels, the label (and an x axis' ×10ᵏ offset at its end), each at its band's offset
935
+ from the axes edge; ``shift``: the axis line moved out by that much (a twin past
936
+ earlier twins, whose ``bands`` are shifted with it)."""
937
+ horizontal = role == "x"
938
+ if not (ps.axes_on and ps.shows(role)):
939
+ return AxisGeometry(role)
940
+ axis = ps.plot.x_axis if horizontal else ps.plot.y_axis
941
+ x0, y0, x1, y1 = ctx.rect
942
+ state = ps.x if horizontal else ps.y
943
+ far = state.on_far_side()
944
+ side = ("top" if far else "bottom") if horizontal else ("right" if far else "left")
945
+ sign = -1.0 if side in ("top", "left") else 1.0 # away from the axes
946
+ off = axis.resolve("spine_offset")
947
+ lo_edge, hi_edge = (y0, y1) if horizontal else (x0, x1)
948
+ edge, other = (lo_edge, hi_edge) if side in ("top", "left") else (hi_edge, lo_edge)
949
+ mapper = ctx.xmap if horizontal else ctx.ymap
950
+ font = state.tick_font()
951
+ runs: list[Any] = []
952
+ sb = _band(bands, side, f"{role}_scale_bar")
953
+ if scale_bar_on(state):
954
+ groups: list[Any] = []
955
+ if sb is not None:
956
+ mid = edge + sign * (sb.offset + sb.pad + sb.extent / 2)
957
+ bars = scale_bar_runs(state, fonts, mapper, mid)
958
+ groups.append(tuple(bar for bar, _, _, _, _ in bars))
959
+ runs = [r for _, r, _, _, _ in bars]
960
+ else:
961
+ groups = [_tick_marks(state, mapper, horizontal, edge + sign * (off + shift),
962
+ flip=far)]
963
+ if axis.resolve("mirror"):
964
+ groups.append(_tick_marks(state, mapper, horizontal, other - sign * off,
965
+ flip=not far))
966
+ lb = _band(bands, side, f"{role}_tick_labels")
967
+ if lb is not None:
968
+ runs = tick_label_runs(state, fonts, mapper, edge + sign * (lb.offset + lb.pad),
969
+ horizontal, far)
970
+ title: Any = None
971
+ offset = None
972
+ tb = _band(bands, side, f"{role}_label")
973
+ if tb is not None:
974
+ at = edge + sign * (tb.offset + tb.pad)
975
+ oc = None if not horizontal or state.hide_labels else offset_content(state)
976
+ if oc is not None:
977
+ offset = run(fonts, oc, x1, at, font, "right", "bottom" if far else "top")
978
+ at += sign * state.offset_row
979
+ b = state.title_block
980
+ if b is not None:
981
+ s0, s1 = (x0, x1) if horizontal else (y0 + state.span[1], y1 - state.span[0])
982
+ parts = [(b, s0, s1)]
983
+ if state.segments is not None:
984
+ parts = [(sb, mapper(seg.u0), mapper(seg.u1))
985
+ for sb, seg in state.segment_titles]
986
+ got = tuple(r for pb, p0, p1 in parts
987
+ for r in title_runs(fonts, pb, state.label_font(), side,
988
+ 0.0 if horizontal else 90.0, "center", at,
989
+ p0, p1)[0])
990
+ title = got[0] if len(got) == 1 else got
991
+ return AxisGeometry(role, True, tuple(g for g in groups if g), tuple(runs), title, offset)
992
+
993
+
994
+ def _axis_shift(state: Any, role: str, shift: dict[str, float]) -> float:
995
+ """How far a twin's axis ``role`` is moved out on its side (``shift``: per side)."""
996
+ far = state.on_far_side()
997
+ side = ("top" if far else "bottom") if role == "x" else ("right" if far else "left")
998
+ return shift.get(side, 0.0)
999
+
1000
+
1001
+ def _clear_corner(fonts: Any, y: AxisGeometry, xs: list[AxisGeometry]) -> AxisGeometry:
1002
+ """A y axis without its tick labels that meet an x axis' (the plot's or a twin's: an
1003
+ end label at a corner of the axes); their ticks stay."""
1004
+ from .axis import _shrunk
1005
+ from .labels import _overlap
1006
+ xq = [_shrunk(q, 0.5) for x in xs for q in (run_quad(fonts, r) for r in x.labels)
1007
+ if q is not None]
1008
+ if not xq or not y.labels:
1009
+ return y
1010
+ keep = []
1011
+ for r in y.labels:
1012
+ q = run_quad(fonts, r)
1013
+ if q is None or not any(_overlap(_shrunk(q, 0.5), o) for o in xq):
1014
+ keep.append(r)
1015
+ return y if len(keep) == len(y.labels) else dataclasses.replace(y, labels=tuple(keep))
1016
+
1017
+
1018
+ def _grid_lines(ps: Any, ctx: DrawContext) -> tuple[tuple[Any, str, bool, tuple[float, ...]], ...]:
1019
+ """Grid lines ``(axis, setting prefix, horizontal, page positions)`` in drawing order:
1020
+ minor and major grids of x then y, then zero lines."""
1021
+ out = []
1022
+ for state, horizontal in ((ps.x, False), (ps.y, True)):
1023
+ axis = state.axis
1024
+ mapper = ctx.ymap if horizontal else ctx.xmap
1025
+ if axis.resolve("minor_grid"):
1026
+ out.append((axis, "minor_grid", horizontal,
1027
+ tuple(mapper(v) for v in state.minor_grid_positions())))
1028
+ if axis.resolve("grid"):
1029
+ out.append((axis, "grid", horizontal,
1030
+ tuple(mapper(t.value) for t in state.ticks if t.major)))
1031
+ for state, horizontal in ((ps.x, False), (ps.y, True)):
1032
+ axis = state.axis
1033
+ if (axis.resolve("zero_line") and state.scale is not None
1034
+ and min(state.lo, state.hi) <= 0.0 <= max(state.lo, state.hi)
1035
+ and math.isfinite(state.f(0.0))):
1036
+ mapper = ctx.ymap if horizontal else ctx.xmap
1037
+ out.append((axis, "zero_line", horizontal, (mapper(0.0),)))
1038
+ return tuple(out)
1039
+
1040
+
1041
+ def _trim(state: Any, axis: Any, p0: float, p1: float, horizontal: bool) -> tuple[float, float]:
1042
+ """Extent of an axis line: the axes side, or (``spine_trim``) first to last major
1043
+ tick."""
1044
+ if not axis.resolve("spine_trim"):
1045
+ return p0, p1
1046
+ majors = [t.value for t in state.ticks if t.major]
1047
+ if not majors:
1048
+ return p0, p1
1049
+ m = state.mapper(p0, p1) if horizontal else state.mapper(p1, p0)
1050
+ ps = [m(v) for v in majors]
1051
+ return min(ps), max(ps)
1052
+
1053
+
1054
+ def _spines(ps: Any, rect: Rect, shift: dict[str, float] | None = None
1055
+ ) -> tuple[tuple[Any, Segment], ...]:
1056
+ """The axis lines; ``shift``: per side, a line moved out by that much."""
1057
+ x0, y0, x1, y1 = rect
1058
+ sh = shift or {}
1059
+ spines = set(ps.plot.resolve("spines"))
1060
+ swap = {"left": "right", "right": "left", "top": "bottom", "bottom": "top"}
1061
+ if ps.x.on_far_side():
1062
+ spines = {swap[s] if s in ("top", "bottom") else s for s in spines}
1063
+ if ps.y.on_far_side():
1064
+ spines = {swap[s] if s in ("left", "right") else s for s in spines}
1065
+ xa, ya = ps.plot.x_axis, ps.plot.y_axis
1066
+ ox, oy = xa.resolve("spine_offset"), ya.resolve("spine_offset")
1067
+ xs0, xs1 = _trim(ps.x, xa, x0, x1, True)
1068
+ lo, hi = ps.y.span # the value lane
1069
+ ys0, ys1 = _trim(ps.y, ya, y0 + hi, y1 - lo, False)
1070
+ lo_, ri, bo, to = (x0 - oy - sh.get("left", 0.0), x1 + oy + sh.get("right", 0.0),
1071
+ y1 + ox + sh.get("bottom", 0.0), y0 - ox - sh.get("top", 0.0))
1072
+ lines = {"left": (ya, (lo_, ys0, lo_, ys1)),
1073
+ "right": (ya, (ri, ys0, ri, ys1)),
1074
+ "bottom": (xa, (xs0, bo, xs1, bo)),
1075
+ "top": (xa, (xs0, to, xs1, to))}
1076
+ out = tuple(lines[s] for s in ("left", "right", "bottom", "top") if s in spines)
1077
+ if ps.x.segments is None:
1078
+ return out
1079
+ m = ps.x.mapper(x0, x1) # x lines broken at the regions' gaps
1080
+ return tuple(piece for axis, (a, b, c, d) in out for piece in (
1081
+ [(axis, (a, b, c, d))] if axis is ya else
1082
+ [(axis, (m(g.u0), b, m(g.u1), d)) for g in ps.x.segments]))
1083
+
1084
+
1085
+ def _bands_at(ps: Any, fonts: Any, w: float, h: float) -> Bands:
1086
+ """The bands for axes ``w`` × ``h``: those measured last when within 0.01 px of that
1087
+ size (their ticks too), else the ticks and bands again (the row bands kept)."""
1088
+ b = ps.bands
1089
+ if b is not None and abs(b.size[0] - w) <= 0.01 and abs(b.size[1] - h) <= 0.01:
1090
+ return b
1091
+ prepare(ps, fonts, w, h)
1092
+ new = measure(ps, fonts, w, h)
1093
+ if b is not None:
1094
+ extra = tuple(k for k in b.left if k.kind in ("row_label", "group"))
1095
+ if extra:
1096
+ new = rows.with_bands(new, extra)
1097
+ ps.bands = new
1098
+ return new
1099
+
1100
+
1101
+ def _legend(state: Any, fonts: Any, ctx: DrawContext, at: dict[str, float]) -> Any:
1102
+ return state.legend.place(fonts, ctx.rect, ctx=ctx, offsets=at) \
1103
+ if state.legend.active() else None
1104
+
1105
+
1106
+ def place_plot(ps: Any, fonts: Any, rect: Rect, figure_size: tuple[float, float]) -> PlotGeometry:
1107
+ """A plot's geometry with its axes at ``rect``: every node of its tree placed from its
1108
+ band (the axes and the twins' axes from the axes edges, the offsets, legend,
1109
+ colorbars and titles at their bands' offsets), the row label at its band."""
1110
+ plot = ps.plot
1111
+ w, h = rect[2] - rect[0], rect[3] - rect[1]
1112
+ bands = _bands_at(ps, fonts, w, h) # its ticks may set the boxes at ≥ 1 px
1113
+ set_boxes(plot, axes_width=w, axes_height=h)
1114
+ p = Placing(ps, fonts, rect, bands)
1115
+ for side, node, band in ps.tree:
1116
+ node.place(p, side, band)
1117
+ ctx = p.ctx
1118
+ grid = _grid_lines(ps, ctx) if ps.axes_on else ()
1119
+ spines = _spines(ps, rect) if ps.axes_on else ()
1120
+ xg, yg = _axis(ps, fonts, ctx, bands, "x"), _axis(ps, fonts, ctx, bands, "y")
1121
+ if id(ps) in p.offsets:
1122
+ yg = dataclasses.replace(yg, offset=p.offsets[id(ps)])
1123
+ twins = []
1124
+ for t, tb in zip(ps.twins, bands.twins):
1125
+ tctx = DrawContext(None, t.plot, rect, t.x, t.y, ps.cycle)
1126
+ shift = {s: v for s, v in getattr(t, "shift", {}).items() if v}
1127
+ if shift:
1128
+ moved = {s: tuple(dataclasses.replace(b, offset=b.offset + shift.get(s, 0.0))
1129
+ for b in tb.side(s)) for s in SIDES}
1130
+ tb = dataclasses.replace(tb, left=moved["left"], right=moved["right"],
1131
+ top=moved["top"], bottom=moved["bottom"])
1132
+ ty = _axis(t, fonts, tctx, tb, "y", _axis_shift(t.y, "y", shift))
1133
+ if id(t) in p.offsets:
1134
+ ty = dataclasses.replace(ty, offset=p.offsets[id(t)])
1135
+ at = {s: b.offset for s in SIDES for b in tb.side(s) if b.kind == "legend"}
1136
+ twins.append(PlotGeometry(t, rect, tb, figure_size, _grid_lines(t, tctx),
1137
+ _spines(t, rect, shift),
1138
+ _axis(t, fonts, tctx, tb, "x", _axis_shift(t.x, "x", shift)),
1139
+ ty,
1140
+ legend=_legend(t, fonts, tctx, at)))
1141
+ xs = [xg, *(t.x for t in twins)]
1142
+ yg = _clear_corner(fonts, yg, xs)
1143
+ twins = [dataclasses.replace(t, y=_clear_corner(fonts, t.y, xs)) for t in twins]
1144
+ reach = sum((b.size for b in bands.left if b.kind not in ("row_label", "group")), 0.0)
1145
+ label = rows.place_label(ps, fonts, rect, bands, reach)
1146
+ title = title_rule = None
1147
+ if p.main_title is not None:
1148
+ runs = p.main_title.runs
1149
+ title = runs[0] if len(runs) == 1 else runs
1150
+ title_rule = p.main_title.rule
1151
+ p.bars.extend(place_inside(ps, fonts, ctx))
1152
+ legend = ps.legend.place(fonts, rect, ctx=ctx, offsets=p.legend_at)
1153
+ return PlotGeometry(ps, rect, bands, figure_size, grid, spines, xg, yg, tuple(twins),
1154
+ label, title, legend, tuple(p.bars), title_rule,
1155
+ tuple(g for _, g in sorted(p.titles, key=lambda e: e[0])),
1156
+ p.lane_names)