plot-io 0.12.0__py3-none-win_amd64.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 (130) hide show
  1. plot_io/__init__.py +73 -0
  2. plot_io/_methods.py +1691 -0
  3. plot_io/_native/__init__.py +199 -0
  4. plot_io/_native/__main__.py +197 -0
  5. plot_io/_native/_api.py +457 -0
  6. plot_io/_native/bin/windows-x86_64.dll +0 -0
  7. plot_io/_native/kernels.py +335 -0
  8. plot_io/_native/manifest.json +49 -0
  9. plot_io/axis.py +1347 -0
  10. plot_io/boxes.py +1156 -0
  11. plot_io/canvas.py +389 -0
  12. plot_io/color/__init__.py +3 -0
  13. plot_io/color/data.py +1646 -0
  14. plot_io/color/maps.py +922 -0
  15. plot_io/color/values.py +295 -0
  16. plot_io/color/variants.py +1246 -0
  17. plot_io/colorbar.py +1426 -0
  18. plot_io/columns.py +614 -0
  19. plot_io/decorations.py +920 -0
  20. plot_io/defaults.py +809 -0
  21. plot_io/density.py +113 -0
  22. plot_io/ext.py +68 -0
  23. plot_io/facets.py +240 -0
  24. plot_io/figure.py +788 -0
  25. plot_io/formatters.py +97 -0
  26. plot_io/genome/__init__.py +17 -0
  27. plot_io/genome/adapters.py +356 -0
  28. plot_io/genome/combine.py +123 -0
  29. plot_io/genome/contacts.py +508 -0
  30. plot_io/genome/intervals.py +750 -0
  31. plot_io/genome/lanes.py +191 -0
  32. plot_io/genome/layout.py +184 -0
  33. plot_io/genome/links.py +364 -0
  34. plot_io/genome/pack.py +27 -0
  35. plot_io/genome/readers.py +145 -0
  36. plot_io/genome/regions.py +228 -0
  37. plot_io/genome/scale.py +349 -0
  38. plot_io/genome/series.py +344 -0
  39. plot_io/genome/signal.py +621 -0
  40. plot_io/genome/sites.py +782 -0
  41. plot_io/genome/sources.py +459 -0
  42. plot_io/genome/testing.py +161 -0
  43. plot_io/geometry.py +289 -0
  44. plot_io/groups.py +308 -0
  45. plot_io/guides.py +503 -0
  46. plot_io/helpers/__init__.py +17 -0
  47. plot_io/helpers/clustermap.py +202 -0
  48. plot_io/helpers/dendrogram.py +172 -0
  49. plot_io/helpers/grids.py +304 -0
  50. plot_io/helpers/qq.py +334 -0
  51. plot_io/helpers/raincloud.py +249 -0
  52. plot_io/helpers/tree.py +214 -0
  53. plot_io/helpers/volcano.py +505 -0
  54. plot_io/hints.py +125 -0
  55. plot_io/kernels.py +740 -0
  56. plot_io/labels.py +566 -0
  57. plot_io/layers/__init__.py +100 -0
  58. plot_io/layers/base.py +630 -0
  59. plot_io/layers/context.py +87 -0
  60. plot_io/layers/core.py +446 -0
  61. plot_io/layers/geoms.py +1362 -0
  62. plot_io/layers/grammar.py +586 -0
  63. plot_io/layers/paint.py +614 -0
  64. plot_io/layers/pipeline.py +170 -0
  65. plot_io/layers/positions.py +431 -0
  66. plot_io/layers/stats.py +1088 -0
  67. plot_io/layout.py +1381 -0
  68. plot_io/legend.py +605 -0
  69. plot_io/levels.py +509 -0
  70. plot_io/limits.py +124 -0
  71. plot_io/lines.py +42 -0
  72. plot_io/mapped.py +522 -0
  73. plot_io/markers.py +110 -0
  74. plot_io/markup.py +128 -0
  75. plot_io/numbers.py +354 -0
  76. plot_io/occupancy.py +295 -0
  77. plot_io/plot.py +597 -0
  78. plot_io/presets/__init__.py +51 -0
  79. plot_io/presets/_util.py +153 -0
  80. plot_io/presets/area.py +145 -0
  81. plot_io/presets/bar.py +261 -0
  82. plot_io/presets/box.py +400 -0
  83. plot_io/presets/catpoints.py +177 -0
  84. plot_io/presets/contour.py +353 -0
  85. plot_io/presets/curves.py +30 -0
  86. plot_io/presets/dots.py +355 -0
  87. plot_io/presets/ecdf.py +97 -0
  88. plot_io/presets/errorbar.py +106 -0
  89. plot_io/presets/errors.py +296 -0
  90. plot_io/presets/fill.py +74 -0
  91. plot_io/presets/heatmap.py +322 -0
  92. plot_io/presets/hexbin.py +198 -0
  93. plot_io/presets/hist.py +391 -0
  94. plot_io/presets/hist2d.py +87 -0
  95. plot_io/presets/kde.py +162 -0
  96. plot_io/presets/kde2d.py +147 -0
  97. plot_io/presets/line.py +143 -0
  98. plot_io/presets/pie.py +502 -0
  99. plot_io/presets/regression.py +115 -0
  100. plot_io/presets/scatter.py +194 -0
  101. plot_io/presets/stem.py +100 -0
  102. plot_io/presets/violin.py +323 -0
  103. plot_io/py.typed +0 -0
  104. plot_io/registry.py +82 -0
  105. plot_io/render.py +480 -0
  106. plot_io/rows.py +491 -0
  107. plot_io/scales.py +103 -0
  108. plot_io/seams.py +138 -0
  109. plot_io/settings.py +807 -0
  110. plot_io/shapes.py +66 -0
  111. plot_io/sharing.py +232 -0
  112. plot_io/sizemaps.py +97 -0
  113. plot_io/spec.py +240 -0
  114. plot_io/states.py +216 -0
  115. plot_io/styles/__init__.py +307 -0
  116. plot_io/styles/library.py +397 -0
  117. plot_io/tables.py +138 -0
  118. plot_io/text.py +412 -0
  119. plot_io/ticks.py +400 -0
  120. plot_io/times.py +287 -0
  121. plot_io/titles.py +225 -0
  122. plot_io/transform.py +114 -0
  123. plot_io/types.py +13604 -0
  124. plot_io/units.py +768 -0
  125. plot_io/validators.py +1029 -0
  126. plot_io/values.py +190 -0
  127. plot_io-0.12.0.dist-info/METADATA +215 -0
  128. plot_io-0.12.0.dist-info/RECORD +130 -0
  129. plot_io-0.12.0.dist-info/WHEEL +4 -0
  130. plot_io-0.12.0.dist-info/licenses/LICENSE +28 -0
