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.
- plot_io/__init__.py +73 -0
- plot_io/_methods.py +1691 -0
- plot_io/_native/__init__.py +199 -0
- plot_io/_native/__main__.py +197 -0
- plot_io/_native/_api.py +457 -0
- plot_io/_native/bin/windows-arm64.dll +0 -0
- plot_io/_native/kernels.py +335 -0
- plot_io/_native/manifest.json +49 -0
- plot_io/axis.py +1347 -0
- plot_io/boxes.py +1156 -0
- plot_io/canvas.py +389 -0
- plot_io/color/__init__.py +3 -0
- plot_io/color/data.py +1646 -0
- plot_io/color/maps.py +922 -0
- plot_io/color/values.py +295 -0
- plot_io/color/variants.py +1246 -0
- plot_io/colorbar.py +1426 -0
- plot_io/columns.py +614 -0
- plot_io/decorations.py +920 -0
- plot_io/defaults.py +809 -0
- plot_io/density.py +113 -0
- plot_io/ext.py +68 -0
- plot_io/facets.py +240 -0
- plot_io/figure.py +788 -0
- plot_io/formatters.py +97 -0
- plot_io/genome/__init__.py +17 -0
- plot_io/genome/adapters.py +356 -0
- plot_io/genome/combine.py +123 -0
- plot_io/genome/contacts.py +508 -0
- plot_io/genome/intervals.py +750 -0
- plot_io/genome/lanes.py +191 -0
- plot_io/genome/layout.py +184 -0
- plot_io/genome/links.py +364 -0
- plot_io/genome/pack.py +27 -0
- plot_io/genome/readers.py +145 -0
- plot_io/genome/regions.py +228 -0
- plot_io/genome/scale.py +349 -0
- plot_io/genome/series.py +344 -0
- plot_io/genome/signal.py +621 -0
- plot_io/genome/sites.py +782 -0
- plot_io/genome/sources.py +459 -0
- plot_io/genome/testing.py +161 -0
- plot_io/geometry.py +289 -0
- plot_io/groups.py +308 -0
- plot_io/guides.py +503 -0
- plot_io/helpers/__init__.py +17 -0
- plot_io/helpers/clustermap.py +202 -0
- plot_io/helpers/dendrogram.py +172 -0
- plot_io/helpers/grids.py +304 -0
- plot_io/helpers/qq.py +334 -0
- plot_io/helpers/raincloud.py +249 -0
- plot_io/helpers/tree.py +214 -0
- plot_io/helpers/volcano.py +505 -0
- plot_io/hints.py +125 -0
- plot_io/kernels.py +740 -0
- plot_io/labels.py +566 -0
- plot_io/layers/__init__.py +100 -0
- plot_io/layers/base.py +630 -0
- plot_io/layers/context.py +87 -0
- plot_io/layers/core.py +446 -0
- plot_io/layers/geoms.py +1362 -0
- plot_io/layers/grammar.py +586 -0
- plot_io/layers/paint.py +614 -0
- plot_io/layers/pipeline.py +170 -0
- plot_io/layers/positions.py +431 -0
- plot_io/layers/stats.py +1088 -0
- plot_io/layout.py +1381 -0
- plot_io/legend.py +605 -0
- plot_io/levels.py +509 -0
- plot_io/limits.py +124 -0
- plot_io/lines.py +42 -0
- plot_io/mapped.py +522 -0
- plot_io/markers.py +110 -0
- plot_io/markup.py +128 -0
- plot_io/numbers.py +354 -0
- plot_io/occupancy.py +295 -0
- plot_io/plot.py +597 -0
- plot_io/presets/__init__.py +51 -0
- plot_io/presets/_util.py +153 -0
- plot_io/presets/area.py +145 -0
- plot_io/presets/bar.py +261 -0
- plot_io/presets/box.py +400 -0
- plot_io/presets/catpoints.py +177 -0
- plot_io/presets/contour.py +353 -0
- plot_io/presets/curves.py +30 -0
- plot_io/presets/dots.py +355 -0
- plot_io/presets/ecdf.py +97 -0
- plot_io/presets/errorbar.py +106 -0
- plot_io/presets/errors.py +296 -0
- plot_io/presets/fill.py +74 -0
- plot_io/presets/heatmap.py +322 -0
- plot_io/presets/hexbin.py +198 -0
- plot_io/presets/hist.py +391 -0
- plot_io/presets/hist2d.py +87 -0
- plot_io/presets/kde.py +162 -0
- plot_io/presets/kde2d.py +147 -0
- plot_io/presets/line.py +143 -0
- plot_io/presets/pie.py +502 -0
- plot_io/presets/regression.py +115 -0
- plot_io/presets/scatter.py +194 -0
- plot_io/presets/stem.py +100 -0
- plot_io/presets/violin.py +323 -0
- plot_io/py.typed +0 -0
- plot_io/registry.py +82 -0
- plot_io/render.py +480 -0
- plot_io/rows.py +491 -0
- plot_io/scales.py +103 -0
- plot_io/seams.py +138 -0
- plot_io/settings.py +807 -0
- plot_io/shapes.py +66 -0
- plot_io/sharing.py +232 -0
- plot_io/sizemaps.py +97 -0
- plot_io/spec.py +240 -0
- plot_io/states.py +216 -0
- plot_io/styles/__init__.py +307 -0
- plot_io/styles/library.py +397 -0
- plot_io/tables.py +138 -0
- plot_io/text.py +412 -0
- plot_io/ticks.py +400 -0
- plot_io/times.py +287 -0
- plot_io/titles.py +225 -0
- plot_io/transform.py +114 -0
- plot_io/types.py +13604 -0
- plot_io/units.py +768 -0
- plot_io/validators.py +1029 -0
- plot_io/values.py +190 -0
- plot_io-0.12.0.dist-info/METADATA +215 -0
- plot_io-0.12.0.dist-info/RECORD +130 -0
- plot_io-0.12.0.dist-info/WHEEL +4 -0
- 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)
|