plot-io 0.1.0__py3-none-any.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 +66 -0
- plot_io/_backend.py +114 -0
- plot_io/axis.py +574 -0
- plot_io/colorbar.py +342 -0
- plot_io/decorations.py +496 -0
- plot_io/defaults.py +766 -0
- plot_io/density.py +97 -0
- plot_io/enums.py +63 -0
- plot_io/facets.py +184 -0
- plot_io/figure.py +331 -0
- plot_io/labels.py +63 -0
- plot_io/legend.py +498 -0
- plot_io/limits.py +92 -0
- plot_io/maps/__init__.py +365 -0
- plot_io/maps/data.py +333 -0
- plot_io/markers.py +117 -0
- plot_io/numbers.py +289 -0
- plot_io/occupancy.py +219 -0
- plot_io/palettes.py +142 -0
- plot_io/plot.py +435 -0
- plot_io/render.py +1122 -0
- plot_io/scales.py +93 -0
- plot_io/series/__init__.py +498 -0
- plot_io/series/area.py +181 -0
- plot_io/series/bar.py +294 -0
- plot_io/series/box.py +401 -0
- plot_io/series/catdata.py +23 -0
- plot_io/series/catpoints.py +182 -0
- plot_io/series/contour.py +294 -0
- plot_io/series/curves.py +128 -0
- plot_io/series/ecdf.py +143 -0
- plot_io/series/errorbar.py +111 -0
- plot_io/series/errors.py +100 -0
- plot_io/series/fill.py +168 -0
- plot_io/series/heatmap.py +304 -0
- plot_io/series/hexbin.py +216 -0
- plot_io/series/hist.py +379 -0
- plot_io/series/hist2d.py +163 -0
- plot_io/series/kde.py +228 -0
- plot_io/series/kde2d.py +153 -0
- plot_io/series/line.py +173 -0
- plot_io/series/pie.py +304 -0
- plot_io/series/points.py +341 -0
- plot_io/series/regression.py +250 -0
- plot_io/series/scatter.py +103 -0
- plot_io/series/stem.py +112 -0
- plot_io/series/violin.py +321 -0
- plot_io/settings.py +281 -0
- plot_io/shapes.py +57 -0
- plot_io/sharing.py +160 -0
- plot_io/slots.py +52 -0
- plot_io/stacking.py +63 -0
- plot_io/stats.py +140 -0
- plot_io/style.py +71 -0
- plot_io/styles/__init__.py +234 -0
- plot_io/styles/library.py +732 -0
- plot_io/tables.py +141 -0
- plot_io/text.py +109 -0
- plot_io/ticks.py +284 -0
- plot_io/times.py +199 -0
- plot_io/values.py +161 -0
- plot_io-0.1.0.dist-info/METADATA +102 -0
- plot_io-0.1.0.dist-info/RECORD +65 -0
- plot_io-0.1.0.dist-info/WHEEL +4 -0
- plot_io-0.1.0.dist-info/licenses/LICENSE +28 -0
plot_io/tables.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Columns from tables (``data=``) and grouping of series by a column (``group=``).
|
|
2
|
+
|
|
3
|
+
``data`` is anything that returns a column for ``data[name]``: a dict of sequences, a
|
|
4
|
+
pandas or polars data frame, a numpy structured array. Columns are read through
|
|
5
|
+
``__getitem__`` and ``to_numpy()`` when present, so no table library is imported.
|
|
6
|
+
|
|
7
|
+
A call with ``group=labels`` is split into one series per group (a :class:`SeriesGroup`),
|
|
8
|
+
each with the rows of every per-point argument that belong to it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import array
|
|
12
|
+
import math
|
|
13
|
+
import warnings
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
__all__ = ["column", "has_column", "SeriesGroup", "MARKER_CYCLE", "LINE_STYLE_CYCLE"]
|
|
17
|
+
|
|
18
|
+
MARKER_CYCLE = ("circle", "square", "triangle_up", "diamond", "triangle_down", "plus",
|
|
19
|
+
"cross", "triangle_left", "triangle_right")
|
|
20
|
+
LINE_STYLE_CYCLE = ("solid", "dashed", "dotted", "dashdot")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def has_column(data: Any, name: str) -> bool:
|
|
24
|
+
try:
|
|
25
|
+
columns = data.columns if hasattr(data, "columns") else None
|
|
26
|
+
if columns is not None and not callable(columns):
|
|
27
|
+
return name in list(columns)
|
|
28
|
+
names = getattr(getattr(data, "dtype", None), "names", None)
|
|
29
|
+
if names is not None:
|
|
30
|
+
return name in names
|
|
31
|
+
if hasattr(data, "keys"):
|
|
32
|
+
return name in data.keys()
|
|
33
|
+
data[name]
|
|
34
|
+
return True
|
|
35
|
+
except (KeyError, IndexError, TypeError, ValueError):
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def column(data: Any, name: str) -> tuple[Any, list[Any] | None]:
|
|
40
|
+
"""The column ``name`` of ``data`` and, for categorical columns (pandas
|
|
41
|
+
``category``), the order of its categories."""
|
|
42
|
+
if not has_column(data, name):
|
|
43
|
+
raise ValueError(f"no column {name!r} in data")
|
|
44
|
+
v = data[name]
|
|
45
|
+
order = None
|
|
46
|
+
cat = getattr(v, "cat", None)
|
|
47
|
+
if cat is not None and hasattr(cat, "categories"):
|
|
48
|
+
try:
|
|
49
|
+
order = list(cat.categories)
|
|
50
|
+
except TypeError:
|
|
51
|
+
order = None
|
|
52
|
+
if order is None and hasattr(v, "dtype") and str(getattr(v, "dtype", "")).startswith("Categorical"):
|
|
53
|
+
try: # polars
|
|
54
|
+
order = list(v.cat.get_categories())
|
|
55
|
+
except (AttributeError, TypeError):
|
|
56
|
+
order = None
|
|
57
|
+
if hasattr(v, "to_numpy"):
|
|
58
|
+
v = v.to_numpy()
|
|
59
|
+
if hasattr(v, "dtype") and getattr(v.dtype, "kind", "") in "OUS":
|
|
60
|
+
v = v.tolist()
|
|
61
|
+
return v, order
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_missing(v: Any) -> bool:
|
|
65
|
+
return v is None or (isinstance(v, float) and math.isnan(v))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def take(v: Any, idx: list[int]) -> Any:
|
|
69
|
+
"""The items ``idx`` of a per-point argument."""
|
|
70
|
+
if isinstance(v, (list, tuple)):
|
|
71
|
+
return [v[i] for i in idx]
|
|
72
|
+
try:
|
|
73
|
+
mv = memoryview(v)
|
|
74
|
+
except TypeError:
|
|
75
|
+
mv = None
|
|
76
|
+
if mv is not None and mv.ndim == 1 and not isinstance(v, (str, bytes)):
|
|
77
|
+
from . import _backend
|
|
78
|
+
d = _backend.doubles(v)
|
|
79
|
+
return array.array("d", (d[i] for i in idx))
|
|
80
|
+
if mv is not None and mv.ndim == 2:
|
|
81
|
+
rows: Any = v.tolist() if hasattr(v, "tolist") else mv.tolist()
|
|
82
|
+
return [tuple(rows[i]) for i in idx]
|
|
83
|
+
return [v[i] for i in idx]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def group_keys(labels: Any, order: list[Any] | None, given: Any, missing: str) -> tuple[
|
|
87
|
+
list[Any], list[list[int]]]:
|
|
88
|
+
"""Group keys in order and the row indices of each."""
|
|
89
|
+
keyed: dict[Any, list[int]] = {}
|
|
90
|
+
for i, lab in enumerate(labels):
|
|
91
|
+
if is_missing(lab):
|
|
92
|
+
if missing == "drop":
|
|
93
|
+
continue
|
|
94
|
+
lab = "nan"
|
|
95
|
+
keyed.setdefault(lab, []).append(i)
|
|
96
|
+
keys = list(keyed)
|
|
97
|
+
if given is not None:
|
|
98
|
+
given = list(given)
|
|
99
|
+
unknown = [k for k in keys if k not in given]
|
|
100
|
+
keys = [k for k in given if k in keyed] + unknown
|
|
101
|
+
elif order is not None:
|
|
102
|
+
keys = [k for k in order if k in keyed] + [k for k in keys if k not in order]
|
|
103
|
+
return keys, [keyed[k] for k in keys]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class SeriesGroup(list):
|
|
107
|
+
"""The series of a call with ``group=``, one per group, in group order.
|
|
108
|
+
|
|
109
|
+
``set(**settings)`` applies to every member; ``group[label]`` is one member (an
|
|
110
|
+
integer index also works); ``labels`` lists the group labels.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(self, members: list[Any], labels: list[Any], title: Any = None) -> None:
|
|
114
|
+
super().__init__(members)
|
|
115
|
+
self.labels = labels
|
|
116
|
+
self.title = title
|
|
117
|
+
|
|
118
|
+
def __getitem__(self, key: Any) -> Any:
|
|
119
|
+
if isinstance(key, (int, slice)) and not (isinstance(key, int) and key in self.labels):
|
|
120
|
+
return super().__getitem__(key)
|
|
121
|
+
try:
|
|
122
|
+
return super().__getitem__(self.labels.index(key))
|
|
123
|
+
except ValueError:
|
|
124
|
+
raise KeyError(key) from None
|
|
125
|
+
|
|
126
|
+
def set(self, **settings: Any) -> "SeriesGroup":
|
|
127
|
+
for s in self:
|
|
128
|
+
s.set(**settings)
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def plot(self) -> Any:
|
|
133
|
+
return self[0].plot if len(self) else None
|
|
134
|
+
|
|
135
|
+
def __repr__(self) -> str:
|
|
136
|
+
return f"SeriesGroup({', '.join(map(repr, self.labels))})"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def color_name_warning(name: str) -> None:
|
|
140
|
+
warnings.warn(f"{name!r} is both a column and a color name; using the column",
|
|
141
|
+
stacklevel=4)
|
plot_io/text.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Text: one line of plain or rich content, measured and drawn on a pdf_io canvas.
|
|
2
|
+
|
|
3
|
+
Rich content is a list of strings and ``pdf_io.Span`` objects. Exponents are spans with
|
|
4
|
+
``baseline_shift="super"`` and ``font_scale=0.7``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pdf_io import Span
|
|
12
|
+
|
|
13
|
+
from . import _backend
|
|
14
|
+
|
|
15
|
+
__all__ = ["superscript", "Font", "measure", "draw_text", "runs_of", "plain"]
|
|
16
|
+
|
|
17
|
+
_SUPER_SHIFT = 0.33
|
|
18
|
+
_SUPER_SCALE = 0.7
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def superscript(text: str) -> Any:
|
|
22
|
+
return Span(text, baseline_shift="super", font_scale=_SUPER_SCALE)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def runs_of(content: Any) -> Iterator[tuple[str, float, float]]:
|
|
26
|
+
"""``(text, size factor, baseline shift in em)`` of each run."""
|
|
27
|
+
if isinstance(content, str):
|
|
28
|
+
yield content, 1.0, 0.0
|
|
29
|
+
return
|
|
30
|
+
for item in content:
|
|
31
|
+
if isinstance(item, str):
|
|
32
|
+
yield item, 1.0, 0.0
|
|
33
|
+
else:
|
|
34
|
+
shift = getattr(item, "baseline_shift", None)
|
|
35
|
+
scale = getattr(item, "font_scale", None) or 1.0
|
|
36
|
+
em = {"super": _SUPER_SHIFT, "sub": -0.2}.get(shift, shift) if shift else 0.0
|
|
37
|
+
yield item.text, float(scale), float(em or 0.0)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def plain(content: Any) -> str:
|
|
41
|
+
"""The text of content, without styles."""
|
|
42
|
+
return "".join(t for t, _, _ in runs_of(content))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Font:
|
|
46
|
+
"""Font settings applied to a canvas before measuring or drawing."""
|
|
47
|
+
|
|
48
|
+
__slots__ = ("size", "family", "weight", "style", "color")
|
|
49
|
+
|
|
50
|
+
def __init__(self, size: float, family: str = "sans-serif", weight: str = "normal",
|
|
51
|
+
style: str = "normal", color: Any = "black") -> None:
|
|
52
|
+
self.size = float(size)
|
|
53
|
+
self.family = family
|
|
54
|
+
self.weight = weight
|
|
55
|
+
self.style = style
|
|
56
|
+
self.color = color
|
|
57
|
+
|
|
58
|
+
def apply(self, canvas: Any) -> None:
|
|
59
|
+
canvas.font_size = self.size
|
|
60
|
+
canvas.font_family = self.family
|
|
61
|
+
canvas.font_weight = self.weight
|
|
62
|
+
canvas.font_style = self.style
|
|
63
|
+
canvas.fill_style = self.color
|
|
64
|
+
canvas.text_align = "start"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def measure(canvas: Any, content: Any, font: Font) -> tuple[float, float, float]:
|
|
68
|
+
"""``(width, ascent, descent)`` of a line of content."""
|
|
69
|
+
if content is None or content == "" or content == []:
|
|
70
|
+
return 0.0, 0.0, 0.0
|
|
71
|
+
font.apply(canvas)
|
|
72
|
+
return _backend.text_extent(canvas, content)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def box(canvas: Any, content: Any, font: Font, rotation: float = 0.0) -> tuple[float, float]:
|
|
76
|
+
"""Width and height of the bounding box of rotated text (degrees, counterclockwise)."""
|
|
77
|
+
w, a, d = measure(canvas, content, font)
|
|
78
|
+
h = a + d
|
|
79
|
+
if not rotation:
|
|
80
|
+
return w, h
|
|
81
|
+
r = math.radians(rotation)
|
|
82
|
+
c, s = abs(math.cos(r)), abs(math.sin(r))
|
|
83
|
+
return w * c + h * s, w * s + h * c
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _fill(canvas: Any, content: Any, x: float, y: float) -> None:
|
|
87
|
+
canvas.fill_text(content, x, y)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def draw_text(canvas: Any, content: Any, x: float, y: float, font: Font, ha: str = "left",
|
|
91
|
+
va: str = "baseline", rotation: float = 0.0) -> None:
|
|
92
|
+
"""Draw a line of content anchored at ``(x, y)``: ``ha`` left/center/right, ``va``
|
|
93
|
+
baseline/top/center/bottom, in the text's own (rotated) frame; ``rotation`` in degrees,
|
|
94
|
+
counterclockwise."""
|
|
95
|
+
if content is None or content == "" or content == []:
|
|
96
|
+
return
|
|
97
|
+
w, a, d = measure(canvas, content, font)
|
|
98
|
+
dx = {"left": 0.0, "center": -w / 2, "right": -w}[ha]
|
|
99
|
+
dy = {"baseline": 0.0, "top": a, "center": (a - d) / 2, "bottom": -d}[va]
|
|
100
|
+
if not rotation:
|
|
101
|
+
_fill(canvas, content, x + dx, y + dy)
|
|
102
|
+
return
|
|
103
|
+
canvas.save()
|
|
104
|
+
try:
|
|
105
|
+
canvas.translate(x, y)
|
|
106
|
+
canvas.rotate(-rotation)
|
|
107
|
+
_fill(canvas, content, dx, dy)
|
|
108
|
+
finally:
|
|
109
|
+
canvas.restore()
|
plot_io/ticks.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
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", "format_values", "numeric_ticks",
|
|
15
|
+
"thin_by_position", "minor_linear", "exponent_label"]
|
|
16
|
+
|
|
17
|
+
_MANTISSAS = (1.0, 2.0, 2.5, 5.0)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Tick(NamedTuple):
|
|
21
|
+
value: float
|
|
22
|
+
label: Any # str, rich content, or None (unlabelled)
|
|
23
|
+
major: bool = True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def nice_step(raw: float) -> float:
|
|
27
|
+
"""The smallest step 1, 2, 2.5 or 5 × 10^k that is at least ``raw``."""
|
|
28
|
+
if not raw > 0 or not math.isfinite(raw):
|
|
29
|
+
return 1.0
|
|
30
|
+
k = math.floor(math.log10(raw))
|
|
31
|
+
for m in _MANTISSAS + (10.0,):
|
|
32
|
+
s = m * 10.0 ** k
|
|
33
|
+
if s >= raw * (1 - 1e-9):
|
|
34
|
+
return s
|
|
35
|
+
return 10.0 ** (k + 1)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _next_step(step: float) -> float:
|
|
39
|
+
k = math.floor(math.log10(step) + 1e-9)
|
|
40
|
+
m = step / 10.0 ** k
|
|
41
|
+
for c in _MANTISSAS + (10.0,):
|
|
42
|
+
if c > m * (1 + 1e-9):
|
|
43
|
+
return c * 10.0 ** k
|
|
44
|
+
return 2.0 * 10.0 ** (k + 1)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _values(lo: float, hi: float, step: float) -> list[float]:
|
|
48
|
+
eps = step * 1e-9
|
|
49
|
+
start = math.ceil((lo - eps) / step)
|
|
50
|
+
end = math.floor((hi + eps) / step)
|
|
51
|
+
out = []
|
|
52
|
+
for i in range(start, end + 1):
|
|
53
|
+
v = i * step
|
|
54
|
+
out.append(float(f"{v:.12g}") if v else 0.0)
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def linear_ticks(lo: float, hi: float, max_ticks: int,
|
|
59
|
+
fits: Callable[[list[float], float], bool] | None = None) -> tuple[list[float], float]:
|
|
60
|
+
"""Tick values in [lo, hi]: the smallest nice step giving at most ``max_ticks``
|
|
61
|
+
ticks (and for which ``fits(values, step)`` holds). Returns ``(values, step)``."""
|
|
62
|
+
if hi < lo:
|
|
63
|
+
lo, hi = hi, lo
|
|
64
|
+
if hi == lo:
|
|
65
|
+
return [lo], 1.0
|
|
66
|
+
n = max(2, int(max_ticks))
|
|
67
|
+
step = nice_step((hi - lo) / n)
|
|
68
|
+
for _ in range(60):
|
|
69
|
+
vals = _values(lo, hi, step)
|
|
70
|
+
if len(vals) <= n and (fits is None or fits(vals, step)):
|
|
71
|
+
return vals, step
|
|
72
|
+
step = _next_step(step)
|
|
73
|
+
return _values(lo, hi, step), step
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def minor_linear(lo: float, hi: float, step: float) -> list[float]:
|
|
77
|
+
k = math.floor(math.log10(step) + 1e-9)
|
|
78
|
+
m = round(step / 10.0 ** k, 6)
|
|
79
|
+
sub = step / (4 if m == 2.0 else 5)
|
|
80
|
+
return _values(lo, hi, sub)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Labels
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def format_values(values: Sequence[float], sci_limits: tuple[int, int] = (-4, 5),
|
|
88
|
+
) -> tuple[list[str], int]:
|
|
89
|
+
"""``"auto"`` labels of values with the default number style (see
|
|
90
|
+
:func:`plot_io.numbers.format_axis`)."""
|
|
91
|
+
labels, exp = format_axis(values, "auto", NumberStyle(), sci_limits)
|
|
92
|
+
return [str(s) for s in labels], exp
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Ticks per scale
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def exponent_label(base: str, k: int, style: NumberStyle | None = None) -> Any:
|
|
100
|
+
from .text import superscript
|
|
101
|
+
style = style or NumberStyle()
|
|
102
|
+
return style.wrap([base, superscript(style.exponent(k))])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _int_powers(flo: float, fhi: float) -> list[int]:
|
|
106
|
+
return list(range(math.ceil(flo - 1e-9), math.floor(fhi + 1e-9) + 1))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _thin_powers(ks: list[int], max_ticks: int, base: int = 1) -> tuple[list[int], int]:
|
|
110
|
+
"""Every k-th power so that at most ``max_ticks`` remain (k a power of two when
|
|
111
|
+
``base`` is 2, else any integer)."""
|
|
112
|
+
every = 1
|
|
113
|
+
while len([k for k in ks if k % every == 0]) > max(2, max_ticks):
|
|
114
|
+
every = every * 2 if base == 2 else every + 1
|
|
115
|
+
return [k for k in ks if k % every == 0], every
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _each(values: list[float], fmt: Any, style: NumberStyle) -> list[Any]:
|
|
119
|
+
"""Labels formatted one by one (log and other non-linear ticks)."""
|
|
120
|
+
if fmt != "auto":
|
|
121
|
+
return format_axis(values, fmt, style, None)[0]
|
|
122
|
+
return [format_axis([v], "auto", style, None)[0][0] for v in values]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def numeric_ticks(scale: Scale, lo: float, hi: float, max_ticks: int, fmt: Any = "auto",
|
|
126
|
+
style: NumberStyle | None = None, sci_limits: tuple[int, int] = (-4, 5),
|
|
127
|
+
minor: bool = True, length: float = 0.0,
|
|
128
|
+
fits: Callable[[list[float], float], bool] | None = None,
|
|
129
|
+
) -> tuple[list[Tick], int]:
|
|
130
|
+
"""Ticks of a numeric scale between data limits ``lo`` < ``hi``, labelled with the
|
|
131
|
+
number format ``fmt``. Returns the ticks and the exponent of a common ×10ᵏ factor
|
|
132
|
+
(0: none)."""
|
|
133
|
+
style = style or NumberStyle()
|
|
134
|
+
if hi < lo:
|
|
135
|
+
lo, hi = hi, lo
|
|
136
|
+
name = scale.name
|
|
137
|
+
flo, fhi = scale.f(lo), scale.f(hi)
|
|
138
|
+
if name in ("log", "log2", "log10"):
|
|
139
|
+
base_num = {"log": math.e, "log2": 2.0, "log10": 10.0}[name]
|
|
140
|
+
ks = _int_powers(flo, fhi)
|
|
141
|
+
if name == "log10" and 1 <= len(ks) < 3:
|
|
142
|
+
values = [m * 10.0 ** k for k in range(min(ks, default=0) - 1, max(ks, default=0) + 1)
|
|
143
|
+
for m in (1, 2, 5) if lo * (1 - 1e-9) <= m * 10.0 ** k <= hi * (1 + 1e-9)]
|
|
144
|
+
if len(values) >= 3 or len(values) > len(ks):
|
|
145
|
+
ticks = [Tick(v, s) for v, s in zip(values, _each(values, fmt, style))]
|
|
146
|
+
if minor:
|
|
147
|
+
ticks += [Tick(m * 10.0 ** k, None, False)
|
|
148
|
+
for k in range(min(ks) - 1, max(ks) + 1)
|
|
149
|
+
for m in (3, 4, 6, 7, 8, 9)
|
|
150
|
+
if lo <= m * 10.0 ** k <= hi]
|
|
151
|
+
return ticks, 0
|
|
152
|
+
if len(ks) >= 2:
|
|
153
|
+
kept, every = _thin_powers(ks, max_ticks, 2 if name == "log2" else 1)
|
|
154
|
+
labelled = [base_num ** k for k in kept]
|
|
155
|
+
custom = dict(zip(kept, _each(labelled, fmt, style))) if fmt != "auto" else {}
|
|
156
|
+
ticks = []
|
|
157
|
+
for k in ks:
|
|
158
|
+
v = base_num ** k
|
|
159
|
+
if k not in kept:
|
|
160
|
+
ticks.append(Tick(v, None, False))
|
|
161
|
+
continue
|
|
162
|
+
if fmt != "auto":
|
|
163
|
+
label: Any = custom[k]
|
|
164
|
+
elif name == "log10":
|
|
165
|
+
label = exponent_label("10", k, style)
|
|
166
|
+
elif name == "log2":
|
|
167
|
+
label = (_each([v], "auto", style)[0] if -10 <= k <= 10
|
|
168
|
+
else exponent_label("2", k, style))
|
|
169
|
+
else:
|
|
170
|
+
label = style.wrap("1") if k == 0 else exponent_label("e", k, style)
|
|
171
|
+
ticks.append(Tick(v, label))
|
|
172
|
+
if name == "log10" and minor and every == 1 and len(ks) <= 8:
|
|
173
|
+
ticks += [Tick(m * 10.0 ** k, None, False) for k in range(ks[0] - 1, ks[-1] + 1)
|
|
174
|
+
for m in range(2, 10) if lo <= m * 10.0 ** k <= hi]
|
|
175
|
+
return ticks, 0
|
|
176
|
+
# Less than two powers in range: nice values in data space.
|
|
177
|
+
if name in ("linear", "log", "log2", "log10"):
|
|
178
|
+
values, step = linear_ticks(lo, hi, max_ticks, fits)
|
|
179
|
+
values = [v for v in values if scale.contains(v)]
|
|
180
|
+
labels, exp = format_axis(values, fmt, style, sci_limits)
|
|
181
|
+
return [Tick(v, s) for v, s in zip(values, labels)], exp
|
|
182
|
+
if name == "symlog":
|
|
183
|
+
return _symlog_ticks(scale, lo, hi, max_ticks, fmt, style, minor, length)
|
|
184
|
+
if name == "logit":
|
|
185
|
+
return _logit_ticks(scale, lo, hi, max_ticks, fmt, style, length)
|
|
186
|
+
# sqrt, exp and other monotone scales: nice candidates thinned on the page.
|
|
187
|
+
values, step = linear_ticks(lo, hi, max(2 * max_ticks, 4))
|
|
188
|
+
values = [v for v in values if scale.contains(v)]
|
|
189
|
+
if length > 0 and len(values) > 2 and fhi != flo:
|
|
190
|
+
pos = [(scale.f(v) - flo) / (fhi - flo) * length for v in values]
|
|
191
|
+
keep = thin_by_position(pos, length / max(max_ticks, 1))
|
|
192
|
+
values = [values[i] for i in keep]
|
|
193
|
+
return [Tick(v, s) for v, s in zip(values, _each(values, fmt, style))], 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def thin_by_position(pos: Sequence[float], spacing: float) -> list[int]:
|
|
197
|
+
"""Indices of increasing positions kept so that neighbours are at least ``spacing``
|
|
198
|
+
apart. Walks from the end where the scale stretches the data most (the sparse end),
|
|
199
|
+
always keeping both ends."""
|
|
200
|
+
n = len(pos)
|
|
201
|
+
if n <= 2:
|
|
202
|
+
return list(range(n))
|
|
203
|
+
order = list(range(n))
|
|
204
|
+
if pos[-1] - pos[-2] > pos[1] - pos[0]:
|
|
205
|
+
order.reverse() # sparse at the high end: walk from there
|
|
206
|
+
kept = [order[0]]
|
|
207
|
+
for i in order[1:-1]:
|
|
208
|
+
if abs(pos[i] - pos[kept[-1]]) >= spacing and abs(pos[order[-1]] - pos[i]) >= spacing:
|
|
209
|
+
kept.append(i)
|
|
210
|
+
kept.append(order[-1])
|
|
211
|
+
return sorted(kept)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _thin(scale: Scale, values: list[float], lo: float, hi: float, max_ticks: int,
|
|
215
|
+
length: float) -> list[float]:
|
|
216
|
+
flo, fhi = scale.f(lo), scale.f(hi)
|
|
217
|
+
if length > 0 and len(values) > 2 and fhi != flo:
|
|
218
|
+
pos = [(scale.f(v) - flo) / (fhi - flo) * length for v in values]
|
|
219
|
+
keep = thin_by_position(pos, length / max(max_ticks, 1))
|
|
220
|
+
values = [values[i] for i in keep]
|
|
221
|
+
return values
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _symlog_ticks(scale: Scale, lo: float, hi: float, max_ticks: int, fmt: Any,
|
|
225
|
+
style: NumberStyle, minor: bool, length: float) -> tuple[list[Tick], int]:
|
|
226
|
+
"""0, ±linthresh, then decades beyond; linear minor ticks inside the linear region."""
|
|
227
|
+
c = scale.options.get("linthresh", 1.0)
|
|
228
|
+
eps = (hi - lo) * 1e-9 + 1e-300
|
|
229
|
+
majors = [0.0] if lo - eps <= 0.0 <= hi + eps else []
|
|
230
|
+
for sign in (1.0, -1.0):
|
|
231
|
+
k = 0
|
|
232
|
+
while k < 400:
|
|
233
|
+
v = sign * c * 10.0 ** k
|
|
234
|
+
if not lo - eps <= v <= hi + eps:
|
|
235
|
+
if (sign > 0 and v > hi) or (sign < 0 and v < lo):
|
|
236
|
+
break
|
|
237
|
+
else:
|
|
238
|
+
majors.append(v)
|
|
239
|
+
k += 1
|
|
240
|
+
majors = sorted(set(majors))
|
|
241
|
+
majors = _thin(scale, majors, lo, hi, max_ticks, length)
|
|
242
|
+
ticks = [Tick(v, s) for v, s in zip(majors, _each(majors, fmt, style))]
|
|
243
|
+
if minor:
|
|
244
|
+
have = set(majors)
|
|
245
|
+
step = _symlog_minor_step(scale, c)
|
|
246
|
+
if step is not None:
|
|
247
|
+
n = int(round(c / step))
|
|
248
|
+
for i in range(-n + 1, n):
|
|
249
|
+
v = i * step
|
|
250
|
+
if i and v not in have and lo <= v <= hi:
|
|
251
|
+
ticks.append(Tick(v, None, False))
|
|
252
|
+
return ticks, 0
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
#: Smallest gap between minor ticks in the linear region of a symlog axis, in scale units
|
|
256
|
+
#: (decades): the gap between minor ticks 4 and 5 of a log decade.
|
|
257
|
+
_SYMLOG_MINOR_GAP = math.log10(1.25)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _symlog_minor_step(scale: Scale, c: float) -> float | None:
|
|
261
|
+
"""The finest nice step (c/10, c/5, c/4, c/2) whose ticks in (−c, c) stay at least
|
|
262
|
+
``_SYMLOG_MINOR_GAP`` apart on the axis, or ``None`` when no step fits."""
|
|
263
|
+
if 2.0 * scale.f(c) < 2.0 * _SYMLOG_MINOR_GAP:
|
|
264
|
+
return None
|
|
265
|
+
for div in (10, 5, 4, 2):
|
|
266
|
+
step = c / div
|
|
267
|
+
pos = [scale.f(i * step) for i in range(0, div + 1)]
|
|
268
|
+
if min(b - a for a, b in zip(pos, pos[1:])) >= _SYMLOG_MINOR_GAP:
|
|
269
|
+
return step
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _logit_ticks(scale: Scale, lo: float, hi: float, max_ticks: int, fmt: Any,
|
|
274
|
+
style: NumberStyle, length: float) -> tuple[list[Tick], int]:
|
|
275
|
+
"""0.5, then 0.1/0.9, 0.01/0.99, … inside the limits."""
|
|
276
|
+
values = [0.5]
|
|
277
|
+
for k in range(1, 16):
|
|
278
|
+
values += [10.0 ** -k, 1.0 - 10.0 ** -k]
|
|
279
|
+
values = sorted(v for v in values if lo <= v <= hi)
|
|
280
|
+
if len(values) < 2:
|
|
281
|
+
values, _ = linear_ticks(lo, hi, max_ticks)
|
|
282
|
+
values = [v for v in values if 0 < v < 1]
|
|
283
|
+
values = _thin(scale, values, lo, hi, max_ticks, length)
|
|
284
|
+
return [Tick(v, s) for v, s in zip(values, _each(values, fmt, style))], 0
|