plot-io 0.12.0__py3-none-win_arm64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plot_io/__init__.py +73 -0
- plot_io/_methods.py +1691 -0
- plot_io/_native/__init__.py +199 -0
- plot_io/_native/__main__.py +197 -0
- plot_io/_native/_api.py +457 -0
- plot_io/_native/bin/windows-arm64.dll +0 -0
- plot_io/_native/kernels.py +335 -0
- plot_io/_native/manifest.json +49 -0
- plot_io/axis.py +1347 -0
- plot_io/boxes.py +1156 -0
- plot_io/canvas.py +389 -0
- plot_io/color/__init__.py +3 -0
- plot_io/color/data.py +1646 -0
- plot_io/color/maps.py +922 -0
- plot_io/color/values.py +295 -0
- plot_io/color/variants.py +1246 -0
- plot_io/colorbar.py +1426 -0
- plot_io/columns.py +614 -0
- plot_io/decorations.py +920 -0
- plot_io/defaults.py +809 -0
- plot_io/density.py +113 -0
- plot_io/ext.py +68 -0
- plot_io/facets.py +240 -0
- plot_io/figure.py +788 -0
- plot_io/formatters.py +97 -0
- plot_io/genome/__init__.py +17 -0
- plot_io/genome/adapters.py +356 -0
- plot_io/genome/combine.py +123 -0
- plot_io/genome/contacts.py +508 -0
- plot_io/genome/intervals.py +750 -0
- plot_io/genome/lanes.py +191 -0
- plot_io/genome/layout.py +184 -0
- plot_io/genome/links.py +364 -0
- plot_io/genome/pack.py +27 -0
- plot_io/genome/readers.py +145 -0
- plot_io/genome/regions.py +228 -0
- plot_io/genome/scale.py +349 -0
- plot_io/genome/series.py +344 -0
- plot_io/genome/signal.py +621 -0
- plot_io/genome/sites.py +782 -0
- plot_io/genome/sources.py +459 -0
- plot_io/genome/testing.py +161 -0
- plot_io/geometry.py +289 -0
- plot_io/groups.py +308 -0
- plot_io/guides.py +503 -0
- plot_io/helpers/__init__.py +17 -0
- plot_io/helpers/clustermap.py +202 -0
- plot_io/helpers/dendrogram.py +172 -0
- plot_io/helpers/grids.py +304 -0
- plot_io/helpers/qq.py +334 -0
- plot_io/helpers/raincloud.py +249 -0
- plot_io/helpers/tree.py +214 -0
- plot_io/helpers/volcano.py +505 -0
- plot_io/hints.py +125 -0
- plot_io/kernels.py +740 -0
- plot_io/labels.py +566 -0
- plot_io/layers/__init__.py +100 -0
- plot_io/layers/base.py +630 -0
- plot_io/layers/context.py +87 -0
- plot_io/layers/core.py +446 -0
- plot_io/layers/geoms.py +1362 -0
- plot_io/layers/grammar.py +586 -0
- plot_io/layers/paint.py +614 -0
- plot_io/layers/pipeline.py +170 -0
- plot_io/layers/positions.py +431 -0
- plot_io/layers/stats.py +1088 -0
- plot_io/layout.py +1381 -0
- plot_io/legend.py +605 -0
- plot_io/levels.py +509 -0
- plot_io/limits.py +124 -0
- plot_io/lines.py +42 -0
- plot_io/mapped.py +522 -0
- plot_io/markers.py +110 -0
- plot_io/markup.py +128 -0
- plot_io/numbers.py +354 -0
- plot_io/occupancy.py +295 -0
- plot_io/plot.py +597 -0
- plot_io/presets/__init__.py +51 -0
- plot_io/presets/_util.py +153 -0
- plot_io/presets/area.py +145 -0
- plot_io/presets/bar.py +261 -0
- plot_io/presets/box.py +400 -0
- plot_io/presets/catpoints.py +177 -0
- plot_io/presets/contour.py +353 -0
- plot_io/presets/curves.py +30 -0
- plot_io/presets/dots.py +355 -0
- plot_io/presets/ecdf.py +97 -0
- plot_io/presets/errorbar.py +106 -0
- plot_io/presets/errors.py +296 -0
- plot_io/presets/fill.py +74 -0
- plot_io/presets/heatmap.py +322 -0
- plot_io/presets/hexbin.py +198 -0
- plot_io/presets/hist.py +391 -0
- plot_io/presets/hist2d.py +87 -0
- plot_io/presets/kde.py +162 -0
- plot_io/presets/kde2d.py +147 -0
- plot_io/presets/line.py +143 -0
- plot_io/presets/pie.py +502 -0
- plot_io/presets/regression.py +115 -0
- plot_io/presets/scatter.py +194 -0
- plot_io/presets/stem.py +100 -0
- plot_io/presets/violin.py +323 -0
- plot_io/py.typed +0 -0
- plot_io/registry.py +82 -0
- plot_io/render.py +480 -0
- plot_io/rows.py +491 -0
- plot_io/scales.py +103 -0
- plot_io/seams.py +138 -0
- plot_io/settings.py +807 -0
- plot_io/shapes.py +66 -0
- plot_io/sharing.py +232 -0
- plot_io/sizemaps.py +97 -0
- plot_io/spec.py +240 -0
- plot_io/states.py +216 -0
- plot_io/styles/__init__.py +307 -0
- plot_io/styles/library.py +397 -0
- plot_io/tables.py +138 -0
- plot_io/text.py +412 -0
- plot_io/ticks.py +400 -0
- plot_io/times.py +287 -0
- plot_io/titles.py +225 -0
- plot_io/transform.py +114 -0
- plot_io/types.py +13604 -0
- plot_io/units.py +768 -0
- plot_io/validators.py +1029 -0
- plot_io/values.py +190 -0
- plot_io-0.12.0.dist-info/METADATA +215 -0
- plot_io-0.12.0.dist-info/RECORD +130 -0
- plot_io-0.12.0.dist-info/WHEEL +4 -0
- 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()
|