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
plot_io/colorbar.py ADDED
@@ -0,0 +1,342 @@
1
+ """Colorbars: the color key of a mapped series or of a shared ``ColorMap``."""
2
+
3
+ from typing import Any
4
+
5
+ from pdf_io import LinearGradient
6
+
7
+ from .axis import Axis, AxisState
8
+ from .numbers import number_settings
9
+ from .scales import get_scale
10
+ from .settings import Setting, Settings, boolean, color, number, one_of, route, table, text
11
+ from .style import resolve_color
12
+ from .text import box, draw_text, measure
13
+
14
+ __all__ = ["Colorbar", "BarState"]
15
+
16
+
17
+ class Colorbar(Settings):
18
+ """A colorbar: attached to a plot (``plot.colorbar(…)``) or in its own grid area
19
+ (``figure.colorbar(…, id=…)``). Its axis is ``colorbar.axis``."""
20
+
21
+ SETTINGS = table(
22
+ position=Setting(one_of("right", "left", "top", "bottom"),
23
+ doc="Side of the plot an attached bar takes."),
24
+ orientation=Setting(one_of("vertical", "horizontal"),
25
+ doc="Bar direction; default: vertical, or horizontal for bars "
26
+ "attached at the top or bottom."),
27
+ size=Setting(number(positive=True),
28
+ doc="Bar thickness, in the figure's unit; default 10 pt."),
29
+ length=Setting(number(lo=0.05, hi=1.0),
30
+ doc="Bar length as a fraction of the axes side (attached bars)."),
31
+ pad=Setting(number(lo=0),
32
+ doc="Gap between the axes and an attached bar, in the figure's unit."),
33
+ label=Setting(text, doc="Bar label: a string or rich content."),
34
+ extend=Setting(one_of("auto", "neither", "min", "max", "both"),
35
+ doc="Triangular ends in the under/over colors: `auto` shows them when "
36
+ "the limits clip the data."),
37
+ frame=Setting(boolean, doc="Outline the bar."),
38
+ frame_color=Setting(color, doc="Outline color."),
39
+ frame_width=Setting(number(lo=0), doc="Outline width (pt).", length=True),
40
+ font_size=Setting(number(positive=True), inherited=True, doc="Base font size (pt).", length=True),
41
+ font_family=Setting(str, inherited=True, doc="Font family."),
42
+ text_color=Setting(color, inherited=True, doc="Text color."),
43
+ **number_settings(),
44
+ )
45
+
46
+ def __init__(self, source: Any = None, plot: Any = None, figure: Any = None,
47
+ **settings: Any) -> None:
48
+ axis_kw = {k: settings.pop(k) for k in list(settings) if k in Axis.SETTINGS
49
+ and k not in Colorbar.SETTINGS}
50
+ own, = route(settings, [Colorbar])
51
+ self.source = source
52
+ self.plot = plot
53
+ self.figure = figure if figure is not None else (plot.figure if plot is not None else None)
54
+ self.id: Any = None
55
+ self.axis = Axis(self, "c")
56
+ super().__init__(**own)
57
+ if axis_kw:
58
+ self.axis.set(**axis_kw)
59
+
60
+ def _parent(self) -> Settings | None:
61
+ return self.plot if self.plot is not None else self.figure
62
+
63
+ def _points_per_unit(self) -> float:
64
+ return self.figure.points_per_unit if self.figure is not None else 72.0
65
+
66
+ def __repr__(self) -> str:
67
+ return f"Colorbar({self.source!r})"
68
+
69
+ def color_map(self) -> Any:
70
+ """The ``ColorMap`` shown (``None`` when no source is found)."""
71
+ from .maps import ColorMap
72
+
73
+ src = self.source
74
+ if src is None and self.plot is not None:
75
+ for s in reversed(self.plot.series):
76
+ if s.color_sources():
77
+ src = s
78
+ break
79
+ if src is None:
80
+ return None
81
+ if isinstance(src, ColorMap):
82
+ return src
83
+ sources = src.color_sources()
84
+ if not sources:
85
+ raise ValueError(f"{src!r} has no color map to show")
86
+ return sources[0][0]
87
+
88
+ def auto_label(self) -> Any:
89
+ """The label: the setting, else the source series' color column name, else the
90
+ name of a density it maps."""
91
+ label = self.get("label")
92
+ if label is not None:
93
+ return label
94
+ src = self.source
95
+ if src is None and self.plot is not None:
96
+ src = next((s for s in reversed(self.plot.series) if s.color_sources()), None)
97
+ if src is None or not hasattr(src, "SETTINGS"):
98
+ return None
99
+ cols = getattr(src, "columns", {})
100
+ for name in ("color", "line_color", "marker_color"):
101
+ if name in cols:
102
+ return cols[name]
103
+ from .density import Density
104
+ for name in ("color", "line_color", "marker_color"):
105
+ if name in src.SETTINGS and isinstance(src.get(name), Density):
106
+ return src.get(name).label()
107
+ return None
108
+
109
+ @property
110
+ def vertical(self) -> bool:
111
+ return self.get("orientation") == "vertical"
112
+
113
+
114
+ class BarState:
115
+ """A colorbar while drawing."""
116
+
117
+ def __init__(self, cb: Colorbar, maps: dict[int, tuple], plot: Any) -> None:
118
+ self.cb = cb
119
+ self.cmap = cb.color_map()
120
+ if self.cmap is None:
121
+ raise ValueError("colorbar without a source: give a mapped series or a ColorMap")
122
+ flo, fhi, dlo, dhi = maps[id(self.cmap)]
123
+ self.state = AxisState(cb.axis)
124
+ scale = self.state.scale = get_scale(self.cmap.scale)
125
+ self.state.flo, self.state.fhi = flo, fhi
126
+ self.state.lo, self.state.hi = scale.inverse(flo), scale.inverse(fhi)
127
+ self.data = (scale.f(dlo), scale.f(dhi))
128
+ self.position = cb.get("position")
129
+ self.vertical = cb.vertical
130
+ self.thickness = cb.get("size") * cb._points_per_unit()
131
+ self.pad = cb.get("pad") * cb._points_per_unit()
132
+ ext = cb.get("extend")
133
+ if ext == "auto":
134
+ lo_clip = self.data[0] < flo - 1e-12 * abs(fhi - flo)
135
+ hi_clip = self.data[1] > fhi + 1e-12 * abs(fhi - flo)
136
+ ext = {(True, True): "both", (True, False): "min", (False, True): "max"}.get(
137
+ (lo_clip, hi_clip), "neither")
138
+ self.extend = ext
139
+ self.side = self._side()
140
+
141
+ def _side(self) -> str:
142
+ """Side of the bar that carries ticks and labels."""
143
+ if self.cb.plot is not None:
144
+ return self.position
145
+ return "right" if self.vertical else "bottom"
146
+
147
+ # -- measurement ----------------------------------------------------------------
148
+
149
+ def _labels_extent(self, canvas: Any, length: float) -> tuple[float, float]:
150
+ """Space outside the bar for ticks, labels and the bar label, and the overhang of
151
+ end labels along the bar."""
152
+ st = self.state
153
+ st.compute_ticks(canvas, length, not self.vertical)
154
+ axis = self.cb.axis
155
+ out = st.tick_out()[0]
156
+ font = st.tick_font()
157
+ labels = [t.label for t in st.ticks if t.major and t.label is not None]
158
+ across = out
159
+ over = 0.0
160
+ if labels:
161
+ sizes = [box(canvas, lab, font) for lab in labels]
162
+ across += axis.get("tick_pad") + max((w if self.vertical else h) for w, h in sizes)
163
+ over = max((h if self.vertical else w) for w, h in sizes) / 2
164
+ label = self.cb.auto_label()
165
+ if label is not None:
166
+ _, a, d = measure(canvas, label, st.label_font())
167
+ across += axis.get("label_pad") + a + d
168
+ return across, over
169
+
170
+ def measure_attached(self, canvas: Any, axes_len: float) -> float:
171
+ length = axes_len * self.cb.get("length")
172
+ across, _ = self._labels_extent(canvas, length)
173
+ return self.pad + self.thickness + across
174
+
175
+ def measure(self, canvas: Any, w: float, h: float) -> tuple[float, float, float, float]:
176
+ """Margins of a bar in its own area."""
177
+ length = h if self.vertical else w
178
+ across, over = self._labels_extent(canvas, max(length, 1.0))
179
+ tri = self.thickness if self.extend != "neither" else 0.0
180
+ over = max(over, tri)
181
+ if self.vertical:
182
+ return (0.0, across, over, over)
183
+ return (over, over, 0.0, across)
184
+
185
+ def bar_rect(self, rect: tuple[float, float, float, float]) -> tuple:
186
+ x0, y0, x1, y1 = rect
187
+ if self.vertical:
188
+ return (x0, y0, min(x1, x0 + self.thickness), y1)
189
+ return (x0, y0, x1, min(y1, y0 + self.thickness))
190
+
191
+ # -- drawing --------------------------------------------------------------------
192
+
193
+ def draw_attached(self, canvas: Any, axes: tuple, offset: float, base: float) -> float:
194
+ x0, y0, x1, y1 = axes
195
+ frac = self.cb.get("length")
196
+ side = self.position
197
+ start = base + offset + self.pad
198
+ if side in ("left", "right"):
199
+ length = (y1 - y0) * frac
200
+ by0 = y0 + (y1 - y0 - length) / 2
201
+ bx0 = x1 + start if side == "right" else x0 - start - self.thickness
202
+ rect = (bx0, by0, bx0 + self.thickness, by0 + length)
203
+ else:
204
+ length = (x1 - x0) * frac
205
+ bx0 = x0 + (x1 - x0 - length) / 2
206
+ by0 = y1 + start if side == "bottom" else y0 - start - self.thickness
207
+ rect = (bx0, by0, bx0 + length, by0 + self.thickness)
208
+ self.draw_area(canvas, rect)
209
+ across, _ = self._labels_extent(canvas, length)
210
+ return self.pad + self.thickness + across
211
+
212
+ def draw_area(self, canvas: Any, rect: tuple[float, float, float, float]) -> None:
213
+ x0, y0, x1, y1 = rect
214
+ cmap = self.cmap
215
+ vertical = self.vertical
216
+ length = (y1 - y0) if vertical else (x1 - x0)
217
+ n = 256 if cmap.interpolation == "linear" else len(cmap.stops)
218
+ stops: list[tuple[float, Any, float]] = []
219
+ if cmap.interpolation == "linear":
220
+ for i in range(n):
221
+ t = i / (n - 1)
222
+ c = cmap(t)
223
+ stops.append((t, c[:3], c[3]))
224
+ else:
225
+ for i, (p, c) in enumerate(cmap.stops):
226
+ nxt = cmap.stops[i + 1][0] if i + 1 < len(cmap.stops) else 1.0
227
+ stops.append((p, c[:3], c[3]))
228
+ stops.append((nxt, c[:3], c[3]))
229
+ inv = self.cb.axis.get("invert")
230
+ if vertical:
231
+ g = LinearGradient(0, y0, 0, y1, stops) if inv else LinearGradient(0, y1, 0, y0, stops)
232
+ else:
233
+ g = LinearGradient(x1, 0, x0, 0, stops) if inv else LinearGradient(x0, 0, x1, 0, stops)
234
+ canvas.save()
235
+ canvas.fill_style = g
236
+ canvas.begin_path()
237
+ canvas.rect(x0, y0, x1 - x0, y1 - y0)
238
+ canvas.fill()
239
+ self._extensions(canvas, rect)
240
+ if self.cb.get("frame"):
241
+ canvas.stroke_style = resolve_color(self.cb.get("frame_color"), ())
242
+ canvas.line_width = self.cb.get("frame_width")
243
+ canvas.begin_path()
244
+ canvas.rect(x0, y0, x1 - x0, y1 - y0)
245
+ canvas.stroke()
246
+ canvas.restore()
247
+ self._axis(canvas, rect, length)
248
+
249
+ def _extensions(self, canvas: Any, rect: tuple) -> None:
250
+ if self.extend == "neither":
251
+ return
252
+ x0, y0, x1, y1 = rect
253
+ t = self.thickness
254
+ cmap = self.cmap
255
+ inv = self.cb.axis.get("invert")
256
+ ends = []
257
+ if self.extend in ("min", "both"):
258
+ ends.append(("min", cmap.under or cmap(0.0)))
259
+ if self.extend in ("max", "both"):
260
+ ends.append(("max", cmap.over or cmap(1.0)))
261
+ for which, c in ends:
262
+ at_start = (which == "min") != inv # start: bottom (vertical) or left
263
+ canvas.fill_style = c
264
+ canvas.begin_path()
265
+ if self.vertical:
266
+ if at_start:
267
+ canvas.move_to(x0, y1)
268
+ canvas.line_to(x1, y1)
269
+ canvas.line_to((x0 + x1) / 2, y1 + t)
270
+ else:
271
+ canvas.move_to(x0, y0)
272
+ canvas.line_to(x1, y0)
273
+ canvas.line_to((x0 + x1) / 2, y0 - t)
274
+ else:
275
+ if at_start:
276
+ canvas.move_to(x0, y0)
277
+ canvas.line_to(x0, y1)
278
+ canvas.line_to(x0 - t, (y0 + y1) / 2)
279
+ else:
280
+ canvas.move_to(x1, y0)
281
+ canvas.line_to(x1, y1)
282
+ canvas.line_to(x1 + t, (y0 + y1) / 2)
283
+ canvas.close_path()
284
+ canvas.fill()
285
+
286
+ def _axis(self, canvas: Any, rect: tuple, length: float) -> None:
287
+ st = self.state
288
+ if abs(st.length - length) > 0.01:
289
+ st.compute_ticks(canvas, length, not self.vertical)
290
+ axis = self.cb.axis
291
+ x0, y0, x1, y1 = rect
292
+ side = self.side
293
+ mapper = st.mapper(y1, y0) if self.vertical else st.mapper(x0, x1)
294
+ out, inside = st.tick_out()
295
+ ml = axis.get("minor_tick_length")
296
+ canvas.save()
297
+ canvas.stroke_style = resolve_color(axis.get("tick_color"), ())
298
+ canvas.line_width = axis.get("tick_width")
299
+ canvas.begin_path()
300
+ base = {"right": x1, "left": x0, "bottom": y1, "top": y0}[side]
301
+ other = {"right": x0, "left": x1, "bottom": y0, "top": y1}[side]
302
+ sign = 1.0 if side in ("right", "bottom") else -1.0
303
+ for t in st.ticks:
304
+ p = mapper(t.value)
305
+ o, i = (out, inside) if t.major else ((ml if out else 0.0), (ml if inside else 0.0))
306
+ ends = [(base - sign * i, base + sign * o)]
307
+ if axis.get("mirror") and i:
308
+ ends.append((other + sign * i, other))
309
+ for a, b in ends:
310
+ if self.vertical:
311
+ canvas.move_to(a, p)
312
+ canvas.line_to(b, p)
313
+ else:
314
+ canvas.move_to(p, a)
315
+ canvas.line_to(p, b)
316
+ canvas.stroke()
317
+ canvas.restore()
318
+ font = st.tick_font()
319
+ pos = base + sign * (out + axis.get("tick_pad"))
320
+ widest = 0.0
321
+ for t in st.ticks:
322
+ if not t.major or t.label is None:
323
+ continue
324
+ p = mapper(t.value)
325
+ w, h = box(canvas, t.label, font)
326
+ if self.vertical:
327
+ draw_text(canvas, t.label, pos, p, font, "left" if sign > 0 else "right", "center")
328
+ widest = max(widest, w)
329
+ else:
330
+ draw_text(canvas, t.label, p, pos, font, "center", "top" if sign > 0 else "bottom")
331
+ widest = max(widest, h)
332
+ label = self.cb.auto_label()
333
+ if label is None:
334
+ return
335
+ lf = st.label_font()
336
+ pos += sign * (widest + axis.get("label_pad"))
337
+ if self.vertical:
338
+ draw_text(canvas, label, pos, (y0 + y1) / 2, lf, "center",
339
+ "top" if sign > 0 else "bottom", 90.0)
340
+ else:
341
+ draw_text(canvas, label, (x0 + x1) / 2, pos, lf, "center",
342
+ "top" if sign > 0 else "bottom")