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.
Files changed (65) hide show
  1. plot_io/__init__.py +66 -0
  2. plot_io/_backend.py +114 -0
  3. plot_io/axis.py +574 -0
  4. plot_io/colorbar.py +342 -0
  5. plot_io/decorations.py +496 -0
  6. plot_io/defaults.py +766 -0
  7. plot_io/density.py +97 -0
  8. plot_io/enums.py +63 -0
  9. plot_io/facets.py +184 -0
  10. plot_io/figure.py +331 -0
  11. plot_io/labels.py +63 -0
  12. plot_io/legend.py +498 -0
  13. plot_io/limits.py +92 -0
  14. plot_io/maps/__init__.py +365 -0
  15. plot_io/maps/data.py +333 -0
  16. plot_io/markers.py +117 -0
  17. plot_io/numbers.py +289 -0
  18. plot_io/occupancy.py +219 -0
  19. plot_io/palettes.py +142 -0
  20. plot_io/plot.py +435 -0
  21. plot_io/render.py +1122 -0
  22. plot_io/scales.py +93 -0
  23. plot_io/series/__init__.py +498 -0
  24. plot_io/series/area.py +181 -0
  25. plot_io/series/bar.py +294 -0
  26. plot_io/series/box.py +401 -0
  27. plot_io/series/catdata.py +23 -0
  28. plot_io/series/catpoints.py +182 -0
  29. plot_io/series/contour.py +294 -0
  30. plot_io/series/curves.py +128 -0
  31. plot_io/series/ecdf.py +143 -0
  32. plot_io/series/errorbar.py +111 -0
  33. plot_io/series/errors.py +100 -0
  34. plot_io/series/fill.py +168 -0
  35. plot_io/series/heatmap.py +304 -0
  36. plot_io/series/hexbin.py +216 -0
  37. plot_io/series/hist.py +379 -0
  38. plot_io/series/hist2d.py +163 -0
  39. plot_io/series/kde.py +228 -0
  40. plot_io/series/kde2d.py +153 -0
  41. plot_io/series/line.py +173 -0
  42. plot_io/series/pie.py +304 -0
  43. plot_io/series/points.py +341 -0
  44. plot_io/series/regression.py +250 -0
  45. plot_io/series/scatter.py +103 -0
  46. plot_io/series/stem.py +112 -0
  47. plot_io/series/violin.py +321 -0
  48. plot_io/settings.py +281 -0
  49. plot_io/shapes.py +57 -0
  50. plot_io/sharing.py +160 -0
  51. plot_io/slots.py +52 -0
  52. plot_io/stacking.py +63 -0
  53. plot_io/stats.py +140 -0
  54. plot_io/style.py +71 -0
  55. plot_io/styles/__init__.py +234 -0
  56. plot_io/styles/library.py +732 -0
  57. plot_io/tables.py +141 -0
  58. plot_io/text.py +109 -0
  59. plot_io/ticks.py +284 -0
  60. plot_io/times.py +199 -0
  61. plot_io/values.py +161 -0
  62. plot_io-0.1.0.dist-info/METADATA +102 -0
  63. plot_io-0.1.0.dist-info/RECORD +65 -0
  64. plot_io-0.1.0.dist-info/WHEEL +4 -0
  65. plot_io-0.1.0.dist-info/licenses/LICENSE +28 -0
@@ -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