plot_io/colorbar.py ADDED
@@ -0,0 +1,1426 @@
1
+ """Colorbars: the guides of color axes, and size keys: the guides of size axes.
2
+
3
+ A colorbar draws its axis' gradient between the trained limits in one of two looks
4
+ (``style``): a key (``"key"``), one line of the low limit, a short thick bar with round
5
+ ends and the high limit, then the label; or a bar (``"bar"``), a long gradient with the
6
+ axis' ticks and label. A colorbar of a size axis is a size key: a key with sample
7
+ markers of increasing size in place of the bar. It is attached to a side of a plot (a
8
+ band of the plot's box), sits in the lanes beside a set of plots (:class:`FigureColorbar`,
9
+ ``figure.add_colorbar`` or a shared axis), or in its own grid area
10
+ (``figure.colorbar_area(…, id=…)``).
11
+ """
12
+
13
+ import math
14
+ from typing import TYPE_CHECKING, Any, Self, Unpack
15
+
16
+ from vec_io import LinearGradient, Path, Span
17
+
18
+ from .canvas import Canvas
19
+ from ._methods import FigureColorbarMixin
20
+ from . import groups
21
+ from .axis import AxisState
22
+ from .color.values import resolve_color
23
+ from .settings import (Setting, Settings, boolean, canonical, either, literal, number, one_of,
24
+ route, table, text)
25
+ from .spec import current, set_boxes
26
+ from .text import _CAP_HEIGHT, box, draw_run, measure, run
27
+ from .guides import anchor, placement, set_place
28
+ from .titles import ALIGNS, CREATED, SIDES, align_of
29
+ from .units import resolve_raw
30
+ from .validators import length
31
+
32
+ if TYPE_CHECKING:
33
+ from .types import ColorbarSettings
34
+
35
+ __all__ = ["Colorbar", "FigureColorbar", "Bar"]
36
+
37
+ #: ``"auto"`` lengths: a key's, then a bar's.
38
+ _AUTO = {"thickness": (4.0, 13.0), "length": (40.0, "100%"), "pad": ("0.8em", 11.0)}
39
+ #: A key's end triangle (× the key's thickness): its depth from the inner corners to the tip,
40
+ #: the gap to the bar (the same all along: its inner side is concentric with the bar's
41
+ #: rounded end), the radius of its corners.
42
+ _TRI_DEPTH, _TRI_GAP, _TRI_RADIUS = 0.9, 0.45, 0.25
43
+ #: The ends' sizes along the bar (× its thickness): a dot, a block, a triangle (from its tip
44
+ #: to the bar's end, less ``_END_GAP``).
45
+ _END = {"dot": 1.0, "block": 0.5,
46
+ "triangle": _TRI_DEPTH + math.sqrt((0.5 + _TRI_GAP) ** 2 - 0.25) - 0.5 - 0.5}
47
+ #: Gap between a key's bar and an end shape (× its thickness).
48
+ _END_GAP = 1 / 2
49
+ #: ``values="signs"``: a bold minus (always U+2212) and plus.
50
+ SIGNS = ([Span("−", font_weight="bold")], [Span("+", font_weight="bold")])
51
+
52
+
53
+ def own_area(cb: "Colorbar") -> bool:
54
+ """Whether a colorbar is in its own grid area (neither attached nor in the lanes)."""
55
+ return cb.plot is None and not cb.lanes
56
+
57
+
58
+ def _orientation(cb: "Colorbar") -> str:
59
+ """``orientation="auto"``: vertical on the left and right and in an area of its own,
60
+ horizontal on top and bottom."""
61
+ if own_area(cb):
62
+ return "vertical"
63
+ kind, where = cb.placement()
64
+ if kind == "inside":
65
+ return "vertical" if where in ("center_left", "center_right") else "horizontal"
66
+ return "vertical" if where in ("left", "right") else "horizontal"
67
+
68
+
69
+ def _extend_shape(cb: "Colorbar") -> str:
70
+ """``extend_shape="auto"``: a triangle on a key, a block on a bar."""
71
+ return "triangle" if cb.resolve("style") == "key" else "block"
72
+
73
+
74
+ def _values(v: Any) -> Any:
75
+ """``"limits"``, ``"ticks"``, ``"signs"`` or a pair of texts (``None``: that end's
76
+ number)."""
77
+ if isinstance(v, str):
78
+ k = v.strip().lower()
79
+ if k in ("limits", "ticks", "signs"):
80
+ return k
81
+ elif isinstance(v, (tuple, list)) and len(v) == 2:
82
+ return tuple(None if x is None else text(x) for x in v)
83
+ raise ValueError(f"expected 'limits', 'ticks', 'signs' or a pair of texts, got {v!r}")
84
+
85
+
86
+ def gradient_stops(cmap: Any, samples: int = 256) -> list[tuple[float, Any, float]]:
87
+ """``(offset, rgb, opacity)`` stops of a color map for a ``LinearGradient``: ``samples``
88
+ samples of a smooth map, two stops per step of a stepped one."""
89
+ stops: list[tuple[float, Any, float]] = []
90
+ if cmap.interpolation != "nearest": # linear, smooth, spline: a sampled gradient
91
+ colors = cmap.table(samples)
92
+ stops = [(i / (samples - 1), c[:3], c[3]) for i, c in enumerate(colors)]
93
+ else: # steps: each from its stop to the next
94
+ for i, (p, c) in enumerate(cmap.stops):
95
+ nxt = cmap.stops[i + 1][0] if i + 1 < len(cmap.stops) else 1.0
96
+ stops.append((p, c[:3], c[3]))
97
+ stops.append((nxt, c[:3], c[3]))
98
+ return stops
99
+
100
+ class Colorbar(Settings):
101
+ """A colorbar: the guide of a color axis, attached to a plot (``plot.add_colorbar(…)``) or
102
+ in its own grid area (``figure.colorbar_area(…, id=…)``). ``source``: a mapped series (its
103
+ color axis) or a color axis; ``colorbar.axis`` is the axis shown, whose limits, number
104
+ format and fonts the colorbar draws.
105
+
106
+ ``style="key"`` (default): one line of the low limit, a short bar with round ends filled
107
+ with the gradient, the high limit, then the label, centered on the bar's middle line in
108
+ the axis' tick label font (``em``: its size). When the limits clip the data
109
+ (``extend``), a rounded triangle in the under or over color points away from that end, its
110
+ inner side following the bar's (``extend_shape``).
111
+ ``values``: ``"limits"`` (in the axis' ``tick_format``; ``"auto"`` rounds to two digits of
112
+ the range), ``"ticks"`` (along the bar), ``"signs"`` (a bold − and +) or a pair of texts
113
+ (``("Low", "High")``; ``None`` keeps that number). Vertical (left and right): the high
114
+ limit above, the low below, the label above everything (``label_position``: ``end``,
115
+ ``start``, ``top``, ``side``). ``style="bar"``: a long bar (``length`` ``100%``) with the
116
+ axis' ticks, tick labels and label beside it.
117
+
118
+ Attached (``side``, default ``bottom``): a band of the plot's box, after an outside
119
+ legend and before the titles, placed along the side by ``align``; ``orientation="auto"``
120
+ is horizontal on top and bottom. A key longer than its axes side passes its ends.
121
+ ``inside`` (an anchor, or ``"auto"`` as a legend's) places it inside the axes, ``pad``
122
+ from their edges, before the legend, which avoids it; a bar is then ``50%`` long.
123
+ ``"auto"`` finding no free place inside attaches it at the bottom instead. In
124
+ its own area (``figure.colorbar_area``) it is vertical, a bar along the neighbouring
125
+ axes (or boxes with ``align="borders"``). Axis settings given to a colorbar go to its
126
+ axis: ``plot.add_colorbar(h, tick_format="{:.0%}")``.
127
+
128
+ A colorbar of a size axis is a size key: ``count`` sample markers of increasing size
129
+ between the limits (``0% · • ● 100% label``), ``marker_gap`` apart, in grey or the
130
+ series' one color; ``values="ticks"`` or a series' ``size_key`` list puts a marker at
131
+ each value. Every series mapping sizes has one under its plot by default
132
+ (``size_key="key"``); a colorbar of the same axis or a ``figure.size_legend`` area
133
+ replaces it. On top or bottom a key joins the line of the keys before it when the line
134
+ fits the axes width (a dot plot's color and size keys side by side). ``style``,
135
+ ``thickness``, ``length`` and ``extend`` do not apply to it.
136
+ """
137
+
138
+ SETTINGS = table(
139
+ side=Setting(one_of(*SIDES), doc="Side of the plot (or of the plots) it sits on; "
140
+ "setting it unsets `inside`."),
141
+ inside=Setting(anchor,
142
+ doc="Place inside the axes of its plot instead (as a legend's: "
143
+ "`auto`, `top_right`, …, `center_left`), `pad` from their edges "
144
+ "(`auto` with no free place: at the bottom); setting it unsets "
145
+ "`side`."),
146
+ align=Setting(one_of(*ALIGNS),
147
+ doc="Place along its side: `start` (left, or top), `center`, `end` "
148
+ "(right, or bottom), or an end by name: `left`, `right` on top "
149
+ "and bottom, `top`, `bottom` on the left and right."),
150
+ order=Setting(number(), doc="Place among the colorbars of its side: lower is nearer "
151
+ "the axes; unset: creation order."),
152
+ style=Setting(one_of("key", "bar"),
153
+ doc="`key`: one line of the limits around a short bar with round ends, "
154
+ "then the label; `bar`: a long bar with the axis' ticks."),
155
+ orientation=Setting(one_of("auto", "vertical", "horizontal"), auto=_orientation,
156
+ doc="Direction; `\"auto\"`: horizontal on top and bottom, "
157
+ "vertical on the left and right and in an area of its own; "
158
+ "inside the axes vertical at `center_left` and "
159
+ "`center_right`."),
160
+ thickness=Setting(either(literal("auto"),
161
+ length(positive=True, percent="bar_thickness")),
162
+ doc="Bar thickness; `\"auto\"`: 4 px on a key, 13 px on a bar."),
163
+ length=Setting(either(literal("auto"), length(positive=True, percent="bar_along")),
164
+ doc="Bar length; `\"auto\"`: 40 px on a key, `100%` on a bar (`50%` "
165
+ "inside the axes). A key given in `%` fills that part of the span "
166
+ "with its texts and bar."),
167
+ values=Setting(_values, expr=False,
168
+ doc="A key's end texts: `limits` (the axis' limits in its "
169
+ "`tick_format`), `ticks` (the axis' ticks along the bar, "
170
+ "lengthened to show at least two), `signs` "
171
+ "(a bold − and +) or a pair of texts (`None`: that end's number)."),
172
+ value_gap=Setting(length(lo=0, percent="em"),
173
+ doc="Gap between a key's end texts and its bar (and above or below "
174
+ "a label on top or under it)."),
175
+ label_gap=Setting(length(lo=0, percent="em"),
176
+ doc="Gap between a key's texts and its label, along the key."),
177
+ label_position=Setting(one_of("end", "start", "side", "top"),
178
+ doc="A key's label: after the high end (`end`; above a "
179
+ "vertical key), before the low end (`start`; below a "
180
+ "vertical key), beside the bar (`side`: under a "
181
+ "horizontal key, rotated right of a vertical one) or above "
182
+ "the key (`top`). A bar reads `top` only: above a vertical "
183
+ "bar from its left edge, `value_gap` below the label; on a "
184
+ "horizontal bar from its start, past its ticks. Otherwise "
185
+ "a bar's label is centred past its ticks."),
186
+ pad=Setting(either(literal("auto"), length(lo=0, percent="bar_across")),
187
+ doc="Gap between the axes and an attached colorbar (inside the axes: "
188
+ "between it and their edges); `\"auto\"`: 0.8em on a key, 11 px on "
189
+ "a bar."),
190
+ label=Setting(text, doc="Label: a string or rich content; default: the axis' "
191
+ "`label`, else the name of the color column or density."),
192
+ extend=Setting(one_of("auto", "neither", "min", "max", "both"),
193
+ doc="Ends in the under/over colors: `auto` shows them when the limits "
194
+ "clip the data."),
195
+ extend_shape=Setting(one_of("auto", "dot", "block", "triangle"), auto=_extend_shape,
196
+ doc="`dot`: a dot past the key's end; `block`: a separate block "
197
+ "at the end (within a bar's length); `triangle`: a triangle "
198
+ "past the end (on a key: rounded, its inner side following "
199
+ "the bar's round end); `\"auto\"`: `triangle` on a key, "
200
+ "`block` on a bar (a bar refuses `dot`)."),
201
+ frame=Setting(boolean, doc="Outline the bar (and its ends), following its shape."),
202
+ count=Setting(number(lo=1, integer=True),
203
+ doc="A size key's markers below its high limit "
204
+ "(`values=\"limits\"`)."),
205
+ marker_gap=Setting(length(lo=0, percent="em"),
206
+ doc="Gap between a size key's markers (edge to edge)."),
207
+ **groups.Stroke.embed("frame_",
208
+ only=("color", "line_width"),
209
+ docs={"color": "Outline color.",
210
+ "line_width": "Outline width."}),
211
+ )
212
+
213
+ #: whether it sits in the lanes beside plots (:class:`FigureColorbar`)
214
+ lanes = False
215
+
216
+ def __init__(self, source: Any = None, plot: Any = None, figure: Any = None,
217
+ **settings: Any) -> None:
218
+ from .color.maps import ColorMap
219
+ from .mapped import ColorAxis, SizeAxis
220
+ if isinstance(source, ColorMap):
221
+ raise ValueError("a colorbar shows a color axis: give a mapped series, or a "
222
+ "shared figure.color_axis(color_map=…)")
223
+ cls = SizeAxis if isinstance(source, SizeAxis) else ColorAxis
224
+ axis_kw = {k: settings.pop(k) for k in list(settings)
225
+ if k not in type(self).SETTINGS and canonical(k, cls.SETTINGS)
226
+ in cls.SETTINGS}
227
+ #: a size key's values, one marker each (a series' ``size_key`` list)
228
+ self.marks: list[float] | None = None
229
+ own, = route(settings, [type(self)])
230
+ self.source = source
231
+ self.plot = plot
232
+ self.figure = figure if figure is not None else (plot.figure if plot is not None else None)
233
+ self.id: Any = None
234
+ super().__init__(**own)
235
+ if axis_kw:
236
+ self.axis.set(**axis_kw)
237
+
238
+ def _parent(self) -> Settings | None:
239
+ return self.plot if self.plot is not None else self.figure
240
+
241
+ def set(self, **settings: "Unpack[ColorbarSettings]") -> Self: # type: ignore[override]
242
+ super().set(**settings)
243
+ set_place(self, dict(settings))
244
+ return self
245
+
246
+ def placement(self) -> tuple[str, Any]:
247
+ """``("inside", anchor)`` (attached to a plot only) or ``("side", side)``."""
248
+ if self.plot is None or "inside" not in self.SETTINGS:
249
+ return "side", self.resolve("side")
250
+ return placement(self, side_first=False)
251
+
252
+ def _font_size(self) -> float:
253
+ """The font size ``em`` is of: its axis' tick label font size."""
254
+ try:
255
+ axis = self.axis
256
+ except ValueError:
257
+ return super()._font_size()
258
+ return axis.resolve("tick_label_font_size")
259
+
260
+ def __repr__(self) -> str:
261
+ return f"{type(self).__name__}({self.source!r})"
262
+
263
+ def _candidates(self) -> list[Any]:
264
+ """The plots whose last mapped series it shows without a source."""
265
+ return [self.plot] if self.plot is not None else []
266
+
267
+ def shown(self, check: bool = False) -> Any:
268
+ """What the colorbar shows: its source, else the plot's last series with mapped
269
+ colors (``check``: a series source must map colors)."""
270
+ from .layers.pipeline import call_of
271
+ from .mapped import ColorAxis, SizeAxis
272
+ src = self.source
273
+ if isinstance(src, SizeAxis):
274
+ return src
275
+ if src is None:
276
+ for p in self._candidates():
277
+ src = next((s for s in reversed(p.series) if call_of(s).color_sources), None)
278
+ if src is not None:
279
+ break
280
+ if src is None:
281
+ raise ValueError("colorbar without a source: give a mapped series or a color "
282
+ "axis")
283
+ if check and not isinstance(src, ColorAxis) and not call_of(src).color_sources:
284
+ raise ValueError(f"{src!r} has no mapped colors to show")
285
+ return src
286
+
287
+ @property
288
+ def axis(self) -> Any:
289
+ """The color (or size) axis shown (its limits, number format and fonts are the
290
+ colorbar's)."""
291
+ from .mapped import ColorAxis, SizeAxis, color_axis_of
292
+ src = self.shown()
293
+ return src if isinstance(src, (ColorAxis, SizeAxis)) else color_axis_of(src)
294
+
295
+ @property
296
+ def sizes(self) -> bool:
297
+ """Whether it shows a size axis (a size key)."""
298
+ from .mapped import SizeAxis
299
+ return isinstance(self.source, SizeAxis)
300
+
301
+ def size_series(self) -> list[Any]:
302
+ """The series whose sizes a size key shows (their marker, their color)."""
303
+ from .mapped import size_group
304
+ axis = self.axis
305
+ g = size_group(axis)
306
+ if g is not None and g.users:
307
+ return list(g.users)
308
+ return [axis.series] if axis.series is not None else []
309
+
310
+ def color_map(self) -> Any:
311
+ """The gradient shown."""
312
+ return self.axis.color_map()
313
+
314
+ def auto_label(self) -> Any:
315
+ """The label: the setting, else the axis' label, else the source series' color
316
+ column name, else the name of a density it maps."""
317
+ label = self.resolve("label")
318
+ if label is not None:
319
+ return label
320
+ if self.sizes:
321
+ axis = self.axis
322
+ if axis.resolve("label") is not None:
323
+ return axis.resolve("label")
324
+ return next((s.columns["size"] for s in self.size_series()
325
+ if "size" in getattr(s, "columns", {})), None)
326
+ from .mapped import ColorAxis
327
+ src = self.shown()
328
+ axis = src if isinstance(src, ColorAxis) else None
329
+ if axis is None and hasattr(src, "SETTINGS"):
330
+ from .mapped import color_axis_of
331
+ axis = color_axis_of(src)
332
+ if axis is not None and axis.resolve("label") is not None:
333
+ return axis.resolve("label")
334
+ if isinstance(src, ColorAxis):
335
+ return None
336
+ cols = getattr(src, "columns", {})
337
+ from .layers.paint import COLOR_SETTINGS
338
+ for name in COLOR_SETTINGS:
339
+ if name in cols:
340
+ return cols[name]
341
+ from .density import Density
342
+ for name in COLOR_SETTINGS:
343
+ if name in src.SETTINGS and isinstance(src.resolve(name), Density):
344
+ return src.resolve(name).label()
345
+ return None
346
+
347
+ @property
348
+ def vertical(self) -> bool:
349
+ return self.resolve("orientation") == "vertical"
350
+
351
+
352
+ class FigureColorbar(FigureColorbarMixin, Colorbar):
353
+ """A colorbar of the figure (``figure.add_colorbar``, or made for a shared color
354
+ axis): the guide of a color axis, in the lanes beside a set of plots as figure titles
355
+ and legends are.
356
+
357
+ ``source`` defaults to the first covered plot's last mapped series. It sits in the lanes
358
+ of :class:`FigureTitle` (over the same plots: legend, then colorbar, then title), placed
359
+ by ``align`` within the axes of its plots; a ``%`` ``length`` is of that span. The covered
360
+ plots' attached colorbars of the same axis are left out. When plots sharing a color
361
+ axis (``share_color``, or one ``figure.color_axis()``) have attached colorbars of it, one
362
+ figure colorbar replaces them: on the right of a row or of the figure, at the bottom of a
363
+ column, with the settings of the first; an attached colorbar whose ``side`` is set stays.
364
+ ``plot.save()`` leaves figure colorbars out (the attached ones show again).
365
+ """
366
+
367
+ SETTINGS = table(**{
368
+ **{k: v for k, v in Colorbar.SETTINGS.items() if k != "inside"},
369
+ "order": Setting(number(), doc="Place in its side's lanes, as figure titles' "
370
+ "`order`: lower is nearer the plots."),
371
+ "pad": Setting(either(literal("auto"), length(lo=0)),
372
+ doc="Gap between the colorbar and the plots; `\"auto\"`: 0.8em on a "
373
+ "key, 11 px on a bar."),
374
+ })
375
+
376
+ lanes = True
377
+
378
+ def __init__(self, figure: Any, source: Any = None, plots: Any = None,
379
+ **settings: Any) -> None:
380
+ #: the plots it covers (``None``: every plot)
381
+ self.plots: list[Any] | None = plots
382
+ self._made = next(CREATED)
383
+ super().__init__(source, figure=figure, **settings)
384
+
385
+ def _candidates(self) -> list[Any]:
386
+ from .plot import Plot
387
+ if self.plots is not None:
388
+ return list(self.plots)
389
+ fig = self.figure
390
+ return [] if fig is None else [p for p in fig._areas.values() if isinstance(p, Plot)]
391
+
392
+
393
+ class _Key:
394
+ """A key laid out in its own frame: the bar ``(x0, y0, x1, y1)`` (round ends), the end
395
+ shapes ``(shape, geometry, color)``, the texts ``(content, font, x, y, ha, va,
396
+ rotation)`` (the end texts from the bar's start, or the tick labels), the label (a
397
+ text, or ``None``) and the box around all."""
398
+
399
+ __slots__ = ("bar", "ends", "texts", "label", "box", "marks")
400
+
401
+ def __init__(self) -> None:
402
+ self.bar = (0.0, 0.0, 0.0, 0.0)
403
+ #: a size key's markers ``(cx, cy, diameter)``
404
+ self.marks: list[tuple[float, float, float]] = []
405
+ self.ends: list[tuple[str, Any, Any]] = []
406
+ self.texts: list[tuple[Any, ...]] = []
407
+ self.label: tuple[Any, ...] | None = None
408
+ self.box = (0.0, 0.0, 0.0, 0.0)
409
+
410
+ def add(self, rect: tuple[float, float, float, float]) -> None:
411
+ b = self.box
412
+ self.box = (min(b[0], rect[0]), min(b[1], rect[1]), max(b[2], rect[2]),
413
+ max(b[3], rect[3]))
414
+
415
+
416
+ def _text_rect(fonts: Any, item: tuple[Any, ...]) -> tuple[float, float, float, float]:
417
+ """The box of a key's text item in the key's frame."""
418
+ content, font, x, y, ha, va, rotation = item
419
+ w, a, d = measure(fonts, content, font)
420
+ if rotation: # 90: reads upward, its top at x
421
+ return (x, y - w / 2, x + a + d, y + w / 2)
422
+ x0 = {"left": x, "center": x - w / 2, "right": x - w}[ha]
423
+ top = {"top": y, "center": y + _CAP_HEIGHT * font.size / 2 - a, "bottom": y - a - d}[va]
424
+ return (x0, top, x0 + w, top + a + d)
425
+
426
+
427
+ class Bar:
428
+ """A colorbar's (or size key's) content (the guide's,
429
+ :class:`~plot_io.guides.GuideState`): its axis' trained limits, laid out as a key or a
430
+ bar for its place: inside or on a side of its plot, in the lanes (a figure colorbar),
431
+ or in its own area."""
432
+
433
+ form = "bar"
434
+ guide: Any = None
435
+
436
+ def __init__(self, cb: Colorbar, spot: tuple[str, Any]) -> None:
437
+ self.cb = cb
438
+ cb.shown(check=True)
439
+ self.axis = cb.axis
440
+ #: a size key: markers in place of the bar (always a key)
441
+ self.sizes = cb.sizes
442
+ self.cmap = None if self.sizes else self.axis.color_map()
443
+ self.label = cb.auto_label()
444
+ self.key = self.sizes or cb.resolve("style") == "key"
445
+ if not self.key and cb.resolve("extend_shape") == "dot":
446
+ raise ValueError('Colorbar: extend_shape="dot" is for a key; a bar takes "block" '
447
+ 'or "triangle"')
448
+ #: a size key's markers ``(value, diameter, label)`` (``label``: ``None`` between
449
+ #: the limits)
450
+ self.marks: list[tuple[float, float, Any]] = []
451
+ self.vertical = cb.vertical
452
+ kind, where = spot
453
+ #: the side it sits on (``None``: in its own area; inside the axes, the side its
454
+ #: ticks and texts are laid out for)
455
+ self.side: str | None = None if kind == "area" else \
456
+ where if kind in ("side", "lanes") else "right" if self.vertical else "bottom"
457
+ self.order = cb.resolve("order")
458
+ self.thickness = self.pad = 0.0
459
+ self.cell = 0.0 # a key in its own area: its size across
460
+ #: how far an attached key passes the start and end of its axes side
461
+ self.over = (0.0, 0.0)
462
+ if kind == "area" and not self.key: # a bar in its own area: no reference for %
463
+ self.thickness = self.size("thickness")
464
+ self.pad = self.size("pad")
465
+ self.tick_side = self.side or ("right" if self.vertical else "bottom")
466
+ self.sync()
467
+
468
+ def size(self, name: str, of: float | None = None) -> float:
469
+ """A length setting (``thickness``, ``length``, ``pad``) in px, ``"auto"`` as the
470
+ style's (``of``: what ``%`` is of)."""
471
+ cb = self.cb
472
+ v = cb.resolve(name) if of is None else cb.resolve(name, of=of)
473
+ if v != "auto":
474
+ return v
475
+ check: Any = cb.SETTINGS[name].check
476
+ return resolve_raw(cb, name, check, check(_AUTO[name][0 if self.key else 1]), of)
477
+
478
+ def sync(self) -> None:
479
+ """Take the axis' trained limits (again, once trained again: stats binned at the
480
+ axes' resolution) and the extension ends they imply."""
481
+ from .mapped import trained
482
+ st = trained(self.axis)
483
+ self.state = AxisState(self.axis)
484
+ self.state.copy_limits(st)
485
+ self.state._auto = (False, False) # the colors' limits: no snapping
486
+ scale = self.state.scale
487
+ assert scale is not None
488
+ flo, fhi = st.flo, st.fhi
489
+ self.data = (scale.f(st.data_lo), scale.f(st.data_hi))
490
+ ext = "neither" if self.sizes else self.cb.resolve("extend")
491
+ if ext == "auto":
492
+ lo_clip = self.data[0] < flo - 1e-12 * abs(fhi - flo)
493
+ hi_clip = self.data[1] > fhi + 1e-12 * abs(fhi - flo)
494
+ ext = {(True, True): "both", (True, False): "min", (False, True): "max"}.get(
495
+ (lo_clip, hi_clip), "neither")
496
+ self.extend = ext
497
+
498
+ def _boxes(self, w: float, h: float) -> None:
499
+ """The boxes `%` of the colorbar's and its axis' settings refer to."""
500
+ set_boxes(self.cb, axes_width=w, axes_height=h)
501
+ set_boxes(self.axis, axes_width=w, axes_height=h, vertical=float(self.vertical))
502
+
503
+ def _start(self, s0: float, s1: float, size: float) -> float:
504
+ """Where something ``size`` long starts along ``s0``–``s1`` (``align``)."""
505
+ a = align_of(self.cb.resolve("align"))
506
+ if a == "start":
507
+ return s0
508
+ if a == "end":
509
+ return s1 - size
510
+ return s0 + (s1 - s0 - size) / 2
511
+
512
+ @property
513
+ def cell_size(self) -> float:
514
+ """Its size across, as a cell of the grid of its own (a bar's thickness)."""
515
+ return self.cell if self.key else self.thickness
516
+
517
+ # -- the key ----------------------------------------------------------------------
518
+
519
+ def _limits(self) -> tuple[Any, Any]:
520
+ """The axis' limits as text, in its ``tick_format``, with the same decimals (each
521
+ its own on log scales); the ``"auto"`` format without a ``precision`` rounds them
522
+ to two digits of their range (two significant digits each on log scales)."""
523
+ from .numbers import NumberStyle, format_numbers
524
+ st = self.state
525
+ a = self.axis
526
+ fmt, style = a.resolve("tick_format"), NumberStyle.of(a)
527
+ lo, hi = st.lo, st.hi
528
+ log = st.scale is not None and (st.scale.base is not None or st.scale.name == "logit")
529
+ if fmt == "auto" and style.precision is None:
530
+ lo, hi = _round_limits(lo, hi, log)
531
+ if log: # each with its own digits
532
+ return (format_numbers([lo], fmt, style, "tick_format")[0],
533
+ format_numbers([hi], fmt, style, "tick_format")[0])
534
+ out = format_numbers([lo, hi], fmt, style, "tick_format")
535
+ return out[0], out[1]
536
+
537
+ def _end_texts(self) -> tuple[Any, Any]:
538
+ """The low and high end texts ``(content, font)`` (``None``: none)."""
539
+ from .text import font_of
540
+ v = self.cb.resolve("values")
541
+ if v == "ticks" or (self.sizes and self.cb.marks is not None):
542
+ return None, None
543
+ st = self.state
544
+ numbers = st.tick_font()
545
+ if v == "signs":
546
+ return (SIGNS[0], numbers), (SIGNS[1], numbers)
547
+ lo, hi = self._limits()
548
+ if v == "limits":
549
+ return (lo, numbers), (hi, numbers)
550
+ a = self.axis
551
+ own = font_of(a, a.resolve("tick_label_font_size"), a.resolve("tick_label_weight"),
552
+ color=a.resolve("tick_label_color"))
553
+ return tuple((n, numbers) if t is None else (t, own) # type: ignore[return-value]
554
+ for t, n in zip(v, (lo, hi)))
555
+
556
+ def _key_length(self, span: float, rest: float) -> float:
557
+ """The bar's length for a span ``span`` long, the rest of the key ``rest`` long."""
558
+ one = self.cb.resolve("length", of=1.0)
559
+ if one == "auto":
560
+ return self.size("length", span)
561
+ b = self.cb.resolve("length", of=2.0) - one # its `%` part: px per px of span
562
+ if not b:
563
+ return one
564
+ return max(self.thickness, one + b * (span - 1.0) - rest)
565
+
566
+ def _key(self, fonts: Any, span: float) -> _Key:
567
+ """The key laid out for a span ``span`` long (what `%` of ``thickness`` and
568
+ ``length`` are of)."""
569
+ if self.sizes:
570
+ self._size_marks()
571
+ self.thickness = t = max([d for _, d, _ in self.marks] + [1.0])
572
+ gap = self.cb.resolve("marker_gap")
573
+ bar = sum(d for _, d, _ in self.marks) + gap * max(0, len(self.marks) - 1)
574
+ if self.vertical:
575
+ self._boxes(t, bar)
576
+ else:
577
+ self._boxes(bar, t)
578
+ return self._layout(fonts, bar)
579
+ self.thickness = t = self.size("thickness", span)
580
+ rest = self._layout(fonts, 0.0)
581
+ along = (rest.box[3] - rest.box[1]) if self.vertical else (rest.box[2] - rest.box[0])
582
+ bar = self._key_length(span, along)
583
+ if self.cb.resolve("values") == "ticks":
584
+ bar = self._ticks_length(fonts, bar)
585
+ if self.vertical:
586
+ self._boxes(t, bar)
587
+ else:
588
+ self._boxes(bar, t)
589
+ return self._layout(fonts, bar)
590
+
591
+ def _size_marks(self) -> None:
592
+ """A size key's markers: ``count`` values evenly spaced below the high limit (in
593
+ the scale's terms; the high limit's own marker, and a zero size at the low limit,
594
+ left out), or one per value of the axis' ticks (``values="ticks"``) or of the
595
+ series' ``size_key`` list, labelled."""
596
+ from .legend import SizeKey, size_key_labels, size_key_values
597
+ cb, st = self.cb, self.state
598
+ series = cb.size_series()
599
+ if not series:
600
+ self.marks = []
601
+ return
602
+ plot = cb.plot if cb.plot is not None else getattr(series[0], "plot", None)
603
+ if cb.marks is not None or cb.resolve("values") == "ticks":
604
+ values = size_key_values(self.axis, cb.marks)
605
+ labels: list[Any] = size_key_labels(self.axis, values, plot)
606
+ else:
607
+ scale = st.scale
608
+ assert scale is not None
609
+ n = cb.resolve("count")
610
+ flo, fhi = st.flo, st.fhi
611
+ first = SizeKey(self.axis, series, [st.lo], [None], None).sizes()[0]
612
+ fr = [(i + 1) / (n + 1) for i in range(n)] if first < 1e-9 else \
613
+ [i / n for i in range(n)]
614
+ values = [scale.inverse(flo + (fhi - flo) * f) for f in fr]
615
+ labels = [None] * len(values)
616
+ sizes = SizeKey(self.axis, series, values, labels, None).sizes()
617
+ self.marks = [(v, d, lab) for v, d, lab in zip(values, sizes, labels) if d > 0]
618
+
619
+ def marker_look(self) -> tuple[Any, Any, Any, float]:
620
+ """A size key's marker: ``(path, fill, stroke, stroke width)``: the series' marker
621
+ shape, grey, or the series' color when one series has one color."""
622
+ from .markers import resolve
623
+ from .values import Values, is_computed
624
+ series = self.cb.size_series()
625
+ s = series[0] if series else None
626
+ marker = s.resolve("marker") if s is not None and "marker" in s.SETTINGS else "circle"
627
+ marker = marker[0] if isinstance(marker, list) else marker
628
+ color: Any = "#888888"
629
+ if s is not None and len(series) == 1 and "color" in s.SETTINGS:
630
+ c = s.resolve("color")
631
+ if c is not None and not isinstance(c, (Values, list)) and not is_computed(c):
632
+ try:
633
+ color = resolve_color(c, ())
634
+ except (TypeError, ValueError):
635
+ pass
636
+ hollow = s is not None and "fill" in s.SETTINGS and s.resolve("fill") is False
637
+ width = s.resolve("edge_width") if hollow and s is not None else 0.0
638
+ return (resolve(marker)[0], None if hollow else color, color if hollow else None,
639
+ width or 1.0)
640
+
641
+ def _ticks_length(self, fonts: Any, bar: float) -> float:
642
+ """``bar``, lengthened by quarters until the axis labels at least two major ticks
643
+ along it."""
644
+ st = self.state
645
+ for _ in range(12):
646
+ st.compute_ticks(fonts, bar, not self.vertical)
647
+ if sum(1 for t in st.ticks if t.major and t.label is not None) >= 2:
648
+ break
649
+ bar *= 1.25
650
+ return bar
651
+
652
+ def _layout(self, fonts: Any, bar: float) -> _Key:
653
+ """The key with a bar ``bar`` long, in its frame: along x (horizontal) or y
654
+ (vertical, down) from 0, the bar's middle line at 0 across."""
655
+ cb, st = self.cb, self.state
656
+ t = self.thickness
657
+ vg, lg = cb.resolve("value_gap"), cb.resolve("label_gap")
658
+ where = cb.resolve("label_position")
659
+ label = self.label
660
+ lf = st.label_font()
661
+ lo, hi = self._end_texts()
662
+ inv = self.axis.resolve("invert")
663
+ first, last = (hi, lo) if inv else (lo, hi) # at the bar's start, at its end
664
+ ends = dict(self._ends())
665
+ shape = cb.resolve("extend_shape")
666
+ s = _END[shape] * t
667
+ gap = _END_GAP * t
668
+ k = _Key()
669
+ texts = k.texts
670
+ ticks = cb.resolve("values") == "ticks"
671
+
672
+ def width(c: Any, f: Any) -> float:
673
+ return measure(fonts, c, f)[0]
674
+
675
+ def height(c: Any, f: Any) -> float:
676
+ _, a, d = measure(fonts, c, f)
677
+ return a + d
678
+
679
+ def end(at_start: bool, p: float) -> None:
680
+ """An end shape starting at ``p`` along the key."""
681
+ c = ends[at_start]
682
+ if shape == "dot":
683
+ geo: Any = (p + t / 2, 0.0, t / 2) if not self.vertical else (0.0, p + t / 2, t / 2)
684
+ elif shape == "block":
685
+ geo = (p, -t / 2, p + s, t / 2) if not self.vertical else (-t / 2, p, t / 2, p + s)
686
+ else: # a triangle pointing away, its inner side round
687
+ # the bar's rounded end: its centre ``mid`` along the key, ``d`` toward the tip
688
+ if not self.vertical:
689
+ d = -1.0 if at_start else 1.0
690
+ mid = p + s + gap + t / 2 if at_start else p - gap - t / 2
691
+ else:
692
+ d = 1.0 if at_start else -1.0
693
+ mid = p - gap - t / 2 if at_start else p + s + gap + t / 2
694
+ inner = mid + d * math.sqrt(((0.5 + _TRI_GAP) * t) ** 2 - (t / 2) ** 2)
695
+ tip = inner + d * _TRI_DEPTH * t
696
+ if not self.vertical:
697
+ geo = ((tip, 0.0), (inner, -t / 2), (inner, t / 2), (mid, 0.0))
698
+ else:
699
+ geo = ((0.0, tip), (-t / 2, inner), (t / 2, inner), (0.0, mid))
700
+ k.ends.append((shape, geo, c))
701
+
702
+ if not self.vertical:
703
+ x = 0.0
704
+ if label is not None and where == "start":
705
+ k.label = (label, lf, x, 0.0, "left", "center", 0.0)
706
+ x += width(label, lf) + lg
707
+ if first is not None:
708
+ texts.append((first[0], first[1], x, 0.0, "left", "center", 0.0))
709
+ x += width(*first) + vg
710
+ if True in ends:
711
+ end(True, x)
712
+ x += s + gap
713
+ k.bar = (x, -t / 2, x + bar, t / 2)
714
+ if self.sizes:
715
+ x = max(x + bar, self._place_marks(fonts, k))
716
+ else:
717
+ x += bar
718
+ if False in ends:
719
+ x += gap
720
+ end(False, x)
721
+ x += s
722
+ if ticks and bar > 0 and not self.sizes:
723
+ x = max(x, self._tick_labels(fonts, k, bar))
724
+ if last is not None:
725
+ x += vg
726
+ texts.append((last[0], last[1], x, 0.0, "left", "center", 0.0))
727
+ x += width(*last)
728
+ if label is not None and where == "end":
729
+ x += lg
730
+ k.label = (label, lf, x, 0.0, "left", "center", 0.0)
731
+ else:
732
+ y = 0.0
733
+ if label is not None and where in ("end", "top"):
734
+ k.label = (label, lf, 0.0, y, "center", "top", 0.0)
735
+ y += height(label, lf) + vg
736
+ top = None
737
+ if last is not None:
738
+ top = (last[0], last[1], 0.0, y, "center", "top", 0.0)
739
+ y += height(*last) + vg
740
+ if False in ends:
741
+ end(False, y)
742
+ y += s + gap
743
+ k.bar = (-t / 2, y, t / 2, y + bar)
744
+ y += bar
745
+ if self.sizes:
746
+ self._place_marks(fonts, k)
747
+ if True in ends:
748
+ y += gap
749
+ end(True, y)
750
+ y += s
751
+ if ticks and bar > 0 and not self.sizes:
752
+ self._tick_labels(fonts, k, bar)
753
+ if first is not None:
754
+ y += vg
755
+ texts.append((first[0], first[1], 0.0, y, "center", "top", 0.0))
756
+ y += height(*first)
757
+ if top is not None: # the texts from the start
758
+ texts.append(top)
759
+ if label is not None and where == "start":
760
+ y += vg
761
+ k.label = (label, lf, 0.0, y, "center", "top", 0.0)
762
+ k.box = k.bar
763
+ for cx, cy, d in k.marks:
764
+ k.add((cx - d / 2, cy - d / 2, cx + d / 2, cy + d / 2))
765
+ for shape_, geo, _ in k.ends:
766
+ k.add(_shape_rect(shape_, geo))
767
+ for item in texts + ([k.label] if k.label is not None else []):
768
+ k.add(_text_rect(fonts, item))
769
+ if label is not None and where in ("side", "top") and not (
770
+ self.vertical and where == "top"):
771
+ bx0, by0, bx1, by1 = k.bar
772
+ if self.vertical: # beside, reading upward
773
+ item = (label, lf, k.box[2] + vg, (by0 + by1) / 2, "center", "top", 90.0)
774
+ elif where == "top":
775
+ item = (label, lf, (bx0 + bx1) / 2, k.box[1] - vg, "center", "bottom", 0.0)
776
+ else:
777
+ item = (label, lf, (bx0 + bx1) / 2, k.box[3] + vg, "center", "top", 0.0)
778
+ k.label = item
779
+ k.add(_text_rect(fonts, item))
780
+ return k
781
+
782
+ def _place_marks(self, fonts: Any, k: _Key) -> float:
783
+ """A size key's markers along the bar of ``k``, smallest first (a vertical key's
784
+ largest at the top), their labels under them (right of a vertical key); returns
785
+ how far they reach along a horizontal key."""
786
+ x0, y0, x1, y1 = k.bar
787
+ gap = self.cb.resolve("marker_gap")
788
+ pad = self.axis.resolve("tick_pad")
789
+ font = self.state.tick_font()
790
+ reach = x1
791
+ marks = self.marks if not self.vertical else list(reversed(self.marks))
792
+ p = x0 if not self.vertical else y0
793
+ for _, d, lab in marks:
794
+ c = p + d / 2
795
+ p += d + gap
796
+ if not self.vertical:
797
+ k.marks.append((c, 0.0, d))
798
+ if lab is not None:
799
+ k.texts.append((lab, font, c, y1 + pad, "center", "top", 0.0))
800
+ reach = max(reach, c + box(fonts, lab, font)[0] / 2)
801
+ else:
802
+ k.marks.append((0.0, c, d))
803
+ if lab is not None:
804
+ k.texts.append((lab, font, x1 + pad, c, "left", "center", 0.0))
805
+ return reach
806
+
807
+ def _tick_labels(self, fonts: Any, k: _Key, bar: float) -> float:
808
+ """The axis' tick labels along the bar of ``k`` (under a horizontal one, right of
809
+ a vertical one); returns how far they reach along a horizontal key."""
810
+ st = self.state
811
+ if abs(st.length - bar) > 0.01:
812
+ st.compute_ticks(fonts, bar, not self.vertical)
813
+ font = st.tick_font()
814
+ pad = self.axis.resolve("tick_pad")
815
+ x0, y0, x1, y1 = k.bar
816
+ reach = x1
817
+ if self.vertical:
818
+ mapper = st.mapper(y1, y0)
819
+ else:
820
+ mapper = st.mapper(x0, x1)
821
+ for tk in st.ticks:
822
+ if not tk.major or tk.label is None:
823
+ continue
824
+ p = mapper(tk.value)
825
+ if self.vertical:
826
+ k.texts.append((tk.label, font, x1 + pad, p, "left", "center", 0.0))
827
+ else:
828
+ k.texts.append((tk.label, font, p, y1 + pad, "center", "top", 0.0))
829
+ reach = max(reach, p + box(fonts, tk.label, font)[0] / 2)
830
+ oc = st.offset_label # ×10ᵏ: past the high end of the labels
831
+ if oc is not None:
832
+ if self.vertical:
833
+ k.texts.append((oc, font, x1 + pad, y0 - pad, "left", "bottom", 0.0))
834
+ else:
835
+ k.texts.append((oc, font, reach + pad, y1 + pad, "left", "top", 0.0))
836
+ reach += pad + box(fonts, oc, font)[0]
837
+ return reach
838
+
839
+ def _across(self, k: _Key, side: str) -> float:
840
+ bx0, by0, bx1, by1 = k.box
841
+ return (by1 - by0) if side in ("top", "bottom") else (bx1 - bx0)
842
+
843
+ def _place_key(self, fonts: Any, k: _Key, side: str, near: float, s0: float,
844
+ s1: float) -> Any:
845
+ """Key ``k`` placed on ``side`` of what it covers, its near edge at ``near``
846
+ across the side, aligned within ``s0``–``s1`` along it."""
847
+ from .geometry import ColorKeyGeometry
848
+ bx0, by0, bx1, by1 = k.box
849
+ if side in ("top", "bottom"):
850
+ dx = self._start(s0, s1, bx1 - bx0) - bx0
851
+ dy = near - (by0 if side == "bottom" else by1)
852
+ else:
853
+ dy = self._start(s0, s1, by1 - by0) - by0
854
+ dx = near - (bx0 if side == "right" else bx1)
855
+ r = current()
856
+ boxes = (r.boxes.get(id(self.cb)), r.boxes.get(id(self.axis))) if r is not None \
857
+ else (None, None)
858
+
859
+ def moved(rect: tuple[float, float, float, float]) -> tuple[float, float, float, float]:
860
+ return (rect[0] + dx, rect[1] + dy, rect[2] + dx, rect[3] + dy)
861
+
862
+ ends = []
863
+ for shape, geo, c in k.ends:
864
+ if shape == "dot":
865
+ geo = (geo[0] + dx, geo[1] + dy, geo[2])
866
+ elif shape == "block":
867
+ geo = moved(geo)
868
+ else:
869
+ geo = tuple((px + dx, py + dy) for px, py in geo)
870
+ ends.append((shape, geo, c))
871
+
872
+ def placed(item: tuple[Any, ...]) -> Any:
873
+ content, font, x, y, ha, va, rotation = item
874
+ return run(fonts, content, x + dx, y + dy, font, ha, va, rotation)
875
+
876
+ runs = tuple(placed(item) for item in k.texts)
877
+ title = None if k.label is None else placed(k.label)
878
+ marks = tuple((cx + dx, cy + dy, d) for cx, cy, d in k.marks)
879
+ return ColorKeyGeometry(self, moved(k.bar), moved(k.box), tuple(ends), runs, title,
880
+ boxes, marks)
881
+
882
+ # -- measurement ------------------------------------------------------------------
883
+
884
+ def _top_label(self) -> bool:
885
+ """A vertical bar with its label above it (``label_position="top"``)."""
886
+ return self.vertical and self.label is not None \
887
+ and self.cb.resolve("label_position") == "top"
888
+
889
+ def _labels_extent(self, fonts: Any, length: float) -> tuple[float, float, float]:
890
+ """A bar's space outside it for ticks, labels and its label, the overhang of end
891
+ labels along it, and its reach past its start (a vertical bar's top: the label
892
+ above it, ``label_position="top"``, included)."""
893
+ from .boxes import tick_label_extent
894
+ st = self.state
895
+ st.compute_ticks(fonts, length, not self.vertical)
896
+ axis = self.axis
897
+ out = st.tick_out()[0]
898
+ pad = axis.resolve("tick_pad")
899
+ across = out
900
+ over = 0.0
901
+ got = tick_label_extent(st, fonts, length, not self.vertical,
902
+ self.tick_side in ("right", "top"))
903
+ if got is not None:
904
+ across += pad + got[0]
905
+ over = max(got[1], got[2])
906
+ row = 0.0
907
+ oc = st.offset_label
908
+ if oc is not None: # ×10ᵏ: above a vertical bar, else in the label's row
909
+ ow, oh = box(fonts, oc, st.tick_font())
910
+ if self.vertical:
911
+ over = max(over, pad + oh)
912
+ across = max(across, out + pad + ow)
913
+ else:
914
+ row = oh
915
+ label = self.label
916
+ if self._top_label():
917
+ lw, a, d = measure(fonts, label, st.label_font())
918
+ tri = self.thickness if self.extend != "neither" and \
919
+ self.cb.resolve("extend_shape") == "triangle" else 0.0
920
+ above = max(tri, pad + box(fonts, oc, st.tick_font())[1] if oc is not None else 0.0)
921
+ top = max(over, above + self.cb.resolve("value_gap") + a + d)
922
+ return max(across, lw - self.thickness), over, top
923
+ if label is not None:
924
+ _, a, d = measure(fonts, label, st.label_font())
925
+ row = max(row, a + d)
926
+ if row:
927
+ across += axis.resolve("label_pad") + row
928
+ return across, over, over
929
+
930
+ def attach(self, w: float, h: float) -> float:
931
+ """A bar's thickness and pad for axes ``w`` × ``h`` (`%` is of the axes side across
932
+ the bar); returns its length (`%` of the side along it)."""
933
+ along, across = (h, w) if self.side in ("left", "right") else (w, h)
934
+ self.thickness = self.size("thickness", across)
935
+ self.pad = self.size("pad", across)
936
+ length = self.size("length", along)
937
+ thick, long = (self.thickness, length)
938
+ if self.vertical:
939
+ self._boxes(thick, long)
940
+ else:
941
+ self._boxes(long, thick)
942
+ return length
943
+
944
+ def _attached_key(self, fonts: Any, w: float, h: float) -> _Key:
945
+ """The key attached to axes ``w`` × ``h``, and how far it passes the ends of their
946
+ side (``over``)."""
947
+ flat = self.side in ("top", "bottom")
948
+ along, across = (w, h) if flat else (h, w)
949
+ self.pad = self.size("pad", across)
950
+ k = self._key(fonts, along)
951
+ bx0, by0, bx1, by1 = k.box
952
+ size = (bx1 - bx0) if flat else (by1 - by0)
953
+ start = self._start(0.0, along, size)
954
+ self.over = (max(0.0, -start), max(0.0, start + size - along))
955
+ return k
956
+
957
+ def measure_attached(self, fonts: Any, w: float, h: float) -> float:
958
+ """The band an attached colorbar takes beside axes ``w`` × ``h``: its pad and its
959
+ size across."""
960
+ if self.key:
961
+ k = self._attached_key(fonts, w, h)
962
+ assert self.side is not None
963
+ return self.pad + self._across(k, self.side)
964
+ length = self.attach(w, h)
965
+ across, over, top = self._labels_extent(fonts, length)
966
+ if self.extend != "neither" and self.cb.resolve("extend_shape") == "triangle":
967
+ over = max(over, self.thickness)
968
+ top = max(top, over)
969
+ along = h if self.side in ("left", "right") else w
970
+ start = self._start(0.0, along, length)
971
+ self.over = (max(0.0, top - start), max(0.0, start + length + over - along))
972
+ return self.pad + self.thickness + across
973
+
974
+ def measure_area(self, fonts: Any, w: float, h: float) -> tuple[float, float, float, float]:
975
+ """Margins of a colorbar in its own area ``w`` × ``h`` (a key's size across is its
976
+ ``cell``)."""
977
+ if self.key:
978
+ k = self._key(fonts, max(h if self.vertical else w, 1.0))
979
+ self.cell = self._across(k, "right" if self.vertical else "bottom")
980
+ return (0.0, 0.0, 0.0, 0.0)
981
+ length = h if self.vertical else w
982
+ self._boxes(w, h)
983
+ across, over, top = self._labels_extent(fonts, max(length, 1.0))
984
+ tri = self.thickness if self.extend != "neither" and \
985
+ self.cb.resolve("extend_shape") == "triangle" else 0.0
986
+ over = max(over, tri)
987
+ if self.vertical:
988
+ return (0.0, across, max(top, over), over)
989
+ return (over, over, 0.0, across)
990
+
991
+ def bar_rect(self, rect: tuple[float, float, float, float]) -> tuple:
992
+ x0, y0, x1, y1 = rect
993
+ t = self.cell_size
994
+ if self.vertical:
995
+ return (x0, y0, min(x1, x0 + t), y1)
996
+ return (x0, y0, x1, min(y1, y0 + t))
997
+
998
+ def lane_extent(self, fonts: Any, span: float) -> float:
999
+ """The size across of a colorbar in a lane over a span ``span`` long (its pad
1000
+ left out)."""
1001
+ assert self.side is not None
1002
+ if self.key:
1003
+ return self._across(self._key(fonts, span), self.side)
1004
+ self.thickness = self.size("thickness", span)
1005
+ length = self.size("length", span)
1006
+ return self.thickness + self._labels_extent(fonts, length)[0]
1007
+
1008
+ def lane_pad(self) -> float:
1009
+ return self.size("pad")
1010
+
1011
+ # -- placement --------------------------------------------------------------------
1012
+
1013
+ def key_width(self, fonts: Any, w: float, h: float) -> float:
1014
+ """The length along its side of a horizontal key attached to axes ``w`` × ``h``."""
1015
+ k = self._attached_key(fonts, w, h)
1016
+ return k.box[2] - k.box[0]
1017
+
1018
+ def key_reach(self, fonts: Any, w: float, h: float) -> tuple[float, float]:
1019
+ """A horizontal key attached on top or bottom of axes ``w`` × ``h``: from the axes
1020
+ side, its pad and box to its middle line, and from there to its far edge."""
1021
+ k = self._attached_key(fonts, w, h)
1022
+ up, down = -k.box[1], k.box[3]
1023
+ near, far = (up, down) if self.side == "bottom" else (down, up)
1024
+ return self.pad + near, far
1025
+
1026
+ def place_attached(self, fonts: Any, axes: tuple, base: float,
1027
+ span: tuple[float, float] | None = None,
1028
+ line: float | None = None) -> Any:
1029
+ """The colorbar attached to axes ``axes``, its band (its pad first) ``base`` px
1030
+ from them. A key sharing a line with others: ``span``, its place along the side,
1031
+ and ``line``, how far the line's middle line is from where it starts (the keys'
1032
+ middle lines aligned on it)."""
1033
+ x0, y0, x1, y1 = axes
1034
+ side = self.side
1035
+ assert side is not None
1036
+ if self.key:
1037
+ k = self._attached_key(fonts, x1 - x0, y1 - y0)
1038
+ start = base + self.pad
1039
+ if line is not None:
1040
+ start = base + line - (-k.box[1] if side == "bottom" else k.box[3])
1041
+ near = {"bottom": y1 + start, "top": y0 - start, "right": x1 + start,
1042
+ "left": x0 - start}[side]
1043
+ s0, s1 = span if span is not None else \
1044
+ ((x0, x1) if side in ("top", "bottom") else (y0, y1))
1045
+ return self._place_key(fonts, k, side, near, s0, s1)
1046
+ length = self.attach(x1 - x0, y1 - y0)
1047
+ start = base + self.pad
1048
+ if side in ("left", "right"):
1049
+ by0 = self._start(y0, y1, length)
1050
+ bx0 = x1 + start if side == "right" else x0 - start - self.thickness
1051
+ rect = (bx0, by0, bx0 + self.thickness, by0 + length)
1052
+ else:
1053
+ bx0 = self._start(x0, x1, length)
1054
+ by0 = y1 + start if side == "bottom" else y0 - start - self.thickness
1055
+ rect = (bx0, by0, bx0 + length, by0 + self.thickness)
1056
+ return self._place_bar(fonts, rect)
1057
+
1058
+ def inside_size(self, fonts: Any, w: float, h: float) -> tuple[float, float, Any]:
1059
+ """Inside axes ``w`` × ``h``: the size of its box (texts included) and how it is
1060
+ laid out (a key, or a bar's ``(length, across, over)``)."""
1061
+ pad = self.size("pad")
1062
+ along = (h if self.vertical else w) - 2 * pad
1063
+ if self.key:
1064
+ k = self._key(fonts, max(along, 1.0))
1065
+ bx0, by0, bx1, by1 = k.box
1066
+ return bx1 - bx0, by1 - by0, k
1067
+ self.thickness = self.size("thickness", w if self.vertical else h)
1068
+ length = 0.5 * along if self.cb.resolve("length") == "auto" else \
1069
+ self.size("length", along)
1070
+ length = max(length, 1.0)
1071
+ if self.vertical:
1072
+ self._boxes(self.thickness, length)
1073
+ else:
1074
+ self._boxes(length, self.thickness)
1075
+ across, over, top = self._labels_extent(fonts, length)
1076
+ if self.extend != "neither" and self.cb.resolve("extend_shape") == "triangle":
1077
+ over = max(over, self.thickness)
1078
+ top = max(top, over)
1079
+ ends = length + 2 * over if top == over else length + over + top
1080
+ size = (self.thickness + across, ends) if self.vertical else \
1081
+ (ends, self.thickness + across)
1082
+ return size[0], size[1], (length, across, over, top)
1083
+
1084
+ def place_inside(self, fonts: Any, axes: tuple, at: Any) -> Any:
1085
+ """The colorbar inside axes ``axes``, its box's top-left corner where
1086
+ ``at(w, h, pad)`` puts a ``w`` × ``h`` box (its anchor, or the place ``"auto"``
1087
+ chooses)."""
1088
+ x0, y0, x1, y1 = axes
1089
+ w, h, how = self.inside_size(fonts, x1 - x0, y1 - y0)
1090
+ pad = self.size("pad")
1091
+ tx, ty = at(w, h, pad)
1092
+ if self.key:
1093
+ if self.vertical:
1094
+ return self._place_key(fonts, how, "right", tx, ty, ty + h)
1095
+ return self._place_key(fonts, how, "bottom", ty, tx, tx + w)
1096
+ length, _, over, top = how
1097
+ t = self.thickness
1098
+ rect = (tx, ty + top, tx + t, ty + top + length) if self.vertical else \
1099
+ (tx + over, ty, tx + over + length, ty + t)
1100
+ return self._place_bar(fonts, rect)
1101
+
1102
+ def place_lane(self, fonts: Any, near: float, s0: float, s1: float) -> Any:
1103
+ """The colorbar in a lane: its near edge at ``near`` across its side, aligned
1104
+ within ``s0``–``s1`` along it."""
1105
+ side = self.side
1106
+ assert side is not None
1107
+ if self.key:
1108
+ return self._place_key(fonts, self._key(fonts, s1 - s0), side, near, s0, s1)
1109
+ self.thickness = t = self.size("thickness", s1 - s0)
1110
+ length = self.size("length", s1 - s0)
1111
+ a = self._start(s0, s1, length)
1112
+ if side in ("top", "bottom"):
1113
+ y = near if side == "bottom" else near - t
1114
+ rect = (a, y, a + length, y + t)
1115
+ else:
1116
+ x = near if side == "right" else near - t
1117
+ rect = (x, a, x + t, a + length)
1118
+ self._boxes(rect[2] - rect[0], rect[3] - rect[1])
1119
+ return self._place_bar(fonts, rect)
1120
+
1121
+ def place_area(self, fonts: Any, rect: tuple[float, float, float, float]) -> Any:
1122
+ """The colorbar in its own area, at ``rect`` (a key along it, a bar filling it)."""
1123
+ if self.key:
1124
+ x0, y0, x1, y1 = rect
1125
+ k = self._key(fonts, max((y1 - y0) if self.vertical else (x1 - x0), 1.0))
1126
+ if self.vertical:
1127
+ return self._place_key(fonts, k, "right", x0, y0, y1)
1128
+ return self._place_key(fonts, k, "bottom", y0, x0, x1)
1129
+ return self._place_bar(fonts, rect)
1130
+
1131
+ def _ends(self) -> list[tuple[bool, Any]]:
1132
+ """The extension ends: ``(at the start, color)``; the start is the bottom of a
1133
+ vertical bar, the left of a horizontal one."""
1134
+ cmap, inv = self.cmap, self.axis.resolve("invert")
1135
+ ends: list[tuple[bool, Any]] = []
1136
+ if cmap is None: # a size key: no ends
1137
+ return ends
1138
+ if self.extend in ("min", "both"):
1139
+ ends.append((not inv, cmap.under or cmap(0.0)))
1140
+ if self.extend in ("max", "both"):
1141
+ ends.append((inv, cmap.over or cmap(1.0)))
1142
+ return ends
1143
+
1144
+ def _blocks(self, rect: tuple) -> tuple[tuple, list[tuple[tuple, Any]]]:
1145
+ """The gradient's part of ``rect`` and the extension blocks ``(rect, color)``: each
1146
+ block is half the thickness long, a fifth of the thickness apart from the bar."""
1147
+ if self.cb.resolve("extend_shape") != "block":
1148
+ return rect, []
1149
+ x0, y0, x1, y1 = rect
1150
+ b, gap = 0.5 * self.thickness, 0.2 * self.thickness
1151
+ blocks = []
1152
+ for at_start, c in self._ends():
1153
+ if self.vertical:
1154
+ if at_start:
1155
+ blocks.append(((x0, y1 - b, x1, y1), c))
1156
+ y1 -= b + gap
1157
+ else:
1158
+ blocks.append(((x0, y0, x1, y0 + b), c))
1159
+ y0 += b + gap
1160
+ else:
1161
+ if at_start:
1162
+ blocks.append(((x0, y0, x0 + b, y1), c))
1163
+ x0 += b + gap
1164
+ else:
1165
+ blocks.append(((x1 - b, y0, x1, y1), c))
1166
+ x1 -= b + gap
1167
+ return (x0, y0, x1, y1), blocks
1168
+
1169
+ def _triangles(self, rect: tuple) -> list[tuple[tuple, Any]]:
1170
+ """Extension triangles past the bar's ends (a dot is drawn as one on a bar):
1171
+ ``(points, color)``."""
1172
+ if self.extend == "neither":
1173
+ return []
1174
+ x0, y0, x1, y1 = rect
1175
+ t = self.thickness
1176
+ out = []
1177
+ for at_start, c in self._ends():
1178
+ if self.vertical:
1179
+ pts = (((x0, y1), (x1, y1), ((x0 + x1) / 2, y1 + t)) if at_start else
1180
+ ((x0, y0), (x1, y0), ((x0 + x1) / 2, y0 - t)))
1181
+ else:
1182
+ pts = (((x0, y0), (x0, y1), (x0 - t, (y0 + y1) / 2)) if at_start else
1183
+ ((x1, y0), (x1, y1), (x1 + t, (y0 + y1) / 2)))
1184
+ out.append((pts, c))
1185
+ return out
1186
+
1187
+ def _place_bar(self, fonts: Any, rect: tuple[float, float, float, float]) -> Any:
1188
+ """A bar's geometry with its rectangle (extension blocks included) at ``rect``."""
1189
+ from .geometry import BarGeometry
1190
+ r = current()
1191
+ boxes = (r.boxes.get(id(self.cb)), r.boxes.get(id(self.axis))) if r is not None \
1192
+ else (None, None)
1193
+ outer = rect
1194
+ rect, blocks = self._blocks(rect)
1195
+ triangles = [] if blocks else self._triangles(rect)
1196
+ x0, y0, x1, y1 = rect
1197
+ length = (y1 - y0) if self.vertical else (x1 - x0)
1198
+ st = self.state
1199
+ if abs(st.length - length) > 0.01:
1200
+ st.compute_ticks(fonts, length, not self.vertical)
1201
+ axis = self.axis
1202
+ side = self.tick_side
1203
+ mapper = st.mapper(y1, y0) if self.vertical else st.mapper(x0, x1)
1204
+ out, inside = st.tick_out(marks=True)
1205
+ ml = axis.resolve("minor_tick_length")
1206
+ base = {"right": x1, "left": x0, "bottom": y1, "top": y0}[side]
1207
+ other = {"right": x0, "left": x1, "bottom": y0, "top": y1}[side]
1208
+ sign = 1.0 if side in ("right", "bottom") else -1.0
1209
+ marks = []
1210
+ for t in st.ticks:
1211
+ p = mapper(t.value)
1212
+ o, i = (out, inside) if t.major else ((ml if out else 0.0), (ml if inside else 0.0))
1213
+ ends = [(base - sign * i, base + sign * o)]
1214
+ if axis.resolve("mirror") and i:
1215
+ ends.append((other + sign * i, other))
1216
+ for a, b in ends:
1217
+ marks.append((a, p, b, p) if self.vertical else (p, a, p, b))
1218
+ from .boxes import tick_label_extent, tick_label_runs
1219
+ font = st.tick_font()
1220
+ pad = axis.resolve("tick_pad")
1221
+ pos = base + sign * (out + pad)
1222
+ far = side in ("right", "top")
1223
+ labels = tick_label_runs(st, fonts, mapper, pos, not self.vertical, far)
1224
+ got = tick_label_extent(st, fonts, length, not self.vertical, far)
1225
+ widest = got[0] if got is not None else 0.0
1226
+ oc = st.offset_label # ×10ᵏ: as on the axes of plots
1227
+ if oc is not None:
1228
+ if self.vertical:
1229
+ labels.append(run(fonts, oc, pos, y0 - pad, font,
1230
+ "left" if sign > 0 else "right", "bottom"))
1231
+ else:
1232
+ labels.append(run(fonts, oc, x1, pos + sign * (widest + axis.resolve(
1233
+ "label_pad")), font, "right", "top" if sign > 0 else "bottom"))
1234
+ title = None
1235
+ if self._top_label(): # above the bar (its ×10ᵏ, its triangle)
1236
+ tri = self.thickness if triangles and any(not s for s, _ in self._ends()) \
1237
+ else 0.0
1238
+ above = max(tri, pad + box(fonts, oc, font)[1] if oc is not None else 0.0)
1239
+ title = run(fonts, self.label, outer[0],
1240
+ outer[1] - above - self.cb.resolve("value_gap"), st.label_font(),
1241
+ "left", "bottom")
1242
+ elif self.label is not None:
1243
+ lf = st.label_font()
1244
+ pos += sign * (widest + axis.resolve("label_pad"))
1245
+ if self.vertical:
1246
+ title = run(fonts, self.label, pos, (y0 + y1) / 2, lf, "center",
1247
+ "top" if sign > 0 else "bottom", 90.0)
1248
+ elif self.cb.resolve("label_position") == "top": # at its start
1249
+ title = run(fonts, self.label, outer[0], pos, lf, "left",
1250
+ "top" if sign > 0 else "bottom")
1251
+ else:
1252
+ title = run(fonts, self.label, (x0 + x1) / 2, pos, lf, "center",
1253
+ "top" if sign > 0 else "bottom")
1254
+ return BarGeometry(self, rect, tuple(blocks), tuple(triangles), tuple(marks),
1255
+ tuple(labels), title, boxes)
1256
+
1257
+ # -- drawing --------------------------------------------------------------------
1258
+
1259
+ def _gradient(self, x0: float, y0: float, x1: float, y1: float) -> Any:
1260
+ """The axis' gradient along the bar ``(x0, y0, x1, y1)``."""
1261
+ cmap = self.cmap
1262
+ assert cmap is not None
1263
+ stops = gradient_stops(cmap)
1264
+ inv = self.axis.resolve("invert")
1265
+ if self.vertical:
1266
+ return LinearGradient(0, y0, 0, y1, stops) if inv else LinearGradient(0, y1, 0, y0, stops)
1267
+ return LinearGradient(x1, 0, x0, 0, stops) if inv else LinearGradient(x0, 0, x1, 0, stops)
1268
+
1269
+ def draw(self, canvas: Canvas, g: Any) -> None:
1270
+ """Draw the colorbar placed as ``g`` (see :meth:`place`)."""
1271
+ r = current()
1272
+ if r is not None:
1273
+ for obj, b in zip((self.cb, self.axis), g.boxes):
1274
+ if b is not None:
1275
+ r.boxes[id(obj)] = b
1276
+ if self.key:
1277
+ self._draw_key(canvas, g)
1278
+ return
1279
+ x0, y0, x1, y1 = g.rect
1280
+ grad = self._gradient(x0, y0, x1, y1)
1281
+ with canvas.state():
1282
+ canvas.fill = grad
1283
+ canvas.draw_rect(x0, y0, x1 - x0, y1 - y0, stroke=None)
1284
+ for (bx0, by0, bx1, by1), c in g.blocks:
1285
+ canvas.fill = c
1286
+ canvas.draw_rect(bx0, by0, bx1 - bx0, by1 - by0, stroke=None)
1287
+ for pts, c in g.triangles:
1288
+ canvas.fill = c
1289
+ path = Path().move_to(*pts[0])
1290
+ for pt in pts[1:]:
1291
+ path.line_to(*pt)
1292
+ canvas.draw_path(path.close(), stroke=None)
1293
+ if self.cb.resolve("frame"):
1294
+ canvas.stroke = resolve_color(self.cb.resolve("frame_color"), ())
1295
+ canvas.stroke_width = self.cb.resolve("frame_width")
1296
+ path = Path()
1297
+ for bx0, by0, bx1, by1 in [g.rect] + [b for b, _ in g.blocks]:
1298
+ path.rect(bx0, by0, bx1 - bx0, by1 - by0)
1299
+ canvas.draw_path(path, fill=None)
1300
+ axis = self.axis
1301
+ with canvas.state():
1302
+ canvas.stroke = resolve_color(axis.resolve("tick_color"), ())
1303
+ canvas.stroke_width = axis.resolve("tick_width")
1304
+ path = Path()
1305
+ for a, b, c, d in g.marks:
1306
+ path.move_to(a, b).line_to(c, d)
1307
+ canvas.draw_path(path, fill=None)
1308
+ for lab in g.labels:
1309
+ draw_run(canvas, lab)
1310
+ draw_run(canvas, g.title)
1311
+
1312
+ def _draw_key(self, canvas: Canvas, g: Any) -> None:
1313
+ """A key: the gradient clipped to the bar's rounded rectangle, the end shapes, the
1314
+ frame along their outlines, the texts."""
1315
+ if self.sizes:
1316
+ path, fill, stroke, width = self.marker_look()
1317
+ for cx, cy, d in g.marks:
1318
+ canvas.draw_markers([cx], [cy], marker=path, size=d, fill=fill,
1319
+ stroke=stroke, stroke_width=width)
1320
+ for lab in g.values:
1321
+ draw_run(canvas, lab)
1322
+ draw_run(canvas, g.title)
1323
+ return
1324
+ x0, y0, x1, y1 = g.rect
1325
+ w, h = x1 - x0, y1 - y0
1326
+ radius = min(w, h) / 2
1327
+ with canvas.state():
1328
+ canvas.clip(Path().rect(x0, y0, w, h, rx=radius))
1329
+ canvas.draw_rect(x0, y0, w, h, fill=self._gradient(x0, y0, x1, y1), stroke=None)
1330
+ with canvas.state():
1331
+ for shape, geo, c in g.ends:
1332
+ canvas.fill = c
1333
+ canvas.draw_path(_shape_path(Path(), shape, geo), stroke=None)
1334
+ if self.cb.resolve("frame"):
1335
+ canvas.stroke = resolve_color(self.cb.resolve("frame_color"), ())
1336
+ canvas.stroke_width = self.cb.resolve("frame_width")
1337
+ path = Path().rect(x0, y0, w, h, rx=radius)
1338
+ for shape, geo, _ in g.ends:
1339
+ _shape_path(path, shape, geo)
1340
+ canvas.draw_path(path, fill=None)
1341
+ for lab in g.values:
1342
+ draw_run(canvas, lab)
1343
+ draw_run(canvas, g.title)
1344
+
1345
+
1346
+ def _round_limits(lo: float, hi: float, log: bool) -> tuple[float, float]:
1347
+ """Limits rounded to two digits of their range (``log``: two significant digits
1348
+ each)."""
1349
+ def digits(v: float) -> float:
1350
+ if not v or not math.isfinite(v):
1351
+ return v
1352
+ return round(v, max(0, 1 - math.floor(math.log10(abs(v)))))
1353
+
1354
+ span = abs(hi - lo)
1355
+ if log or not span or not math.isfinite(span):
1356
+ return digits(lo), digits(hi)
1357
+ d = max(0, 1 - math.floor(math.log10(span)))
1358
+ return round(lo, d), round(hi, d)
1359
+
1360
+
1361
+ def _shape_rect(shape: str, geo: Any) -> tuple[float, float, float, float]:
1362
+ """The box of an end shape."""
1363
+ if shape == "dot":
1364
+ cx, cy, r = geo
1365
+ return (cx - r, cy - r, cx + r, cy + r)
1366
+ if shape == "block":
1367
+ return geo
1368
+ xs = [p[0] for p in geo[:3]] # a triangle: its corners (not the centre)
1369
+ ys = [p[1] for p in geo[:3]]
1370
+ return (min(xs), min(ys), max(xs), max(ys))
1371
+
1372
+
1373
+ def _shape_path(path: Path, shape: str, geo: Any) -> Path:
1374
+ """``path`` with an end shape added."""
1375
+ if shape == "dot":
1376
+ return path.circle(*geo)
1377
+ if shape == "block":
1378
+ return path.rect(geo[0], geo[1], geo[2] - geo[0], geo[3] - geo[1])
1379
+ return _round_triangle(path, *geo)
1380
+
1381
+
1382
+ def _round_triangle(path: Path, tip: tuple, a: tuple, b: tuple, c: tuple) -> Path:
1383
+ """A key's end triangle: corners ``a``, ``b`` on a circle about ``c`` (the bar's rounded
1384
+ end), its side between them along that circle (bulging toward ``tip``), every corner
1385
+ rounded by ``_TRI_RADIUS`` × the triangle's height."""
1386
+ r = _TRI_RADIUS * math.dist(a, b)
1387
+ radius = math.dist(c, a)
1388
+
1389
+ def toward(p: tuple, q: tuple, length: float) -> tuple:
1390
+ k = length / math.dist(p, q)
1391
+ return (p[0] + (q[0] - p[0]) * k, p[1] + (q[1] - p[1]) * k)
1392
+
1393
+ def bend(path: Path, corner: tuple, end: tuple) -> None:
1394
+ """From the current point round ``corner`` to ``end`` (a cubic near a circular arc)."""
1395
+ point = path.current_point()
1396
+ assert point is not None
1397
+ x, y = point
1398
+ k = 0.55
1399
+ path.curve_to(x + (corner[0] - x) * k, y + (corner[1] - y) * k,
1400
+ end[0] + (corner[0] - end[0]) * k, end[1] + (corner[1] - end[1]) * k,
1401
+ end[0], end[1])
1402
+
1403
+ def angle(p: tuple) -> float:
1404
+ return math.degrees(math.atan2(p[1] - c[1], p[0] - c[0]))
1405
+
1406
+ # the arc from a to b through the side facing the tip
1407
+ t0, t1, mid = angle(a), angle(b), angle(tip)
1408
+ sweep = (t1 - t0) % 360.0 # clockwise on screen
1409
+ clockwise = (mid - t0) % 360.0 < sweep
1410
+ if not clockwise:
1411
+ sweep -= 360.0
1412
+ step = math.degrees(r / radius) * (1.0 if sweep > 0 else -1.0)
1413
+ a_in, b_in = t0 + step, t0 + sweep - step # where the corners' roundings meet the arc
1414
+
1415
+ def on(deg: float) -> tuple:
1416
+ return (c[0] + radius * math.cos(math.radians(deg)),
1417
+ c[1] + radius * math.sin(math.radians(deg)))
1418
+
1419
+ path.move_to(*toward(tip, a, r))
1420
+ path.line_to(*toward(a, tip, r))
1421
+ bend(path, a, on(a_in))
1422
+ path.arc(c[0], c[1], radius, a_in, b_in, counterclockwise=sweep < 0)
1423
+ bend(path, b, toward(b, tip, r))
1424
+ path.line_to(*toward(tip, b, r))
1425
+ bend(path, tip, toward(tip, a, r))
1426
+ return path.close()