plot-io 0.12.0__py3-none-win_amd64.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-x86_64.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/axis.py
ADDED
|
@@ -0,0 +1,1347 @@
|
|
|
1
|
+
"""Axes: settings (:class:`Axis`) and their state while a figure is drawn
|
|
2
|
+
(:class:`AxisState`: scale, limits, ticks, mapping to the page)."""
|
|
3
|
+
|
|
4
|
+
import inspect
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from vec_io.arrays import doubles
|
|
10
|
+
|
|
11
|
+
from ._methods import AxisMixin
|
|
12
|
+
from . import kernels
|
|
13
|
+
from .transform import affine, apply
|
|
14
|
+
from .limits import check_limits, needs_quantiles, resolve_limits, symmetric
|
|
15
|
+
from .scales import AXIS_SCALES, Scale, get_scale
|
|
16
|
+
from .color.values import color
|
|
17
|
+
from . import groups
|
|
18
|
+
from .settings import (Setting, Settings, boolean, either, literal, number, one_of, pair,
|
|
19
|
+
table, text, time_zone)
|
|
20
|
+
from .validators import angle, length
|
|
21
|
+
from .text import Font, font_of, lines_box, wrap
|
|
22
|
+
from .numbers import NumberStyle
|
|
23
|
+
from .formatters import CategoryFormatter, NumberFormatter, TimeFormatter
|
|
24
|
+
from .ticks import (MAX_TICKS, Every, Tick, minor_linear, numeric_ticks, power_labels,
|
|
25
|
+
step_values)
|
|
26
|
+
from .times import axis_value, calendar_ticks, from_seconds
|
|
27
|
+
|
|
28
|
+
__all__ = ["Axis", "AxisState", "Mapper"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _shrunk(q: list[tuple[float, float]], d: float) -> list[tuple[float, float]]:
|
|
32
|
+
"""A rectangle's corners (in order around it) moved ``d`` px inward along its sides
|
|
33
|
+
(outward for a negative ``d``)."""
|
|
34
|
+
out = []
|
|
35
|
+
for k in range(4):
|
|
36
|
+
p, a, b = q[k], q[(k + 1) % 4], q[(k - 1) % 4]
|
|
37
|
+
move = [0.0, 0.0]
|
|
38
|
+
for o in (a, b):
|
|
39
|
+
ux, uy = o[0] - p[0], o[1] - p[1]
|
|
40
|
+
n = math.hypot(ux, uy)
|
|
41
|
+
if n > 2 * d and n > 0:
|
|
42
|
+
move[0] += d * ux / n
|
|
43
|
+
move[1] += d * uy / n
|
|
44
|
+
out.append((p[0] + move[0], p[1] + move[1]))
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _values(v: Any, what: str) -> list[Any] | None:
|
|
49
|
+
"""The items of a non-string iterable (``None`` for anything else), each a number, a
|
|
50
|
+
date or text."""
|
|
51
|
+
if isinstance(v, (str, bytes)) or not hasattr(v, "__iter__") or (
|
|
52
|
+
getattr(v, "shape", None) == ()):
|
|
53
|
+
return None
|
|
54
|
+
out: list[Any] = []
|
|
55
|
+
for x in v:
|
|
56
|
+
if isinstance(x, str):
|
|
57
|
+
out.append(x)
|
|
58
|
+
continue
|
|
59
|
+
try:
|
|
60
|
+
out.append(axis_value(x))
|
|
61
|
+
except ValueError:
|
|
62
|
+
raise ValueError(f"{what}: expected numbers, dates or text, got {x!r}") from None
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _ticks_value(v: Any) -> Any:
|
|
67
|
+
if v is False or isinstance(v, Every) or callable(v):
|
|
68
|
+
return v
|
|
69
|
+
out = _values(v, "ticks")
|
|
70
|
+
if out is None:
|
|
71
|
+
raise ValueError(f"expected False, a list of values, plt.every(step) or a function "
|
|
72
|
+
f"f(lo, hi), got {v!r}")
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _minor_value(v: Any) -> Any:
|
|
77
|
+
if isinstance(v, bool) or isinstance(v, Every) or callable(v):
|
|
78
|
+
return v
|
|
79
|
+
out = _values(v, "minor_ticks")
|
|
80
|
+
if out is None:
|
|
81
|
+
raise ValueError(f"expected True, False, a list of values, plt.every(step) or a "
|
|
82
|
+
f"function f(lo, hi), got {v!r}")
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _check_count(labels: list[Any], ticks: list[Any], what: str) -> None:
|
|
87
|
+
if len(labels) != len(ticks):
|
|
88
|
+
raise ValueError(f"tick_labels: {len(labels)} labels for {len(ticks)} {what}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_rotation = either(literal("auto"), angle(-180, 180))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _region(v: Any) -> Any:
|
|
95
|
+
from .genome.regions import parse_regions
|
|
96
|
+
return parse_regions(v)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _chr_sizes(v: Any) -> Any:
|
|
100
|
+
"""A mapping of chromosome names to sizes (bp)."""
|
|
101
|
+
from collections.abc import Mapping
|
|
102
|
+
if not isinstance(v, Mapping):
|
|
103
|
+
raise ValueError(f"expected a mapping of chromosome names to sizes, got {v!r}")
|
|
104
|
+
from .genome.regions import parse_bp
|
|
105
|
+
return {str(k): parse_bp(n, f"the size of {k!r}", positive=True) for k, n in v.items()}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _scale_bar(v: Any) -> Any:
|
|
109
|
+
from .genome.scale import check_scale_bar
|
|
110
|
+
return check_scale_bar(v)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _tick_format(v: Any) -> Any:
|
|
114
|
+
from .genome.scale import check_tick_format
|
|
115
|
+
return check_tick_format(v)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _origin(v: Any) -> Any:
|
|
119
|
+
from .transform import check_origin
|
|
120
|
+
return check_origin(v)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Axis(AxisMixin, Settings):
|
|
125
|
+
"""An axis of a plot (``plot.x_axis``, ``plot.y_axis``); color and size axes
|
|
126
|
+
(:mod:`plot_io.mapped`) are axes too.
|
|
127
|
+
|
|
128
|
+
Scales (``scale``): ``linear``; ``log``, ``log10``, ``log2``, ``ln`` (v > 0; they place
|
|
129
|
+
data alike and differ in their ticks); ``sqrt`` (v ≥ 0); ``exp`` (v < 709); ``symlog``
|
|
130
|
+
(sign(v)·log₁₀(1 + abs(v)/``linthresh``)); ``logit`` (0 < p < 1); ``time`` (seconds since
|
|
131
|
+
1970-01-01 UTC); ``category``; ``genome`` (bp less the axis' ``origin``, limits the
|
|
132
|
+
axis' ``region`` without padding, tick labels in ``unit``). Values outside the domain
|
|
133
|
+
are missing, except the ends of shapes (bar baselines, fill edges, stems, error-bar
|
|
134
|
+
ends), drawn at the axes' edge. Mixing category and numeric series on an axis raises.
|
|
135
|
+
|
|
136
|
+
Dates (``datetime``, ``date``, ``datetime64``, pandas timestamps) make a ``time`` axis:
|
|
137
|
+
naive values read as UTC, aware ones shown in their zone (``tz``). Ticks follow the
|
|
138
|
+
calendar (years, months, days, hours, minutes, seconds), labels are concise (``2026``,
|
|
139
|
+
``Mar``, ``Mar 14``, ``14:30``) with the unit common to every tick written at the end of
|
|
140
|
+
the axis; ``tick_format`` takes a ``strftime`` pattern or a function of a ``datetime``.
|
|
141
|
+
|
|
142
|
+
Limits: ``"auto"`` is the union of the series' extents plus ``padding`` (per side, in
|
|
143
|
+
position space); ``"lo/hi"`` takes ``min``, ``max``, a number, an ISO date or ``q:F`` (a
|
|
144
|
+
quantile of the axis' values) each side (``"0/max"``, ``"q:0.01/q:0.99"``), or
|
|
145
|
+
``(lo, hi)`` with ``None`` automatic. A single value widens by ±0.5 (a time: by one unit
|
|
146
|
+
of its data). Limits high to low, or ``invert=True``, reverse the axis.
|
|
147
|
+
``include_zero="auto"`` reaches 0 where a series asks (both axes of scatter plots, the
|
|
148
|
+
value axis of lines, areas and boxes), before padding. ``snap`` moves automatic limits
|
|
149
|
+
onto major ticks (linear and log axes): the default ``("4%", "8%")`` extends a limit to
|
|
150
|
+
the next tick within 8% of the axis or pulls it back to a tick it passed by at most 4%;
|
|
151
|
+
``True`` always extends, ``False`` keeps the padded limits.
|
|
152
|
+
|
|
153
|
+
Ticks: linear steps 1, 2, 2.5 (not with a ``precision``) or 5 × 10ᵏ, at most
|
|
154
|
+
``max_ticks`` (on x the step grows until labels do not overlap); log decades labelled
|
|
155
|
+
10ᵏ (below three decades 1, 2 and 5 × 10ᵏ as numbers), minor ticks 2–9; ``log2`` powers of two; ``ln`` powers of e;
|
|
156
|
+
``symlog`` 0, ±``linthresh``, then decades; ``logit`` 0.5, 0.1/0.9, 0.01/0.99, …;
|
|
157
|
+
categories one each, turned 45° when they overlap (``tick_rotation="auto"``), else every
|
|
158
|
+
k-th. Tick labels still closer than ``tick_label_gap`` (chosen or given, either axis)
|
|
159
|
+
are labelled every k-th, then turned 45° (``"auto"``, x), wrapped at spaces, made smaller
|
|
160
|
+
down to ``label_min_size``, and else one is drawn with a warning; a y label at the corner
|
|
161
|
+
over an x label is left out; a dendrogram's leaves (and the axes sharing them) all labelled, turned 90°, then
|
|
162
|
+
smaller down to ``label_min_size``, and every k-th only below it. Labels share the fewest decimals that tell them apart; beyond 10^``sci_limits``
|
|
163
|
+
they are mantissas and ×10ᵏ is written at the end of the axis. ``ticks`` takes values
|
|
164
|
+
(numbers, dates, ISO text, category names), :func:`every` (``every("3 months")``,
|
|
165
|
+
``every(1)``: each decade) or a function of the limits; ``ticks=False`` hides them.
|
|
166
|
+
``grid`` and ``minor_grid`` draw at the major and minor ticks, ``zero_line`` at 0, under
|
|
167
|
+
the series. ``side`` moves ticks, labels and axis line; ``mirror`` repeats the ticks on
|
|
168
|
+
the other side; ``spine_offset`` moves the axis line away; ``spine_trim`` draws it from
|
|
169
|
+
the first to the last major tick. ``color`` sets the default of ``line_color``,
|
|
170
|
+
``tick_color``, ``tick_label_color`` and ``label_color``.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def nominal_scale(self) -> str:
|
|
175
|
+
"""``scale``, or ``"linear"`` while unset (before it is detected from the data)."""
|
|
176
|
+
s = self.resolve("scale")
|
|
177
|
+
return "linear" if s is None else s
|
|
178
|
+
|
|
179
|
+
SETTINGS = table(
|
|
180
|
+
label=Setting(text, doc="Axis title: a string or rich content; `\\n` starts a line "
|
|
181
|
+
"in a row band."),
|
|
182
|
+
label_position=Setting(one_of("axis", "band", "left", "inside"),
|
|
183
|
+
doc="Where the label of a plot's y axis goes: `axis` (beside "
|
|
184
|
+
"the tick labels); in a band left of the plot, lined up "
|
|
185
|
+
"with the labels of the other plots of its grid column "
|
|
186
|
+
"(row labels): `band` (rotated) or `left` (horizontal, "
|
|
187
|
+
"right-aligned); `inside` (top left inside the axes)."),
|
|
188
|
+
**groups.TextStyle.embed("label_", docs={
|
|
189
|
+
"font_size": "Font size of the label; default: the base font size.",
|
|
190
|
+
"font_weight": "Axis title weight: `normal`, `bold` or 1–1000.",
|
|
191
|
+
"font_style": "Axis title style: `normal`, `italic`, `oblique` or "
|
|
192
|
+
"`\"oblique 10deg\"`.",
|
|
193
|
+
"color": "Axis title color."}),
|
|
194
|
+
scale=Setting(one_of(*AXIS_SCALES),
|
|
195
|
+
doc="`linear`, `log` (base 10), `ln` (base e), `log2`, `log10`, `sqrt`, "
|
|
196
|
+
"`exp`, `symlog`, `logit`, `time`, `category` or `genome`; "
|
|
197
|
+
"default: `category` for categorical series, `time` for dates, `genome` for genome "
|
|
198
|
+
"series, else `linear`."),
|
|
199
|
+
region=Setting(_region,
|
|
200
|
+
doc="Genome scale: the region drawn, its limits: "
|
|
201
|
+
"`\"chr2:143,120,001-143,780,000\"` (1-based, inclusive), "
|
|
202
|
+
"`(chr, start, end)` (0-based, half-open) or a mapping; genome "
|
|
203
|
+
"series read it. A chromosome name (`\"chr1\"`): the whole "
|
|
204
|
+
"chromosome (`chr_sizes`). A list of regions or names: drawn "
|
|
205
|
+
"side by side (`region_widths`, `region_gap`), each series "
|
|
206
|
+
"reading each."),
|
|
207
|
+
chr_sizes=Setting(_chr_sizes,
|
|
208
|
+
doc="Genome scale: chromosome sizes (a mapping, "
|
|
209
|
+
"`gwseq_io.get_chr_sizes(\"mm10\")`) giving the end of a whole "
|
|
210
|
+
"chromosome (`region=\"chr1\"`); `None`: the largest size the "
|
|
211
|
+
"figure's readers give."),
|
|
212
|
+
region_widths=Setting(one_of("span", "equal"),
|
|
213
|
+
doc="Genome scale over several regions: the width of each, "
|
|
214
|
+
"its share of the bp (`span`) or `equal`."),
|
|
215
|
+
region_gap=Setting(length(lo=0, percent="em"),
|
|
216
|
+
doc="Genome scale over several regions: the gap between them."),
|
|
217
|
+
unit=Setting(one_of("auto", "bp", "kb", "Mb", "Gb", aliases={"mb": "Mb", "gb": "Gb"}),
|
|
218
|
+
doc="Genome scale: unit of the tick labels; `auto`: with ticks about a "
|
|
219
|
+
"fifth of the span apart, the largest unit in which they are at "
|
|
220
|
+
"least 1 apart and at most 1000, else the largest in which they are "
|
|
221
|
+
"at most 1000 with at most 3 decimals, else bp."),
|
|
222
|
+
unit_in=Setting(one_of("label", "ticks"),
|
|
223
|
+
doc="Genome scale: where the unit is written, in the axis label "
|
|
224
|
+
"(`chr2 (Mb)`) or after each tick label (`500kb`)."),
|
|
225
|
+
scale_bar=Setting(_scale_bar,
|
|
226
|
+
doc="Genome scale (x): a scale bar at the axis' right end (at "
|
|
227
|
+
"each region's) with its length written left of it, in "
|
|
228
|
+
"place of the ticks and tick labels; the label is then the "
|
|
229
|
+
"chromosome alone. `True`: the largest 1, 2 or 5 × 10ᵏ bp at "
|
|
230
|
+
"most a fifth of the axis; a length (`\"10kb\"`); `False`: "
|
|
231
|
+
"ticks."),
|
|
232
|
+
origin=Setting(_origin,
|
|
233
|
+
doc="The value drawn at 0 for the axis' series (a series' own "
|
|
234
|
+
"`x_origin` wins): a number, or `\"center\"`, `\"start\"`, "
|
|
235
|
+
"`\"end\"` of the region (genome scale), else of each series' "
|
|
236
|
+
"range. Values are drawn at `(v − origin) × x_scale`; limits, "
|
|
237
|
+
"ticks and decorations are in the drawn units."),
|
|
238
|
+
linthresh=Setting(number(positive=True),
|
|
239
|
+
doc="Half-width of the linear region of a `symlog` axis."),
|
|
240
|
+
tz=Setting(time_zone,
|
|
241
|
+
doc="Time zone of a time axis (a `tzinfo`, or a zone name such as "
|
|
242
|
+
"`\"Europe/Paris\"`); default: the zone of the first aware date, else "
|
|
243
|
+
"naive (UTC)."),
|
|
244
|
+
limits=Setting(check_limits,
|
|
245
|
+
doc="`\"auto\"`, `\"lo/hi\"` or `[lo, hi]` (a tuple too); each side a "
|
|
246
|
+
"number, `\"min\"`, `\"max\"`, `\"q:F\"` (a quantile), a date, or "
|
|
247
|
+
"`\"auto\"`/`None`: `\"0/max\"` is `[0, \"max\"]`; in a pair on a "
|
|
248
|
+
"category axis, a category by name (shown whole); `\"-m/m\"`: "
|
|
249
|
+
"symmetric about the `center` (or 0), `m` the farthest value."),
|
|
250
|
+
padding=Setting(either(literal("auto"), length(lo=0, percent="data_range")),
|
|
251
|
+
doc="Space added to automatic limits, as a fraction of the range; "
|
|
252
|
+
"`\"auto\"`: `auto_padding`, or 0 on axes of series drawn to "
|
|
253
|
+
"exact limits (box value axes, heatmaps, …) and in plots "
|
|
254
|
+
"without axes."),
|
|
255
|
+
auto_padding=Setting(length(lo=0, percent="data_range"),
|
|
256
|
+
doc="The fraction `padding=\"auto\"` adds on each side."),
|
|
257
|
+
sticky=Setting(boolean,
|
|
258
|
+
doc="No padding past the baseline of bars and histograms when an "
|
|
259
|
+
"automatic limit is on it (matplotlib's sticky edges)."),
|
|
260
|
+
invert=Setting(boolean, doc="Reverse the axis (limits given high to low, "
|
|
261
|
+
"`(2, 0)`, reverse it too)."),
|
|
262
|
+
ticks=Setting(_ticks_value,
|
|
263
|
+
doc="`False` (no ticks), the tick values: any iterable (a list, a "
|
|
264
|
+
"range, an array) of numbers, dates, `datetime64` or ISO 8601 "
|
|
265
|
+
"text, on a category axis categories by name or index; "
|
|
266
|
+
"`plt.every(step)` (`plt.every(\"2 months\")`; decades on log "
|
|
267
|
+
"axes); or a function `f(lo, hi)` of the limits (datetimes on a "
|
|
268
|
+
"time axis) returning the values."),
|
|
269
|
+
tick_labels=Setting(lambda v: [text(x) for x in v],
|
|
270
|
+
doc="Labels of `ticks` (of the categories without `ticks`), as "
|
|
271
|
+
"many as they; markup is read, `\\n` starts a line."),
|
|
272
|
+
minor_ticks=Setting(_minor_value,
|
|
273
|
+
doc="`True`, `False`, or the minor tick values, "
|
|
274
|
+
"`plt.every(step)` or a function (as `ticks`); default: on "
|
|
275
|
+
"for base-10 logs."),
|
|
276
|
+
tick_format=Setting(_tick_format,
|
|
277
|
+
doc="Number format: `auto`, `percent`, `sci`, `eng`, `si`, `int`, "
|
|
278
|
+
"a format string (`\"{:.2f}\"`, printf `\"%.2f\"` or a "
|
|
279
|
+
"bare spec `\".2f\"`; strftime `%` codes on a time axis) or "
|
|
280
|
+
"a function; on a genome axis also `chr:pos` "
|
|
281
|
+
"(`chr2:143,250,000`)."),
|
|
282
|
+
tick_rotation=Setting(_rotation,
|
|
283
|
+
doc="Label rotation in degrees, or `\"auto\"` (45° when "
|
|
284
|
+
"x labels overlap; 90° for a dendrogram's leaves)."),
|
|
285
|
+
label_min_size=Setting(length(positive=True, font=True),
|
|
286
|
+
doc="Smallest font size tick labels that overlap shrink to "
|
|
287
|
+
"(a dendrogram's leaves: before some are left out)."),
|
|
288
|
+
max_ticks=Setting(either(literal("auto"), number(lo=1, hi=MAX_TICKS, integer=True)),
|
|
289
|
+
doc="Most major ticks; `\"auto\"`: one per `tick_spacing` of "
|
|
290
|
+
"axis length, at least 2, at most `auto_max_ticks`."),
|
|
291
|
+
tick_spacing=Setting(pair(length(positive=True, percent="em", em="tick_label_font_size")),
|
|
292
|
+
doc="Space per automatic tick, in tick label font sizes, on "
|
|
293
|
+
"horizontal and vertical axes."),
|
|
294
|
+
time_tick_spacing=Setting(pair(length(positive=True, percent="em",
|
|
295
|
+
em="tick_label_font_size")),
|
|
296
|
+
doc="`tick_spacing` of time axes."),
|
|
297
|
+
tick_label_gap=Setting(length(lo=0, percent="em", em="tick_label_font_size"),
|
|
298
|
+
doc="Smallest gap between neighbouring tick labels side by "
|
|
299
|
+
"side: fewer numeric ticks, or category labels rotated "
|
|
300
|
+
"and thinned out, below it."),
|
|
301
|
+
category_label_spacing=Setting(pair(length(positive=True, percent="em",
|
|
302
|
+
em="tick_label_font_size")),
|
|
303
|
+
doc="Distance between category labels: rotated "
|
|
304
|
+
"(horizontal axes) and stacked (vertical "
|
|
305
|
+
"axes); labels closer than it are thinned out."),
|
|
306
|
+
auto_max_ticks=Setting(pair(number(lo=2, hi=MAX_TICKS, integer=True)),
|
|
307
|
+
doc="Most automatic ticks on numeric and time axes."),
|
|
308
|
+
sci_limits=Setting(pair(number(integer=True)),
|
|
309
|
+
doc="Exponents beyond which labels become mantissas × 10ᵏ."),
|
|
310
|
+
tick_length=Setting(length(lo=0, percent="across"), doc="Tick length."),
|
|
311
|
+
minor_tick_length=Setting(length(lo=0, percent="tick_length"),
|
|
312
|
+
doc="Minor tick length; default 0.6 × `tick_length`."),
|
|
313
|
+
tick_direction=Setting(one_of("out", "in", "both"), doc="`out`, `in` or `both`."),
|
|
314
|
+
**groups.Stroke.embed("tick_", only=("color", "line_width"),
|
|
315
|
+
docs={"color": "Tick color.", "line_width": "Tick line width."}),
|
|
316
|
+
**groups.TextStyle.embed("tick_label_", only=("font_size", "font_weight", "color"),
|
|
317
|
+
docs={"font_size": "Font size of tick labels.",
|
|
318
|
+
"font_weight": "Tick label weight: `normal`, `bold` or "
|
|
319
|
+
"1–1000.",
|
|
320
|
+
"color": "Tick label color."}),
|
|
321
|
+
tick_pad=Setting(length(lo=0, percent="across"), doc="Gap between ticks and labels."),
|
|
322
|
+
label_pad=Setting(length(lo=0, percent="across"), doc="Gap between tick labels and the label."),
|
|
323
|
+
color=Setting(color,
|
|
324
|
+
doc="Color of the axis line, ticks and labels (the default of "
|
|
325
|
+
"`line_color`, `tick_color`, `tick_label_color`, `label_color`)."),
|
|
326
|
+
**groups.Stroke.embed("line_", only=("color", "line_width"),
|
|
327
|
+
docs={"color": "Axis line color.", "line_width": "Axis line width."}),
|
|
328
|
+
side=Setting(one_of("left", "right", "bottom", "top"),
|
|
329
|
+
doc="Where the ticks, labels and axis line go: `bottom` or `top` (x "
|
|
330
|
+
"axis), `left` or `right` (y axis)."),
|
|
331
|
+
mirror=Setting(boolean, doc="Ticks also on the opposite side of the axes."),
|
|
332
|
+
spine_offset=Setting(length(lo=0, percent="across"),
|
|
333
|
+
doc="Distance the axis line (with its ticks and labels) is "
|
|
334
|
+
"moved away from the axes."),
|
|
335
|
+
spine_trim=Setting(boolean,
|
|
336
|
+
doc="Axis line from the first to the last major tick only."),
|
|
337
|
+
include_zero=Setting(either(boolean, literal("auto")),
|
|
338
|
+
doc="Extend automatic limits to 0 (linear and sqrt scales), "
|
|
339
|
+
"before padding and `snap`: `True`, or `\"auto\"` where a "
|
|
340
|
+
"series on the axis asks for it (Vega-Lite's rule: scatter "
|
|
341
|
+
"axes, the value axis of lines, areas and boxes)."),
|
|
342
|
+
zero_line=Setting(boolean, doc="A line across the plot at 0."),
|
|
343
|
+
**groups.Stroke.embed("zero_line_", only=("color", "line_width"),
|
|
344
|
+
docs={"color": "Zero line color; default `line_color`.",
|
|
345
|
+
"line_width": "Zero line width; default `line_width`."}),
|
|
346
|
+
snap=Setting(either(boolean, pair(length(lo=0, percent="data_range"))),
|
|
347
|
+
doc="Automatic limits on major ticks (linear and log scales): `True`, "
|
|
348
|
+
"out to the enclosing ticks; `(back, out)`, out to a tick within "
|
|
349
|
+
"`out` (`%` of the axis), or back to one within `back` that "
|
|
350
|
+
"is still outside the data; `False`: as padded."),
|
|
351
|
+
grid=Setting(boolean, doc="Major grid lines across the plot."),
|
|
352
|
+
**groups.Stroke.embed("grid_", only=("color", "line_width", "line_style"),
|
|
353
|
+
docs={"color": "Grid line color.", "line_width": "Grid line width.",
|
|
354
|
+
"line_style": "Grid line style or dash list."}),
|
|
355
|
+
minor_grid=Setting(boolean,
|
|
356
|
+
doc="Minor grid lines: at the minor ticks, or halfway between "
|
|
357
|
+
"major ticks on linear axes without minor ticks."),
|
|
358
|
+
**groups.Stroke.embed("minor_grid_", only=("color", "line_width", "line_style"),
|
|
359
|
+
checks={"line_width": length(lo=0, percent="grid_width")},
|
|
360
|
+
docs={"color": "Minor grid color; default `grid_color`.",
|
|
361
|
+
"line_width": "Minor grid width; default half `grid_width`.",
|
|
362
|
+
"line_style": "Minor grid style; default `grid_style`."}),
|
|
363
|
+
visible=Setting(boolean, doc="Draw the axis (ticks, labels)."),
|
|
364
|
+
**groups.Font.embed(),
|
|
365
|
+
text_color=Setting(color, inherited=True, doc="Text color."),
|
|
366
|
+
**groups.NumberFormat.embed(),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
def __init__(self, owner: Any, role: str) -> None:
|
|
370
|
+
self._owner = owner
|
|
371
|
+
self.role = role
|
|
372
|
+
fig = getattr(owner, "figure", None)
|
|
373
|
+
#: the figure's axis of this role, whose own values this axis takes (see
|
|
374
|
+
#: :func:`plot_io.settings.cascade`)
|
|
375
|
+
self._figure_axis: Settings | None = getattr(fig, f"{role}_axis", None) \
|
|
376
|
+
if role in ("x", "y") else None
|
|
377
|
+
super().__init__()
|
|
378
|
+
|
|
379
|
+
def _parent(self) -> Settings | None:
|
|
380
|
+
return self._owner
|
|
381
|
+
|
|
382
|
+
def _shared(self) -> Settings | None:
|
|
383
|
+
"""The figure's axis of this role (``figure.x_axis``), for a plot's x or y axis."""
|
|
384
|
+
return self._figure_axis
|
|
385
|
+
|
|
386
|
+
def _levels(self, name: str) -> tuple[tuple[str, ...], ...]:
|
|
387
|
+
"""One level: ``XAxis``/``YAxis`` keys before the class's; a color axis takes
|
|
388
|
+
``Colorbar`` keys of the axis settings a colorbar lacks (its guide's)."""
|
|
389
|
+
classes = tuple(c for level in super()._levels(name) for c in level)
|
|
390
|
+
if self.role == "c":
|
|
391
|
+
from .colorbar import Colorbar
|
|
392
|
+
return (classes,) if name in Colorbar.SETTINGS else (("Colorbar",) + classes,)
|
|
393
|
+
return ((f"{self.role.upper()}Axis",) + classes,)
|
|
394
|
+
|
|
395
|
+
def _level_key(self) -> Any:
|
|
396
|
+
return self.role
|
|
397
|
+
|
|
398
|
+
def _box(self, kind: str, name: str) -> float:
|
|
399
|
+
if kind in ("across", "along"):
|
|
400
|
+
vertical = self.role == "y"
|
|
401
|
+
if (kind == "across") == vertical:
|
|
402
|
+
kind = "axes_width"
|
|
403
|
+
else:
|
|
404
|
+
kind = "axes_height"
|
|
405
|
+
return super()._box(kind, name)
|
|
406
|
+
|
|
407
|
+
def __call__(self, label: Any = None, **settings: Any) -> "Axis":
|
|
408
|
+
"""Set the axis title (``label``) and any axis settings; returns the axis."""
|
|
409
|
+
if label is not None:
|
|
410
|
+
settings["label"] = label
|
|
411
|
+
return self.set(**settings)
|
|
412
|
+
|
|
413
|
+
def __repr__(self) -> str:
|
|
414
|
+
return f"Axis({self.role})"
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class Mapper:
|
|
418
|
+
"""Data → page coordinate on one axis: ``page = a·f(v) + b``."""
|
|
419
|
+
|
|
420
|
+
__slots__ = ("function", "a", "b", "forward", "options", "flo", "fhi")
|
|
421
|
+
|
|
422
|
+
def __init__(self, scale: Scale | None, flo: float, fhi: float, p0: float, p1: float) -> None:
|
|
423
|
+
self.function = scale.function if scale is not None else None
|
|
424
|
+
self.options = scale.options if scale is not None else {}
|
|
425
|
+
self.forward = scale.f if scale is not None else (lambda v: v)
|
|
426
|
+
self.flo, self.fhi = flo, fhi
|
|
427
|
+
span = fhi - flo
|
|
428
|
+
self.a = (p1 - p0) / span if span else 0.0
|
|
429
|
+
self.b = p0 - self.a * flo
|
|
430
|
+
|
|
431
|
+
def __call__(self, v: float) -> float:
|
|
432
|
+
return self.a * self.forward(v) + self.b
|
|
433
|
+
|
|
434
|
+
def many(self, values: Any) -> Any:
|
|
435
|
+
return kernels.scaled(values, self.a, self.b, self.function, **self.options)
|
|
436
|
+
|
|
437
|
+
def ends(self, values: Any) -> Any:
|
|
438
|
+
"""As :meth:`many`, for the ends of shapes (bar baselines, band edges, segment
|
|
439
|
+
ends): values below the scale's domain (0 on a log axis) at the edge of the low
|
|
440
|
+
limit, values above it at the edge of the high limit."""
|
|
441
|
+
if self.function is None:
|
|
442
|
+
return self.many(values)
|
|
443
|
+
return kernels.scaled(values, self.a, self.b, self.function, **self.options,
|
|
444
|
+
below=self.position(self.flo), above=self.position(self.fhi))
|
|
445
|
+
|
|
446
|
+
def position(self, f: float) -> float:
|
|
447
|
+
"""Page coordinate of a value already through the scale function."""
|
|
448
|
+
return self.a * f + self.b
|
|
449
|
+
|
|
450
|
+
def affine(self, origin: float, scale: float, role: str) -> "Mapper":
|
|
451
|
+
"""This mapper for a series whose values ``v`` are drawn at ``(v - origin) *
|
|
452
|
+
scale`` (``x_origin``…): on a linear or time axis, still ``a·v + b``."""
|
|
453
|
+
if self.function is not None:
|
|
454
|
+
raise ValueError(f"{role}_origin, {role}_scale and {role}_extent need a linear "
|
|
455
|
+
f"or time {role} axis")
|
|
456
|
+
m = Mapper(None, 0.0, 1.0, 0.0, 1.0)
|
|
457
|
+
m.a, m.b = self.a * scale, self.b - self.a * scale * origin
|
|
458
|
+
return m
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _about(scale: Scale, center: float, flo: float, fhi: float, lo_auto: bool, hi_auto: bool,
|
|
462
|
+
lo: float, hi: float) -> tuple[float, float, float, float]:
|
|
463
|
+
"""Limits symmetric about ``center`` (on the scale): the automatic sides as far from it
|
|
464
|
+
as the farther side (both automatic) or the given side."""
|
|
465
|
+
fc = scale.f(center)
|
|
466
|
+
if not math.isfinite(fc):
|
|
467
|
+
return flo, fhi, lo, hi
|
|
468
|
+
a, b = sorted((flo, fhi))
|
|
469
|
+
d = max(fc - a, b - fc) if lo_auto and hi_auto else (b - fc if lo_auto else fc - a)
|
|
470
|
+
if not d > 0:
|
|
471
|
+
return flo, fhi, lo, hi
|
|
472
|
+
return fc - d, fc + d, scale.inverse(fc - d), scale.inverse(fc + d)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
class AxisState:
|
|
476
|
+
"""An axis while drawing: resolved scale, limits, categories and ticks."""
|
|
477
|
+
|
|
478
|
+
def __init__(self, axis: Axis) -> None:
|
|
479
|
+
self.axis = axis
|
|
480
|
+
self.scale: Scale | None = None # None: category
|
|
481
|
+
self.categories: list[Any] = []
|
|
482
|
+
self.lo = 0.0 # data limits (category: index range)
|
|
483
|
+
self.hi = 1.0
|
|
484
|
+
self.flo = 0.0 # limits after the scale function
|
|
485
|
+
self.fhi = 1.0
|
|
486
|
+
self.data_lo = math.nan # union of the series' extents
|
|
487
|
+
self.data_hi = math.nan
|
|
488
|
+
self.moved = False # a series' x_origin, … moved its values
|
|
489
|
+
self.ticks: list[Tick] = []
|
|
490
|
+
#: what is written at the end of the axis (×10ᵏ, ``+1×10⁹``, a date), or None
|
|
491
|
+
self.offset_label: Any = None
|
|
492
|
+
self.rotation = 0.0
|
|
493
|
+
self.length = 0.0
|
|
494
|
+
self.auto_invert = False # inverted by a series (heatmap rows)
|
|
495
|
+
self.reversed = False # limits given high to low: drawn inverted
|
|
496
|
+
self._auto = (False, False) # automatic low / high limit (for `snap`)
|
|
497
|
+
self._zero_pad = False # a series wants exact limits (heatmaps)
|
|
498
|
+
self._base = (0.0, 1.0, 0.0, 1.0) # lo, hi, flo, fhi before `snap`
|
|
499
|
+
self._inner = (0.0, 1.0) # flo, fhi before padding (`snap` keeps them)
|
|
500
|
+
self._pad_px = 0.0 # padding in px, added when the length is known
|
|
501
|
+
self._roomers: list[Any] = [] # series with labels past their values
|
|
502
|
+
self._pad_frac = 0.0 # padding as a fraction of the range
|
|
503
|
+
self._unpadded_px = self._base # limits before the px padding
|
|
504
|
+
self.shared: "AxisState | None" = None # the group's leader (shared axes)
|
|
505
|
+
self.hide_labels = False # an inner plot of a shared group
|
|
506
|
+
self.auto_label: Any = None # the column name of the series' data
|
|
507
|
+
self.tz: Any = None # time zone of a time axis
|
|
508
|
+
self.genome = False # a genome scale (linear, in bp)
|
|
509
|
+
self.whole = False # integer ticks only (grid rows and columns)
|
|
510
|
+
self.region: Any = None # its region (the axis', else a series')
|
|
511
|
+
self.origin = 0.0 # the position drawn at 0 on a genome axis
|
|
512
|
+
#: the regions of a genome axis over several, side by side
|
|
513
|
+
#: (:class:`plot_io.genome.scale.Segments`), else None
|
|
514
|
+
self.segments: Any = None
|
|
515
|
+
#: the label of each region of an axis over several: ``[(Block, Segment)]``
|
|
516
|
+
self.segment_titles: list[Any] = []
|
|
517
|
+
self.hide_title = False # an inner plot of a group: tick labels only
|
|
518
|
+
#: the axis label's lines as last measured (wrapped to the plot's box)
|
|
519
|
+
self.title_block: Any = None
|
|
520
|
+
#: the height of the ×10ᵏ offset's own row under the label row (0: beside the label)
|
|
521
|
+
self.offset_row = 0.0
|
|
522
|
+
#: the font size the tick labels were made at when even then only one fits
|
|
523
|
+
self.crowded: float | None = None
|
|
524
|
+
#: ``[(value, label)]`` the calls on the axis give as its ticks (named rows)
|
|
525
|
+
self.call_ticks: list[tuple[float, Any]] | None = None
|
|
526
|
+
#: whether those ticks' labels are thinned until none overlap
|
|
527
|
+
self.call_thin = False
|
|
528
|
+
#: whether the labels are a dendrogram's leaves (:meth:`_fit_leaves`)
|
|
529
|
+
self.call_leaves = False
|
|
530
|
+
#: the leaves' font size when shrunk to fit, else ``None``
|
|
531
|
+
self.leaf_size: float | None = None
|
|
532
|
+
#: px the axis leaves at its low and high ends of the axes (a y axis over the
|
|
533
|
+
#: value lane of a plot with interval lanes, :mod:`plot_io.genome.lanes`)
|
|
534
|
+
self.span = (0.0, 0.0)
|
|
535
|
+
|
|
536
|
+
def on_far_side(self) -> bool:
|
|
537
|
+
"""Whether the axis is drawn on the top (x) or right (y) side of the axes."""
|
|
538
|
+
return self.axis.resolve("side") in ("top", "right")
|
|
539
|
+
|
|
540
|
+
@property
|
|
541
|
+
def is_category(self) -> bool:
|
|
542
|
+
return self.scale is None
|
|
543
|
+
|
|
544
|
+
def f(self, v: float) -> float:
|
|
545
|
+
return self.scale.f(v) if self.scale is not None else v
|
|
546
|
+
|
|
547
|
+
@property
|
|
548
|
+
def inverted(self) -> bool:
|
|
549
|
+
if self.shared is not None:
|
|
550
|
+
return self.shared.inverted
|
|
551
|
+
a = self.axis
|
|
552
|
+
if self.reversed:
|
|
553
|
+
return True
|
|
554
|
+
return a.resolve("invert") if a.is_set("invert") else self.auto_invert
|
|
555
|
+
|
|
556
|
+
def tick_out(self, marks: bool = False) -> tuple[float, float]:
|
|
557
|
+
"""Tick extent outside and inside the axes: of all ticks (the room they take), or
|
|
558
|
+
with ``marks`` of the major tick marks."""
|
|
559
|
+
a = self.axis
|
|
560
|
+
n = a.resolve("tick_length")
|
|
561
|
+
if not marks and any(not t.major for t in self.ticks):
|
|
562
|
+
n = max(n, a.resolve("minor_tick_length"))
|
|
563
|
+
d = a.resolve("tick_direction")
|
|
564
|
+
return (n if d in ("out", "both") else 0.0), (n if d in ("in", "both") else 0.0)
|
|
565
|
+
|
|
566
|
+
def mapper(self, p0: float, p1: float) -> Mapper:
|
|
567
|
+
"""Data → page from the axes' low end ``p0`` to their high end ``p1``, less the
|
|
568
|
+
axis' ``span`` at each end."""
|
|
569
|
+
lo, hi = self.span
|
|
570
|
+
if lo or hi:
|
|
571
|
+
d = 1.0 if p1 >= p0 else -1.0
|
|
572
|
+
p0, p1 = p0 + d * lo, p1 - d * hi
|
|
573
|
+
inv = self.inverted
|
|
574
|
+
if inv:
|
|
575
|
+
p0, p1 = p1, p0
|
|
576
|
+
return Mapper(self.scale, self.flo, self.fhi, p0, p1)
|
|
577
|
+
|
|
578
|
+
# -- limits ------------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
def resolve(self, series: Sequence[Any], padded: bool) -> None:
|
|
581
|
+
"""Scale, categories and limits from the series drawn on this axis."""
|
|
582
|
+
role = self.axis.role
|
|
583
|
+
scale_name = self.axis.resolve("scale")
|
|
584
|
+
cats: list[Any] = []
|
|
585
|
+
categorical = False
|
|
586
|
+
for s in series:
|
|
587
|
+
c = s.categories(role)
|
|
588
|
+
if c is not None:
|
|
589
|
+
categorical = True
|
|
590
|
+
for k in c:
|
|
591
|
+
if k not in cats:
|
|
592
|
+
cats.append(k)
|
|
593
|
+
on_axis = [s for s in series if s.has_axis(role)]
|
|
594
|
+
self._roomers = [s for s in on_axis if hasattr(s, "label_room")]
|
|
595
|
+
self.auto_invert = any(s.inverts(role) for s in on_axis)
|
|
596
|
+
self.whole = bool(on_axis) and all(
|
|
597
|
+
getattr(s, "whole_ticks", lambda r: False)(role) for s in on_axis)
|
|
598
|
+
times = [s for s in on_axis if getattr(s, "time_role", lambda r: None)(role)]
|
|
599
|
+
genome = [s for s in on_axis if getattr(s, "scale_of", lambda r: None)(role) == "genome"]
|
|
600
|
+
if scale_name is None:
|
|
601
|
+
scale_name = "category" if categorical else ("time" if times else
|
|
602
|
+
"genome" if genome else "linear")
|
|
603
|
+
self.genome = scale_name == "genome"
|
|
604
|
+
if self.genome:
|
|
605
|
+
scale_name = "linear"
|
|
606
|
+
from .genome.series import axis_region
|
|
607
|
+
self.region = axis_region(self.axis) or next(
|
|
608
|
+
(r for r in (s.region_of(role) if hasattr(s, "region_of") else
|
|
609
|
+
getattr(s, "region", None) for s in genome) if r is not None),
|
|
610
|
+
None)
|
|
611
|
+
from .genome.scale import origin_of, segments_of
|
|
612
|
+
self.segments = segments_of(self.axis._owner) if role == "x" else None
|
|
613
|
+
if self.segments is not None:
|
|
614
|
+
self._check_segmented(on_axis)
|
|
615
|
+
self.region = None
|
|
616
|
+
o = origin_of(self.axis.resolve("origin"), self.region)
|
|
617
|
+
self.origin = 0.0 if o is None else o
|
|
618
|
+
self.tz = self.axis.resolve("tz")
|
|
619
|
+
if self.tz is None and times:
|
|
620
|
+
self.tz = times[0].time_role(role)[1]
|
|
621
|
+
if scale_name == "category":
|
|
622
|
+
if any(s.categories(role) is None and s.has_axis(role) for s in series):
|
|
623
|
+
raise ValueError(f"{role} axis mixes categorical and numeric series")
|
|
624
|
+
self.scale = None
|
|
625
|
+
self.categories = cats
|
|
626
|
+
n = max(len(cats), 1)
|
|
627
|
+
self.lo, self.hi = -0.5, n - 0.5
|
|
628
|
+
spec = self._named_limits(self.axis.resolve("limits"))
|
|
629
|
+
if spec != "auto":
|
|
630
|
+
lo, hi, _, _ = resolve_limits(spec, self.lo, self.hi)
|
|
631
|
+
self.lo, self.hi = float(lo), float(hi)
|
|
632
|
+
self.flo, self.fhi = self.lo, self.hi
|
|
633
|
+
return
|
|
634
|
+
if categorical:
|
|
635
|
+
raise ValueError(f"{role} axis has categorical series but scale {scale_name!r}")
|
|
636
|
+
scale = self.scale = get_scale(scale_name, self.axis.resolve("linthresh"))
|
|
637
|
+
count, lo, hi = 0, math.inf, -math.inf
|
|
638
|
+
zero_pad, self.moved = False, False
|
|
639
|
+
sticky: list[float] = []
|
|
640
|
+
for s in series:
|
|
641
|
+
if not s.has_axis(role):
|
|
642
|
+
continue
|
|
643
|
+
t = affine(s, role) # x_origin, …: limits in the drawn units
|
|
644
|
+
if t is not None and scale.function is not None:
|
|
645
|
+
raise ValueError(f"{role}_origin, {role}_scale and {role}_extent need a "
|
|
646
|
+
f"linear or time {role} axis")
|
|
647
|
+
n, a, b = s.extent(role, scale)
|
|
648
|
+
if n:
|
|
649
|
+
if t is not None:
|
|
650
|
+
finite = math.isfinite(b - a)
|
|
651
|
+
a, b = sorted((apply(t, a), apply(t, b)))
|
|
652
|
+
if finite and not math.isfinite(b - a):
|
|
653
|
+
kind = getattr(getattr(s, "series", s), "kind", "series")
|
|
654
|
+
raise ValueError(f"{kind}: {role}_origin, {role}_scale or "
|
|
655
|
+
f"{role}_extent draw its {role} values beyond the "
|
|
656
|
+
f"number range")
|
|
657
|
+
self.moved = True
|
|
658
|
+
count += n
|
|
659
|
+
lo, hi = min(lo, a), max(hi, b)
|
|
660
|
+
zero_pad = zero_pad or s.zero_padding(role)
|
|
661
|
+
sticky.extend(s.sticky(role) if t is None else [apply(t, v) for v in s.sticky(role)])
|
|
662
|
+
if not count:
|
|
663
|
+
lo, hi = (1.0, 10.0) if scale.base is not None else (0.0, 1.0)
|
|
664
|
+
self.data_lo, self.data_hi = lo, hi
|
|
665
|
+
zero = self.axis.resolve("include_zero")
|
|
666
|
+
if zero == "auto":
|
|
667
|
+
zero = any(s.zero_default(role) for s in on_axis)
|
|
668
|
+
spec = self._named_limits(self.axis.resolve("limits"))
|
|
669
|
+
if self.genome: # the region (drawn), without padding or 0
|
|
670
|
+
zero, zero_pad = False, True
|
|
671
|
+
if spec == "auto" and self.region is not None:
|
|
672
|
+
spec = (self.region.start - self.origin, self.region.end - self.origin)
|
|
673
|
+
elif spec == "auto" and self.segments is not None:
|
|
674
|
+
spec = (0.0, self.segments.total)
|
|
675
|
+
if zero and scale.name in ("linear", "sqrt"):
|
|
676
|
+
lo, hi = min(lo, 0.0), max(hi, 0.0)
|
|
677
|
+
qs = needs_quantiles(spec)
|
|
678
|
+
qmap: dict[float, float] = {}
|
|
679
|
+
if qs:
|
|
680
|
+
from array import array
|
|
681
|
+
vals = array("d")
|
|
682
|
+
for s in series:
|
|
683
|
+
if s.has_axis(role):
|
|
684
|
+
v = s.values(role)
|
|
685
|
+
if v is not None:
|
|
686
|
+
t = affine(s, role)
|
|
687
|
+
v = doubles(v)
|
|
688
|
+
vals.extend(v if t is None else kernels.scaled(v, t[1], -t[0] * t[1]))
|
|
689
|
+
vals = array("d", [v for v in vals if math.isfinite(v) and (
|
|
690
|
+
scale.function is None or scale.contains(v))])
|
|
691
|
+
if len(vals): # none: quantile sides at the data's ends
|
|
692
|
+
for q, r in zip(qs, kernels.quantiles(vals, qs)):
|
|
693
|
+
qmap[q] = r
|
|
694
|
+
lo2, hi2, lo_auto, hi_auto = resolve_limits(spec, lo, hi,
|
|
695
|
+
(lambda q: qmap[q]) if qmap else None)
|
|
696
|
+
flo, fhi = scale.f(lo2), scale.f(hi2)
|
|
697
|
+
if not (math.isfinite(flo) and math.isfinite(fhi)):
|
|
698
|
+
what = {"c": "color", "s": "size"}.get(role, role)
|
|
699
|
+
raise ValueError(f"{what} axis limits {lo2!r}/{hi2!r} are outside the "
|
|
700
|
+
f"{scale.name} scale's domain")
|
|
701
|
+
center = self.axis.resolve("center") if "center" in self.axis.SETTINGS else None
|
|
702
|
+
if symmetric(spec):
|
|
703
|
+
flo, fhi, lo2, hi2 = _about(scale, 0.0 if center is None else center, flo, fhi,
|
|
704
|
+
True, True, lo2, hi2)
|
|
705
|
+
elif center is not None and (lo_auto or hi_auto):
|
|
706
|
+
flo, fhi, lo2, hi2 = _about(scale, center, flo, fhi, lo_auto, hi_auto, lo2, hi2)
|
|
707
|
+
self.reversed = flo > fhi and not (lo_auto or hi_auto) # (2, 0): inverted
|
|
708
|
+
if flo > fhi:
|
|
709
|
+
flo, fhi = fhi, flo
|
|
710
|
+
lo_auto, hi_auto = hi_auto, lo_auto
|
|
711
|
+
inner = (flo, fhi)
|
|
712
|
+
self._pad_frac = 0.0
|
|
713
|
+
if fhi == flo and scale.name == "time": # one time: a unit of its data each side
|
|
714
|
+
from .times import time_unit
|
|
715
|
+
u = time_unit(flo, self.tz)
|
|
716
|
+
flo, fhi = flo - u, fhi + u
|
|
717
|
+
elif fhi - flo < 1e-280: # one value (or too close to map to px)
|
|
718
|
+
flo, fhi = (flo + fhi) / 2 - 0.5, (flo + fhi) / 2 + 0.5
|
|
719
|
+
if scale.name == "sqrt" and flo < 0:
|
|
720
|
+
flo, fhi = 0.0, fhi + 0.5
|
|
721
|
+
else:
|
|
722
|
+
if sticky and self.axis.resolve("sticky"): # a side on a baseline: unpadded
|
|
723
|
+
tol = 1e-12 * max(1.0, abs(flo), abs(fhi))
|
|
724
|
+
fs = [f for f in (scale.f(v) for v in sticky) if math.isfinite(f)]
|
|
725
|
+
lo_auto = lo_auto and not any(abs(f - flo) <= tol for f in fs)
|
|
726
|
+
hi_auto = hi_auto and not any(abs(f - fhi) <= tol for f in fs)
|
|
727
|
+
name: str | None = "padding"
|
|
728
|
+
if self.axis.get("padding") == "auto":
|
|
729
|
+
name = "auto_padding" if padded and not zero_pad else None
|
|
730
|
+
pad, self._pad_px = self._padding(name)
|
|
731
|
+
self._pad_frac = pad
|
|
732
|
+
d = (fhi - flo) * pad
|
|
733
|
+
if lo_auto:
|
|
734
|
+
flo -= d
|
|
735
|
+
if scale.name == "sqrt" and flo < 0:
|
|
736
|
+
flo = 0.0
|
|
737
|
+
if hi_auto:
|
|
738
|
+
fhi += d
|
|
739
|
+
if not math.isfinite(scale.inverse(flo)):
|
|
740
|
+
flo = scale.f(lo2) if scale.f(lo2) < fhi else flo + d
|
|
741
|
+
if not math.isfinite(scale.inverse(fhi)):
|
|
742
|
+
fhi = scale.f(hi2)
|
|
743
|
+
self._inner = inner
|
|
744
|
+
self.flo, self.fhi = flo, fhi
|
|
745
|
+
self.lo, self.hi = scale.inverse(flo), scale.inverse(fhi)
|
|
746
|
+
self._auto = (lo_auto, hi_auto)
|
|
747
|
+
self._zero_pad = zero_pad
|
|
748
|
+
self._base = (self.lo, self.hi, self.flo, self.fhi)
|
|
749
|
+
self._unpadded_px = self._base
|
|
750
|
+
|
|
751
|
+
def _check_segmented(self, on_axis: Sequence[Any]) -> None:
|
|
752
|
+
"""An axis over several regions: only genome series, no limits or origin."""
|
|
753
|
+
role = self.axis.role
|
|
754
|
+
plain = [s for s in on_axis
|
|
755
|
+
if getattr(s, "scale_of", lambda r: None)(role) != "genome"]
|
|
756
|
+
if plain:
|
|
757
|
+
what = getattr(getattr(plain[0], "series", plain[0]), "kind", "a series")
|
|
758
|
+
raise ValueError(f"{what} has plain {role} values, which an axis over several "
|
|
759
|
+
f"regions cannot place: draw it over one region, or mark "
|
|
760
|
+
f"regions with fig.vspan(region)")
|
|
761
|
+
for name in ("limits", "origin"):
|
|
762
|
+
if self.axis.resolve(name) not in (None, "auto"):
|
|
763
|
+
raise ValueError(f"{role} axis over several regions: {name} cannot be set "
|
|
764
|
+
f"(each region is drawn whole)")
|
|
765
|
+
|
|
766
|
+
def _named_limits(self, spec: Any) -> Any:
|
|
767
|
+
"""``limits`` with its category names (``("b", "d")``) as positions that show those
|
|
768
|
+
categories whole."""
|
|
769
|
+
if spec == "auto" or not any(isinstance(s, tuple) and s[0] == "name" for s in spec):
|
|
770
|
+
return spec
|
|
771
|
+
if not self.is_category:
|
|
772
|
+
bad = next(s[1] for s in spec if isinstance(s, tuple) and s[0] == "name")
|
|
773
|
+
raise ValueError(f"limits: {bad!r} is not a value of the {self.axis.role} axis "
|
|
774
|
+
f"(a number, a date or ISO 8601 text)")
|
|
775
|
+
at = [self.value(s[1]) if isinstance(s, tuple) and s[0] == "name" else s
|
|
776
|
+
for s in spec]
|
|
777
|
+
sign = -1.0 if all(isinstance(v, float) for v in at) and at[0] > at[1] else 1.0
|
|
778
|
+
return tuple(v - sign * 0.5 * (1 if i == 0 else -1)
|
|
779
|
+
if isinstance(s, tuple) and s[0] == "name" else v
|
|
780
|
+
for i, (s, v) in enumerate(zip(spec, at)))
|
|
781
|
+
|
|
782
|
+
def _padding(self, name: str | None) -> tuple[float, float]:
|
|
783
|
+
"""Padding setting ``name`` as (fraction of the range, px): `%` is of the range,
|
|
784
|
+
lengths are px, added once the axis length is known."""
|
|
785
|
+
if name is None:
|
|
786
|
+
return 0.0, 0.0
|
|
787
|
+
a = self.axis
|
|
788
|
+
px = a.resolve(name, of=0.0)
|
|
789
|
+
return a.resolve(name, of=1.0) - px, px
|
|
790
|
+
|
|
791
|
+
def _apply_px_padding(self, length: float) -> None:
|
|
792
|
+
"""Add the px part of the padding to the automatic limits of an axis ``length`` px
|
|
793
|
+
long."""
|
|
794
|
+
if not self._pad_px or self.scale is None:
|
|
795
|
+
return
|
|
796
|
+
lo_auto, hi_auto = self._auto
|
|
797
|
+
n = int(lo_auto) + int(hi_auto)
|
|
798
|
+
lo, hi, flo, fhi = self._unpadded_px
|
|
799
|
+
if not n or length <= n * self._pad_px or fhi == flo:
|
|
800
|
+
return
|
|
801
|
+
d = (fhi - flo) * self._pad_px / (length - n * self._pad_px)
|
|
802
|
+
if lo_auto:
|
|
803
|
+
flo -= d
|
|
804
|
+
if self.scale.name == "sqrt" and flo < 0:
|
|
805
|
+
flo = 0.0
|
|
806
|
+
if hi_auto:
|
|
807
|
+
fhi += d
|
|
808
|
+
if not (math.isfinite(self.scale.inverse(flo)) and math.isfinite(self.scale.inverse(fhi))):
|
|
809
|
+
return
|
|
810
|
+
self.flo, self.fhi = flo, fhi
|
|
811
|
+
self.lo, self.hi = self.scale.inverse(flo), self.scale.inverse(fhi)
|
|
812
|
+
self._base = (self.lo, self.hi, self.flo, self.fhi)
|
|
813
|
+
|
|
814
|
+
def _apply_label_room(self, fonts: Any, length: float) -> None:
|
|
815
|
+
"""Widen automatic limits until the labels series draw past their values (outside
|
|
816
|
+
bar labels) fit within an axis ``length`` px long, with the axis padding past them."""
|
|
817
|
+
if not self._roomers or self.scale is None or length <= 0 or not any(self._auto):
|
|
818
|
+
return
|
|
819
|
+
scale = self.scale
|
|
820
|
+
needs = []
|
|
821
|
+
for s in self._roomers:
|
|
822
|
+
t = affine(s, self.axis.role)
|
|
823
|
+
for v, px, d in s.label_room(fonts, self.axis.role):
|
|
824
|
+
if t is not None:
|
|
825
|
+
v, d = apply(t, v), (d if t[1] > 0 else -d)
|
|
826
|
+
f = scale.f(v)
|
|
827
|
+
k = (px + self._pad_px) / length + self._pad_frac
|
|
828
|
+
if math.isfinite(f) and px > 0 and k < 0.5:
|
|
829
|
+
needs.append((f, k, d))
|
|
830
|
+
if not needs:
|
|
831
|
+
return
|
|
832
|
+
lo_auto, hi_auto = self._auto
|
|
833
|
+
flo, fhi = self.flo, self.fhi
|
|
834
|
+
for _ in range(8): # each side's need depends on the other limit
|
|
835
|
+
nlo, nhi = flo, fhi
|
|
836
|
+
for f, k, d in needs:
|
|
837
|
+
if d > 0 and hi_auto:
|
|
838
|
+
nhi = max(nhi, (f - k * flo) / (1 - k))
|
|
839
|
+
elif d < 0 and lo_auto:
|
|
840
|
+
nlo = min(nlo, (f - k * fhi) / (1 - k))
|
|
841
|
+
if (nlo, nhi) == (flo, fhi):
|
|
842
|
+
break
|
|
843
|
+
flo, fhi = nlo, nhi
|
|
844
|
+
if (flo, fhi) == (self.flo, self.fhi) or not (
|
|
845
|
+
math.isfinite(scale.inverse(flo)) and math.isfinite(scale.inverse(fhi))):
|
|
846
|
+
return
|
|
847
|
+
in_lo, in_hi = self._inner # snapping keeps the labels' room
|
|
848
|
+
self._inner = (flo if flo < self.flo else in_lo, fhi if fhi > self.fhi else in_hi)
|
|
849
|
+
self.flo, self.fhi = flo, fhi
|
|
850
|
+
self.lo, self.hi = scale.inverse(flo), scale.inverse(fhi)
|
|
851
|
+
self._base = (self.lo, self.hi, self.flo, self.fhi)
|
|
852
|
+
|
|
853
|
+
def copy_limits(self, other: "AxisState") -> None:
|
|
854
|
+
"""Take the scale, categories and limits of ``other`` (a shared group's leader)."""
|
|
855
|
+
for k in ("scale", "categories", "lo", "hi", "flo", "fhi", "data_lo", "data_hi",
|
|
856
|
+
"segments",
|
|
857
|
+
"auto_invert", "reversed", "_auto", "_zero_pad", "_base", "_inner",
|
|
858
|
+
"_pad_px", "_pad_frac", "_unpadded_px"):
|
|
859
|
+
setattr(self, k, getattr(other, k))
|
|
860
|
+
self.categories = list(other.categories)
|
|
861
|
+
|
|
862
|
+
# -- ticks -------------------------------------------------------------------
|
|
863
|
+
|
|
864
|
+
def tick_font(self) -> Font:
|
|
865
|
+
a = self.axis
|
|
866
|
+
size = self.leaf_size or a.resolve("tick_label_font_size")
|
|
867
|
+
return font_of(a, size, a.resolve("tick_label_weight"),
|
|
868
|
+
color=a.resolve("tick_label_color"), markup=False)
|
|
869
|
+
|
|
870
|
+
def label_font(self) -> Font:
|
|
871
|
+
a = self.axis
|
|
872
|
+
return font_of(a, a.resolve("label_font_size"), a.resolve("label_weight"),
|
|
873
|
+
a.resolve("label_style"), a.resolve("label_color"))
|
|
874
|
+
|
|
875
|
+
def compute_ticks(self, fonts: Any, length: float, horizontal: bool) -> None:
|
|
876
|
+
"""Ticks for an axis ``length`` px long (``horizontal``: labels side by side);
|
|
877
|
+
with ``snap``, automatic limits are then moved onto major ticks."""
|
|
878
|
+
lead = self.shared
|
|
879
|
+
if lead is not None:
|
|
880
|
+
self.copy_limits(lead)
|
|
881
|
+
if lead.length and abs(lead.length - length) <= 0.5:
|
|
882
|
+
self.lo, self.hi, self.flo, self.fhi = lead.lo, lead.hi, lead.flo, lead.fhi
|
|
883
|
+
self.ticks, self.offset_label = list(lead.ticks), lead.offset_label
|
|
884
|
+
self.rotation, self.length = lead.rotation, length
|
|
885
|
+
self.leaf_size = lead.leaf_size
|
|
886
|
+
self.crowded = lead.crowded
|
|
887
|
+
return
|
|
888
|
+
mode = self.axis.get("snap") if self.scale is not None else False
|
|
889
|
+
on = (self.scale is not None and mode is not False and any(self._auto)
|
|
890
|
+
and not self._zero_pad
|
|
891
|
+
and (self.scale.name in ("linear", "logit") or self.scale.base is not None))
|
|
892
|
+
self._apply_px_padding(length)
|
|
893
|
+
self._apply_label_room(fonts, length)
|
|
894
|
+
if on:
|
|
895
|
+
self.lo, self.hi, self.flo, self.fhi = self._base
|
|
896
|
+
self._compute_ticks(fonts, length, horizontal)
|
|
897
|
+
if not on:
|
|
898
|
+
return
|
|
899
|
+
assert self.scale is not None
|
|
900
|
+
base_flo, base_fhi = self._base[2], self._base[3]
|
|
901
|
+
if mode is True:
|
|
902
|
+
back = out = math.inf
|
|
903
|
+
else: # `%` of the axis, px: as position-space distances
|
|
904
|
+
px, frac = self.axis.resolve("snap", of=0.0), self.axis.resolve("snap", of=1.0)
|
|
905
|
+
span = base_fhi - base_flo
|
|
906
|
+
back, out = (((p / length if length > 0 else 0.0) + f - p) * span
|
|
907
|
+
for p, f in zip(px, frac))
|
|
908
|
+
in_lo, in_hi = self._inner
|
|
909
|
+
# The wider range can need a larger step (labels must fit): repeat until stable.
|
|
910
|
+
for _ in range(8):
|
|
911
|
+
majors = sorted(self.major_positions())
|
|
912
|
+
if self.scale.name == "linear":
|
|
913
|
+
if len(majors) < 2:
|
|
914
|
+
return
|
|
915
|
+
step = majors[1] - majors[0]
|
|
916
|
+
else:
|
|
917
|
+
step = 1.0
|
|
918
|
+
flo, fhi = self.flo, self.fhi
|
|
919
|
+
eps = step * 1e-9
|
|
920
|
+
if self._auto[0]:
|
|
921
|
+
outer = math.floor((base_flo + eps) / step) * step
|
|
922
|
+
tick = math.ceil((base_flo - eps) / step) * step
|
|
923
|
+
if base_flo - outer <= out:
|
|
924
|
+
flo = outer
|
|
925
|
+
elif tick - base_flo <= back and tick < in_lo - eps:
|
|
926
|
+
flo = tick
|
|
927
|
+
else:
|
|
928
|
+
flo = base_flo
|
|
929
|
+
if self._auto[1]:
|
|
930
|
+
outer = math.ceil((base_fhi - eps) / step) * step
|
|
931
|
+
tick = math.floor((base_fhi + eps) / step) * step
|
|
932
|
+
if outer - base_fhi <= out:
|
|
933
|
+
fhi = outer
|
|
934
|
+
elif base_fhi - tick <= back and tick > in_hi + eps:
|
|
935
|
+
fhi = tick
|
|
936
|
+
else:
|
|
937
|
+
fhi = base_fhi
|
|
938
|
+
if (flo, fhi) == (self.flo, self.fhi) or not math.isfinite(self.scale.inverse(flo)) \
|
|
939
|
+
or not math.isfinite(self.scale.inverse(fhi)) or not flo < fhi:
|
|
940
|
+
return
|
|
941
|
+
self.flo, self.fhi = flo, fhi
|
|
942
|
+
self.lo, self.hi = self.scale.inverse(flo), self.scale.inverse(fhi)
|
|
943
|
+
self._compute_ticks(fonts, length, horizontal)
|
|
944
|
+
|
|
945
|
+
def minor_grid_positions(self) -> list[float]:
|
|
946
|
+
"""Values of the minor grid lines: the minor ticks, else (linear axes) halfway
|
|
947
|
+
between major ticks."""
|
|
948
|
+
minors = [t.value for t in self.ticks if not t.major]
|
|
949
|
+
if minors or self.scale is None or self.scale.name != "linear":
|
|
950
|
+
return minors
|
|
951
|
+
majors = sorted(t.value for t in self.ticks if t.major)
|
|
952
|
+
if len(majors) < 2:
|
|
953
|
+
return []
|
|
954
|
+
step = majors[1] - majors[0]
|
|
955
|
+
lo, hi = min(self.lo, self.hi), max(self.lo, self.hi)
|
|
956
|
+
out = [m + step / 2 for m in [majors[0] - step] + majors]
|
|
957
|
+
return [v for v in out if lo <= v <= hi]
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _max_ticks(self, length: float, horizontal: bool, time: bool = False) -> int:
|
|
961
|
+
"""``max_ticks``, or the automatic count for an axis of ``length`` px."""
|
|
962
|
+
a = self.axis
|
|
963
|
+
most = a.resolve("max_ticks")
|
|
964
|
+
if most != "auto":
|
|
965
|
+
return int(most)
|
|
966
|
+
spacing = a.resolve("time_tick_spacing" if time else "tick_spacing")[0 if horizontal else 1]
|
|
967
|
+
cap = a.resolve("auto_max_ticks")[1 if time else 0]
|
|
968
|
+
return min(cap, max(2, int(length / spacing) + 1))
|
|
969
|
+
|
|
970
|
+
def value(self, v: Any) -> float:
|
|
971
|
+
"""``v`` in the axis' units: a number (numpy's too), a date or time (``datetime``,
|
|
972
|
+
``date``, ``datetime64`` or ISO 8601 text: seconds since the epoch) or, on a
|
|
973
|
+
category axis, a category by name (its index; numbers are positions)."""
|
|
974
|
+
if self.is_category and not isinstance(v, (int, float)):
|
|
975
|
+
try:
|
|
976
|
+
if v in self.categories:
|
|
977
|
+
return float(self.categories.index(v))
|
|
978
|
+
except (TypeError, ValueError): # values that do not compare to names
|
|
979
|
+
pass
|
|
980
|
+
try:
|
|
981
|
+
return axis_value(v)
|
|
982
|
+
except ValueError:
|
|
983
|
+
what = "a category, a number" if self.is_category else "a number, a date"
|
|
984
|
+
raise ValueError(f"{v!r} is not a value of the {self.axis.role} axis ({what} "
|
|
985
|
+
f"or ISO 8601 text)") from None
|
|
986
|
+
|
|
987
|
+
def formatter(self) -> Any:
|
|
988
|
+
"""The axis' tick label formatter: of categories, times or numbers (``tick_format``;
|
|
989
|
+
a genome axis' unit)."""
|
|
990
|
+
a = self.axis
|
|
991
|
+
fmt = a.resolve("tick_format")
|
|
992
|
+
style = NumberStyle.of(a)
|
|
993
|
+
if self.is_category:
|
|
994
|
+
return CategoryFormatter(self.categories, fmt, style)
|
|
995
|
+
assert self.scale is not None
|
|
996
|
+
if self.scale.name == "time":
|
|
997
|
+
return TimeFormatter(fmt, self.tz)
|
|
998
|
+
if self.genome:
|
|
999
|
+
from .genome.scale import tick_format
|
|
1000
|
+
fmt = tick_format(self) or fmt
|
|
1001
|
+
elif fmt == "chr:pos":
|
|
1002
|
+
raise ValueError("tick_format=\"chr:pos\" needs a genome axis (scale=\"genome\")")
|
|
1003
|
+
return NumberFormatter(fmt, style, a.resolve("sci_limits"))
|
|
1004
|
+
|
|
1005
|
+
def _limits_of(self, f: Any) -> tuple[Any, Any]:
|
|
1006
|
+
"""The arguments of a ticks function ``f(lo, hi)``: the limits low to high, as
|
|
1007
|
+
datetimes on a time axis."""
|
|
1008
|
+
lo, hi = min(self.lo, self.hi), max(self.lo, self.hi)
|
|
1009
|
+
if self.scale is not None and self.scale.name == "time":
|
|
1010
|
+
return from_seconds(lo, self.tz), from_seconds(hi, self.tz)
|
|
1011
|
+
return lo, hi
|
|
1012
|
+
|
|
1013
|
+
def _every(self, e: Every) -> tuple[list[float], list[Any] | None]:
|
|
1014
|
+
"""The values of ``plt.every(step)`` within the limits, and their labels when the
|
|
1015
|
+
step makes them (powers on a log axis)."""
|
|
1016
|
+
lo, hi = min(self.lo, self.hi), max(self.lo, self.hi)
|
|
1017
|
+
role = self.axis.role
|
|
1018
|
+
name = "category" if self.scale is None else self.scale.name
|
|
1019
|
+
if e.unit is not None:
|
|
1020
|
+
if name != "time":
|
|
1021
|
+
raise ValueError(f"{role} axis: {e!r} needs a time axis")
|
|
1022
|
+
unit, size = {"millisecond": ("microsecond", 1000), "week": ("day", 7)}.get(
|
|
1023
|
+
e.unit, (e.unit, 1))
|
|
1024
|
+
return calendar_ticks(lo, hi, unit, int(e.step) * size, self.tz), None
|
|
1025
|
+
base = self.scale.base if self.scale is not None else None
|
|
1026
|
+
if name == "category" or base is not None:
|
|
1027
|
+
if not float(e.step).is_integer():
|
|
1028
|
+
what = "categories" if name == "category" else "powers of the base"
|
|
1029
|
+
raise ValueError(f"{role} axis: {e!r} needs a whole number of {what}")
|
|
1030
|
+
n = int(e.step)
|
|
1031
|
+
if name == "category":
|
|
1032
|
+
return [float(i) for i in range(0, len(self.categories), n)
|
|
1033
|
+
if lo - 1e-9 <= i <= hi + 1e-9], None
|
|
1034
|
+
assert base is not None
|
|
1035
|
+
flo, fhi = min(self.flo, self.fhi), max(self.flo, self.fhi)
|
|
1036
|
+
ks = [k for k in range(math.ceil(flo - 1e-9), math.floor(fhi + 1e-9) + 1)
|
|
1037
|
+
if k % n == 0]
|
|
1038
|
+
return [base ** k for k in ks], power_labels(
|
|
1039
|
+
base, ks, self.axis.resolve("tick_format"), NumberStyle.of(self.axis))
|
|
1040
|
+
return step_values(lo, hi, e.step), None
|
|
1041
|
+
|
|
1042
|
+
def _spec_values(self, spec: Any) -> tuple[list[float], list[Any] | None]:
|
|
1043
|
+
"""The values of ``ticks`` or ``minor_ticks`` given as values, ``plt.every`` or a
|
|
1044
|
+
function ``f(lo, hi)``, in axis units, and labels the step makes."""
|
|
1045
|
+
if isinstance(spec, Every):
|
|
1046
|
+
return self._every(spec)
|
|
1047
|
+
if callable(spec):
|
|
1048
|
+
limits = self._limits_of(spec)
|
|
1049
|
+
try:
|
|
1050
|
+
inspect.signature(spec).bind(*limits)
|
|
1051
|
+
except TypeError as e:
|
|
1052
|
+
raise ValueError(f"{self.axis.role} axis: the function of the ticks or "
|
|
1053
|
+
f"minor_ticks setting takes (lo, hi): {e}") from None
|
|
1054
|
+
except ValueError: # no signature (a builtin): called as is
|
|
1055
|
+
pass
|
|
1056
|
+
got = spec(*limits)
|
|
1057
|
+
if isinstance(got, (str, bytes)) or not hasattr(got, "__iter__"):
|
|
1058
|
+
raise ValueError(f"{self.axis.role} axis: the ticks function returned "
|
|
1059
|
+
f"{got!r}, not a list of values")
|
|
1060
|
+
spec = list(got)
|
|
1061
|
+
return [self.value(v) for v in spec], None
|
|
1062
|
+
|
|
1063
|
+
def _compute_ticks(self, fonts: Any, length: float, horizontal: bool) -> None:
|
|
1064
|
+
if self.segments is not None:
|
|
1065
|
+
from .genome.scale import segment_ticks
|
|
1066
|
+
segment_ticks(self, fonts, length, horizontal)
|
|
1067
|
+
return
|
|
1068
|
+
self.crowded = None
|
|
1069
|
+
self._choose_ticks(fonts, length, horizontal)
|
|
1070
|
+
if not self.call_leaves: # leaves: :meth:`_fit_leaves`
|
|
1071
|
+
self._fit_labels(fonts, length, horizontal)
|
|
1072
|
+
|
|
1073
|
+
def _overlaps(self, fonts: Any, length: float, horizontal: bool) -> bool:
|
|
1074
|
+
"""Whether two neighbouring tick labels, placed as drawn, are closer than
|
|
1075
|
+
``tick_label_gap``."""
|
|
1076
|
+
from .boxes import tick_label_runs
|
|
1077
|
+
from .labels import _overlap, run_quad
|
|
1078
|
+
mapper = self.mapper(0.0, length) if horizontal else self.mapper(length, 0.0)
|
|
1079
|
+
quads = [q for q in (run_quad(fonts, r) for r in
|
|
1080
|
+
tick_label_runs(self, fonts, mapper, 0.0, horizontal, False,
|
|
1081
|
+
True))
|
|
1082
|
+
if q is not None]
|
|
1083
|
+
quads.sort(key=lambda q: sum(p[0 if horizontal else 1] for p in q))
|
|
1084
|
+
half = -float(self.axis.resolve("tick_label_gap")) / 2
|
|
1085
|
+
inner = [_shrunk(q, half) for q in quads]
|
|
1086
|
+
return any(_overlap(p, q) for p, q in zip(inner, inner[1:]))
|
|
1087
|
+
|
|
1088
|
+
def _fit_labels(self, fonts: Any, length: float, horizontal: bool) -> None:
|
|
1089
|
+
"""Tick labels closer than ``tick_label_gap``, chosen or given: every k-th
|
|
1090
|
+
labelled (the others' ticks kept), then turned 45° (``tick_rotation="auto"``, x),
|
|
1091
|
+
then wrapped at spaces (unrotated x labels), then smaller down to
|
|
1092
|
+
``label_min_size``, each with as few labels left out as fit; else one label
|
|
1093
|
+
(``crowded``: the axis warns when drawn)."""
|
|
1094
|
+
if length <= 0:
|
|
1095
|
+
return
|
|
1096
|
+
labelled = [i for i, t in enumerate(self.ticks)
|
|
1097
|
+
if t.major and t.label is not None and t.label != ""]
|
|
1098
|
+
if len(labelled) < 2 or not self._overlaps(fonts, length, horizontal):
|
|
1099
|
+
return
|
|
1100
|
+
a = self.axis
|
|
1101
|
+
full = list(self.ticks)
|
|
1102
|
+
order = sorted(labelled, key=lambda i: self.f(full[i].value))
|
|
1103
|
+
n = len(order)
|
|
1104
|
+
|
|
1105
|
+
def thinned() -> bool:
|
|
1106
|
+
for k in range(1, max(n - 1, 1) + 1):
|
|
1107
|
+
drop = {i for j, i in enumerate(order) if j % k}
|
|
1108
|
+
self.ticks = [Tick(t.value, None, t.major) if i in drop else t
|
|
1109
|
+
for i, t in enumerate(full)]
|
|
1110
|
+
if not self._overlaps(fonts, length, horizontal):
|
|
1111
|
+
return True
|
|
1112
|
+
return False
|
|
1113
|
+
|
|
1114
|
+
if thinned():
|
|
1115
|
+
return
|
|
1116
|
+
if horizontal and not self.rotation and a.resolve("tick_rotation") == "auto":
|
|
1117
|
+
self.rotation = 45.0
|
|
1118
|
+
if thinned():
|
|
1119
|
+
return
|
|
1120
|
+
if horizontal and not self.rotation:
|
|
1121
|
+
room = max(length / max(n, 1), 1.0)
|
|
1122
|
+
font = self.tick_font()
|
|
1123
|
+
wrapped = [Tick(t.value, wrap(fonts, t.label, font, room), t.major)
|
|
1124
|
+
if i in labelled else t for i, t in enumerate(full)]
|
|
1125
|
+
if wrapped != full:
|
|
1126
|
+
full = wrapped
|
|
1127
|
+
if thinned():
|
|
1128
|
+
return
|
|
1129
|
+
base = self.tick_font().size
|
|
1130
|
+
least = min(base, float(a.resolve("label_min_size")))
|
|
1131
|
+
size = base
|
|
1132
|
+
while size > least:
|
|
1133
|
+
size = max(least, size * 0.9)
|
|
1134
|
+
self.leaf_size = size
|
|
1135
|
+
if thinned():
|
|
1136
|
+
return
|
|
1137
|
+
self.ticks = [Tick(t.value, None, t.major) if i in order[1:] else t
|
|
1138
|
+
for i, t in enumerate(full)]
|
|
1139
|
+
self.crowded = size
|
|
1140
|
+
|
|
1141
|
+
def _choose_ticks(self, fonts: Any, length: float, horizontal: bool) -> None:
|
|
1142
|
+
a = self.axis
|
|
1143
|
+
self.length = length
|
|
1144
|
+
self.offset_label = None
|
|
1145
|
+
self.rotation = 0.0
|
|
1146
|
+
self.leaf_size = None
|
|
1147
|
+
given = a.resolve("ticks")
|
|
1148
|
+
font = self.tick_font()
|
|
1149
|
+
if given is False:
|
|
1150
|
+
self.ticks = []
|
|
1151
|
+
return
|
|
1152
|
+
if self.is_category:
|
|
1153
|
+
self._category_ticks(fonts, length, horizontal, font)
|
|
1154
|
+
return
|
|
1155
|
+
assert self.scale is not None
|
|
1156
|
+
if not (math.isfinite(self.fhi - self.flo) and math.isfinite(self.lo)
|
|
1157
|
+
and math.isfinite(self.hi)):
|
|
1158
|
+
origin = a.resolve("origin")
|
|
1159
|
+
raise ValueError(f"{self.axis.role} axis limits {self.lo!r}/{self.hi!r}: the range is "
|
|
1160
|
+
f"not finite on the {self.scale.name} scale"
|
|
1161
|
+
+ (f" (origin {origin!r})" if origin is not None else "")
|
|
1162
|
+
+ (f"; check the series' {self.axis.role}_origin, "
|
|
1163
|
+
f"{self.axis.role}_scale and {self.axis.role}_extent"
|
|
1164
|
+
if self.moved else ""))
|
|
1165
|
+
form = self.formatter()
|
|
1166
|
+
if isinstance(form, TimeFormatter) and given is None:
|
|
1167
|
+
most = self._max_ticks(length, horizontal, time=True)
|
|
1168
|
+
pairs, self.offset_label = form.ticks(self.lo, self.hi, most)
|
|
1169
|
+
self.ticks = [Tick(v, lab) for v, lab in pairs]
|
|
1170
|
+
rot = a.resolve("tick_rotation")
|
|
1171
|
+
self.rotation = 0.0 if rot == "auto" else float(rot)
|
|
1172
|
+
return
|
|
1173
|
+
if given is None and self.call_ticks is not None and a.resolve("tick_labels") is None:
|
|
1174
|
+
self.ticks = [Tick(float(v), lab) for v, lab in self.call_ticks]
|
|
1175
|
+
if self.call_leaves and len(self.ticks) > 1:
|
|
1176
|
+
span = abs(self.fhi - self.flo) or 1.0
|
|
1177
|
+
self.ticks = self._fit_leaves(fonts, self.ticks, length / span, horizontal,
|
|
1178
|
+
font)
|
|
1179
|
+
return
|
|
1180
|
+
if self.call_thin:
|
|
1181
|
+
self._thin_labels(fonts, font, length, horizontal)
|
|
1182
|
+
elif given is not None:
|
|
1183
|
+
labels = self._given_labels()
|
|
1184
|
+
values, made = self._spec_values(given)
|
|
1185
|
+
if labels is not None:
|
|
1186
|
+
_check_count(labels, values, "ticks")
|
|
1187
|
+
elif made is not None:
|
|
1188
|
+
labels = made
|
|
1189
|
+
else:
|
|
1190
|
+
labels, self.offset_label = form(values)
|
|
1191
|
+
self.ticks = [Tick(v, s) for v, s in zip(values, labels)]
|
|
1192
|
+
else:
|
|
1193
|
+
label_gap = a.resolve("tick_label_gap")
|
|
1194
|
+
max_ticks = self._max_ticks(length, horizontal)
|
|
1195
|
+
|
|
1196
|
+
def fits(values: list[float], step: float) -> bool:
|
|
1197
|
+
if len(values) < 2:
|
|
1198
|
+
return True
|
|
1199
|
+
labels = form(values).labels
|
|
1200
|
+
span = self.fhi - self.flo
|
|
1201
|
+
if not span:
|
|
1202
|
+
return True
|
|
1203
|
+
gap = length * abs(self.f(values[1]) - self.f(values[0])) / span
|
|
1204
|
+
if not horizontal: # stacked labels must not overlap
|
|
1205
|
+
return max(lines_box(fonts, s, font)[1] for s in labels) <= gap
|
|
1206
|
+
widest = max(lines_box(fonts, s, font)[0] for s in labels)
|
|
1207
|
+
return widest + label_gap <= gap
|
|
1208
|
+
|
|
1209
|
+
minor = a.resolve("minor_ticks")
|
|
1210
|
+
linear = self.scale.name == "linear"
|
|
1211
|
+
self.ticks, factor = numeric_ticks(
|
|
1212
|
+
self.scale, self.lo, self.hi, max_ticks, form.fmt, form.style,
|
|
1213
|
+
form.sci_limits, minor=minor is not False, length=length,
|
|
1214
|
+
fits=fits if linear else None,
|
|
1215
|
+
min_step=1.0 if self.whole or self.genome else 0.0)
|
|
1216
|
+
if linear: # labelled together: the additive offset
|
|
1217
|
+
values = [t.value for t in self.ticks if t.major]
|
|
1218
|
+
labels, self.offset_label = form(values)
|
|
1219
|
+
self.ticks = [Tick(v, s) for v, s in zip(values, labels)] + [
|
|
1220
|
+
t for t in self.ticks if not t.major]
|
|
1221
|
+
else:
|
|
1222
|
+
self.offset_label = form.offset(factor)
|
|
1223
|
+
if minor is True and linear:
|
|
1224
|
+
majors = [t.value for t in self.ticks if t.major]
|
|
1225
|
+
if len(majors) >= 2:
|
|
1226
|
+
step = majors[1] - majors[0]
|
|
1227
|
+
have = set(majors)
|
|
1228
|
+
self.ticks += [Tick(v, None, False) for v in minor_linear(self.lo, self.hi, step)
|
|
1229
|
+
if not any(abs(v - m) < step * 1e-6 for m in have)]
|
|
1230
|
+
minors = a.resolve("minor_ticks")
|
|
1231
|
+
if not isinstance(minors, bool) and minors is not None:
|
|
1232
|
+
self.ticks += [Tick(v, None, False) for v in self._spec_values(minors)[0]]
|
|
1233
|
+
lo, hi = min(self.flo, self.fhi), max(self.flo, self.fhi)
|
|
1234
|
+
eps = (hi - lo) * 1e-9
|
|
1235
|
+
self.ticks = [t for t in self.ticks
|
|
1236
|
+
if lo - eps <= self.f(t.value) <= hi + eps]
|
|
1237
|
+
rot = a.resolve("tick_rotation")
|
|
1238
|
+
self.rotation = 0.0 if rot == "auto" else float(rot)
|
|
1239
|
+
|
|
1240
|
+
def _thin_labels(self, fonts: Any, font: Font, length: float, horizontal: bool) -> None:
|
|
1241
|
+
"""Drop the labels that collide: from the low end, a label is kept when it clears
|
|
1242
|
+
the last kept one by ``tick_label_gap``; the others' ticks stay, unlabelled."""
|
|
1243
|
+
span = self.fhi - self.flo
|
|
1244
|
+
if len(self.ticks) < 2 or not span or length <= 0:
|
|
1245
|
+
return
|
|
1246
|
+
gap = self.axis.resolve("tick_label_gap")
|
|
1247
|
+
out, last = [], None
|
|
1248
|
+
for t in sorted(self.ticks, key=lambda t: self.f(t.value)):
|
|
1249
|
+
if t.label in (None, ""):
|
|
1250
|
+
out.append(t)
|
|
1251
|
+
continue
|
|
1252
|
+
at = length * (self.f(t.value) - self.flo) / span
|
|
1253
|
+
half = lines_box(fonts, t.label, font)[0 if horizontal else 1] / 2
|
|
1254
|
+
if last is not None and at - half < last + gap:
|
|
1255
|
+
out.append(Tick(t.value, None, t.major))
|
|
1256
|
+
continue
|
|
1257
|
+
out.append(t)
|
|
1258
|
+
last = at + half
|
|
1259
|
+
self.ticks = out
|
|
1260
|
+
|
|
1261
|
+
def _category_index(self, v: Any) -> int:
|
|
1262
|
+
"""The index of a category given in ``ticks``: by name, else by index."""
|
|
1263
|
+
x = self.value(v)
|
|
1264
|
+
if x.is_integer() and 0 <= x < len(self.categories):
|
|
1265
|
+
return int(x)
|
|
1266
|
+
raise ValueError(f"ticks: {v!r} is neither a category of the {self.axis.role} axis "
|
|
1267
|
+
f"nor the index of one")
|
|
1268
|
+
|
|
1269
|
+
def _given_labels(self) -> list[Any] | None:
|
|
1270
|
+
"""``tick_labels``, their markup read (unless ``markup`` is off)."""
|
|
1271
|
+
labels = self.axis.resolve("tick_labels")
|
|
1272
|
+
if labels is None or not self.axis.resolve("markup"):
|
|
1273
|
+
return labels
|
|
1274
|
+
from .markup import parse
|
|
1275
|
+
return [parse(x) for x in labels]
|
|
1276
|
+
|
|
1277
|
+
def _category_ticks(self, fonts: Any, length: float, horizontal: bool, font: Font) -> None:
|
|
1278
|
+
a = self.axis
|
|
1279
|
+
given = a.resolve("ticks")
|
|
1280
|
+
index = list(range(len(self.categories))) if given is None else [
|
|
1281
|
+
self._category_index(v) for v in self._spec_values(given)[0]]
|
|
1282
|
+
labels = self._given_labels()
|
|
1283
|
+
if labels is not None:
|
|
1284
|
+
_check_count(labels, index, "categories" if given is None else "ticks")
|
|
1285
|
+
else:
|
|
1286
|
+
labels = self.formatter()(index).labels
|
|
1287
|
+
lo, hi = min(self.lo, self.hi), max(self.lo, self.hi)
|
|
1288
|
+
ticks = sorted((Tick(float(i), lab) for i, lab in zip(index, labels)
|
|
1289
|
+
if lo - 1e-9 <= i <= hi + 1e-9), key=lambda t: t.value)
|
|
1290
|
+
labels = [t.label for t in ticks]
|
|
1291
|
+
span = abs(self.fhi - self.flo) or 1.0
|
|
1292
|
+
step = length / span # px per category
|
|
1293
|
+
rot = a.resolve("tick_rotation")
|
|
1294
|
+
gap = a.resolve("tick_label_gap")
|
|
1295
|
+
rotated, stacked = a.resolve("category_label_spacing")
|
|
1296
|
+
if not ticks:
|
|
1297
|
+
self.ticks = []
|
|
1298
|
+
return
|
|
1299
|
+
if self.call_leaves:
|
|
1300
|
+
self.ticks = self._fit_leaves(fonts, ticks, step, horizontal, font)
|
|
1301
|
+
return
|
|
1302
|
+
if horizontal:
|
|
1303
|
+
widest = max(lines_box(fonts, lab, font)[0] for lab in labels)
|
|
1304
|
+
if rot == "auto":
|
|
1305
|
+
rot = 45.0 if widest + gap > step else 0.0
|
|
1306
|
+
need = (widest + gap) if rot == 0 else rotated
|
|
1307
|
+
else:
|
|
1308
|
+
rot = 0.0 if rot == "auto" else rot
|
|
1309
|
+
need = stacked
|
|
1310
|
+
self.rotation = float(rot)
|
|
1311
|
+
every = max(1, math.ceil(need / step)) if step > 0 else 1
|
|
1312
|
+
if every > 1:
|
|
1313
|
+
ticks = [t for i, t in enumerate(ticks) if i % every == 0]
|
|
1314
|
+
self.ticks = ticks
|
|
1315
|
+
|
|
1316
|
+
def _fit_leaves(self, fonts: Any, ticks: list[Tick], step: float, horizontal: bool,
|
|
1317
|
+
font: Font) -> list[Tick]:
|
|
1318
|
+
"""A dendrogram's leaf labels, ``step`` px apart: flat when they fit, else rotated
|
|
1319
|
+
90° (x; ``tick_rotation="auto"``), then at a smaller font size (down to
|
|
1320
|
+
``label_min_size``), and only then every k-th."""
|
|
1321
|
+
a = self.axis
|
|
1322
|
+
rot = a.resolve("tick_rotation")
|
|
1323
|
+
gap = a.resolve("tick_label_gap")
|
|
1324
|
+
rotated, stacked = a.resolve("category_label_spacing")
|
|
1325
|
+
if horizontal:
|
|
1326
|
+
widest = max(lines_box(fonts, t.label, font)[0] for t in ticks)
|
|
1327
|
+
if rot == "auto":
|
|
1328
|
+
rot = 0.0 if widest + gap <= step else 90.0
|
|
1329
|
+
# at 90° the labels lie side by side as stacked lines do
|
|
1330
|
+
need = (widest + gap) if rot == 0 else stacked if abs(rot) == 90 else rotated
|
|
1331
|
+
else:
|
|
1332
|
+
rot = 0.0 if rot == "auto" else rot
|
|
1333
|
+
need = stacked
|
|
1334
|
+
self.rotation = float(rot)
|
|
1335
|
+
if step <= 0:
|
|
1336
|
+
return ticks
|
|
1337
|
+
base = font.size
|
|
1338
|
+
if need > step:
|
|
1339
|
+
size = min(base, max(a.resolve("label_min_size"), base * step / need))
|
|
1340
|
+
if size < base:
|
|
1341
|
+
self.leaf_size = size
|
|
1342
|
+
need *= size / base
|
|
1343
|
+
every = max(1, math.ceil(need / step - 1e-9))
|
|
1344
|
+
return ticks if every == 1 else [t for i, t in enumerate(ticks) if i % every == 0]
|
|
1345
|
+
|
|
1346
|
+
def major_positions(self) -> list[float]:
|
|
1347
|
+
return [self.f(t.value) for t in self.ticks if t.major]
|