plot-io 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plot_io/__init__.py +66 -0
- plot_io/_backend.py +114 -0
- plot_io/axis.py +574 -0
- plot_io/colorbar.py +342 -0
- plot_io/decorations.py +496 -0
- plot_io/defaults.py +766 -0
- plot_io/density.py +97 -0
- plot_io/enums.py +63 -0
- plot_io/facets.py +184 -0
- plot_io/figure.py +331 -0
- plot_io/labels.py +63 -0
- plot_io/legend.py +498 -0
- plot_io/limits.py +92 -0
- plot_io/maps/__init__.py +365 -0
- plot_io/maps/data.py +333 -0
- plot_io/markers.py +117 -0
- plot_io/numbers.py +289 -0
- plot_io/occupancy.py +219 -0
- plot_io/palettes.py +142 -0
- plot_io/plot.py +435 -0
- plot_io/render.py +1122 -0
- plot_io/scales.py +93 -0
- plot_io/series/__init__.py +498 -0
- plot_io/series/area.py +181 -0
- plot_io/series/bar.py +294 -0
- plot_io/series/box.py +401 -0
- plot_io/series/catdata.py +23 -0
- plot_io/series/catpoints.py +182 -0
- plot_io/series/contour.py +294 -0
- plot_io/series/curves.py +128 -0
- plot_io/series/ecdf.py +143 -0
- plot_io/series/errorbar.py +111 -0
- plot_io/series/errors.py +100 -0
- plot_io/series/fill.py +168 -0
- plot_io/series/heatmap.py +304 -0
- plot_io/series/hexbin.py +216 -0
- plot_io/series/hist.py +379 -0
- plot_io/series/hist2d.py +163 -0
- plot_io/series/kde.py +228 -0
- plot_io/series/kde2d.py +153 -0
- plot_io/series/line.py +173 -0
- plot_io/series/pie.py +304 -0
- plot_io/series/points.py +341 -0
- plot_io/series/regression.py +250 -0
- plot_io/series/scatter.py +103 -0
- plot_io/series/stem.py +112 -0
- plot_io/series/violin.py +321 -0
- plot_io/settings.py +281 -0
- plot_io/shapes.py +57 -0
- plot_io/sharing.py +160 -0
- plot_io/slots.py +52 -0
- plot_io/stacking.py +63 -0
- plot_io/stats.py +140 -0
- plot_io/style.py +71 -0
- plot_io/styles/__init__.py +234 -0
- plot_io/styles/library.py +732 -0
- plot_io/tables.py +141 -0
- plot_io/text.py +109 -0
- plot_io/ticks.py +284 -0
- plot_io/times.py +199 -0
- plot_io/values.py +161 -0
- plot_io-0.1.0.dist-info/METADATA +102 -0
- plot_io-0.1.0.dist-info/RECORD +65 -0
- plot_io-0.1.0.dist-info/WHEEL +4 -0
- plot_io-0.1.0.dist-info/licenses/LICENSE +28 -0
plot_io/series/line.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Line: a polyline through points (or several sharing x)."""
|
|
2
|
+
|
|
3
|
+
import array
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pdf_io import arrays as pdf_arrays
|
|
7
|
+
|
|
8
|
+
from .. import _backend
|
|
9
|
+
from ..settings import Setting, color, number, one_of, table
|
|
10
|
+
from ..style import dash_of
|
|
11
|
+
from . import Series, common
|
|
12
|
+
from .errors import error_value
|
|
13
|
+
from .curves import curve_settings, line_args
|
|
14
|
+
from .points import MarkerStyle, marker_settings
|
|
15
|
+
|
|
16
|
+
__all__ = ["Line"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _dash_value(v: Any) -> Any:
|
|
20
|
+
if isinstance(v, (list, tuple)):
|
|
21
|
+
return [number(lo=0)(x) for x in v]
|
|
22
|
+
return one_of("solid", "dashed", "dotted", "dashdot")(v)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _rows(y: Any) -> list[array.array]:
|
|
26
|
+
rows = pdf_arrays.rows(y)
|
|
27
|
+
if rows is not None:
|
|
28
|
+
return rows
|
|
29
|
+
if isinstance(y, (list, tuple)) and y and all(
|
|
30
|
+
hasattr(r, "__len__") and not isinstance(r, str) for r in y):
|
|
31
|
+
return [_backend.doubles(r) for r in y]
|
|
32
|
+
return [_backend.doubles(y)]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Line(Series):
|
|
36
|
+
"""A line through ``(x, y)``; ``y`` may be 2-D (one line per row, sharing ``x``), and
|
|
37
|
+
``x`` may be omitted (``0, 1, 2, …``)."""
|
|
38
|
+
|
|
39
|
+
SETTINGS = table(
|
|
40
|
+
color=Setting(color, doc="Line color; default: the next palette color."),
|
|
41
|
+
line_width=Setting(number(lo=0), inherited=True, doc="Line width (pt).", length=True),
|
|
42
|
+
line_style=Setting(_dash_value,
|
|
43
|
+
doc="`solid`, `dashed`, `dotted`, `dashdot` or a dash list (pt); "
|
|
44
|
+
"named styles scale with the width."),
|
|
45
|
+
**curve_settings(),
|
|
46
|
+
cap=Setting(one_of("butt", "round", "square"), doc="Line cap."),
|
|
47
|
+
join=Setting(one_of("miter", "round", "bevel"), doc="Line join."),
|
|
48
|
+
**{k: v for k, v in marker_settings("marker_").items()},
|
|
49
|
+
x_error=Setting(error_value, doc="Horizontal error bars at the points."),
|
|
50
|
+
y_error=Setting(error_value, doc="Vertical error bars at the points."),
|
|
51
|
+
show_markers=Setting(lambda v: bool(v),
|
|
52
|
+
doc="Draw markers at the points (also turned on by setting "
|
|
53
|
+
"`marker`)."),
|
|
54
|
+
**common("name", "alpha", "z", "rasterize", "simplify", "font_size", "font_family",
|
|
55
|
+
"text_color"),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def __init__(self, x: Any, y: Any = None, plot: Any = None, **settings: Any) -> None:
|
|
59
|
+
if y is None:
|
|
60
|
+
y = x
|
|
61
|
+
rows = _rows(y)
|
|
62
|
+
x = array.array("d", range(len(rows[0])))
|
|
63
|
+
else:
|
|
64
|
+
rows = _rows(y)
|
|
65
|
+
self.x = _backend.doubles(x)
|
|
66
|
+
for r in rows:
|
|
67
|
+
if len(r) != len(self.x):
|
|
68
|
+
raise ValueError(f"x has {len(self.x)} values and y {len(r)}")
|
|
69
|
+
self.ys = rows
|
|
70
|
+
self._markers_on = "marker" in settings
|
|
71
|
+
super().__init__(plot=plot, **settings)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def y(self) -> array.array:
|
|
75
|
+
return self.ys[0]
|
|
76
|
+
|
|
77
|
+
def set(self, **settings: Any) -> "Line":
|
|
78
|
+
if "marker" in settings:
|
|
79
|
+
self._markers_on = settings["marker"] is not None
|
|
80
|
+
super().set(**settings)
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
def _markers(self) -> bool:
|
|
84
|
+
return self._markers_on or bool(self.get("show_markers"))
|
|
85
|
+
|
|
86
|
+
def _error(self, role: str) -> Any:
|
|
87
|
+
e = self.get("x_error" if role == "x" else "y_error")
|
|
88
|
+
if e is not None:
|
|
89
|
+
e.bind(len(self.x))
|
|
90
|
+
return e
|
|
91
|
+
|
|
92
|
+
def values(self, role: str) -> Any:
|
|
93
|
+
if role == "x":
|
|
94
|
+
v = self.x
|
|
95
|
+
elif len(self.ys) == 1:
|
|
96
|
+
v = self.ys[0]
|
|
97
|
+
else:
|
|
98
|
+
v = array.array("d")
|
|
99
|
+
for r in self.ys:
|
|
100
|
+
v.extend(r)
|
|
101
|
+
e = self._error(role)
|
|
102
|
+
if e is None:
|
|
103
|
+
return v
|
|
104
|
+
center = self.x if role == "x" else self.ys[0]
|
|
105
|
+
lo, hi = e.ends(center)
|
|
106
|
+
out = array.array("d", v)
|
|
107
|
+
out.extend(lo)
|
|
108
|
+
out.extend(hi)
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
def occupy(self, ctx: Any, occ: Any) -> None:
|
|
112
|
+
from ..occupancy import MAX_VERTICES, thin
|
|
113
|
+
for r in self.ys:
|
|
114
|
+
x, y = thin(self.x, r, limit=MAX_VERTICES)
|
|
115
|
+
occ.lines(ctx.xmap.many(x), ctx.ymap.many(y))
|
|
116
|
+
|
|
117
|
+
def legend_points(self) -> tuple[Any, Any]:
|
|
118
|
+
if len(self.ys) == 1:
|
|
119
|
+
return self.x, self.ys[0]
|
|
120
|
+
xs, ys = array.array("d"), array.array("d")
|
|
121
|
+
for r in self.ys:
|
|
122
|
+
xs.extend(self.x)
|
|
123
|
+
ys.extend(r)
|
|
124
|
+
return xs, ys
|
|
125
|
+
|
|
126
|
+
def _line_args(self, ctx: Any) -> tuple[Any, float, Any, float]:
|
|
127
|
+
c = self.base_color(ctx)
|
|
128
|
+
w = self.get("line_width")
|
|
129
|
+
alpha = self.get("alpha")
|
|
130
|
+
return c, w, dash_of(self.get("line_style"), w), 1.0 if alpha is None else alpha
|
|
131
|
+
|
|
132
|
+
def draw(self, ctx: Any) -> None:
|
|
133
|
+
c, w, dash, opacity = self._line_args(ctx)
|
|
134
|
+
simplify = self.simplify_arg()
|
|
135
|
+
canvas = ctx.canvas
|
|
136
|
+
for role in ("x", "y"):
|
|
137
|
+
e = self._error(role)
|
|
138
|
+
if e is not None:
|
|
139
|
+
e.draw(ctx, self.x, self.ys[0], role == "x", c, 0.8, simplify, opacity)
|
|
140
|
+
px = ctx.xmap.many(self.x)
|
|
141
|
+
xs_page = [px] * len(self.ys)
|
|
142
|
+
ys_page = [ctx.ymap.many(r) for r in self.ys]
|
|
143
|
+
args = line_args(self)
|
|
144
|
+
if c is not None and w:
|
|
145
|
+
canvas.line_cap = self.get("cap")
|
|
146
|
+
canvas.line_join = self.get("join")
|
|
147
|
+
canvas.draw_lines(xs_page if len(xs_page) > 1 else xs_page[0],
|
|
148
|
+
ys_page if len(ys_page) > 1 else ys_page[0],
|
|
149
|
+
color=c, width=w, dash=dash, opacity=opacity,
|
|
150
|
+
simplify=self.simplify_arg(lines=True),
|
|
151
|
+
**args)
|
|
152
|
+
if self._markers():
|
|
153
|
+
style = MarkerStyle(self, ctx, "marker_", c, len(self.x))
|
|
154
|
+
for r in self.ys:
|
|
155
|
+
style.draw(canvas, ctx.xmap.many(self.x), ctx.ymap.many(r), simplify)
|
|
156
|
+
|
|
157
|
+
def zero_default(self, role: str) -> bool:
|
|
158
|
+
return role == "y"
|
|
159
|
+
|
|
160
|
+
def legend_colors(self, ctx: Any) -> tuple[Any, Any]:
|
|
161
|
+
c = self.base_color(ctx)
|
|
162
|
+
return c, c
|
|
163
|
+
|
|
164
|
+
def legend_glyph(self, ctx: Any, x: float, y: float, w: float, h: float) -> None:
|
|
165
|
+
c, lw, dash, opacity = self._line_args(ctx)
|
|
166
|
+
canvas = ctx.canvas
|
|
167
|
+
cy = y + h / 2
|
|
168
|
+
if c is not None and lw:
|
|
169
|
+
canvas.draw_lines([x, x + w], [cy, cy], color=c, width=lw, dash=dash,
|
|
170
|
+
opacity=opacity)
|
|
171
|
+
if self._markers():
|
|
172
|
+
MarkerStyle(self, ctx, "marker_", c, len(self.x), glyph=True).glyph(
|
|
173
|
+
canvas, x + w / 2, cy, h * 1.1)
|
plot_io/series/pie.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Pie and donut charts, on a plot without axes."""
|
|
2
|
+
|
|
3
|
+
import array
|
|
4
|
+
import math
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pdf_io
|
|
8
|
+
|
|
9
|
+
from .. import _backend
|
|
10
|
+
from ..labels import ink_for
|
|
11
|
+
from ..numbers import NumberStyle, check_format, format_numbers, number_settings
|
|
12
|
+
from ..settings import Setting, boolean, color, either, literal, number, one_of, table
|
|
13
|
+
from ..style import with_alpha
|
|
14
|
+
from ..text import Font, draw_text, measure
|
|
15
|
+
from . import Series, common
|
|
16
|
+
|
|
17
|
+
__all__ = ["Pie"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _colors(v: Any) -> list[Any]:
|
|
21
|
+
return [color(c) for c in v]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _explode(v: Any) -> Any:
|
|
25
|
+
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
|
26
|
+
return number(lo=0)(v)
|
|
27
|
+
return [number(lo=0)(x) for x in v]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _arc(path: Any, cx: float, cy: float, r: float, a0: float, a1: float) -> None:
|
|
31
|
+
"""Append an arc of radius ``r`` from angle ``a0`` to ``a1`` (radians, counterclockwise
|
|
32
|
+
on the page: y up) as cubic Béziers of at most 90°."""
|
|
33
|
+
n = max(1, math.ceil(abs(a1 - a0) / (math.pi / 2) - 1e-9))
|
|
34
|
+
step = (a1 - a0) / n
|
|
35
|
+
k = 4.0 / 3.0 * math.tan(step / 4.0)
|
|
36
|
+
for i in range(n):
|
|
37
|
+
t0, t1 = a0 + i * step, a0 + (i + 1) * step
|
|
38
|
+
x0, y0 = cx + r * math.cos(t0), cy - r * math.sin(t0)
|
|
39
|
+
x3, y3 = cx + r * math.cos(t1), cy - r * math.sin(t1)
|
|
40
|
+
x1, y1 = x0 - k * r * math.sin(t0), y0 - k * r * math.cos(t0)
|
|
41
|
+
x2, y2 = x3 + k * r * math.sin(t1), y3 + k * r * math.cos(t1)
|
|
42
|
+
path.curve_to(x1, y1, x2, y2, x3, y3)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Pie(Series):
|
|
46
|
+
"""A pie (or donut, with ``hole``) of ``values``: a sequence, or a dict
|
|
47
|
+
``{label: value}``; the plot is drawn without axes."""
|
|
48
|
+
|
|
49
|
+
SETTINGS = table(
|
|
50
|
+
labels=Setting(lambda v: [str(x) for x in v],
|
|
51
|
+
doc="Wedge names (default: the dict's keys)."),
|
|
52
|
+
colors=Setting(_colors, doc="One color per wedge; default palette colors."),
|
|
53
|
+
start=Setting(number(), doc="Angle where the first wedge starts (degrees, 0: "
|
|
54
|
+
"3 o'clock, 90: 12 o'clock)."),
|
|
55
|
+
counterclockwise=Setting(boolean, doc="Direction of the wedges."),
|
|
56
|
+
hole=Setting(number(0, 0.95), doc="Radius of the hole (fraction of the "
|
|
57
|
+
"radius): a donut."),
|
|
58
|
+
explode=Setting(_explode, doc="Offset of the wedges from the centre (fraction of the "
|
|
59
|
+
"radius): one value or one per wedge."),
|
|
60
|
+
sort=Setting(boolean, doc="Sort the wedges by decreasing value."),
|
|
61
|
+
label_position=Setting(one_of("outside", "inside", "none"),
|
|
62
|
+
doc="Where the names go: `outside`, `inside` or `none` (in "
|
|
63
|
+
"the legend instead)."),
|
|
64
|
+
value_labels=Setting(either(literal(False), one_of("percent", "value")),
|
|
65
|
+
doc="Write each wedge's `percent` or `value` inside it (outside, "
|
|
66
|
+
"with a leader line, when it does not fit)."),
|
|
67
|
+
label_format=Setting(check_format,
|
|
68
|
+
doc="Number format of the value labels (percentages: of 100)."),
|
|
69
|
+
line_color=Setting(color, doc="Wedge outline color; `none`: no outline."),
|
|
70
|
+
line_width=Setting(number(lo=0), doc="Wedge outline width (pt).", length=True),
|
|
71
|
+
alpha=Setting(number(0, 1), doc="Opacity."),
|
|
72
|
+
**common("name", "z", "rasterize", "simplify", "font_size", "font_family", "text_color"),
|
|
73
|
+
**number_settings(),
|
|
74
|
+
)
|
|
75
|
+
AXES = False
|
|
76
|
+
|
|
77
|
+
def __init__(self, values: Any, plot: Any = None, **settings: Any) -> None:
|
|
78
|
+
if isinstance(values, dict):
|
|
79
|
+
self.keys: list[Any] = [str(k) for k in values]
|
|
80
|
+
vals = _backend.doubles(list(values.values()))
|
|
81
|
+
else:
|
|
82
|
+
vals = _backend.doubles(values)
|
|
83
|
+
self.keys = []
|
|
84
|
+
if any(not (v >= 0) for v in vals if math.isfinite(v)):
|
|
85
|
+
raise ValueError("pie values must be non-negative")
|
|
86
|
+
self.vals = vals
|
|
87
|
+
super().__init__(plot=plot, **settings)
|
|
88
|
+
if not self.plot.is_set("axes"):
|
|
89
|
+
self.plot.set(axes=False)
|
|
90
|
+
if not self.plot.legend.is_set("position"):
|
|
91
|
+
self.plot.legend.set(position="outside right")
|
|
92
|
+
|
|
93
|
+
# -- geometry ---------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def names(self) -> list[Any]:
|
|
96
|
+
given = self.get("labels")
|
|
97
|
+
if given is not None:
|
|
98
|
+
if len(given) != len(self.vals):
|
|
99
|
+
raise ValueError(f"{len(given)} labels for {len(self.vals)} wedges")
|
|
100
|
+
return given
|
|
101
|
+
return list(self.keys)
|
|
102
|
+
|
|
103
|
+
def order(self) -> list[int]:
|
|
104
|
+
idx = [i for i in range(len(self.vals)) if math.isfinite(self.vals[i]) and self.vals[i] > 0]
|
|
105
|
+
if self.get("sort"):
|
|
106
|
+
idx.sort(key=lambda i: -self.vals[i])
|
|
107
|
+
return idx
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def stats(self) -> list[dict[str, Any]]:
|
|
111
|
+
"""Per wedge (in drawing order): ``index``, ``value``, ``fraction``, ``start`` and
|
|
112
|
+
``end`` angles (degrees, counterclockwise from 3 o'clock)."""
|
|
113
|
+
order = self.order()
|
|
114
|
+
total = math.fsum(self.vals[i] for i in order)
|
|
115
|
+
sign = 1.0 if self.get("counterclockwise") else -1.0
|
|
116
|
+
a = self.get("start")
|
|
117
|
+
out = []
|
|
118
|
+
for i in order:
|
|
119
|
+
frac = self.vals[i] / total if total else 0.0
|
|
120
|
+
b = a + sign * 360.0 * frac
|
|
121
|
+
out.append({"index": i, "value": self.vals[i], "fraction": frac, "start": a, "end": b})
|
|
122
|
+
a = b
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
def _explode_of(self, i: int) -> float:
|
|
126
|
+
e = self.get("explode")
|
|
127
|
+
if e is None:
|
|
128
|
+
return 0.0
|
|
129
|
+
if isinstance(e, list):
|
|
130
|
+
if len(e) != len(self.vals):
|
|
131
|
+
raise ValueError(f"{len(e)} explode values for {len(self.vals)} wedges")
|
|
132
|
+
return e[i]
|
|
133
|
+
return e
|
|
134
|
+
|
|
135
|
+
def _reach(self) -> float:
|
|
136
|
+
e = self.get("explode")
|
|
137
|
+
ex = max(e) if isinstance(e, list) and e else (e or 0.0)
|
|
138
|
+
out = self.get("label_position") == "outside" or self.get("value_labels")
|
|
139
|
+
return 1.0 + ex + (0.45 if out else 0.05)
|
|
140
|
+
|
|
141
|
+
def values(self, role: str) -> Any:
|
|
142
|
+
r = self._reach()
|
|
143
|
+
return array.array("d", [-r, r])
|
|
144
|
+
|
|
145
|
+
def zero_padding(self, role: str) -> bool:
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
def palette_count(self) -> int:
|
|
149
|
+
return len(self.vals)
|
|
150
|
+
|
|
151
|
+
def has_own_color(self) -> bool:
|
|
152
|
+
return self.is_set("colors")
|
|
153
|
+
|
|
154
|
+
def legend_labels(self) -> list[Any]:
|
|
155
|
+
if self.get("label_position") == "none":
|
|
156
|
+
names = self.names()
|
|
157
|
+
return [names[w["index"]] for w in self.stats] if names else []
|
|
158
|
+
return []
|
|
159
|
+
|
|
160
|
+
def wedge_colors(self, ctx: Any) -> list[Any]:
|
|
161
|
+
"""Colors in value order (one per value)."""
|
|
162
|
+
given = self.get("colors")
|
|
163
|
+
n = len(self.vals)
|
|
164
|
+
if given is not None:
|
|
165
|
+
if len(given) < n:
|
|
166
|
+
raise ValueError(f"{len(given)} colors for {n} wedges")
|
|
167
|
+
cols = [ctx.color(c) for c in given[:n]]
|
|
168
|
+
else:
|
|
169
|
+
cols = self.auto_colors(ctx)
|
|
170
|
+
cols = (cols * (n // max(len(cols), 1) + 1))[:n]
|
|
171
|
+
a = self.get("alpha")
|
|
172
|
+
return [with_alpha(c, a) for c in cols]
|
|
173
|
+
|
|
174
|
+
# -- drawing ----------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def _center(self, ctx: Any) -> tuple[float, float, float]:
|
|
177
|
+
cx, cy = ctx.xmap(0.0), ctx.ymap(0.0)
|
|
178
|
+
r = abs(ctx.xmap(1.0) - cx)
|
|
179
|
+
return cx, cy, r
|
|
180
|
+
|
|
181
|
+
def draw(self, ctx: Any) -> None:
|
|
182
|
+
cx, cy, r = self._center(ctx)
|
|
183
|
+
hole = self.get("hole")
|
|
184
|
+
cols = self.wedge_colors(ctx)
|
|
185
|
+
paths, fills = [], []
|
|
186
|
+
for w in self.stats:
|
|
187
|
+
i = w["index"]
|
|
188
|
+
a0, a1 = math.radians(w["start"]), math.radians(w["end"])
|
|
189
|
+
mid = (a0 + a1) / 2
|
|
190
|
+
ex = self._explode_of(i) * r
|
|
191
|
+
ox, oy = cx + ex * math.cos(mid), cy - ex * math.sin(mid)
|
|
192
|
+
p = pdf_io.Path()
|
|
193
|
+
if w["fraction"] >= 1.0 - 1e-12:
|
|
194
|
+
p.move_to(ox + r, oy)
|
|
195
|
+
_arc(p, ox, oy, r, 0.0, 2 * math.pi)
|
|
196
|
+
p.close()
|
|
197
|
+
if hole:
|
|
198
|
+
p.move_to(ox + hole * r, oy)
|
|
199
|
+
_arc(p, ox, oy, hole * r, 2 * math.pi, 0.0)
|
|
200
|
+
p.close()
|
|
201
|
+
else:
|
|
202
|
+
p.move_to(ox + r * math.cos(a0), oy - r * math.sin(a0))
|
|
203
|
+
_arc(p, ox, oy, r, a0, a1)
|
|
204
|
+
if hole:
|
|
205
|
+
p.line_to(ox + hole * r * math.cos(a1), oy - hole * r * math.sin(a1))
|
|
206
|
+
_arc(p, ox, oy, hole * r, a1, a0)
|
|
207
|
+
else:
|
|
208
|
+
p.line_to(ox, oy)
|
|
209
|
+
p.close()
|
|
210
|
+
paths.append(p)
|
|
211
|
+
fills.append(cols[i])
|
|
212
|
+
lc = ctx.color(self.get("line_color"))
|
|
213
|
+
lw = self.get("line_width")
|
|
214
|
+
if paths:
|
|
215
|
+
ctx.canvas.fill_polygons(paths, fill=fills, stroke=lc if lw else None,
|
|
216
|
+
line_width=lw or None, simplify=self.simplify_arg())
|
|
217
|
+
|
|
218
|
+
def draw_overlay(self, ctx: Any) -> None:
|
|
219
|
+
cx, cy, r = self._center(ctx)
|
|
220
|
+
hole = self.get("hole")
|
|
221
|
+
canvas = ctx.canvas
|
|
222
|
+
names = self.names()
|
|
223
|
+
pos = self.get("label_position")
|
|
224
|
+
vl = self.get("value_labels")
|
|
225
|
+
stats = self.stats
|
|
226
|
+
font = Font(self.get("font_size"), self.get("font_family"),
|
|
227
|
+
color=ctx.color(self.get("text_color")))
|
|
228
|
+
cols = self.wedge_colors(ctx)
|
|
229
|
+
texts: list[Any] = [None] * len(stats)
|
|
230
|
+
placed: list[list[Any]] = []
|
|
231
|
+
if vl:
|
|
232
|
+
vals = [100.0 * w["fraction"] if vl == "percent" else w["value"] for w in stats]
|
|
233
|
+
style = NumberStyle.of(self)
|
|
234
|
+
fmt = self.get("label_format")
|
|
235
|
+
if vl == "percent" and fmt == "auto":
|
|
236
|
+
style.suffix = style.suffix or "%"
|
|
237
|
+
if style.precision is None:
|
|
238
|
+
style.precision = 2
|
|
239
|
+
texts = format_numbers(vals, fmt, style)
|
|
240
|
+
for k, w in enumerate(stats):
|
|
241
|
+
i = w["index"]
|
|
242
|
+
a0, a1 = math.radians(w["start"]), math.radians(w["end"])
|
|
243
|
+
mid = (a0 + a1) / 2
|
|
244
|
+
ex = self._explode_of(i) * r
|
|
245
|
+
ox, oy = cx + ex * math.cos(mid), cy - ex * math.sin(mid)
|
|
246
|
+
ux, uy = math.cos(mid), -math.sin(mid)
|
|
247
|
+
side = "left" if ux >= 0 else "right"
|
|
248
|
+
outside_texts = []
|
|
249
|
+
if names and pos == "outside":
|
|
250
|
+
outside_texts.append(names[i])
|
|
251
|
+
if names and pos == "inside":
|
|
252
|
+
draw_text(canvas, names[i], ox + ux * r * (0.5 + hole / 2),
|
|
253
|
+
oy + uy * r * (0.5 + hole / 2),
|
|
254
|
+
Font(font.size, font.family, color=ink_for(cols[i])), "center",
|
|
255
|
+
"bottom" if vl else "center")
|
|
256
|
+
if texts[k] is not None:
|
|
257
|
+
rin = r * (0.5 + hole / 2) if hole else r * 0.62
|
|
258
|
+
tw, ta, td = measure(canvas, texts[k], font)
|
|
259
|
+
arc_len = abs(a1 - a0) * rin
|
|
260
|
+
fits = arc_len >= 1.2 * max(tw, ta + td) and (1 - hole) * r >= 1.1 * (ta + td)
|
|
261
|
+
if fits:
|
|
262
|
+
draw_text(canvas, texts[k], ox + ux * rin, oy + uy * rin,
|
|
263
|
+
Font(font.size, font.family, color=ink_for(cols[i])), "center",
|
|
264
|
+
"top" if names and pos == "inside" else "center")
|
|
265
|
+
else:
|
|
266
|
+
outside_texts.append(texts[k])
|
|
267
|
+
if outside_texts:
|
|
268
|
+
text = " ".join(str(t) for t in outside_texts) if all(
|
|
269
|
+
isinstance(t, str) for t in outside_texts) else outside_texts[0]
|
|
270
|
+
lead = not (names and pos == "outside") or len(outside_texts) > 1
|
|
271
|
+
placed.append([oy + uy * r * 1.12, ox + ux * r * 1.12, side, text, lead,
|
|
272
|
+
(ox + ux * r, oy + uy * r)])
|
|
273
|
+
self._outside_labels(canvas, placed, font)
|
|
274
|
+
|
|
275
|
+
@staticmethod
|
|
276
|
+
def _outside_labels(canvas: Any, placed: list[list[Any]], font: Font) -> None:
|
|
277
|
+
"""Draw the outside labels, pushed apart vertically on each side so that they do
|
|
278
|
+
not overlap; moved labels get a leader line."""
|
|
279
|
+
gap = 1.25 * font.size
|
|
280
|
+
for side in ("left", "right"):
|
|
281
|
+
items = sorted((p for p in placed if p[2] == side), key=lambda p: p[0])
|
|
282
|
+
want = [p[0] for p in items]
|
|
283
|
+
for k in range(1, len(items)):
|
|
284
|
+
items[k][0] = max(items[k][0], items[k - 1][0] + gap)
|
|
285
|
+
for p, w in zip(items, want):
|
|
286
|
+
ty, tx, _, text, lead, (ex, ey) = p
|
|
287
|
+
if lead or abs(ty - w) > 0.5:
|
|
288
|
+
canvas.draw_lines([ex, tx - (2 if side == "left" else -2)], [ey, ty],
|
|
289
|
+
color=font.color, width=0.5)
|
|
290
|
+
draw_text(canvas, text, tx, ty, font, side, "center")
|
|
291
|
+
|
|
292
|
+
def legend_colors_at(self, ctx: Any, i: int) -> tuple[Any, Any]:
|
|
293
|
+
cols = self.wedge_colors(ctx)
|
|
294
|
+
st = self.stats
|
|
295
|
+
return (cols[st[i]["index"]] if i < len(st) else None), None
|
|
296
|
+
|
|
297
|
+
def legend_glyph_at(self, ctx: Any, i: int, x: float, y: float, w: float, h: float) -> None:
|
|
298
|
+
fill, _ = self.legend_colors_at(ctx, i)
|
|
299
|
+
if fill is not None:
|
|
300
|
+
ctx.canvas.fill_style = fill
|
|
301
|
+
ctx.canvas.fill_rect(x + (w - h) / 2, y, h, h)
|
|
302
|
+
|
|
303
|
+
def legend_glyph(self, ctx: Any, x: float, y: float, w: float, h: float) -> None:
|
|
304
|
+
self.legend_glyph_at(ctx, 0, x, y, w, h)
|