bfplot 1.0.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.
- bfplot/__init__.py +38 -0
- bfplot/adapters/__init__.py +1 -0
- bfplot/adapters/matplotlib.py +543 -0
- bfplot/api.py +196 -0
- bfplot/artifact.py +302 -0
- bfplot/bfir.py +263 -0
- bfplot/bfir.schema.json +110 -0
- bfplot/preview.py +553 -0
- bfplot/py.typed +0 -0
- bfplot/result.py +231 -0
- bfplot/temporal.py +212 -0
- bfplot-1.0.0.dist-info/METADATA +220 -0
- bfplot-1.0.0.dist-info/RECORD +15 -0
- bfplot-1.0.0.dist-info/WHEEL +4 -0
- bfplot-1.0.0.dist-info/licenses/LICENSE +21 -0
bfplot/preview.py
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
"""Rough text drawings of a BFIR document, written into the manifest so commits show the plot.
|
|
2
|
+
|
|
3
|
+
Lines, markers and outlines are drawn with Braille dots (2x4 per character), images
|
|
4
|
+
and colorbars with an ASCII shade ramp, and axes are placed where their figure bounds
|
|
5
|
+
put them. The drawing is approximate by design; the document stays the source of truth.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from .bfir import BfirDocument
|
|
18
|
+
|
|
19
|
+
WIDTH = 72
|
|
20
|
+
SHADES = " .:-=+*#%@"
|
|
21
|
+
_DOTS = np.array([[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]], dtype=np.uint8)
|
|
22
|
+
_NO_LINE = {None, "None", "none", "", " "}
|
|
23
|
+
_CHUNK = 1 << 21 # rasterized points per batch, to bound memory on dense data
|
|
24
|
+
|
|
25
|
+
Project = Callable[..., np.ndarray]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def render(bfir: BfirDocument, width: int = WIDTH) -> list[str]:
|
|
29
|
+
"""Text lines (without comment markers) drawing every figure in ``bfir``."""
|
|
30
|
+
|
|
31
|
+
def array(value: Any) -> np.ndarray:
|
|
32
|
+
if isinstance(value, dict) and "$buffer" in value:
|
|
33
|
+
return bfir.buffers[value["$buffer"]].array
|
|
34
|
+
return np.asarray(value)
|
|
35
|
+
|
|
36
|
+
document = bfir.document
|
|
37
|
+
animated = {
|
|
38
|
+
figure_id
|
|
39
|
+
for timeline in document.get("timelines", [])
|
|
40
|
+
for figure_id in timeline.get("figures", [])
|
|
41
|
+
}
|
|
42
|
+
lines: list[str] = []
|
|
43
|
+
for index, figure in enumerate(document.get("figures", [])):
|
|
44
|
+
if index:
|
|
45
|
+
lines.append("")
|
|
46
|
+
lines.append(_heading(figure, index, width, animated=figure.get("id") in animated))
|
|
47
|
+
try:
|
|
48
|
+
lines += _figure(figure, array, width)
|
|
49
|
+
except Exception as error: # a preview must never stop a result from being written
|
|
50
|
+
lines.append(f"(no preview: {type(error).__name__}: {_clean(str(error))})")
|
|
51
|
+
return lines
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _clean(text: Any) -> str:
|
|
55
|
+
return "".join(char if char.isprintable() else " " for char in str(text or ""))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _heading(figure: dict[str, Any], index: int, width: int, *, animated: bool) -> str:
|
|
59
|
+
parts = [_clean(figure.get("name") or f"figure {index + 1}")]
|
|
60
|
+
if figure.get("title"):
|
|
61
|
+
parts.append(_clean(figure["title"]))
|
|
62
|
+
text = "── " + " · ".join(parts) + (" (first frame)" if animated else "") + " "
|
|
63
|
+
return text + "─" * max(0, width - len(text))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Canvas
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _Canvas:
|
|
70
|
+
def __init__(self, rows: int, cols: int):
|
|
71
|
+
self.rows, self.cols = rows, cols
|
|
72
|
+
self.cells = [[" "] * cols for _ in range(rows)]
|
|
73
|
+
|
|
74
|
+
def put(self, row: int, col: int, text: str, *, stop: int | None = None) -> None:
|
|
75
|
+
stop = self.cols if stop is None else min(stop, self.cols)
|
|
76
|
+
if 0 <= row < self.rows:
|
|
77
|
+
for offset, char in enumerate(text):
|
|
78
|
+
if 0 <= col + offset < stop:
|
|
79
|
+
self.cells[row][col + offset] = char
|
|
80
|
+
|
|
81
|
+
def label(self, row: int, col: int, text: str) -> None:
|
|
82
|
+
"""Write ``text`` only if all of it fits in blank cells, so labels never cut or overlap."""
|
|
83
|
+
if (
|
|
84
|
+
text
|
|
85
|
+
and 0 <= row < self.rows
|
|
86
|
+
and 0 <= col
|
|
87
|
+
and col + len(text) <= self.cols
|
|
88
|
+
and all(char == " " for char in self.cells[row][col : col + len(text)])
|
|
89
|
+
):
|
|
90
|
+
self.put(row, col, text)
|
|
91
|
+
|
|
92
|
+
def lines(self) -> list[str]:
|
|
93
|
+
lines = ["".join(row).rstrip() for row in self.cells]
|
|
94
|
+
while lines and not lines[-1]:
|
|
95
|
+
lines.pop()
|
|
96
|
+
while lines and not lines[0]:
|
|
97
|
+
lines.pop(0)
|
|
98
|
+
return lines
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _Plot:
|
|
102
|
+
"""A Braille dot grid over an axes interior, in unit coordinates (0, 0) bottom left."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, rows: int, cols: int):
|
|
105
|
+
self.rows, self.cols = rows, cols
|
|
106
|
+
self.dots = np.zeros((rows, cols), dtype=np.uint8)
|
|
107
|
+
self.shade: list[list[str | None]] = [[None] * cols for _ in range(rows)]
|
|
108
|
+
self.labels: list[tuple[int, int, str]] = []
|
|
109
|
+
|
|
110
|
+
def _pixels(self, uv: np.ndarray) -> np.ndarray:
|
|
111
|
+
uv = np.asarray(uv, dtype=float).reshape(-1, 2)
|
|
112
|
+
return np.column_stack([uv[:, 0] * 2 * self.cols, (1 - uv[:, 1]) * 4 * self.rows])
|
|
113
|
+
|
|
114
|
+
def _set(self, pixels: np.ndarray) -> None:
|
|
115
|
+
pixels = pixels[np.all(np.isfinite(pixels), axis=1)]
|
|
116
|
+
x, y = np.floor(pixels[:, 0]).astype(np.int64), np.floor(pixels[:, 1]).astype(np.int64)
|
|
117
|
+
inside = (x >= 0) & (x < 2 * self.cols) & (y >= 0) & (y < 4 * self.rows)
|
|
118
|
+
x, y = x[inside], y[inside]
|
|
119
|
+
np.bitwise_or.at(self.dots, (y // 4, x // 2), _DOTS[y % 4, x % 2])
|
|
120
|
+
|
|
121
|
+
def points(self, uv: np.ndarray) -> None:
|
|
122
|
+
self._set(self._pixels(uv))
|
|
123
|
+
|
|
124
|
+
def segments(self, starts: np.ndarray, ends: np.ndarray) -> None:
|
|
125
|
+
if not len(starts):
|
|
126
|
+
return
|
|
127
|
+
size = (2 * self.cols - 1e-9, 4 * self.rows - 1e-9)
|
|
128
|
+
a, b = _clip(self._pixels(starts), self._pixels(ends), *size)
|
|
129
|
+
steps = np.ceil(np.max(np.abs(b - a), axis=1)).astype(np.int64) + 1
|
|
130
|
+
bounds = np.searchsorted(np.cumsum(steps), np.arange(_CHUNK, steps.sum(), _CHUNK))
|
|
131
|
+
for first, last in zip(np.r_[0, bounds], np.r_[bounds, len(steps)], strict=True):
|
|
132
|
+
if last <= first:
|
|
133
|
+
continue
|
|
134
|
+
count = steps[first:last]
|
|
135
|
+
segment = np.repeat(np.arange(first, last), count)
|
|
136
|
+
offset = np.arange(count.sum()) - np.repeat(np.cumsum(count) - count, count)
|
|
137
|
+
t = offset / np.maximum(np.repeat(count - 1, count), 1)
|
|
138
|
+
self._set(a[segment] + t[:, None] * (b - a)[segment])
|
|
139
|
+
|
|
140
|
+
def paths(
|
|
141
|
+
self,
|
|
142
|
+
uv: np.ndarray,
|
|
143
|
+
lengths: np.ndarray | None = None,
|
|
144
|
+
*,
|
|
145
|
+
closed: bool = False,
|
|
146
|
+
codes: np.ndarray | None = None,
|
|
147
|
+
) -> None:
|
|
148
|
+
starts, ends = _path_segments(np.asarray(uv, dtype=float), lengths, closed, codes)
|
|
149
|
+
self.segments(starts, ends)
|
|
150
|
+
|
|
151
|
+
def fill(self, sample: Callable[[np.ndarray, np.ndarray], np.ndarray]) -> None:
|
|
152
|
+
"""Shade cells by ``sample(u, v)`` at their centers (values in [0, 1], NaN for none)."""
|
|
153
|
+
u = (np.arange(self.cols) + 0.5) / self.cols
|
|
154
|
+
v = 1 - (np.arange(self.rows) + 0.5) / self.rows
|
|
155
|
+
grid_u, grid_v = np.meshgrid(u, v)
|
|
156
|
+
values = np.asarray(sample(grid_u, grid_v), dtype=float)
|
|
157
|
+
for row in range(self.rows):
|
|
158
|
+
for col in range(self.cols):
|
|
159
|
+
value = values[row, col]
|
|
160
|
+
if math.isfinite(value):
|
|
161
|
+
level = round(min(max(value, 0.0), 1.0) * (len(SHADES) - 1))
|
|
162
|
+
self.shade[row][col] = SHADES[level]
|
|
163
|
+
|
|
164
|
+
def text(self, uv: np.ndarray, text: str) -> None:
|
|
165
|
+
u, v = np.asarray(uv, dtype=float).ravel()[:2]
|
|
166
|
+
if math.isfinite(u) and math.isfinite(v) and 0 <= u <= 1 and 0 <= v <= 1:
|
|
167
|
+
row = min(int((1 - v) * self.rows), self.rows - 1)
|
|
168
|
+
self.labels.append((row, min(int(u * self.cols), self.cols - 1), _clean(text)))
|
|
169
|
+
|
|
170
|
+
def blit(self, canvas: _Canvas, row: int, col: int, *, opaque: bool) -> None:
|
|
171
|
+
for r in range(self.rows):
|
|
172
|
+
for c in range(self.cols):
|
|
173
|
+
bits = int(self.dots[r, c])
|
|
174
|
+
char = chr(0x2800 + bits) if bits else (self.shade[r][c] or " ")
|
|
175
|
+
if opaque or char != " ":
|
|
176
|
+
canvas.put(row + r, col + c, char)
|
|
177
|
+
for r, c, text in self.labels:
|
|
178
|
+
canvas.put(row + r, col + c, text, stop=col + self.cols)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _clip(a: np.ndarray, b: np.ndarray, xmax: float, ymax: float) -> tuple[np.ndarray, np.ndarray]:
|
|
182
|
+
"""Liang-Barsky clipping of segments ``a -> b`` to ``[0, xmax] x [0, ymax]``."""
|
|
183
|
+
keep = np.all(np.isfinite(a), axis=1) & np.all(np.isfinite(b), axis=1)
|
|
184
|
+
a = np.where(keep[:, None], a, 0.0)
|
|
185
|
+
d = np.where(keep[:, None], b - a, 0.0)
|
|
186
|
+
low, high = np.zeros(len(a)), np.ones(len(a))
|
|
187
|
+
for p, q in (
|
|
188
|
+
(-d[:, 0], a[:, 0]),
|
|
189
|
+
(d[:, 0], xmax - a[:, 0]),
|
|
190
|
+
(-d[:, 1], a[:, 1]),
|
|
191
|
+
(d[:, 1], ymax - a[:, 1]),
|
|
192
|
+
):
|
|
193
|
+
parallel = p == 0
|
|
194
|
+
keep &= ~(parallel & (q < 0))
|
|
195
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
196
|
+
ratio = q / p
|
|
197
|
+
low = np.where(~parallel & (p < 0), np.maximum(low, ratio), low)
|
|
198
|
+
high = np.where(~parallel & (p > 0), np.minimum(high, ratio), high)
|
|
199
|
+
keep &= low <= high
|
|
200
|
+
return (a + low[:, None] * d)[keep], (a + high[:, None] * d)[keep]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _path_segments(
|
|
204
|
+
points: np.ndarray,
|
|
205
|
+
lengths: np.ndarray | None,
|
|
206
|
+
closed: bool,
|
|
207
|
+
codes: np.ndarray | None,
|
|
208
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
209
|
+
"""Segments of polylines stored back to back, split by ``lengths`` and MOVETO codes."""
|
|
210
|
+
count = len(points)
|
|
211
|
+
if count < 2:
|
|
212
|
+
return np.empty((0, 2)), np.empty((0, 2))
|
|
213
|
+
starts = np.zeros(count, dtype=bool)
|
|
214
|
+
starts[0] = True
|
|
215
|
+
if lengths is not None:
|
|
216
|
+
offsets = np.cumsum(np.asarray(lengths, dtype=np.int64))[:-1]
|
|
217
|
+
starts[offsets[offsets < count]] = True
|
|
218
|
+
closing = np.zeros(count, dtype=bool)
|
|
219
|
+
if codes is not None:
|
|
220
|
+
codes = np.asarray(codes)[:count]
|
|
221
|
+
starts[: len(codes)] |= codes == 1
|
|
222
|
+
closing[: len(codes)] = codes == 79
|
|
223
|
+
linked = ~starts[1:] & ~closing[1:]
|
|
224
|
+
first, last = [points[:-1][linked]], [points[1:][linked]]
|
|
225
|
+
group_start = np.flatnonzero(starts)[np.cumsum(starts) - 1]
|
|
226
|
+
if closed:
|
|
227
|
+
ends = np.r_[np.flatnonzero(starts)[1:] - 1, count - 1]
|
|
228
|
+
ends = ends[~closing[ends]]
|
|
229
|
+
first.append(points[ends])
|
|
230
|
+
last.append(points[group_start[ends]])
|
|
231
|
+
close_at = np.flatnonzero(closing[1:]) + 1
|
|
232
|
+
first.append(points[close_at - 1])
|
|
233
|
+
last.append(points[group_start[close_at]])
|
|
234
|
+
return np.concatenate(first), np.concatenate(last)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# Scales and projections
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class _Scale:
|
|
241
|
+
"""Maps one axis between data values and unit coordinates along the axes."""
|
|
242
|
+
|
|
243
|
+
TRANSFORMS: dict[str, tuple[Callable[[Any], Any], Callable[[Any], Any]]] = {
|
|
244
|
+
"log": (lambda v: np.log10(np.where(v > 0, v, np.nan)), lambda w: 10.0**w),
|
|
245
|
+
"symlog": (
|
|
246
|
+
lambda v: np.sign(v) * np.log10(1 + np.abs(v)),
|
|
247
|
+
lambda w: np.sign(w) * (10.0 ** np.abs(w) - 1),
|
|
248
|
+
),
|
|
249
|
+
"logit": (
|
|
250
|
+
lambda v: np.log10(np.where((v > 0) & (v < 1), v, np.nan) / (1 - v)),
|
|
251
|
+
lambda w: 1 / (1 + 10.0 ** (-w)),
|
|
252
|
+
),
|
|
253
|
+
"asinh": (np.arcsinh, np.sinh),
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
def __init__(self, axis: dict[str, Any]):
|
|
257
|
+
self.axis = axis
|
|
258
|
+
self._forward, self._inverse = self.TRANSFORMS.get(
|
|
259
|
+
axis.get("scale", "linear"), (lambda v: v, lambda w: w)
|
|
260
|
+
)
|
|
261
|
+
limits = axis.get("limits") or [None, None]
|
|
262
|
+
with np.errstate(all="ignore"):
|
|
263
|
+
low, high = (
|
|
264
|
+
self._forward(np.asarray(item, dtype=float)) if item is not None else np.nan
|
|
265
|
+
for item in limits[:2]
|
|
266
|
+
)
|
|
267
|
+
if not (np.isfinite(low) and np.isfinite(high)) or low == high:
|
|
268
|
+
raise ValueError(f"axis has unusable limits {limits!r}")
|
|
269
|
+
self.low, self.high = float(low), float(high)
|
|
270
|
+
|
|
271
|
+
def unit(self, values: Any) -> np.ndarray:
|
|
272
|
+
with np.errstate(all="ignore"):
|
|
273
|
+
return (self._forward(np.asarray(values, dtype=float)) - self.low) / (
|
|
274
|
+
self.high - self.low
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def value(self, unit: np.ndarray) -> np.ndarray:
|
|
278
|
+
with np.errstate(all="ignore"):
|
|
279
|
+
return self._inverse(self.low + unit * (self.high - self.low))
|
|
280
|
+
|
|
281
|
+
def label(self, index: int) -> str:
|
|
282
|
+
value = self.axis["limits"][index]
|
|
283
|
+
if self.axis.get("scale") == "date":
|
|
284
|
+
try:
|
|
285
|
+
return (datetime(1970, 1, 1) + timedelta(days=float(value))).strftime("%Y-%m-%d")
|
|
286
|
+
except (OverflowError, ValueError):
|
|
287
|
+
pass
|
|
288
|
+
return f"{value:.3g}"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _projection(axes: dict[str, Any]) -> tuple[Project, Callable[[_Plot], None]]:
|
|
292
|
+
"""A function from data coordinates to unit coordinates, and one drawing the axes frame."""
|
|
293
|
+
kind = axes.get("projection", "rectilinear")
|
|
294
|
+
if kind == "3d":
|
|
295
|
+
scales = [_Scale(axes[name]) for name in "xyz"]
|
|
296
|
+
view = axes.get("view") or {}
|
|
297
|
+
azim, elev = math.radians(view.get("azim", -60)), math.radians(view.get("elev", 30))
|
|
298
|
+
|
|
299
|
+
def rotate(x: Any, y: Any, z: Any) -> np.ndarray:
|
|
300
|
+
u = -x * math.sin(azim) + y * math.cos(azim)
|
|
301
|
+
v = -(x * math.cos(azim) + y * math.sin(azim)) * math.sin(elev) + z * math.cos(elev)
|
|
302
|
+
return np.column_stack([np.ravel(u) / 1.5 + 0.5, np.ravel(v) / 1.8 + 0.5])
|
|
303
|
+
|
|
304
|
+
def project_3d(x: Any, y: Any, z: Any) -> np.ndarray:
|
|
305
|
+
return rotate(*(scale.unit(item) - 0.5 for scale, item in zip(scales, (x, y, z))))
|
|
306
|
+
|
|
307
|
+
def floor(plot: _Plot) -> None:
|
|
308
|
+
corners = rotate(
|
|
309
|
+
np.array([-0.5, 0.5, 0.5, -0.5]), np.array([-0.5, -0.5, 0.5, 0.5]), -0.5
|
|
310
|
+
)
|
|
311
|
+
plot.paths(corners, closed=True)
|
|
312
|
+
|
|
313
|
+
return project_3d, floor
|
|
314
|
+
x_scale, y_scale = _Scale(axes["x"]), _Scale(axes["y"])
|
|
315
|
+
if kind == "polar":
|
|
316
|
+
|
|
317
|
+
def project_polar(theta: Any, radius: Any) -> np.ndarray:
|
|
318
|
+
theta, r = np.ravel(np.asarray(theta, dtype=float)), np.ravel(y_scale.unit(radius))
|
|
319
|
+
r = np.where(r >= 0, r, np.nan)
|
|
320
|
+
return np.column_stack([0.5 + 0.5 * r * np.cos(theta), 0.5 + 0.5 * r * np.sin(theta)])
|
|
321
|
+
|
|
322
|
+
def rim(plot: _Plot) -> None:
|
|
323
|
+
angles = np.linspace(0, 2 * np.pi, 97)
|
|
324
|
+
plot.paths(np.column_stack([0.5 + 0.5 * np.cos(angles), 0.5 + 0.5 * np.sin(angles)]))
|
|
325
|
+
|
|
326
|
+
return project_polar, rim
|
|
327
|
+
|
|
328
|
+
def project(x: Any, y: Any) -> np.ndarray:
|
|
329
|
+
return np.column_stack([np.ravel(x_scale.unit(x)), np.ravel(y_scale.unit(y))])
|
|
330
|
+
|
|
331
|
+
return project, lambda plot: None
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# Figures, axes and artists
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _figure(figure: dict[str, Any], array: Callable[[Any], np.ndarray], width: int) -> list[str]:
|
|
338
|
+
size = figure.get("size_inches") or [6.4, 4.8]
|
|
339
|
+
rows = int(np.clip(round(width * size[1] / size[0] * 0.45), 8, 60))
|
|
340
|
+
canvas = _Canvas(rows, width)
|
|
341
|
+
drawn: list[tuple[float, ...]] = []
|
|
342
|
+
for axes in figure.get("axes", []):
|
|
343
|
+
bounds = tuple(axes.get("bounds") or (0.125, 0.11, 0.775, 0.77))
|
|
344
|
+
_axes(canvas, axes, bounds, array, twin=bounds in drawn)
|
|
345
|
+
drawn.append(bounds)
|
|
346
|
+
return canvas.lines()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _axes(
|
|
350
|
+
canvas: _Canvas,
|
|
351
|
+
axes: dict[str, Any],
|
|
352
|
+
bounds: tuple[float, ...],
|
|
353
|
+
array: Callable[[Any], np.ndarray],
|
|
354
|
+
*,
|
|
355
|
+
twin: bool,
|
|
356
|
+
) -> None:
|
|
357
|
+
left, bottom, width, height = bounds
|
|
358
|
+
c0, c1 = round(left * canvas.cols), round((left + width) * canvas.cols) - 1
|
|
359
|
+
r0, r1 = round((1 - bottom - height) * canvas.rows), round((1 - bottom) * canvas.rows) - 1
|
|
360
|
+
c0, r0 = max(c0, 0), max(r0, 0)
|
|
361
|
+
c1, r1 = min(c1, canvas.cols - 1), min(r1, canvas.rows - 1)
|
|
362
|
+
if c1 - c0 < 3 or r1 - r0 < 2:
|
|
363
|
+
return
|
|
364
|
+
if axes.get("role") == "colorbar":
|
|
365
|
+
_colorbar(canvas, axes.get("colorbar") or {}, r0, r1, c0, c1)
|
|
366
|
+
return
|
|
367
|
+
kind = axes.get("projection", "rectilinear")
|
|
368
|
+
framed = kind not in ("3d", "polar")
|
|
369
|
+
title = _clean(axes.get("title"))
|
|
370
|
+
if framed:
|
|
371
|
+
plot = _Plot(r1 - r0 - 1, c1 - c0 - 1)
|
|
372
|
+
_frame(canvas, r0, r1, c0, c1, title)
|
|
373
|
+
else:
|
|
374
|
+
plot = _Plot(r1 - r0, c1 - c0 + 1)
|
|
375
|
+
if title:
|
|
376
|
+
canvas.put(r0, c0 + max(0, (c1 - c0 + 1 - len(title)) // 2), title, stop=c1 + 1)
|
|
377
|
+
project, decorate = _projection(axes)
|
|
378
|
+
decorate(plot)
|
|
379
|
+
for artist in sorted(
|
|
380
|
+
axes.get("artists", []), key=lambda item: item["props"].get("zorder") or 0
|
|
381
|
+
):
|
|
382
|
+
if artist.get("props", {}).get("visible") is not False:
|
|
383
|
+
_artist(plot, artist, project, array, axes)
|
|
384
|
+
if framed:
|
|
385
|
+
plot.blit(canvas, r0 + 1, c0 + 1, opaque=axes.get("role") == "inset")
|
|
386
|
+
_limits(canvas, axes, r0, r1, c0, c1, twin=twin)
|
|
387
|
+
else:
|
|
388
|
+
plot.blit(canvas, r0 + (1 if title else 0), c0, opaque=False)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _frame(canvas: _Canvas, r0: int, r1: int, c0: int, c1: int, title: str) -> None:
|
|
392
|
+
span = c1 - c0 - 1
|
|
393
|
+
top = "─" * span
|
|
394
|
+
if title and span >= 5:
|
|
395
|
+
label = f" {title[: span - 2]} "
|
|
396
|
+
start = (span - len(label)) // 2
|
|
397
|
+
top = top[:start] + label + top[start + len(label) :]
|
|
398
|
+
canvas.put(r0, c0, "┌" + top + "┐")
|
|
399
|
+
canvas.put(r1, c0, "└" + "─" * span + "┘")
|
|
400
|
+
for row in range(r0 + 1, r1):
|
|
401
|
+
canvas.put(row, c0, "│")
|
|
402
|
+
canvas.put(row, c1, "│")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _limits(
|
|
406
|
+
canvas: _Canvas, axes: dict[str, Any], r0: int, r1: int, c0: int, c1: int, *, twin: bool
|
|
407
|
+
) -> None:
|
|
408
|
+
x_scale, y_scale = _Scale(axes["x"]), _Scale(axes["y"])
|
|
409
|
+
for row, index in ((r0, 1), (r1, 0)):
|
|
410
|
+
label = y_scale.label(index)
|
|
411
|
+
canvas.label(row, c1 + 1 if twin else c0 - len(label), label)
|
|
412
|
+
if not twin:
|
|
413
|
+
low, high = x_scale.label(0), x_scale.label(1)
|
|
414
|
+
canvas.label(r1 + 1, c0, low)
|
|
415
|
+
canvas.label(r1 + 1, max(c1 + 1 - len(high), c0 + len(low) + 1), high)
|
|
416
|
+
xlabel = _clean(axes["x"].get("label"))[: c1 - c0 + 1]
|
|
417
|
+
canvas.label(r1 + 2, c0 + (c1 - c0 + 1 - len(xlabel)) // 2, xlabel)
|
|
418
|
+
ylabel = _clean(axes["y"].get("label"))
|
|
419
|
+
room = r1 - r0 - 1
|
|
420
|
+
if ylabel and room > 0:
|
|
421
|
+
column = c1 + 2 if twin else c0 - 2
|
|
422
|
+
text = ylabel[:room]
|
|
423
|
+
first = r0 + 1 + (room - len(text)) // 2
|
|
424
|
+
for offset, char in enumerate(text):
|
|
425
|
+
canvas.label(first + offset, column, char)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _colorbar(
|
|
429
|
+
canvas: _Canvas, colorbar: dict[str, Any], r0: int, r1: int, c0: int, c1: int
|
|
430
|
+
) -> None:
|
|
431
|
+
vertical = colorbar.get("orientation", "vertical") == "vertical"
|
|
432
|
+
_frame(canvas, r0, r1, c0, c1, "")
|
|
433
|
+
plot = _Plot(r1 - r0 - 1, c1 - c0 - 1)
|
|
434
|
+
plot.fill(lambda u, v: v if vertical else u)
|
|
435
|
+
plot.blit(canvas, r0 + 1, c0 + 1, opaque=True)
|
|
436
|
+
limits = [f"{item:.3g}" if item is not None else "" for item in colorbar.get("limits", [])]
|
|
437
|
+
if len(limits) == 2:
|
|
438
|
+
if vertical:
|
|
439
|
+
canvas.label(r0, c1 + 1, limits[1])
|
|
440
|
+
canvas.label(r1, c1 + 1, limits[0])
|
|
441
|
+
else:
|
|
442
|
+
canvas.label(r1 + 1, c0, limits[0])
|
|
443
|
+
canvas.label(r1 + 1, max(c1 + 1 - len(limits[1]), c0), limits[1])
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _artist(
|
|
447
|
+
plot: _Plot,
|
|
448
|
+
artist: dict[str, Any],
|
|
449
|
+
project: Project,
|
|
450
|
+
array: Callable[[Any], np.ndarray],
|
|
451
|
+
axes: dict[str, Any],
|
|
452
|
+
) -> None:
|
|
453
|
+
kind = artist["type"].removeprefix("bfplot.")
|
|
454
|
+
data, props = artist.get("data", {}), artist.get("props", {})
|
|
455
|
+
|
|
456
|
+
def values(key: str) -> np.ndarray:
|
|
457
|
+
return np.asarray(array(data[key]), dtype=float)
|
|
458
|
+
|
|
459
|
+
if kind in ("line", "line3d", "errorbar"):
|
|
460
|
+
uv = project(*(values(key) for key in ("x", "y", "z") if key in data))
|
|
461
|
+
if props.get("linestyle") not in _NO_LINE:
|
|
462
|
+
plot.paths(uv)
|
|
463
|
+
if props.get("marker") not in _NO_LINE or props.get("linestyle") in _NO_LINE:
|
|
464
|
+
plot.points(uv)
|
|
465
|
+
if "error_segments" in data:
|
|
466
|
+
segments = values("error_segments").reshape(-1, 2, 2)
|
|
467
|
+
plot.segments(
|
|
468
|
+
project(segments[:, 0, 0], segments[:, 0, 1]),
|
|
469
|
+
project(segments[:, 1, 0], segments[:, 1, 1]),
|
|
470
|
+
)
|
|
471
|
+
if "cap_segments" in data:
|
|
472
|
+
caps = values("cap_segments").reshape(-1, 2)
|
|
473
|
+
plot.points(project(caps[:, 0], caps[:, 1]))
|
|
474
|
+
elif kind == "scatter":
|
|
475
|
+
xy = values("xy").reshape(-1, 2)
|
|
476
|
+
plot.points(project(xy[:, 0], xy[:, 1]))
|
|
477
|
+
elif kind == "scatter3d":
|
|
478
|
+
plot.points(project(values("x"), values("y"), values("z")))
|
|
479
|
+
elif kind == "histogram":
|
|
480
|
+
x0, y0 = values("left"), values("bottom")
|
|
481
|
+
x1, y1 = x0 + values("width"), y0 + values("height")
|
|
482
|
+
corners = np.stack(
|
|
483
|
+
[np.stack(pair, axis=-1) for pair in ((x0, y0), (x1, y0), (x1, y1), (x0, y1))], axis=1
|
|
484
|
+
)
|
|
485
|
+
flat = corners.reshape(-1, 2)
|
|
486
|
+
plot.paths(project(flat[:, 0], flat[:, 1]), np.full(len(x0), 4), closed=True)
|
|
487
|
+
elif kind in ("surface3d", "mesh3d"):
|
|
488
|
+
vertices = values("vertices").reshape(-1, 3)
|
|
489
|
+
uv = project(vertices[:, 0], vertices[:, 1], vertices[:, 2])
|
|
490
|
+
plot.paths(uv, array(data["path_lengths"]), closed=kind == "surface3d")
|
|
491
|
+
elif kind in ("fill_between", "patch", "contour", "contourf") and "vertices" in data:
|
|
492
|
+
vertices = values("vertices").reshape(-1, 2)
|
|
493
|
+
lengths = array(data["path_lengths"]) if "path_lengths" in data else None
|
|
494
|
+
codes = array(data["path_codes"]) if "path_codes" in data else None
|
|
495
|
+
plot.paths(
|
|
496
|
+
project(vertices[:, 0], vertices[:, 1]),
|
|
497
|
+
lengths,
|
|
498
|
+
closed=kind in ("fill_between", "patch"),
|
|
499
|
+
codes=codes,
|
|
500
|
+
)
|
|
501
|
+
elif kind == "patch" and "path" in data:
|
|
502
|
+
vertices = values("path").reshape(-1, 2)
|
|
503
|
+
matrix = np.asarray(data.get("transform") or np.eye(3), dtype=float)
|
|
504
|
+
moved = np.column_stack([vertices, np.ones(len(vertices))]) @ matrix.T
|
|
505
|
+
plot.paths(project(moved[:, 0], moved[:, 1]), closed=True)
|
|
506
|
+
elif kind == "contour" and "segments" in data:
|
|
507
|
+
segments = values("segments")
|
|
508
|
+
if segments.ndim == 3 and segments.shape[1]:
|
|
509
|
+
flat = segments.reshape(-1, 2)
|
|
510
|
+
plot.paths(project(flat[:, 0], flat[:, 1]), np.full(len(segments), segments.shape[1]))
|
|
511
|
+
elif kind == "image" and axes.get("projection", "rectilinear") == "rectilinear":
|
|
512
|
+
_image(plot, values("values"), data.get("extent"), props, axes)
|
|
513
|
+
elif kind == "text":
|
|
514
|
+
position = data.get("position") or [None, None]
|
|
515
|
+
if None not in position:
|
|
516
|
+
plot.text(project(*position), data.get("text", ""))
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _image(
|
|
520
|
+
plot: _Plot,
|
|
521
|
+
pixels: np.ndarray,
|
|
522
|
+
extent: list[float] | None,
|
|
523
|
+
props: dict[str, Any],
|
|
524
|
+
axes: dict[str, Any],
|
|
525
|
+
) -> None:
|
|
526
|
+
if pixels.ndim == 3:
|
|
527
|
+
rgb = pixels[..., :3] / (255.0 if pixels.max(initial=0) > 1 else 1.0)
|
|
528
|
+
intensity = rgb @ np.array([0.2126, 0.7152, 0.0722])
|
|
529
|
+
if pixels.shape[-1] == 4:
|
|
530
|
+
intensity = np.where(pixels[..., 3] > 0, intensity, np.nan)
|
|
531
|
+
else:
|
|
532
|
+
low, high = props.get("clim") or [None, None]
|
|
533
|
+
low = np.nanmin(pixels) if low is None else low
|
|
534
|
+
high = np.nanmax(pixels) if high is None else high
|
|
535
|
+
intensity = (pixels - low) / (high - low) if high != low else np.zeros_like(pixels)
|
|
536
|
+
if intensity.ndim != 2 or not intensity.size:
|
|
537
|
+
return
|
|
538
|
+
image_rows, image_cols = intensity.shape
|
|
539
|
+
x0, x1, y0, y1 = extent or (-0.5, image_cols - 0.5, image_rows - 0.5, -0.5)
|
|
540
|
+
x_scale, y_scale = _Scale(axes["x"]), _Scale(axes["y"])
|
|
541
|
+
upper = props.get("origin", "upper") == "upper"
|
|
542
|
+
|
|
543
|
+
def sample(u: np.ndarray, v: np.ndarray) -> np.ndarray:
|
|
544
|
+
with np.errstate(all="ignore"):
|
|
545
|
+
col = (x_scale.value(u) - x0) / (x1 - x0) * image_cols
|
|
546
|
+
frac = (y_scale.value(v) - y0) / (y1 - y0)
|
|
547
|
+
row = (1 - frac if upper else frac) * image_rows
|
|
548
|
+
inside = (col >= 0) & (col < image_cols) & (row >= 0) & (row < image_rows)
|
|
549
|
+
col = np.clip(np.nan_to_num(col), 0, image_cols - 1).astype(np.int64)
|
|
550
|
+
row = np.clip(np.nan_to_num(row), 0, image_rows - 1).astype(np.int64)
|
|
551
|
+
return np.where(inside, intensity[row, col], np.nan)
|
|
552
|
+
|
|
553
|
+
plot.fill(sample)
|
bfplot/py.typed
ADDED
|
File without changes
|