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/ticks.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Tick choice and tick labels.
|
|
2
|
+
|
|
3
|
+
Every function works on data values and returns :class:`Tick` lists; positions on the
|
|
4
|
+
page are the axis's business. Labels are strings or rich content (exponents).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
from collections.abc import Callable, Sequence
|
|
9
|
+
from typing import Any, NamedTuple
|
|
10
|
+
|
|
11
|
+
from .numbers import NumberStyle, format_axis
|
|
12
|
+
from .scales import Scale
|
|
13
|
+
|
|
14
|
+
__all__ = ["Tick", "linear_ticks", "nice_step", "numeric_ticks", "power_labels",
|
|
15
|
+
"step_values", "thin_by_position", "minor_linear", "exponent_label", "Every",
|
|
16
|
+
"every", "LOG_BASES"]
|
|
17
|
+
|
|
18
|
+
_MANTISSAS = (1.0, 2.0, 2.5, 5.0)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Tick(NamedTuple):
|
|
22
|
+
value: float
|
|
23
|
+
label: Any # str, rich content, or None (unlabelled)
|
|
24
|
+
major: bool = True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def nice_step(raw: float) -> float:
|
|
28
|
+
"""The smallest step 1, 2, 2.5 or 5 × 10^k that is at least ``raw``."""
|
|
29
|
+
if not raw > 0 or not math.isfinite(raw):
|
|
30
|
+
return 1.0
|
|
31
|
+
k = math.floor(math.log10(raw))
|
|
32
|
+
for m in _MANTISSAS + (10.0,):
|
|
33
|
+
s = m * 10.0 ** k
|
|
34
|
+
if s >= raw * (1 - 1e-9):
|
|
35
|
+
return s
|
|
36
|
+
return 10.0 ** (k + 1)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _next_step(step: float) -> float:
|
|
40
|
+
k = math.floor(math.log10(step) + 1e-9)
|
|
41
|
+
m = step / 10.0 ** k
|
|
42
|
+
for c in _MANTISSAS + (10.0,):
|
|
43
|
+
if c > m * (1 + 1e-9):
|
|
44
|
+
return c * 10.0 ** k
|
|
45
|
+
return 2.0 * 10.0 ** (k + 1)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
#: The most tick values one call makes.
|
|
49
|
+
MAX_TICKS = 100_000
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _values(lo: float, hi: float, step: float) -> list[float]:
|
|
53
|
+
eps = step * 1e-9
|
|
54
|
+
a, b = (lo - eps) / step, (hi + eps) / step
|
|
55
|
+
if not (math.isfinite(a) and math.isfinite(b)) or b - a > MAX_TICKS:
|
|
56
|
+
raise ValueError(f"ticks every {step:g} from {lo:g} to {hi:g}: more than "
|
|
57
|
+
f"{MAX_TICKS} ticks")
|
|
58
|
+
start = math.ceil(a)
|
|
59
|
+
end = math.floor(b)
|
|
60
|
+
digits = 2 - math.floor(math.log10(step)) # rounded relative to the step
|
|
61
|
+
return [round(i * step, digits) + 0.0 for i in range(start, end + 1)]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def linear_ticks(lo: float, hi: float, max_ticks: int,
|
|
65
|
+
fits: Callable[[list[float], float], bool] | None = None,
|
|
66
|
+
min_step: float = 0.0) -> tuple[list[float], float]:
|
|
67
|
+
"""Tick values in [lo, hi]: the smallest nice step giving at most ``max_ticks``
|
|
68
|
+
ticks (and for which ``fits(values, step)`` holds); with ``min_step``, a whole multiple
|
|
69
|
+
of it (``1``: integers only). Returns ``(values, step)``."""
|
|
70
|
+
if hi < lo:
|
|
71
|
+
lo, hi = hi, lo
|
|
72
|
+
if hi == lo:
|
|
73
|
+
return [lo], 1.0
|
|
74
|
+
n = max(2, int(max_ticks))
|
|
75
|
+
step = max(nice_step((hi - lo) / n), min_step)
|
|
76
|
+
for _ in range(60):
|
|
77
|
+
whole = not min_step or abs(step / min_step - round(step / min_step)) < 1e-9
|
|
78
|
+
if whole:
|
|
79
|
+
vals = _values(lo, hi, step)
|
|
80
|
+
if len(vals) <= n and (fits is None or fits(vals, step)):
|
|
81
|
+
return vals, step
|
|
82
|
+
step = _next_step(step)
|
|
83
|
+
return _values(lo, hi, step), step
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def minor_linear(lo: float, hi: float, step: float) -> list[float]:
|
|
87
|
+
k = math.floor(math.log10(step) + 1e-9)
|
|
88
|
+
m = round(step / 10.0 ** k, 6)
|
|
89
|
+
sub = step / (4 if m == 2.0 else 5)
|
|
90
|
+
return _values(lo, hi, sub)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Ticks per scale
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def exponent_label(base: str, k: int, style: NumberStyle | None = None) -> Any:
|
|
98
|
+
from .text import superscript
|
|
99
|
+
style = style or NumberStyle()
|
|
100
|
+
return style.wrap([base, superscript(style.exponent(k))])
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _int_powers(flo: float, fhi: float) -> list[int]:
|
|
104
|
+
return list(range(math.ceil(flo - 1e-9), math.floor(fhi + 1e-9) + 1))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _thin_powers(ks: list[int], max_ticks: int, base: int = 1) -> tuple[list[int], int]:
|
|
108
|
+
"""Every k-th power so that at most ``max_ticks`` remain (k a power of two when
|
|
109
|
+
``base`` is 2, else any integer)."""
|
|
110
|
+
every = 1
|
|
111
|
+
while len([k for k in ks if k % every == 0]) > max(2, max_ticks):
|
|
112
|
+
every = every * 2 if base == 2 else every + 1
|
|
113
|
+
return [k for k in ks if k % every == 0], every
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _each(values: list[float], fmt: Any, style: NumberStyle) -> list[Any]:
|
|
117
|
+
"""Labels formatted one by one (log and other non-linear ticks)."""
|
|
118
|
+
if fmt != "auto":
|
|
119
|
+
return format_axis(values, fmt, style, None)[0]
|
|
120
|
+
return [format_axis([v], "auto", style, None)[0][0] for v in values]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
#: The label of each logarithmic base in power labels.
|
|
124
|
+
LOG_BASES = {math.e: "e", 2.0: "2", 10.0: "10"}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def power_labels(base: float, ks: list[int], fmt: Any = "auto",
|
|
128
|
+
style: NumberStyle | None = None) -> list[Any]:
|
|
129
|
+
"""Labels of the powers ``base**k`` of a log scale: ``10ᵏ``; in base 2 the numbers up
|
|
130
|
+
to 2¹⁰, else ``2ᵏ``; ``eᵏ`` (``1`` at 0); in ``fmt`` when it is set."""
|
|
131
|
+
style = style or NumberStyle()
|
|
132
|
+
if fmt != "auto":
|
|
133
|
+
return _each([base ** k for k in ks], fmt, style)
|
|
134
|
+
out: list[Any] = []
|
|
135
|
+
for k in ks:
|
|
136
|
+
if base == 10.0:
|
|
137
|
+
out.append(exponent_label("10", k, style))
|
|
138
|
+
elif base == 2.0:
|
|
139
|
+
out.append(_each([base ** k], "auto", style)[0] if -10 <= k <= 10
|
|
140
|
+
else exponent_label("2", k, style))
|
|
141
|
+
else:
|
|
142
|
+
out.append(style.wrap("1") if k == 0
|
|
143
|
+
else exponent_label(LOG_BASES[base], k, style))
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def step_values(lo: float, hi: float, step: float) -> list[float]:
|
|
148
|
+
"""The multiples of ``step`` from ``lo`` to ``hi`` (rounded relative to the step)."""
|
|
149
|
+
if not (step > 0 and math.isfinite(step)):
|
|
150
|
+
raise ValueError(f"expected a positive step, got {step!r}")
|
|
151
|
+
return _values(min(lo, hi), max(lo, hi), step)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class Every:
|
|
155
|
+
"""Ticks every ``step`` (:func:`every`)."""
|
|
156
|
+
|
|
157
|
+
__slots__ = ("step", "unit")
|
|
158
|
+
|
|
159
|
+
def __init__(self, step: float, unit: str | None) -> None:
|
|
160
|
+
self.step, self.unit = step, unit
|
|
161
|
+
|
|
162
|
+
def __repr__(self) -> str:
|
|
163
|
+
if self.unit is None:
|
|
164
|
+
return f"every({self.step:g})"
|
|
165
|
+
return f"every({str(int(self.step)) + ' ' + self.unit + 's'!r})"
|
|
166
|
+
|
|
167
|
+
def __eq__(self, other: object) -> bool:
|
|
168
|
+
return isinstance(other, Every) and (self.step, self.unit) == (other.step, other.unit)
|
|
169
|
+
|
|
170
|
+
def __hash__(self) -> int:
|
|
171
|
+
return hash((self.step, self.unit))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
#: Calendar units of :func:`every` and their size in the units of the time locator.
|
|
175
|
+
_UNITS = {"microsecond": ("microsecond", 1), "millisecond": ("microsecond", 1000),
|
|
176
|
+
"second": ("second", 1), "minute": ("minute", 1), "hour": ("hour", 1),
|
|
177
|
+
"day": ("day", 1), "week": ("day", 7), "month": ("month", 1), "year": ("year", 1)}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def every(step: Any) -> Every:
|
|
181
|
+
"""Ticks every ``step`` for ``ticks=`` and ``minor_ticks=``: a number in axis units
|
|
182
|
+
(on a log axis in powers of its base: ``every(1)`` is every decade; on a category axis
|
|
183
|
+
every n-th category; on a time axis seconds, or a ``timedelta``), or a calendar step
|
|
184
|
+
on a time axis: ``"2 months"``, ``"week"``, ``"15 minutes"`` (units ``microsecond``,
|
|
185
|
+
``millisecond``, ``second``, ``minute``, ``hour``, ``day``, ``week``, ``month``,
|
|
186
|
+
``year``)."""
|
|
187
|
+
import datetime as dt
|
|
188
|
+
from .validators import real
|
|
189
|
+
if isinstance(step, dt.timedelta):
|
|
190
|
+
step = step.total_seconds()
|
|
191
|
+
if isinstance(step, str):
|
|
192
|
+
parts = step.split()
|
|
193
|
+
if len(parts) in (1, 2):
|
|
194
|
+
count = parts[0] if len(parts) == 2 else "1"
|
|
195
|
+
unit = parts[-1].lower()
|
|
196
|
+
unit = unit[:-1] if unit.endswith("s") and unit[:-1] in _UNITS else unit
|
|
197
|
+
if unit in _UNITS and count.isdigit() and int(count) > 0:
|
|
198
|
+
return Every(float(count), unit)
|
|
199
|
+
raise ValueError(f"every: expected a number or a calendar step such as "
|
|
200
|
+
f"'2 months' (units {', '.join(_UNITS)}), got {step!r}")
|
|
201
|
+
x = real(step)
|
|
202
|
+
if x is None or not (x > 0 and math.isfinite(x)):
|
|
203
|
+
raise ValueError(f"every: expected a positive number, got {step!r}")
|
|
204
|
+
return Every(x, None)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _no_quarters(fits: Callable[[list[float], float], bool] | None
|
|
208
|
+
) -> Callable[[list[float], float], bool]:
|
|
209
|
+
"""``fits`` refusing steps of 2.5 × 10^k (labels with a set precision round them)."""
|
|
210
|
+
def out(vals: list[float], step: float) -> bool:
|
|
211
|
+
m = step / 10.0 ** math.floor(math.log10(step) + 1e-9)
|
|
212
|
+
return abs(m - 2.5) > 1e-6 and (fits is None or fits(vals, step))
|
|
213
|
+
return out
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def numeric_ticks(scale: Scale, lo: float, hi: float, max_ticks: int, fmt: Any = "auto",
|
|
217
|
+
style: NumberStyle | None = None, sci_limits: tuple[int, int] = (-4, 5),
|
|
218
|
+
minor: bool = True, length: float = 0.0,
|
|
219
|
+
fits: Callable[[list[float], float], bool] | None = None,
|
|
220
|
+
min_step: float = 0.0) -> tuple[list[Tick], int]:
|
|
221
|
+
"""Ticks of a numeric scale between data limits ``lo`` < ``hi``, labelled with the
|
|
222
|
+
number format ``fmt``; a linear scale's step is a whole multiple of ``min_step``.
|
|
223
|
+
Returns the ticks and the exponent of a common ×10ᵏ factor (0: none)."""
|
|
224
|
+
style = style or NumberStyle()
|
|
225
|
+
if hi < lo:
|
|
226
|
+
lo, hi = hi, lo
|
|
227
|
+
name = scale.name
|
|
228
|
+
flo, fhi = scale.f(lo), scale.f(hi)
|
|
229
|
+
base_num = scale.base
|
|
230
|
+
if base_num is not None:
|
|
231
|
+
ks = _int_powers(flo, fhi)
|
|
232
|
+
if base_num == 10.0 and 1 <= len(ks) < 3:
|
|
233
|
+
values = [m * 10.0 ** k for k in range(min(ks, default=0) - 1, max(ks, default=0) + 1)
|
|
234
|
+
for m in (1, 2, 5) if lo * (1 - 1e-9) <= m * 10.0 ** k <= hi * (1 + 1e-9)]
|
|
235
|
+
if len(values) >= 3 or len(values) > len(ks):
|
|
236
|
+
ticks = [Tick(v, s) for v, s in zip(values, _each(values, fmt, style))]
|
|
237
|
+
if minor:
|
|
238
|
+
ticks += [Tick(m * 10.0 ** k, None, False)
|
|
239
|
+
for k in range(min(ks) - 1, max(ks) + 1)
|
|
240
|
+
for m in (3, 4, 6, 7, 8, 9)
|
|
241
|
+
if lo <= m * 10.0 ** k <= hi]
|
|
242
|
+
return ticks, 0
|
|
243
|
+
if len(ks) >= 2:
|
|
244
|
+
kept, every = _thin_powers(ks, max_ticks, 2 if base_num == 2.0 else 1)
|
|
245
|
+
powers = dict(zip(kept, power_labels(base_num, kept, fmt, style)))
|
|
246
|
+
ticks = [Tick(base_num ** k, powers[k]) if k in powers
|
|
247
|
+
else Tick(base_num ** k, None, False) for k in ks]
|
|
248
|
+
if base_num == 10.0 and minor and every == 1 and len(ks) <= 8:
|
|
249
|
+
ticks += [Tick(m * 10.0 ** k, None, False) for k in range(ks[0] - 1, ks[-1] + 1)
|
|
250
|
+
for m in range(2, 10) if lo <= m * 10.0 ** k <= hi]
|
|
251
|
+
return ticks, 0
|
|
252
|
+
# Less than two powers in range: nice values in data space.
|
|
253
|
+
if name == "linear" or base_num is not None:
|
|
254
|
+
if style.precision is not None: # 0.25 at one digit would read 0.3
|
|
255
|
+
fits = _no_quarters(fits)
|
|
256
|
+
values, step = linear_ticks(lo, hi, max_ticks, fits,
|
|
257
|
+
min_step if name == "linear" else 0.0)
|
|
258
|
+
values = [v for v in values if scale.contains(v)]
|
|
259
|
+
labels, exp = format_axis(values, fmt, style, sci_limits)
|
|
260
|
+
return [Tick(v, s) for v, s in zip(values, labels)], exp
|
|
261
|
+
if name == "symlog":
|
|
262
|
+
return _symlog_ticks(scale, lo, hi, max_ticks, fmt, style, minor, length)
|
|
263
|
+
if name == "logit":
|
|
264
|
+
return _logit_ticks(scale, lo, hi, max_ticks, fmt, style, length)
|
|
265
|
+
# sqrt, exp and other monotone scales: the smallest nice step whose ticks are at least
|
|
266
|
+
# `length / max_ticks` apart on the page.
|
|
267
|
+
spacing = length / max(max_ticks, 1)
|
|
268
|
+
|
|
269
|
+
def spaced(vals: list[float], step: float) -> bool:
|
|
270
|
+
vals = [v for v in vals if scale.contains(v)]
|
|
271
|
+
if length <= 0 or len(vals) < 2 or fhi == flo:
|
|
272
|
+
return True
|
|
273
|
+
pos = [(scale.f(v) - flo) / (fhi - flo) * length for v in vals]
|
|
274
|
+
return min(abs(b - a) for a, b in zip(pos, pos[1:])) >= spacing - 1e-9
|
|
275
|
+
|
|
276
|
+
values, step = linear_ticks(lo, hi, max(2 * max_ticks, 4), spaced)
|
|
277
|
+
values = [v for v in values if scale.contains(v)]
|
|
278
|
+
return [Tick(v, s) for v, s in zip(values, _each(values, fmt, style))], 0
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def thin_by_position(pos: Sequence[float], spacing: float) -> list[int]:
|
|
282
|
+
"""Indices of increasing positions kept so that neighbours are at least ``spacing``
|
|
283
|
+
apart. Walks from the end where the scale stretches the data most (the sparse end),
|
|
284
|
+
always keeping both ends."""
|
|
285
|
+
n = len(pos)
|
|
286
|
+
if n <= 2:
|
|
287
|
+
return list(range(n))
|
|
288
|
+
order = list(range(n))
|
|
289
|
+
if pos[-1] - pos[-2] > pos[1] - pos[0]:
|
|
290
|
+
order.reverse() # sparse at the high end: walk from there
|
|
291
|
+
kept = [order[0]]
|
|
292
|
+
for i in order[1:-1]:
|
|
293
|
+
if abs(pos[i] - pos[kept[-1]]) >= spacing and abs(pos[order[-1]] - pos[i]) >= spacing:
|
|
294
|
+
kept.append(i)
|
|
295
|
+
kept.append(order[-1])
|
|
296
|
+
return sorted(kept)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _thin(scale: Scale, values: list[float], lo: float, hi: float, max_ticks: int,
|
|
300
|
+
length: float) -> list[float]:
|
|
301
|
+
flo, fhi = scale.f(lo), scale.f(hi)
|
|
302
|
+
if length > 0 and len(values) > 2 and fhi != flo:
|
|
303
|
+
pos = [(scale.f(v) - flo) / (fhi - flo) * length for v in values]
|
|
304
|
+
keep = thin_by_position(pos, length / max(max_ticks, 1))
|
|
305
|
+
values = [values[i] for i in keep]
|
|
306
|
+
return values
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _thin_symlog(scale: Scale, values: list[float], lo: float, hi: float, max_ticks: int,
|
|
310
|
+
length: float) -> list[float]:
|
|
311
|
+
"""Symlog ticks kept from 0 outwards on each side (0 and the decades nearest to it
|
|
312
|
+
first), neighbours at least ``length / max_ticks`` apart, both ends kept."""
|
|
313
|
+
flo, fhi = scale.f(lo), scale.f(hi)
|
|
314
|
+
if length <= 0 or len(values) <= 2 or fhi == flo:
|
|
315
|
+
return values
|
|
316
|
+
spacing = length / max(max_ticks, 1)
|
|
317
|
+
pos = {v: (scale.f(v) - flo) / (fhi - flo) * length for v in values}
|
|
318
|
+
center = min(values, key=abs)
|
|
319
|
+
kept = [center]
|
|
320
|
+
for side in ([v for v in values if v > center], [v for v in reversed(values) if v < center]):
|
|
321
|
+
last = center
|
|
322
|
+
for i, v in enumerate(side):
|
|
323
|
+
end = side[-1]
|
|
324
|
+
if v is end or (abs(pos[v] - pos[last]) >= spacing
|
|
325
|
+
and abs(pos[end] - pos[v]) >= spacing):
|
|
326
|
+
if v is end and abs(pos[v] - pos[last]) < spacing and last is not center:
|
|
327
|
+
kept.remove(last)
|
|
328
|
+
kept.append(v)
|
|
329
|
+
last = v
|
|
330
|
+
return sorted(kept)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _symlog_ticks(scale: Scale, lo: float, hi: float, max_ticks: int, fmt: Any,
|
|
334
|
+
style: NumberStyle, minor: bool, length: float) -> tuple[list[Tick], int]:
|
|
335
|
+
"""0, ±linthresh, then decades beyond; linear minor ticks inside the linear region."""
|
|
336
|
+
c = scale.options.get("linthresh", 1.0)
|
|
337
|
+
eps = (hi - lo) * 1e-9 + 1e-300
|
|
338
|
+
majors = [0.0] if lo - eps <= 0.0 <= hi + eps else []
|
|
339
|
+
for sign in (1.0, -1.0):
|
|
340
|
+
k = 0
|
|
341
|
+
while k < 400:
|
|
342
|
+
v = sign * c * 10.0 ** k
|
|
343
|
+
if not lo - eps <= v <= hi + eps:
|
|
344
|
+
if (sign > 0 and v > hi) or (sign < 0 and v < lo):
|
|
345
|
+
break
|
|
346
|
+
else:
|
|
347
|
+
majors.append(v)
|
|
348
|
+
k += 1
|
|
349
|
+
if len(majors) < 2: # inside the linear region: linear ticks
|
|
350
|
+
values, lin = linear_ticks(lo, hi, max_ticks)
|
|
351
|
+
labels = format_axis(values, fmt, style, None)[0] # together: shared decimals
|
|
352
|
+
ticks = [Tick(v, s) for v, s in zip(values, labels)]
|
|
353
|
+
if minor and len(values) >= 2:
|
|
354
|
+
ticks += [Tick(v, None, False) for v in minor_linear(lo, hi, lin)
|
|
355
|
+
if not any(abs(v - m) < lin * 1e-6 for m in values)]
|
|
356
|
+
return ticks, 0
|
|
357
|
+
majors = _thin_symlog(scale, sorted(set(majors)), lo, hi, max_ticks, length)
|
|
358
|
+
ticks = [Tick(v, s) for v, s in zip(majors, _each(majors, fmt, style))]
|
|
359
|
+
if minor:
|
|
360
|
+
have = set(majors)
|
|
361
|
+
step = _symlog_minor_step(scale, c)
|
|
362
|
+
if step is not None:
|
|
363
|
+
n = int(round(c / step))
|
|
364
|
+
for i in range(-n + 1, n):
|
|
365
|
+
v = i * step
|
|
366
|
+
if i and v not in have and lo <= v <= hi:
|
|
367
|
+
ticks.append(Tick(v, None, False))
|
|
368
|
+
return ticks, 0
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
#: Smallest gap between minor ticks in the linear region of a symlog axis, in scale units
|
|
372
|
+
#: (decades): the gap between minor ticks 4 and 5 of a log decade.
|
|
373
|
+
_SYMLOG_MINOR_GAP = math.log10(1.25)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _symlog_minor_step(scale: Scale, c: float) -> float | None:
|
|
377
|
+
"""The finest nice step (c/10, c/5, c/4, c/2) whose ticks in (−c, c) stay at least
|
|
378
|
+
``_SYMLOG_MINOR_GAP`` apart on the axis, or ``None`` when no step fits."""
|
|
379
|
+
if 2.0 * scale.f(c) < 2.0 * _SYMLOG_MINOR_GAP:
|
|
380
|
+
return None
|
|
381
|
+
for div in (10, 5, 4, 2):
|
|
382
|
+
step = c / div
|
|
383
|
+
pos = [scale.f(i * step) for i in range(0, div + 1)]
|
|
384
|
+
if min(b - a for a, b in zip(pos, pos[1:])) >= _SYMLOG_MINOR_GAP:
|
|
385
|
+
return step
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _logit_ticks(scale: Scale, lo: float, hi: float, max_ticks: int, fmt: Any,
|
|
390
|
+
style: NumberStyle, length: float) -> tuple[list[Tick], int]:
|
|
391
|
+
"""0.5, then 0.1/0.9, 0.01/0.99, … inside the limits."""
|
|
392
|
+
values = [0.5]
|
|
393
|
+
for k in range(1, 16):
|
|
394
|
+
values += [10.0 ** -k, 1.0 - 10.0 ** -k]
|
|
395
|
+
values = sorted(v for v in values if lo <= v <= hi)
|
|
396
|
+
if len(values) < 2:
|
|
397
|
+
values, _ = linear_ticks(lo, hi, max_ticks)
|
|
398
|
+
values = [v for v in values if 0 < v < 1]
|
|
399
|
+
values = _thin(scale, values, lo, hi, max_ticks, length)
|
|
400
|
+
return [Tick(v, s) for v, s in zip(values, _each(values, fmt, style))], 0
|
plot_io/times.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Dates and times on axes.
|
|
2
|
+
|
|
3
|
+
Values (``datetime``, ``date``, numpy ``datetime64`` arrays, pandas timestamps) become
|
|
4
|
+
float seconds since 1970-01-01 UTC, so scales, limits and the C mapping are unchanged; the
|
|
5
|
+
axis (``scale="time"``, set by date data) chooses calendar-aware ticks and concise labels.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import array
|
|
9
|
+
import calendar
|
|
10
|
+
import datetime as dt
|
|
11
|
+
import math
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
__all__ = ["is_time_like", "to_seconds", "seconds", "time_ticks", "time_labels", "time_unit",
|
|
15
|
+
"from_seconds", "axis_value", "calendar_ticks"]
|
|
16
|
+
|
|
17
|
+
_UTC = dt.timezone.utc
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _first(v: Any) -> Any:
|
|
21
|
+
for x in v:
|
|
22
|
+
if x is not None and not (isinstance(x, float) and math.isnan(x)):
|
|
23
|
+
return x
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_time_like(v: Any) -> bool:
|
|
28
|
+
"""Whether ``v`` is a date or time value, or a sequence or array of them."""
|
|
29
|
+
if isinstance(v, (dt.date, dt.datetime)):
|
|
30
|
+
return True
|
|
31
|
+
kind = getattr(getattr(v, "dtype", None), "kind", None)
|
|
32
|
+
if kind == "M":
|
|
33
|
+
return True
|
|
34
|
+
if isinstance(v, (list, tuple)) and v:
|
|
35
|
+
return isinstance(_first(v), (dt.date, dt.datetime))
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def seconds(x: Any) -> float:
|
|
40
|
+
"""Seconds since the epoch of one value (naive datetimes are UTC)."""
|
|
41
|
+
if x is None or (isinstance(x, float) and math.isnan(x)):
|
|
42
|
+
return math.nan
|
|
43
|
+
if isinstance(x, dt.datetime):
|
|
44
|
+
if x.tzinfo is not None:
|
|
45
|
+
return x.timestamp()
|
|
46
|
+
return calendar.timegm(x.timetuple()) + x.microsecond / 1e6
|
|
47
|
+
if isinstance(x, dt.date):
|
|
48
|
+
return float(calendar.timegm(x.timetuple()))
|
|
49
|
+
if hasattr(x, "astype") and getattr(getattr(x, "dtype", None), "kind", None) == "M":
|
|
50
|
+
n = int(x.astype("datetime64[ns]").astype("int64"))
|
|
51
|
+
return math.nan if n == -(2 ** 63) else n / 1e9
|
|
52
|
+
if isinstance(x, (int, float)):
|
|
53
|
+
return float(x)
|
|
54
|
+
raise ValueError(f"not a date or time: {x!r}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def axis_value(v: Any) -> float:
|
|
58
|
+
"""A position on an axis as a float: a real number, a date or time (seconds since the
|
|
59
|
+
epoch), a numpy ``datetime64`` or ISO 8601 text."""
|
|
60
|
+
from .validators import real
|
|
61
|
+
x = real(v)
|
|
62
|
+
if x is not None:
|
|
63
|
+
return x
|
|
64
|
+
if isinstance(v, (dt.date, dt.datetime)) or getattr(
|
|
65
|
+
getattr(v, "dtype", None), "kind", None) == "M":
|
|
66
|
+
return seconds(v)
|
|
67
|
+
if isinstance(v, str):
|
|
68
|
+
try:
|
|
69
|
+
return seconds(dt.datetime.fromisoformat(v.strip()))
|
|
70
|
+
except ValueError:
|
|
71
|
+
pass
|
|
72
|
+
raise ValueError(f"expected a number, a date or ISO 8601 text, got {v!r}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def to_seconds(v: Any) -> tuple[array.array, Any]:
|
|
76
|
+
"""Seconds since the epoch of every value, and the time zone of the first aware
|
|
77
|
+
value (``None``: naive, shown as UTC)."""
|
|
78
|
+
kind = getattr(getattr(v, "dtype", None), "kind", None)
|
|
79
|
+
if kind == "M":
|
|
80
|
+
ns = v.astype("datetime64[ns]").astype("int64").tolist()
|
|
81
|
+
nat = -(2 ** 63)
|
|
82
|
+
return array.array("d", (math.nan if n == nat else n / 1e9 for n in ns)), None
|
|
83
|
+
if isinstance(v, (dt.date, dt.datetime)):
|
|
84
|
+
v = [v]
|
|
85
|
+
first = _first(v)
|
|
86
|
+
tz = first.tzinfo if isinstance(first, dt.datetime) else None
|
|
87
|
+
return array.array("d", (seconds(x) for x in v)), tz
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
#: The first and last times a ``datetime`` holds, in seconds.
|
|
91
|
+
_FIRST = seconds(dt.datetime.min)
|
|
92
|
+
_LAST = seconds(dt.datetime.max)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def from_seconds(s: float, tz: Any) -> dt.datetime:
|
|
96
|
+
"""The datetime of ``s`` seconds in zone ``tz`` (naive UTC when ``tz`` is ``None``);
|
|
97
|
+
times before year 1 or after 9999 are taken at those ends."""
|
|
98
|
+
s = min(max(s, _FIRST), _LAST)
|
|
99
|
+
try:
|
|
100
|
+
d = dt.datetime.fromtimestamp(s, tz or _UTC)
|
|
101
|
+
except (ValueError, OverflowError): # the zone's offset passes an end
|
|
102
|
+
d = (dt.datetime.min if s < 0 else dt.datetime.max).replace(tzinfo=tz or _UTC)
|
|
103
|
+
return d if tz is not None else d.replace(tzinfo=None)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def time_unit(s: float, tz: Any) -> float:
|
|
107
|
+
"""The calendar unit of a time (s): a day at midnight in zone ``tz``, else an hour,
|
|
108
|
+
a minute or a second at a whole one, else a millisecond."""
|
|
109
|
+
d = from_seconds(s, tz)
|
|
110
|
+
if d.microsecond:
|
|
111
|
+
return 1e-3
|
|
112
|
+
if d.second:
|
|
113
|
+
return 1.0
|
|
114
|
+
if d.minute:
|
|
115
|
+
return 60.0
|
|
116
|
+
if d.hour:
|
|
117
|
+
return 3600.0
|
|
118
|
+
return 86400.0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _epoch(d: dt.datetime, tz: Any) -> float:
|
|
122
|
+
return d.timestamp() if tz is not None else seconds(d)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
#: (unit, step, approximate seconds) candidates, finest first.
|
|
126
|
+
_STEPS = ([("microsecond", s, s * 1e-6) for s in (1, 2, 5, 10, 20, 50, 100, 200, 500, 1000,
|
|
127
|
+
2000, 5000, 10000, 20000, 50000, 100000,
|
|
128
|
+
200000, 500000)]
|
|
129
|
+
+ [("second", s, s) for s in (1, 2, 5, 10, 15, 30)]
|
|
130
|
+
+ [("minute", s, 60 * s) for s in (1, 2, 5, 10, 15, 30)]
|
|
131
|
+
+ [("hour", s, 3600 * s) for s in (1, 2, 3, 6, 12)]
|
|
132
|
+
+ [("day", s, 86400 * s) for s in (1, 2, 7, 14)]
|
|
133
|
+
+ [("month", s, 2629746 * s) for s in (1, 2, 3, 6)]
|
|
134
|
+
+ [("year", s, 31556952 * s) for s in (1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500,
|
|
135
|
+
1000)])
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _floor(d: dt.datetime, unit: str, step: int) -> dt.datetime:
|
|
139
|
+
if unit == "year": # year 0 does not exist: the first multiple
|
|
140
|
+
return d.replace(year=max(d.year - d.year % step, min(step, 9999)), month=1, day=1,
|
|
141
|
+
hour=0, minute=0, second=0, microsecond=0)
|
|
142
|
+
if unit == "month":
|
|
143
|
+
m = (d.month - 1) - (d.month - 1) % step + 1
|
|
144
|
+
return d.replace(month=m, day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
145
|
+
if unit == "day":
|
|
146
|
+
return d.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
147
|
+
if unit == "hour":
|
|
148
|
+
return d.replace(hour=d.hour - d.hour % step, minute=0, second=0, microsecond=0)
|
|
149
|
+
if unit == "minute":
|
|
150
|
+
return d.replace(minute=d.minute - d.minute % step, second=0, microsecond=0)
|
|
151
|
+
if unit == "second":
|
|
152
|
+
return d.replace(second=d.second - d.second % step, microsecond=0)
|
|
153
|
+
return d.replace(microsecond=0)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _add(d: dt.datetime, unit: str, step: int) -> dt.datetime:
|
|
157
|
+
if unit == "year":
|
|
158
|
+
return d.replace(year=d.year + step)
|
|
159
|
+
if unit == "month":
|
|
160
|
+
m = d.month - 1 + step
|
|
161
|
+
return d.replace(year=d.year + m // 12, month=m % 12 + 1)
|
|
162
|
+
delta = {"hour": dt.timedelta(hours=step), "minute": dt.timedelta(minutes=step),
|
|
163
|
+
"second": dt.timedelta(seconds=step),
|
|
164
|
+
"microsecond": dt.timedelta(microseconds=step),
|
|
165
|
+
"day": dt.timedelta(days=1)}[unit]
|
|
166
|
+
return d + delta
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _candidates(lo: float, hi: float, unit: str, step: int, tz: Any) -> list[float]:
|
|
170
|
+
start = _floor(from_seconds(lo, tz), unit, step)
|
|
171
|
+
out: list[float] = []
|
|
172
|
+
d = start
|
|
173
|
+
guard = 0
|
|
174
|
+
while guard < 100000:
|
|
175
|
+
guard += 1
|
|
176
|
+
s = _epoch(d, tz)
|
|
177
|
+
if s > hi + 1e-9:
|
|
178
|
+
break
|
|
179
|
+
keep = unit != "day" or (d.toordinal() - 1) % step == 0 # every step-th day
|
|
180
|
+
if keep and s >= lo - 1e-9:
|
|
181
|
+
out.append(s)
|
|
182
|
+
try:
|
|
183
|
+
d = _add(d, unit, step)
|
|
184
|
+
except (ValueError, OverflowError): # past year 9999
|
|
185
|
+
break
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def calendar_ticks(lo: float, hi: float, unit: str, step: int, tz: Any) -> list[float]:
|
|
190
|
+
"""Times from ``lo`` to ``hi`` (seconds) every ``step`` calendar ``unit`` (``year``,
|
|
191
|
+
``month``, ``day``, ``hour``, ``minute``, ``second``, ``microsecond``) in zone
|
|
192
|
+
``tz``."""
|
|
193
|
+
from .ticks import MAX_TICKS
|
|
194
|
+
if hi < lo:
|
|
195
|
+
lo, hi = hi, lo
|
|
196
|
+
approx = next(a / s for u, s, a in _STEPS if u == unit) * step
|
|
197
|
+
if (hi - lo) / approx > MAX_TICKS:
|
|
198
|
+
raise ValueError(f"ticks every {step} {unit}s: more than {MAX_TICKS} ticks")
|
|
199
|
+
return _candidates(lo, hi, unit, step, tz)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def time_ticks(lo: float, hi: float, max_ticks: int, tz: Any,
|
|
203
|
+
fmt: Any = "auto") -> tuple[list[tuple[float, str]], str | None]:
|
|
204
|
+
"""Ticks between ``lo`` and ``hi`` seconds: ``[(value, label)]`` and the offset text
|
|
205
|
+
(the larger unit common to every tick: ``"Mar 2026"``)."""
|
|
206
|
+
if hi < lo:
|
|
207
|
+
lo, hi = hi, lo
|
|
208
|
+
span = max(hi - lo, 1e-6)
|
|
209
|
+
unit = "year"
|
|
210
|
+
ticks: list[float] = []
|
|
211
|
+
for u, s, approx in _STEPS:
|
|
212
|
+
if span / approx > max_ticks * 1.5:
|
|
213
|
+
continue
|
|
214
|
+
cand = _candidates(lo, hi, u, s, tz)
|
|
215
|
+
if 2 <= len(cand) <= max_ticks or (len(cand) <= max_ticks and u == "year"):
|
|
216
|
+
unit, ticks = u, cand
|
|
217
|
+
break
|
|
218
|
+
if not ticks:
|
|
219
|
+
ticks = [lo, hi]
|
|
220
|
+
unit = "second" if span < 86400 else "day"
|
|
221
|
+
dts = [from_seconds(t, tz) for t in ticks]
|
|
222
|
+
if isinstance(fmt, str) and "%" in fmt:
|
|
223
|
+
return [(t, d.strftime(fmt)) for t, d in zip(ticks, dts)], None
|
|
224
|
+
if callable(fmt):
|
|
225
|
+
return [(t, str(fmt(d))) for t, d in zip(ticks, dts)], None
|
|
226
|
+
return _concise(ticks, dts, unit, from_seconds((lo + hi) / 2, tz))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def time_labels(values: list[float], tz: Any, fmt: Any = "auto"
|
|
230
|
+
) -> tuple[list[str], str | None]:
|
|
231
|
+
"""Labels of given tick times (seconds) and the offset text, as :func:`time_ticks`
|
|
232
|
+
writes them: concise labels in the finest calendar unit the ticks need."""
|
|
233
|
+
dts = [from_seconds(t, tz) for t in values]
|
|
234
|
+
if isinstance(fmt, str) and "%" in fmt:
|
|
235
|
+
return [d.strftime(fmt) for d in dts], None
|
|
236
|
+
if callable(fmt):
|
|
237
|
+
return [str(fmt(d)) for d in dts], None
|
|
238
|
+
if not dts:
|
|
239
|
+
return [], None
|
|
240
|
+
if any(d.microsecond for d in dts):
|
|
241
|
+
unit = "microsecond"
|
|
242
|
+
elif any(d.second for d in dts):
|
|
243
|
+
unit = "second"
|
|
244
|
+
elif any(d.hour or d.minute for d in dts):
|
|
245
|
+
unit = "minute"
|
|
246
|
+
elif any(d.day != 1 for d in dts):
|
|
247
|
+
unit = "day"
|
|
248
|
+
elif any(d.month != 1 for d in dts):
|
|
249
|
+
unit = "month"
|
|
250
|
+
else:
|
|
251
|
+
unit = "year"
|
|
252
|
+
pairs, offset = _concise(list(values), dts, unit, from_seconds(
|
|
253
|
+
(min(values) + max(values)) / 2, tz))
|
|
254
|
+
return [lab for _, lab in pairs], offset
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _concise(ticks: list[float], dts: list[dt.datetime], unit: str, mid: dt.datetime
|
|
258
|
+
) -> tuple[list[tuple[float, str]], str | None]:
|
|
259
|
+
"""Concise labels of the ticks of ``unit`` and the offset text, the date part common
|
|
260
|
+
to the ticks written from the axis' middle (``mid``)."""
|
|
261
|
+
same_year = len({d.year for d in dts}) == 1
|
|
262
|
+
same_day = len({d.date() for d in dts}) == 1
|
|
263
|
+
if unit == "year":
|
|
264
|
+
return [(t, d.strftime("%Y")) for t, d in zip(ticks, dts)], None
|
|
265
|
+
if unit == "month":
|
|
266
|
+
labels = [d.strftime("%Y") if d.month == 1 and not same_year else d.strftime("%b")
|
|
267
|
+
for d in dts]
|
|
268
|
+
return list(zip(ticks, labels)), (mid.strftime("%Y") if same_year else None)
|
|
269
|
+
if unit == "day":
|
|
270
|
+
labels = [d.strftime("%b %d").replace(" 0", " ") for d in dts]
|
|
271
|
+
return list(zip(ticks, labels)), (mid.strftime("%Y") if same_year else None)
|
|
272
|
+
if unit in ("hour", "minute"):
|
|
273
|
+
labels = [d.strftime("%H:%M") if (d.hour, d.minute) != (0, 0) or same_day
|
|
274
|
+
else d.strftime("%b %d").replace(" 0", " ") for d in dts]
|
|
275
|
+
off = mid.strftime("%b %d, %Y").replace(" 0", " ") if same_day else (
|
|
276
|
+
mid.strftime("%Y") if same_year else None)
|
|
277
|
+
return list(zip(ticks, labels)), off
|
|
278
|
+
if unit == "second":
|
|
279
|
+
labels = [d.strftime("%H:%M:%S") for d in dts]
|
|
280
|
+
off = mid.strftime("%b %d, %Y").replace(" 0", " ") if same_day else None
|
|
281
|
+
return list(zip(ticks, labels)), off
|
|
282
|
+
digits = 6
|
|
283
|
+
while digits > 0 and all(d.microsecond % (10 ** (7 - digits)) == 0 for d in dts):
|
|
284
|
+
digits -= 1
|
|
285
|
+
labels = [d.strftime("%S") + ("." + f"{d.microsecond:06d}"[:digits] if digits else "")
|
|
286
|
+
for d in dts]
|
|
287
|
+
return list(zip(ticks, labels)), mid.strftime("%b %d, %Y %H:%M").replace(" 0", " ", 1)
|