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/series/errors.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Error bars attached to scatter and line series."""
|
|
2
|
+
|
|
3
|
+
import array
|
|
4
|
+
import math
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .. import _backend
|
|
8
|
+
from ..settings import color, number
|
|
9
|
+
from ..style import resolve_color
|
|
10
|
+
|
|
11
|
+
__all__ = ["ErrorBars", "error_value"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ErrorBars:
|
|
15
|
+
"""Error bars: ``values`` is a value or sequence (symmetric) or a pair
|
|
16
|
+
``(low, high)``; ``color`` defaults to the series color, ``cap`` is the cap width
|
|
17
|
+
(pt; 0: no caps)."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, values: Any, color: Any = None, line_width: float | None = None,
|
|
20
|
+
cap: float = 3.0) -> None:
|
|
21
|
+
self.values = values
|
|
22
|
+
self.color = None if color is None else _color(color)
|
|
23
|
+
self.line_width = None if line_width is None else number(lo=0)(line_width)
|
|
24
|
+
self.cap = number(lo=0)(cap)
|
|
25
|
+
self._low: Any = None
|
|
26
|
+
self._high: Any = None
|
|
27
|
+
|
|
28
|
+
def __repr__(self) -> str:
|
|
29
|
+
return "ErrorBars()"
|
|
30
|
+
|
|
31
|
+
def bind(self, n: int) -> None:
|
|
32
|
+
"""Convert the values for ``n`` points."""
|
|
33
|
+
v = self.values
|
|
34
|
+
if isinstance(v, (tuple, list)) and len(v) == 2 and not _is_number(v[0]) \
|
|
35
|
+
and hasattr(v[0], "__len__"):
|
|
36
|
+
low, high = _column(v[0], n), _column(v[1], n)
|
|
37
|
+
else:
|
|
38
|
+
low = high = _column(v, n)
|
|
39
|
+
self._low, self._high = low, high
|
|
40
|
+
|
|
41
|
+
def ends(self, center: Any) -> tuple[array.array, array.array]:
|
|
42
|
+
lo = array.array("d", (c - e for c, e in zip(center, self._low)))
|
|
43
|
+
hi = array.array("d", (c + e for c, e in zip(center, self._high)))
|
|
44
|
+
return lo, hi
|
|
45
|
+
|
|
46
|
+
def draw(self, ctx: Any, xs: Any, ys: Any, horizontal: bool, base_color: Any,
|
|
47
|
+
width: float, simplify: Any, opacity: float) -> None:
|
|
48
|
+
"""Bars along y (or x when ``horizontal``) from data ``xs``, ``ys``."""
|
|
49
|
+
center = xs if horizontal else ys
|
|
50
|
+
lo, hi = self.ends(center)
|
|
51
|
+
c = resolve_color(self.color, ctx.palette) if self.color is not None else base_color
|
|
52
|
+
w = self.line_width if self.line_width is not None else width
|
|
53
|
+
if c is None or not w:
|
|
54
|
+
return
|
|
55
|
+
canvas = ctx.canvas
|
|
56
|
+
if horizontal:
|
|
57
|
+
px0, px1, py = ctx.xmap.many(lo), ctx.xmap.many(hi), ctx.ymap.many(ys)
|
|
58
|
+
canvas.draw_segments(px0, py, px1, py, color=c, width=w, opacity=opacity,
|
|
59
|
+
simplify=simplify)
|
|
60
|
+
if self.cap:
|
|
61
|
+
h = self.cap / 2
|
|
62
|
+
for px in (px0, px1):
|
|
63
|
+
canvas.draw_segments(px, _backend.scaled(py, 1.0, -h), px,
|
|
64
|
+
_backend.scaled(py, 1.0, h), color=c, width=w,
|
|
65
|
+
opacity=opacity, simplify=simplify)
|
|
66
|
+
else:
|
|
67
|
+
px, py0, py1 = ctx.xmap.many(xs), ctx.ymap.many(lo), ctx.ymap.many(hi)
|
|
68
|
+
canvas.draw_segments(px, py0, px, py1, color=c, width=w, opacity=opacity,
|
|
69
|
+
simplify=simplify)
|
|
70
|
+
if self.cap:
|
|
71
|
+
h = self.cap / 2
|
|
72
|
+
for py in (py0, py1):
|
|
73
|
+
canvas.draw_segments(_backend.scaled(px, 1.0, -h), py,
|
|
74
|
+
_backend.scaled(px, 1.0, h), py, color=c, width=w,
|
|
75
|
+
opacity=opacity, simplify=simplify)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _is_number(v: Any) -> bool:
|
|
79
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _color(v: Any) -> Any:
|
|
83
|
+
return color(v)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _column(v: Any, n: int) -> array.array:
|
|
87
|
+
if _is_number(v):
|
|
88
|
+
return array.array("d", [float(v)] * n)
|
|
89
|
+
col = _backend.doubles(v)
|
|
90
|
+
if len(col) != n:
|
|
91
|
+
raise ValueError(f"{len(col)} error values for {n} points")
|
|
92
|
+
return col
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def error_value(v: Any) -> ErrorBars:
|
|
96
|
+
if isinstance(v, ErrorBars):
|
|
97
|
+
return v
|
|
98
|
+
if _is_number(v) and not (v >= 0 and math.isfinite(v)):
|
|
99
|
+
raise ValueError(f"expected a non-negative error, got {v!r}")
|
|
100
|
+
return ErrorBars(v)
|
plot_io/series/fill.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Fill: the area between two curves."""
|
|
2
|
+
|
|
3
|
+
import array
|
|
4
|
+
import math
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pdf_io import arrays as pdf_arrays
|
|
8
|
+
|
|
9
|
+
from .. import _backend
|
|
10
|
+
from ..settings import Setting, color, number, one_of, table
|
|
11
|
+
from ..style import with_alpha
|
|
12
|
+
from . import Series, common
|
|
13
|
+
from .curves import curve_settings, expand, interpolation_of, line_args
|
|
14
|
+
|
|
15
|
+
__all__ = ["Fill"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _curve(v: Any, n: int, what: str) -> Any:
|
|
19
|
+
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
|
20
|
+
return float(v)
|
|
21
|
+
c = _backend.doubles(v)
|
|
22
|
+
if len(c) != n:
|
|
23
|
+
raise ValueError(f"{what} has {len(c)} values for {n} points")
|
|
24
|
+
return c
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Fill(Series):
|
|
28
|
+
"""The area between ``y1`` and ``y2`` (a sequence or a constant) along ``x``;
|
|
29
|
+
``orientation="horizontal"`` fills between x curves along y (``fill(y, x1, x2)``)."""
|
|
30
|
+
|
|
31
|
+
SETTINGS = table(
|
|
32
|
+
color=Setting(color, doc="Fill color; default: the next palette color."),
|
|
33
|
+
alpha=Setting(number(0, 1),
|
|
34
|
+
doc="Opacity; default 0.3."),
|
|
35
|
+
line_color=Setting(color, doc="Outline color; default none."),
|
|
36
|
+
line_width=Setting(number(lo=0), inherited=True, doc="Outline width (pt).", length=True),
|
|
37
|
+
**curve_settings(),
|
|
38
|
+
orientation=Setting(one_of("vertical", "horizontal"),
|
|
39
|
+
doc="`vertical`: between y curves; `horizontal`: between x "
|
|
40
|
+
"curves."),
|
|
41
|
+
**common("name", "z", "rasterize", "simplify", "font_size", "font_family",
|
|
42
|
+
"text_color"),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def __init__(self, x: Any, y1: Any, y2: Any = 0.0, plot: Any = None,
|
|
46
|
+
**settings: Any) -> None:
|
|
47
|
+
self.t = _backend.doubles(x)
|
|
48
|
+
n = len(self.t)
|
|
49
|
+
rows = pdf_arrays.rows(y1)
|
|
50
|
+
if rows is None and isinstance(y1, (list, tuple)) and len(y1) == 2 and all(
|
|
51
|
+
hasattr(r, "__len__") and not isinstance(r, str) for r in y1):
|
|
52
|
+
rows = [_backend.doubles(r) for r in y1]
|
|
53
|
+
if rows is not None and len(rows) == 2 and (isinstance(y2, (int, float)) and y2 == 0):
|
|
54
|
+
y1, y2 = rows
|
|
55
|
+
self.v1 = _curve(y1, n, "y1")
|
|
56
|
+
self.v2 = _curve(y2, n, "y2")
|
|
57
|
+
super().__init__(plot=plot, **settings)
|
|
58
|
+
|
|
59
|
+
def _horizontal(self) -> bool:
|
|
60
|
+
return self.get("orientation") == "horizontal"
|
|
61
|
+
|
|
62
|
+
def values(self, role: str) -> Any:
|
|
63
|
+
if (role == "x") != self._horizontal():
|
|
64
|
+
return self.t
|
|
65
|
+
out = array.array("d")
|
|
66
|
+
for v in (self.v1, self.v2):
|
|
67
|
+
if isinstance(v, float):
|
|
68
|
+
out.append(v)
|
|
69
|
+
else:
|
|
70
|
+
out.extend(v)
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
def occupy(self, ctx: Any, occ: Any) -> None:
|
|
74
|
+
from ..occupancy import MAX_VERTICES, thin
|
|
75
|
+
n = len(self.t)
|
|
76
|
+
v1, v2 = ([v] * n if isinstance(v, float) else v for v in (self.v1, self.v2))
|
|
77
|
+
t, v1, v2 = thin(self.t, v1, v2, limit=MAX_VERTICES)
|
|
78
|
+
if self._horizontal():
|
|
79
|
+
occ.band(ctx.ymap.many(t), ctx.xmap.many(v1), ctx.xmap.many(v2), True)
|
|
80
|
+
else:
|
|
81
|
+
occ.band(ctx.xmap.many(t), ctx.ymap.many(v1), ctx.ymap.many(v2))
|
|
82
|
+
|
|
83
|
+
def legend_points(self) -> tuple[Any, Any] | None:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
def draw(self, ctx: Any) -> None:
|
|
87
|
+
c = self.base_color(ctx)
|
|
88
|
+
alpha = self.get("alpha")
|
|
89
|
+
stroke = ctx.color(self.get("line_color"))
|
|
90
|
+
width = self.get("line_width")
|
|
91
|
+
canvas = ctx.canvas
|
|
92
|
+
args = line_args(self)
|
|
93
|
+
interp = interpolation_of(self)
|
|
94
|
+
missing = self.get("missing")
|
|
95
|
+
if not self._horizontal():
|
|
96
|
+
xs = ctx.xmap.many(self.t)
|
|
97
|
+
y1 = ctx.ymap.many(self.v1) if not isinstance(self.v1, float) else ctx.ymap(self.v1)
|
|
98
|
+
y2 = ctx.ymap.many(self.v2) if not isinstance(self.v2, float) else ctx.ymap(self.v2)
|
|
99
|
+
simplify = self.simplify_arg(lines=True)
|
|
100
|
+
if c is not None:
|
|
101
|
+
canvas.fill_between(xs, y1, y2, color=c, opacity=alpha, stroke=None,
|
|
102
|
+
simplify=simplify, **args)
|
|
103
|
+
if stroke is not None and width:
|
|
104
|
+
for y in (y1, y2):
|
|
105
|
+
if isinstance(y, float):
|
|
106
|
+
if len(xs):
|
|
107
|
+
canvas.draw_lines([min(xs), max(xs)], [y, y], color=stroke,
|
|
108
|
+
width=width)
|
|
109
|
+
else:
|
|
110
|
+
canvas.draw_lines(xs, y, color=stroke, width=width, **args)
|
|
111
|
+
return
|
|
112
|
+
# Horizontal: the curves run along y; steps and skipped points are expanded here,
|
|
113
|
+
# curves drawn straight.
|
|
114
|
+
ys = ctx.ymap.many(self.t)
|
|
115
|
+
n = len(ys)
|
|
116
|
+
x1 = (ctx.xmap.many(self.v1) if not isinstance(self.v1, float)
|
|
117
|
+
else array.array("d", [ctx.xmap(self.v1)] * n))
|
|
118
|
+
x2 = (ctx.xmap.many(self.v2) if not isinstance(self.v2, float)
|
|
119
|
+
else array.array("d", [ctx.xmap(self.v2)] * n))
|
|
120
|
+
ys, (x1, x2) = expand(ys, [x1, x2], interp if interp.startswith("step") else "linear",
|
|
121
|
+
missing)
|
|
122
|
+
n = len(ys)
|
|
123
|
+
runs: list[list[int]] = []
|
|
124
|
+
cur: list[int] = []
|
|
125
|
+
for i in range(n):
|
|
126
|
+
if all(math.isfinite(v) for v in (ys[i], x1[i], x2[i])):
|
|
127
|
+
cur.append(i)
|
|
128
|
+
elif cur:
|
|
129
|
+
runs.append(cur)
|
|
130
|
+
cur = []
|
|
131
|
+
if cur:
|
|
132
|
+
runs.append(cur)
|
|
133
|
+
canvas.save()
|
|
134
|
+
if c is not None:
|
|
135
|
+
canvas.fill_style = with_alpha(c, alpha)
|
|
136
|
+
for run in runs:
|
|
137
|
+
canvas.begin_path()
|
|
138
|
+
canvas.move_to(x1[run[0]], ys[run[0]])
|
|
139
|
+
for i in run[1:]:
|
|
140
|
+
canvas.line_to(x1[i], ys[i])
|
|
141
|
+
for i in reversed(run):
|
|
142
|
+
canvas.line_to(x2[i], ys[i])
|
|
143
|
+
canvas.close_path()
|
|
144
|
+
canvas.fill()
|
|
145
|
+
canvas.restore()
|
|
146
|
+
if stroke is not None and width:
|
|
147
|
+
canvas.draw_lines(x1, ys, color=stroke, width=width)
|
|
148
|
+
canvas.draw_lines(x2, ys, color=stroke, width=width)
|
|
149
|
+
|
|
150
|
+
def zero_default(self, role: str) -> bool:
|
|
151
|
+
return role == ("x" if self.get("orientation") == "horizontal" else "y")
|
|
152
|
+
|
|
153
|
+
def legend_colors(self, ctx: Any) -> tuple[Any, Any]:
|
|
154
|
+
c = self.base_color(ctx)
|
|
155
|
+
stroke = ctx.color(self.get("line_color")) if self.get("line_width") else None
|
|
156
|
+
return (with_alpha(c, self.get("alpha")) if c is not None else None), stroke
|
|
157
|
+
|
|
158
|
+
def legend_glyph(self, ctx: Any, x: float, y: float, w: float, h: float) -> None:
|
|
159
|
+
c = self.base_color(ctx)
|
|
160
|
+
canvas = ctx.canvas
|
|
161
|
+
if c is not None:
|
|
162
|
+
canvas.fill_style = with_alpha(c, self.get("alpha"))
|
|
163
|
+
canvas.fill_rect(x, y, w, h)
|
|
164
|
+
stroke = ctx.color(self.get("line_color"))
|
|
165
|
+
if stroke is not None and self.get("line_width"):
|
|
166
|
+
canvas.stroke_style = stroke
|
|
167
|
+
canvas.line_width = self.get("line_width")
|
|
168
|
+
canvas.stroke_rect(x, y, w, h)
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Heatmap: a grid of values drawn as colored cells."""
|
|
2
|
+
|
|
3
|
+
import array
|
|
4
|
+
import math
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pdf_io import arrays as pdf_arrays
|
|
8
|
+
|
|
9
|
+
from .. import _backend
|
|
10
|
+
from ..maps import ColorMap, check_cmap
|
|
11
|
+
from ..numbers import NumberStyle, check_format, format_numbers, number_settings
|
|
12
|
+
from ..limits import check_limits
|
|
13
|
+
from ..scales import NUMERIC_SCALES, get_scale
|
|
14
|
+
from ..settings import Setting, boolean, color, either, literal, number, one_of, table
|
|
15
|
+
from ..text import Font, draw_text
|
|
16
|
+
from . import Series, common
|
|
17
|
+
|
|
18
|
+
__all__ = ["Heatmap"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _labels(v: Any) -> Any:
|
|
22
|
+
if v == "all":
|
|
23
|
+
return "all"
|
|
24
|
+
return boolean(v)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _Coords:
|
|
28
|
+
"""Cell coordinates along one axis: categories, centres or edges."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, given: Any, n: int, what: str) -> None:
|
|
31
|
+
self.n = n
|
|
32
|
+
self.categories: list[Any] | None = None
|
|
33
|
+
self.kind = "index"
|
|
34
|
+
self.values: array.array = array.array("d")
|
|
35
|
+
if given is None:
|
|
36
|
+
return
|
|
37
|
+
items = list(given)
|
|
38
|
+
if items and all(isinstance(v, str) for v in items):
|
|
39
|
+
if len(items) != n:
|
|
40
|
+
raise ValueError(f"{len(items)} {what} labels for {n} cells")
|
|
41
|
+
self.categories = items
|
|
42
|
+
self.kind = "category"
|
|
43
|
+
return
|
|
44
|
+
vals = _backend.doubles(given)
|
|
45
|
+
if len(vals) == n:
|
|
46
|
+
self.kind = "centers"
|
|
47
|
+
elif len(vals) == n + 1:
|
|
48
|
+
self.kind = "edges"
|
|
49
|
+
else:
|
|
50
|
+
raise ValueError(f"{what} needs {n} centres or {n + 1} edges, got {len(vals)}")
|
|
51
|
+
if any(not math.isfinite(v) for v in vals):
|
|
52
|
+
raise ValueError(f"{what} coordinates must be finite")
|
|
53
|
+
d = [b - a for a, b in zip(vals, vals[1:])]
|
|
54
|
+
if d and not (all(x > 0 for x in d) or all(x < 0 for x in d)):
|
|
55
|
+
raise ValueError(f"{what} coordinates must be monotonic")
|
|
56
|
+
self.values = vals
|
|
57
|
+
|
|
58
|
+
def edges_f(self, f: Any) -> list[float]:
|
|
59
|
+
"""Cell edges after the scale function ``f``."""
|
|
60
|
+
if self.kind in ("index", "category"):
|
|
61
|
+
return [i - 0.5 for i in range(self.n + 1)]
|
|
62
|
+
fv = [f(v) for v in self.values]
|
|
63
|
+
if any(not math.isfinite(v) for v in fv):
|
|
64
|
+
raise ValueError("heatmap coordinates outside the axis scale's domain")
|
|
65
|
+
if self.kind == "edges":
|
|
66
|
+
return fv
|
|
67
|
+
if len(fv) == 1:
|
|
68
|
+
return [fv[0] - 0.5, fv[0] + 0.5]
|
|
69
|
+
mids = [(a + b) / 2 for a, b in zip(fv, fv[1:])]
|
|
70
|
+
return [fv[0] - (mids[0] - fv[0])] + mids + [fv[-1] + (fv[-1] - mids[-1])]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Heatmap(Series):
|
|
74
|
+
"""A grid of values (rows first) as colored cells; ``x``/``y`` give cell labels,
|
|
75
|
+
centres or edges."""
|
|
76
|
+
|
|
77
|
+
SETTINGS = table(
|
|
78
|
+
color_map=Setting(check_cmap, inherited=True,
|
|
79
|
+
doc="A `ColorMap`, a map name or a list of colors."),
|
|
80
|
+
color_limits=Setting(check_limits, doc="Override of the map's limits for this series."),
|
|
81
|
+
color_scale=Setting(one_of(*NUMERIC_SCALES), doc="Override of the map's scale."),
|
|
82
|
+
mode=Setting(one_of("image", "cells", "smooth"),
|
|
83
|
+
doc="`image` (one image pixel per cell), `cells` (rectangles) or "
|
|
84
|
+
"`smooth` (interpolated)."),
|
|
85
|
+
nan_color=Setting(color, doc="Color of missing cells; default: the map's `bad` "
|
|
86
|
+
"(transparent)."),
|
|
87
|
+
origin=Setting(one_of("top", "bottom"),
|
|
88
|
+
doc="`top`: row 0 at the top (y axis inverted); `bottom`: at the bottom."),
|
|
89
|
+
labels=Setting(_labels, doc="Write each cell's value (`\"all\"` above 2,500 cells)."),
|
|
90
|
+
label_format=Setting(check_format,
|
|
91
|
+
doc="Number format of cell labels (`auto`: 3 significant digits, "
|
|
92
|
+
"the same decimals for all cells)."),
|
|
93
|
+
label_size=Setting(number(positive=True), doc="Font size of cell labels (pt).", length=True),
|
|
94
|
+
colorbar=Setting(either(literal(False), literal(True)),
|
|
95
|
+
doc="Attach a colorbar to the plot."),
|
|
96
|
+
**common("name", "alpha", "z", "rasterize", "simplify", "font_size", "font_family",
|
|
97
|
+
"text_color"),
|
|
98
|
+
**number_settings(),
|
|
99
|
+
)
|
|
100
|
+
USES_PALETTE = False
|
|
101
|
+
|
|
102
|
+
def __init__(self, values: Any, x: Any = None, y: Any = None, plot: Any = None,
|
|
103
|
+
**settings: Any) -> None:
|
|
104
|
+
g = pdf_arrays.grid(values)
|
|
105
|
+
if g is not None:
|
|
106
|
+
h, w, k, flat = g
|
|
107
|
+
self.h, self.w, self.k = h, w, k
|
|
108
|
+
self.flat = flat
|
|
109
|
+
else:
|
|
110
|
+
rows = [list(r) for r in values]
|
|
111
|
+
if not rows:
|
|
112
|
+
raise ValueError("a heatmap needs at least one row")
|
|
113
|
+
w = len(rows[0])
|
|
114
|
+
if any(len(r) != w for r in rows):
|
|
115
|
+
raise ValueError("heatmap rows must have the same length")
|
|
116
|
+
if w and rows[0] and isinstance(rows[0][0], (list, tuple)):
|
|
117
|
+
self.k = len(rows[0][0])
|
|
118
|
+
self.flat = array.array("d", [float(c) for r in rows for cell in r for c in cell])
|
|
119
|
+
else:
|
|
120
|
+
self.k = 1
|
|
121
|
+
self.flat = _backend.doubles([v for r in rows for v in r])
|
|
122
|
+
self.h, self.w = len(rows), w
|
|
123
|
+
if self.k not in (1, 3, 4):
|
|
124
|
+
raise ValueError("cells must be values or (r, g, b[, a]) colors")
|
|
125
|
+
self.xc = _Coords(x, self.w, "x")
|
|
126
|
+
self.yc = _Coords(y, self.h, "y")
|
|
127
|
+
self._map: Any = None
|
|
128
|
+
self._map_key: Any = None
|
|
129
|
+
self._bar: Any = None
|
|
130
|
+
super().__init__(plot=plot, **settings)
|
|
131
|
+
if self.get("colorbar"):
|
|
132
|
+
self._attach_bar()
|
|
133
|
+
|
|
134
|
+
def set(self, **settings: Any) -> "Heatmap":
|
|
135
|
+
super().set(**settings)
|
|
136
|
+
if settings.get("colorbar") and getattr(self, "plot", None) is not None:
|
|
137
|
+
self._attach_bar()
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
def _attach_bar(self) -> None:
|
|
141
|
+
if self._bar is None and self.k == 1:
|
|
142
|
+
self._bar = self.plot.colorbar(self)
|
|
143
|
+
|
|
144
|
+
# -- color map ----------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def color_map(self) -> ColorMap | None:
|
|
147
|
+
"""The map used: ``cmap`` with ``color_limits``/``color_scale`` applied."""
|
|
148
|
+
if self.k != 1:
|
|
149
|
+
return None
|
|
150
|
+
cmap, lim, sc = self.get("color_map"), self.get("color_limits"), self.get("color_scale")
|
|
151
|
+
key = (id(cmap) if isinstance(cmap, ColorMap) else repr(cmap), lim, sc)
|
|
152
|
+
if self._map is None or self._map_key != key:
|
|
153
|
+
if isinstance(cmap, ColorMap) and lim is None and sc is None:
|
|
154
|
+
self._map = cmap
|
|
155
|
+
else:
|
|
156
|
+
base = cmap if isinstance(cmap, ColorMap) else ColorMap(cmap)
|
|
157
|
+
self._map = ColorMap(base, limits=lim if lim is not None else base.limits,
|
|
158
|
+
scale=sc or base.scale)
|
|
159
|
+
self._map_key = key
|
|
160
|
+
return self._map
|
|
161
|
+
|
|
162
|
+
def color_sources(self) -> list[tuple[Any, Any]]:
|
|
163
|
+
m = self.color_map()
|
|
164
|
+
return [] if m is None else [(m, self.flat)]
|
|
165
|
+
|
|
166
|
+
def has_own_color(self) -> bool:
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
# -- axes ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def _coords(self, role: str) -> _Coords:
|
|
172
|
+
return self.xc if role == "x" else self.yc
|
|
173
|
+
|
|
174
|
+
def categories(self, role: str) -> list[Any] | None:
|
|
175
|
+
return self._coords(role).categories
|
|
176
|
+
|
|
177
|
+
def inverts(self, role: str) -> bool:
|
|
178
|
+
return role == "y" and self.get("origin") == "top"
|
|
179
|
+
|
|
180
|
+
def extent(self, role: str, scale: Any) -> tuple[int, float, float]:
|
|
181
|
+
c = self._coords(role)
|
|
182
|
+
if c.kind in ("index", "category"):
|
|
183
|
+
return c.n, -0.5, c.n - 0.5
|
|
184
|
+
e = c.edges_f(scale.f)
|
|
185
|
+
lo, hi = min(e), max(e)
|
|
186
|
+
return c.n, scale.inverse(lo), scale.inverse(hi)
|
|
187
|
+
|
|
188
|
+
def values(self, role: str) -> Any:
|
|
189
|
+
c = self._coords(role)
|
|
190
|
+
return c.values if c.kind in ("centers", "edges") else None
|
|
191
|
+
|
|
192
|
+
def zero_padding(self, role: str) -> bool:
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
# -- drawing ------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def _page_edges(self, ctx: Any, role: str) -> list[float]:
|
|
198
|
+
state = ctx.x if role == "x" else ctx.y
|
|
199
|
+
mapper = ctx.xmap if role == "x" else ctx.ymap
|
|
200
|
+
return [mapper.position(e) for e in self._coords(role).edges_f(state.f)]
|
|
201
|
+
|
|
202
|
+
def occupy(self, ctx: Any, occ: Any) -> None:
|
|
203
|
+
self.occupy_all(ctx, occ)
|
|
204
|
+
|
|
205
|
+
def draw(self, ctx: Any) -> None:
|
|
206
|
+
xe = self._page_edges(ctx, "x")
|
|
207
|
+
ye = self._page_edges(ctx, "y")
|
|
208
|
+
h, w, k = self.h, self.w, self.k
|
|
209
|
+
flip_rows = ye[0] > ye[-1] # row 0 must come first from the top
|
|
210
|
+
flip_cols = xe[0] > xe[-1]
|
|
211
|
+
if flip_rows:
|
|
212
|
+
ye = ye[::-1]
|
|
213
|
+
if flip_cols:
|
|
214
|
+
xe = xe[::-1]
|
|
215
|
+
cmap = self.color_map()
|
|
216
|
+
alpha = self.get("alpha")
|
|
217
|
+
alpha = 1.0 if alpha is None else alpha
|
|
218
|
+
if cmap is not None:
|
|
219
|
+
flo, fhi = ctx.maps[id(cmap)][:2]
|
|
220
|
+
fn = get_scale(cmap.scale).function
|
|
221
|
+
flat = _backend.scaled(self.flat, 1.0, 0.0, fn) if fn else self.flat
|
|
222
|
+
rows = [flat[r * w:(r + 1) * w] for r in range(h)]
|
|
223
|
+
else:
|
|
224
|
+
flo = fhi = 0.0
|
|
225
|
+
rows = [[tuple(self.flat[(r * w + c) * k:(r * w + c + 1) * k]) for c in range(w)]
|
|
226
|
+
for r in range(h)]
|
|
227
|
+
order = list(range(h))[::-1] if flip_rows else list(range(h))
|
|
228
|
+
rows = [rows[i] for i in order]
|
|
229
|
+
if flip_cols:
|
|
230
|
+
rows = [r[::-1] for r in rows]
|
|
231
|
+
even_x = _even(xe)
|
|
232
|
+
even_y = _even(ye)
|
|
233
|
+
mode = self.get("mode")
|
|
234
|
+
nan_color = ctx.color(self.get("nan_color"))
|
|
235
|
+
pdf_map = cmap.to_pdf_io() if cmap is not None else None
|
|
236
|
+
args = dict(cmap=pdf_map, vmin=flo if cmap is not None else None,
|
|
237
|
+
vmax=fhi if cmap is not None else None, nan_color=nan_color,
|
|
238
|
+
opacity=alpha)
|
|
239
|
+
canvas = ctx.canvas
|
|
240
|
+
if pdf_map is None:
|
|
241
|
+
args.pop("cmap")
|
|
242
|
+
if even_x and even_y:
|
|
243
|
+
canvas.draw_heatmap(rows, xe[0], ye[0], xe[-1] - xe[0], ye[-1] - ye[0], mode=mode,
|
|
244
|
+
**args)
|
|
245
|
+
else:
|
|
246
|
+
if mode == "image":
|
|
247
|
+
mode = "cells"
|
|
248
|
+
canvas.draw_heatmap(rows, x_edges=xe, y_edges=ye, mode=mode, **args)
|
|
249
|
+
if self.get("labels"):
|
|
250
|
+
self._labels(ctx, rows, xe, ye, cmap, flo, fhi)
|
|
251
|
+
|
|
252
|
+
def _cell_color(self, v: Any, cmap: Any, flo: float, fhi: float) -> Any:
|
|
253
|
+
if cmap is None:
|
|
254
|
+
return v
|
|
255
|
+
if v != v:
|
|
256
|
+
return cmap.bad
|
|
257
|
+
span = (fhi - flo) or 1.0
|
|
258
|
+
return cmap((v - flo) / span)
|
|
259
|
+
|
|
260
|
+
def _labels(self, ctx: Any, rows: list[Any], xe: list[float], ye: list[float], cmap: Any,
|
|
261
|
+
flo: float, fhi: float) -> None:
|
|
262
|
+
if self.h * self.w > 2500 and self.get("labels") != "all":
|
|
263
|
+
raise ValueError(f"{self.h * self.w} cells is too many for labels "
|
|
264
|
+
"(use labels=\"all\" to force)")
|
|
265
|
+
fmt = self.get("label_format")
|
|
266
|
+
size = self.get("label_size")
|
|
267
|
+
family = self.get("font_family")
|
|
268
|
+
fn = get_scale(cmap.scale).inverse if cmap is not None else None
|
|
269
|
+
cells = [(r, c, v) for r, row in enumerate(rows) for c, v in enumerate(row)]
|
|
270
|
+
if cmap is not None:
|
|
271
|
+
cells = [(r, c, v) for r, c, v in cells if v == v]
|
|
272
|
+
style = NumberStyle.of(self)
|
|
273
|
+
if style.precision is None and fmt == "auto":
|
|
274
|
+
style.precision = 3
|
|
275
|
+
texts = format_numbers([fn(v) if fn else v for _, _, v in cells], fmt, style)
|
|
276
|
+
elif callable(fmt):
|
|
277
|
+
texts = [fmt(v) for _, _, v in cells]
|
|
278
|
+
elif fmt not in ("auto", "percent", "sci", "eng", "si", "int"):
|
|
279
|
+
texts = [fmt.format(*v[:3]) for _, _, v in cells]
|
|
280
|
+
else:
|
|
281
|
+
texts = ["" for _ in cells]
|
|
282
|
+
for (r, c, v), text in zip(cells, texts):
|
|
283
|
+
col = self._cell_color(v, cmap, flo, fhi)
|
|
284
|
+
ink = "black" if _luminance(col) > 0.45 else "white"
|
|
285
|
+
draw_text(ctx.canvas, text, (xe[c] + xe[c + 1]) / 2, (ye[r] + ye[r + 1]) / 2,
|
|
286
|
+
Font(size, family, color=ink), "center", "center")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _even(e: list[float]) -> bool:
|
|
290
|
+
if len(e) < 3:
|
|
291
|
+
return True
|
|
292
|
+
d0 = (e[-1] - e[0]) / (len(e) - 1)
|
|
293
|
+
return all(abs((b - a) - d0) <= 1e-6 * max(1.0, abs(d0)) for a, b in zip(e, e[1:]))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _luminance(c: Any) -> float:
|
|
297
|
+
if c is None:
|
|
298
|
+
return 1.0
|
|
299
|
+
from pdf_io.colors import to_rgba
|
|
300
|
+
rgba = to_rgba(c) if not isinstance(c, tuple) else c
|
|
301
|
+
if rgba is None:
|
|
302
|
+
return 1.0
|
|
303
|
+
r, g, b = (float(x) for x in rgba[:3])
|
|
304
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|