codefig 0.17.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.
- codefig/__init__.py +210 -0
- codefig/_validate.py +12 -0
- codefig/autowrap.py +277 -0
- codefig/charts.py +1806 -0
- codefig/code.py +602 -0
- codefig/compose.py +374 -0
- codefig/constraints.py +1571 -0
- codefig/dim.py +214 -0
- codefig/edge_align.py +231 -0
- codefig/edges.py +2824 -0
- codefig/export.py +204 -0
- codefig/figure.py +1923 -0
- codefig/fills.py +146 -0
- codefig/flows.py +102 -0
- codefig/glyphs.py +395 -0
- codefig/icon.py +288 -0
- codefig/icons/LICENSE-tabler-icons.txt +21 -0
- codefig/icons/__init__.py +101 -0
- codefig/icons/tabler.json +1 -0
- codefig/legend.py +483 -0
- codefig/lint.py +1737 -0
- codefig/lint_geom.py +528 -0
- codefig/node.py +864 -0
- codefig/notices.py +34 -0
- codefig/overlaps.py +392 -0
- codefig/policy.py +137 -0
- codefig/primitives.py +2721 -0
- codefig/py.typed +0 -0
- codefig/regions.py +727 -0
- codefig/render_svg.py +512 -0
- codefig/routing.py +932 -0
- codefig/shapes.py +642 -0
- codefig/solids.py +514 -0
- codefig/steps.py +203 -0
- codefig/styles.py +893 -0
- codefig/textutil.py +889 -0
- codefig/timeline.py +1288 -0
- codefig/tree.py +277 -0
- codefig/venues.py +93 -0
- codefig-0.17.0.dist-info/METADATA +542 -0
- codefig-0.17.0.dist-info/RECORD +44 -0
- codefig-0.17.0.dist-info/WHEEL +5 -0
- codefig-0.17.0.dist-info/licenses/LICENSE +21 -0
- codefig-0.17.0.dist-info/top_level.txt +1 -0
codefig/charts.py
ADDED
|
@@ -0,0 +1,1806 @@
|
|
|
1
|
+
"""Embedded mini charts drawn by codefig itself (v0.7.0) — no matplotlib.
|
|
2
|
+
|
|
3
|
+
* :class:`StackedBarChart` — one or more stacked bars (vertical or horizontal), segment
|
|
4
|
+
names beside the last bar (``labels="side"``), as a swatch key (``"legend"``) or
|
|
5
|
+
inside the segments (``"inside"``); optional value axis.
|
|
6
|
+
* :class:`LineChart` — one or more series with optional markers, axes, a few ticks and
|
|
7
|
+
end-of-line series labels (no legend box needed).
|
|
8
|
+
* :class:`GroupedBarChart` (v0.8.0) — clustered bars per category, swatch legend.
|
|
9
|
+
* :class:`Sparkline` — a tiny axis-free line (``LineChart`` preset) for inline trends.
|
|
10
|
+
|
|
11
|
+
v0.8.0: ``yscale="log"`` / ``xscale="log"`` axes (:class:`Scale`), light ``grid=``
|
|
12
|
+
lines (``theme.grid_color``), ``errors=`` bars (symmetric or ``(minus, plus)``).
|
|
13
|
+
|
|
14
|
+
All text uses the theme's size roles (ticks / labels = ``theme.small_size``, never below
|
|
15
|
+
the paper theme's 6 pt) and colours come from the theme palette / accents, so a chart
|
|
16
|
+
inside a figure passes ``fig.lint()`` like any other node. Sizes are theme lengths;
|
|
17
|
+
``width`` / ``height`` override the plot area.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import math
|
|
23
|
+
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple, Union
|
|
24
|
+
|
|
25
|
+
from .node import Node
|
|
26
|
+
from .styles import themed
|
|
27
|
+
from .textutil import measure_text
|
|
28
|
+
|
|
29
|
+
Number = Union[int, float]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _nice_ticks(lo: float, hi: float, n: int = 3) -> List[float]:
|
|
33
|
+
"""About ``n`` round tick values covering [lo, hi]."""
|
|
34
|
+
if hi <= lo:
|
|
35
|
+
return [lo]
|
|
36
|
+
raw = (hi - lo) / max(1, n - 1)
|
|
37
|
+
mag = 10 ** math.floor(math.log10(raw))
|
|
38
|
+
step = min((m * mag for m in (1, 2, 2.5, 5, 10) if m * mag >= raw), default=raw)
|
|
39
|
+
t0 = math.floor(lo / step + 1e-9) * step
|
|
40
|
+
out = []
|
|
41
|
+
v = t0
|
|
42
|
+
while v <= hi + step * 1e-6:
|
|
43
|
+
if v >= lo - step * 1e-6:
|
|
44
|
+
out.append(round(v, 10))
|
|
45
|
+
v += step
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _tick_n(length: float, fs: float, vertical: bool = True, label_w: float = 0.0,
|
|
50
|
+
lo: int = 3, hi: int = 5) -> int:
|
|
51
|
+
"""v0.9.0: how many nice ticks (≈ ``lo``..``hi``) fit along an axis of ``length``:
|
|
52
|
+
vertical axes need ~2.2 font heights per tick, horizontal ones the label width plus
|
|
53
|
+
1.5 font sizes."""
|
|
54
|
+
need = fs * 2.2 if vertical else label_w + fs * 1.5
|
|
55
|
+
n = lo
|
|
56
|
+
for k in range(lo, hi + 1):
|
|
57
|
+
if length / (k - 1) >= need:
|
|
58
|
+
n = k
|
|
59
|
+
return n
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _nice_hi(lo: float, hi: float, n: int = 3) -> float:
|
|
63
|
+
"""v0.9.0: the first nice tick at or above ``hi`` (so a value axis ends on a
|
|
64
|
+
labelled tick instead of stopping just past the last one)."""
|
|
65
|
+
if hi <= lo:
|
|
66
|
+
return hi
|
|
67
|
+
raw = (hi - lo) / max(1, n - 1)
|
|
68
|
+
mag = 10 ** math.floor(math.log10(raw))
|
|
69
|
+
step = min((m * mag for m in (1, 2, 2.5, 5, 10) if m * mag >= raw), default=raw)
|
|
70
|
+
return round(math.ceil(hi / step - 1e-9) * step, 10)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _fmt(v: float) -> str:
|
|
74
|
+
if abs(v - round(v)) < 1e-9:
|
|
75
|
+
return f"{int(round(v))}"
|
|
76
|
+
return f"{v:g}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _spread(centres: List[float], h: float, lo: float, hi: float) -> List[float]:
|
|
80
|
+
"""Push label centres apart to at least ``h`` (keeps order, stays in [lo, hi])."""
|
|
81
|
+
if not centres:
|
|
82
|
+
return []
|
|
83
|
+
order = sorted(range(len(centres)), key=lambda i: centres[i])
|
|
84
|
+
ys = [centres[i] for i in order]
|
|
85
|
+
for k in range(1, len(ys)):
|
|
86
|
+
ys[k] = max(ys[k], ys[k - 1] + h)
|
|
87
|
+
over = ys[-1] - hi
|
|
88
|
+
if over > 0:
|
|
89
|
+
ys = [y - over for y in ys]
|
|
90
|
+
for k in range(len(ys) - 2, -1, -1):
|
|
91
|
+
ys[k] = min(ys[k], ys[k + 1] - h)
|
|
92
|
+
out = [0.0] * len(ys)
|
|
93
|
+
for k, i in enumerate(order):
|
|
94
|
+
out[i] = max(lo, ys[k]) if k == 0 else ys[k]
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _nice_range(lo: float, hi: float, n: int = 3) -> Tuple[float, float, float]:
|
|
99
|
+
"""v0.10.0: round ``[lo, hi]`` outwards to a common nice step with at most ``n - 1``
|
|
100
|
+
intervals (both ends on labelled ticks) → (lo, hi, step)."""
|
|
101
|
+
if hi <= lo:
|
|
102
|
+
return lo, hi, 1.0
|
|
103
|
+
raw = (hi - lo) / max(1, n - 1)
|
|
104
|
+
mag = 10 ** math.floor(math.log10(raw))
|
|
105
|
+
for e in (mag / 10, mag, mag * 10, mag * 100):
|
|
106
|
+
for m in (1, 2, 2.5, 5):
|
|
107
|
+
st = m * e
|
|
108
|
+
a = math.floor(lo / st + 1e-9) * st
|
|
109
|
+
b = math.ceil(hi / st - 1e-9) * st
|
|
110
|
+
if round((b - a) / st) <= max(1, n - 1):
|
|
111
|
+
return round(a, 10), round(b, 10), st
|
|
112
|
+
return lo, hi, raw
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _fmt_values(values: Sequence[float], log: bool = False) -> List[str]:
|
|
116
|
+
"""v0.9.0: value labels with one shared number of decimals (≤ 2), so 66 and 61.2
|
|
117
|
+
print as 66.0 and 61.2 next to each other."""
|
|
118
|
+
if log:
|
|
119
|
+
return [_fmt_si(v) for v in values]
|
|
120
|
+
d = 0
|
|
121
|
+
for v in values:
|
|
122
|
+
for k in range(3):
|
|
123
|
+
if abs(v * 10 ** k - round(v * 10 ** k)) < 1e-6:
|
|
124
|
+
d = max(d, k)
|
|
125
|
+
break
|
|
126
|
+
else:
|
|
127
|
+
d = 2
|
|
128
|
+
return [f"{v:.{d}f}" for v in values]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _fmt_si(v: float) -> str:
|
|
132
|
+
"""Compact tick text for log axes: 0.01, 1, 10, 1k, 10k, 1M, 1G."""
|
|
133
|
+
for div, suf in ((1e9, "G"), (1e6, "M"), (1e3, "k")):
|
|
134
|
+
if abs(v) >= div:
|
|
135
|
+
return _fmt(round(v / div, 6)) + suf
|
|
136
|
+
return _fmt(round(v, 10))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _125(v: float, down: bool) -> float:
|
|
140
|
+
"""Nearest 1, 2 or 5 × 10^k at or below (``down``) / above ``v`` (> 0)."""
|
|
141
|
+
e = math.floor(math.log10(v) + 1e-9)
|
|
142
|
+
cands = [m * 10.0 ** k for k in (e - 1, e, e + 1) for m in (1, 2, 5)]
|
|
143
|
+
if down:
|
|
144
|
+
return max(c for c in cands if c <= v * (1 + 1e-9))
|
|
145
|
+
return min(c for c in cands if c >= v * (1 - 1e-9))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class Scale:
|
|
149
|
+
"""Value → [0, 1] mapping for chart axes: ``kind="linear"`` or ``"log"`` (base 10).
|
|
150
|
+
|
|
151
|
+
Linear ranges are extended to round ticks; log ranges to whole decades. ``ticks()``
|
|
152
|
+
gives about ``n`` round values (decades on log axes; 1-2-5 steps inside one decade).
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def __init__(self, lo: float, hi: float, kind: str = "linear", *, nice: bool = True,
|
|
156
|
+
n: int = 3, fixed_lo: bool = False, fixed_hi: bool = False) -> None:
|
|
157
|
+
if kind not in ("linear", "log"):
|
|
158
|
+
raise ValueError("scale must be 'linear' or 'log'")
|
|
159
|
+
self.kind, self.n = kind, n
|
|
160
|
+
self.step: Optional[float] = None
|
|
161
|
+
if kind == "log":
|
|
162
|
+
if lo <= 0 or hi <= 0:
|
|
163
|
+
raise ValueError("log scale needs positive values (set ymin= / xmin= > 0)")
|
|
164
|
+
if nice: # snap to 1-2-5 × 10^k (not whole decades: no empty top decade)
|
|
165
|
+
lo = lo if fixed_lo else _125(lo, down=True)
|
|
166
|
+
hi = hi if fixed_hi else _125(hi, down=False)
|
|
167
|
+
if hi <= lo:
|
|
168
|
+
hi = lo * 10.0
|
|
169
|
+
else:
|
|
170
|
+
if hi <= lo:
|
|
171
|
+
hi = lo + 1.0
|
|
172
|
+
if nice and lo != 0 and not fixed_lo and not fixed_hi:
|
|
173
|
+
lo, hi, self.step = _nice_range(lo, hi, n) # off-zero range (v0.10.0)
|
|
174
|
+
elif nice and not fixed_hi:
|
|
175
|
+
hi = max(hi, _nice_hi(lo, hi, n))
|
|
176
|
+
elif nice and lo != 0 and not fixed_lo: # v0.12.0: fixed top, off-zero floor
|
|
177
|
+
lo2, _h, st = _nice_range(lo, hi, n) # snap the floor to a tick
|
|
178
|
+
lo = min(lo, lo2)
|
|
179
|
+
self.lo, self.hi = float(lo), float(hi)
|
|
180
|
+
|
|
181
|
+
def norm(self, v: float) -> float:
|
|
182
|
+
if self.kind == "log":
|
|
183
|
+
v = max(v, self.lo * 1e-6)
|
|
184
|
+
return (math.log10(v) - math.log10(self.lo)) / (math.log10(self.hi) - math.log10(self.lo))
|
|
185
|
+
return (v - self.lo) / (self.hi - self.lo)
|
|
186
|
+
|
|
187
|
+
def ticks(self) -> List[float]:
|
|
188
|
+
if self.kind == "linear":
|
|
189
|
+
if self.step:
|
|
190
|
+
k = int(round((self.hi - self.lo) / self.step))
|
|
191
|
+
return [round(self.lo + i * self.step, 10) for i in range(k + 1)]
|
|
192
|
+
return _nice_ticks(self.lo, self.hi, self.n)
|
|
193
|
+
e0 = math.ceil(math.log10(self.lo) - 1e-9)
|
|
194
|
+
e1 = math.floor(math.log10(self.hi) + 1e-9)
|
|
195
|
+
dec = [10.0 ** e for e in range(e0, e1 + 1)]
|
|
196
|
+
if len(dec) >= 2:
|
|
197
|
+
step = max(1, math.ceil((len(dec) - 1) / 4)) # ≤ 5 decade ticks
|
|
198
|
+
return dec[::step]
|
|
199
|
+
out = [m * 10.0 ** e for e in range(e0 - 1, e1 + 1) for m in (1, 2, 5)]
|
|
200
|
+
return [v for v in out if self.lo * (1 - 1e-9) <= v <= self.hi * (1 + 1e-9)]
|
|
201
|
+
|
|
202
|
+
def fmt(self, v: float) -> str:
|
|
203
|
+
return _fmt_si(v) if self.kind == "log" else _fmt(v)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _err_pairs(errors: Any, n: int) -> List[Tuple[float, float]]:
|
|
207
|
+
"""Normalise error bars: a number for all points, or one entry per point — a number
|
|
208
|
+
(symmetric), a ``(minus, plus)`` pair, or None."""
|
|
209
|
+
if errors is None:
|
|
210
|
+
return [(0.0, 0.0)] * n
|
|
211
|
+
if isinstance(errors, (int, float)): # one symmetric error for every point
|
|
212
|
+
return [(abs(float(errors)), abs(float(errors)))] * n
|
|
213
|
+
out = []
|
|
214
|
+
for e in list(errors)[:n]:
|
|
215
|
+
if e is None:
|
|
216
|
+
out.append((0.0, 0.0))
|
|
217
|
+
elif isinstance(e, (tuple, list)):
|
|
218
|
+
if len(e) != 2:
|
|
219
|
+
raise ValueError("asymmetric error must be (minus, plus)")
|
|
220
|
+
out.append((abs(float(e[0])), abs(float(e[1]))))
|
|
221
|
+
else:
|
|
222
|
+
out.append((abs(float(e)), abs(float(e))))
|
|
223
|
+
return out + [(0.0, 0.0)] * (n - len(out))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _grid_axes(grid: Any, default: str = "y") -> Tuple[bool, bool]:
|
|
227
|
+
"""``grid=`` False | True | "x" | "y" | "both" → (x lines, y lines)."""
|
|
228
|
+
if grid in (False, None):
|
|
229
|
+
return False, False
|
|
230
|
+
if grid is True:
|
|
231
|
+
grid = default
|
|
232
|
+
if grid not in ("x", "y", "both"):
|
|
233
|
+
raise ValueError("grid must be False, True, 'x', 'y' or 'both'")
|
|
234
|
+
return grid in ("x", "both"), grid in ("y", "both")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _draw_errbar(buf: List[str], x: float, y_lo: float, y_hi: float, cap: float, color: str,
|
|
238
|
+
width: float, horizontal: bool = False) -> None:
|
|
239
|
+
"""One error bar (whisker + caps). Vertical by default; ``horizontal`` swaps axes."""
|
|
240
|
+
from .render_svg import draw_line
|
|
241
|
+
|
|
242
|
+
if horizontal:
|
|
243
|
+
draw_line(buf, y_lo, x, y_hi, x, stroke=color, stroke_width=width)
|
|
244
|
+
for yy in (y_lo, y_hi):
|
|
245
|
+
draw_line(buf, yy, x - cap, yy, x + cap, stroke=color, stroke_width=width)
|
|
246
|
+
return
|
|
247
|
+
draw_line(buf, x, y_lo, x, y_hi, stroke=color, stroke_width=width)
|
|
248
|
+
for yy in (y_lo, y_hi):
|
|
249
|
+
draw_line(buf, x - cap, yy, x + cap, yy, stroke=color, stroke_width=width)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _ref_list(ref: Any) -> List[Tuple[float, Optional[str]]]:
|
|
253
|
+
"""``ref=`` → [(value, label)]: a number, ``(value, label)`` or a list of those."""
|
|
254
|
+
if ref is None:
|
|
255
|
+
return []
|
|
256
|
+
if isinstance(ref, (int, float)):
|
|
257
|
+
return [(float(ref), None)]
|
|
258
|
+
if isinstance(ref, tuple) and len(ref) == 2 and isinstance(ref[1], (str, type(None))):
|
|
259
|
+
return [(float(ref[0]), ref[1])]
|
|
260
|
+
out: List[Tuple[float, Optional[str]]] = []
|
|
261
|
+
for r in ref:
|
|
262
|
+
out.extend(_ref_list(r))
|
|
263
|
+
return out
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _bar_fills(spec: Any, rows: List[List[float]], cats: List[str], series: List[str],
|
|
267
|
+
colors: List[str]) -> List[List[str]]:
|
|
268
|
+
"""``bar_colors=`` → a fill per (category, series); defaults to the series colour."""
|
|
269
|
+
out = [[colors[s] for s in range(len(r))] for r in rows]
|
|
270
|
+
if spec is None:
|
|
271
|
+
return out
|
|
272
|
+
if isinstance(spec, Mapping):
|
|
273
|
+
for k, col in spec.items():
|
|
274
|
+
if isinstance(k, tuple):
|
|
275
|
+
c, s_ = k
|
|
276
|
+
ci = cats.index(c) if isinstance(c, str) else int(c)
|
|
277
|
+
si = series.index(s_) if isinstance(s_, str) else int(s_)
|
|
278
|
+
out[ci][si] = col
|
|
279
|
+
else:
|
|
280
|
+
ci = cats.index(k) if isinstance(k, str) else int(k)
|
|
281
|
+
out[ci] = [col] * len(out[ci])
|
|
282
|
+
return out
|
|
283
|
+
for ci, col in enumerate(list(spec)[: len(rows)]):
|
|
284
|
+
if col is None:
|
|
285
|
+
continue
|
|
286
|
+
if isinstance(col, str):
|
|
287
|
+
out[ci] = [col] * len(out[ci])
|
|
288
|
+
else:
|
|
289
|
+
for si, cc in enumerate(list(col)[: len(out[ci])]):
|
|
290
|
+
if cc is not None:
|
|
291
|
+
out[ci][si] = cc
|
|
292
|
+
return out
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _gapped_line(buf: List[str], x0: float, y0: float, x1: float, y1: float,
|
|
296
|
+
holes: Sequence[Tuple[float, float, float, float]], pad: float = 1.0,
|
|
297
|
+
**kw: Any) -> None:
|
|
298
|
+
"""v0.9.0: an axis-parallel gridline broken where it would run through a label box."""
|
|
299
|
+
from .render_svg import draw_line
|
|
300
|
+
|
|
301
|
+
horiz = abs(y0 - y1) < 1e-9
|
|
302
|
+
a, b = (min(x0, x1), max(x0, x1)) if horiz else (min(y0, y1), max(y0, y1))
|
|
303
|
+
cuts = []
|
|
304
|
+
for hx, hy, hw, hh in holes:
|
|
305
|
+
if horiz and hy - pad < y0 < hy + hh + pad:
|
|
306
|
+
cuts.append((hx - pad, hx + hw + pad))
|
|
307
|
+
elif not horiz and hx - pad < x0 < hx + hw + pad:
|
|
308
|
+
cuts.append((hy - pad, hy + hh + pad))
|
|
309
|
+
segs, cur = [], a
|
|
310
|
+
for c0, c1 in sorted(cuts):
|
|
311
|
+
if c0 > cur:
|
|
312
|
+
segs.append((cur, min(c0, b)))
|
|
313
|
+
cur = max(cur, c1)
|
|
314
|
+
if cur < b:
|
|
315
|
+
segs.append((cur, b))
|
|
316
|
+
for s0, s1 in segs:
|
|
317
|
+
if s1 - s0 > 0.3:
|
|
318
|
+
if horiz:
|
|
319
|
+
draw_line(buf, s0, y0, s1, y0, **kw)
|
|
320
|
+
else:
|
|
321
|
+
draw_line(buf, x0, s0, x0, s1, **kw)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _err_cap(theme: Any, floor: float = 0.0) -> float:
|
|
325
|
+
"""Cap half-width for error bars: theme ``err_cap`` (≥ ``floor``)."""
|
|
326
|
+
return max(theme.L(getattr(theme, "err_cap", 2.2)), floor)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _rot_text(buf: List[str], theme: Any, s: str, cx: float, cy: float, fs: float) -> None:
|
|
330
|
+
from .render_svg import draw_text
|
|
331
|
+
|
|
332
|
+
sub: List[str] = []
|
|
333
|
+
draw_text(sub, s, cx + fs * 0.35, cy, font_size=fs, anchor="middle", fill=theme.text_color)
|
|
334
|
+
buf.append(f'<g transform="rotate(-90 {cx:.2f} {cy:.2f})">')
|
|
335
|
+
buf.extend(sub)
|
|
336
|
+
buf.append("</g>")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class _Chart(Node):
|
|
340
|
+
"""Shared text helpers."""
|
|
341
|
+
|
|
342
|
+
def _fs(self) -> float:
|
|
343
|
+
return float(self.font_size) if getattr(self, "font_size", None) else self.theme.small_size
|
|
344
|
+
|
|
345
|
+
def _tw(self, s: str) -> Tuple[float, float]:
|
|
346
|
+
return measure_text(s, self._fs())
|
|
347
|
+
|
|
348
|
+
def _text(self, buf: List[str], s: str, x: float, cy: float, anchor: str = "start",
|
|
349
|
+
color: Optional[str] = None) -> None:
|
|
350
|
+
from .render_svg import draw_text
|
|
351
|
+
|
|
352
|
+
fs = self._fs()
|
|
353
|
+
draw_text(buf, s, x, cy + fs * 0.35, font_size=fs, anchor=anchor,
|
|
354
|
+
fill=color or self.theme.text_color)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class StackedBarChart(_Chart):
|
|
358
|
+
"""Stacked bars: ``StackedBarChart({"Matmul": 2, "Mask": 4, "Softmax": 4})``.
|
|
359
|
+
|
|
360
|
+
data: a mapping (one bar, segment → value), a list of mappings (one per bar), or a
|
|
361
|
+
list of value lists with ``segments=`` names.
|
|
362
|
+
categories: bar names (tick labels under / beside each bar).
|
|
363
|
+
horizontal: bars grow to the right instead of up.
|
|
364
|
+
labels: ``"side"`` (each segment named beside the last bar that contains it — bars
|
|
365
|
+
whose segments are missing from the last bar get their names in a widened gap —
|
|
366
|
+
spread so names never collide), ``"legend"`` (a swatch key column right of the plot),
|
|
367
|
+
``"inside"`` (names inside segments that fit) or ``None``. values: print each
|
|
368
|
+
segment's value inside it when it fits.
|
|
369
|
+
axis: draw a value axis with ~3 round ticks; axis_label: its title.
|
|
370
|
+
grid: light value gridlines (``True``; theme ``grid_color``).
|
|
371
|
+
errors: one error per bar on its total — a number (symmetric) or ``(minus, plus)``.
|
|
372
|
+
colors: one per segment (default: theme palette, cycled).
|
|
373
|
+
``segment_port(bar, seg, side)`` → a port on one segment; ``bar_port(bar, side)`` → the
|
|
374
|
+
whole bar.
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
bar_len = themed(90.0)
|
|
378
|
+
bar_thick = themed(18.0)
|
|
379
|
+
bar_gap = themed(10.0)
|
|
380
|
+
|
|
381
|
+
def __init__(self, data: Any, *, segments: Optional[Sequence[str]] = None,
|
|
382
|
+
categories: Optional[Sequence[str]] = None, horizontal: bool = False,
|
|
383
|
+
labels: Optional[str] = "side", values: bool = False, axis: bool = False,
|
|
384
|
+
axis_label: Optional[str] = None, colors: Optional[Sequence[str]] = None,
|
|
385
|
+
max_value: Optional[float] = None, length: Optional[float] = None,
|
|
386
|
+
thickness: Optional[float] = None, gap: Optional[float] = None,
|
|
387
|
+
font_size: Optional[float] = None, stroke: Optional[str] = None,
|
|
388
|
+
grid: Any = False, errors: Optional[Sequence[Any]] = None,
|
|
389
|
+
**kwargs: Any) -> None:
|
|
390
|
+
super().__init__(**kwargs)
|
|
391
|
+
if labels not in ("side", "legend", "inside", None):
|
|
392
|
+
raise ValueError("labels must be side|legend|inside|None")
|
|
393
|
+
if isinstance(data, Mapping):
|
|
394
|
+
data = [data]
|
|
395
|
+
rows: List[List[float]] = []
|
|
396
|
+
names: List[str] = list(segments or [])
|
|
397
|
+
dup = sorted({n for n in names if names.count(n) > 1})
|
|
398
|
+
if dup:
|
|
399
|
+
raise ValueError(f"StackedBarChart segments must be unique (repeated: {dup}); "
|
|
400
|
+
"name repeated phases apart (e.g. 'decode 1', 'decode 2') and "
|
|
401
|
+
"share a colour with colors=[...]")
|
|
402
|
+
for d in data:
|
|
403
|
+
if isinstance(d, Mapping):
|
|
404
|
+
for k in d:
|
|
405
|
+
if k not in names:
|
|
406
|
+
names.append(str(k))
|
|
407
|
+
for d in data:
|
|
408
|
+
if isinstance(d, Mapping):
|
|
409
|
+
rows.append([float(d.get(k, 0.0)) for k in names])
|
|
410
|
+
else:
|
|
411
|
+
rows.append([float(v) for v in d])
|
|
412
|
+
if not rows or not any(rows):
|
|
413
|
+
raise ValueError("StackedBarChart needs at least one bar")
|
|
414
|
+
nseg = max(len(r) for r in rows)
|
|
415
|
+
self.rows = [r + [0.0] * (nseg - len(r)) for r in rows]
|
|
416
|
+
self.segments = (names + [f"s{i}" for i in range(len(names), nseg)])[:nseg]
|
|
417
|
+
self.categories = list(categories or [])
|
|
418
|
+
self.horizontal = bool(horizontal)
|
|
419
|
+
self.labels = labels
|
|
420
|
+
self.values = bool(values)
|
|
421
|
+
self.axis = bool(axis)
|
|
422
|
+
self.axis_label = axis_label
|
|
423
|
+
pal = list(colors or self.theme.palette)
|
|
424
|
+
self.colors = [pal[i % len(pal)] for i in range(nseg)]
|
|
425
|
+
self.max_value = max_value
|
|
426
|
+
self.bar_len = length
|
|
427
|
+
self.bar_thick = thickness
|
|
428
|
+
self.bar_gap = gap
|
|
429
|
+
self.font_size = font_size
|
|
430
|
+
self.stroke = stroke
|
|
431
|
+
self.grid = grid
|
|
432
|
+
_grid_axes(grid)
|
|
433
|
+
self.errors = _err_pairs(errors, len(self.rows)) if errors is not None else None
|
|
434
|
+
|
|
435
|
+
# -- geometry -------------------------------------------------------------------
|
|
436
|
+
def _vmax(self) -> float:
|
|
437
|
+
tops = [sum(r) + (self.errors[i][1] if self.errors else 0.0) for i, r in enumerate(self.rows)]
|
|
438
|
+
top = max(tops)
|
|
439
|
+
if self.max_value is not None:
|
|
440
|
+
return float(self.max_value)
|
|
441
|
+
if self.axis or self.grid:
|
|
442
|
+
return max(top, _nice_hi(0.0, top, self._tick_n()))
|
|
443
|
+
return top
|
|
444
|
+
|
|
445
|
+
def _side_hosts(self) -> Dict[int, List[int]]:
|
|
446
|
+
"""bar → segments named beside it (``labels="side"``): each segment is named at the
|
|
447
|
+
last bar (vertical) / first bar (horizontal, names above) that contains it."""
|
|
448
|
+
hosts: Dict[int, List[int]] = {}
|
|
449
|
+
n = len(self.rows)
|
|
450
|
+
order = range(n - 1, -1, -1) if not self.horizontal else range(n)
|
|
451
|
+
for k in range(len(self.segments)):
|
|
452
|
+
for b in order:
|
|
453
|
+
if self.rows[b][k] > 0:
|
|
454
|
+
hosts.setdefault(b, []).append(k)
|
|
455
|
+
break
|
|
456
|
+
return hosts
|
|
457
|
+
|
|
458
|
+
def _side_w(self, ks: Sequence[int]) -> float:
|
|
459
|
+
return max((self._tw(self.segments[k])[0] for k in ks), default=0.0)
|
|
460
|
+
|
|
461
|
+
def _gaps(self) -> List[float]:
|
|
462
|
+
"""Spacing after each bar (index b → gap between bar b and b + 1)."""
|
|
463
|
+
n = len(self.rows)
|
|
464
|
+
g = self._gap()
|
|
465
|
+
out = [g] * max(0, n - 1)
|
|
466
|
+
if self.labels == "side":
|
|
467
|
+
lg = self.theme.L(4.0)
|
|
468
|
+
fs = self._fs()
|
|
469
|
+
for b, ks in self._side_hosts().items():
|
|
470
|
+
if not self.horizontal and b < n - 1:
|
|
471
|
+
out[b] = max(out[b], self._side_w(ks) + lg * 2.5)
|
|
472
|
+
elif self.horizontal and b > 0:
|
|
473
|
+
out[b - 1] = max(out[b - 1], fs * 1.25 + lg)
|
|
474
|
+
return out
|
|
475
|
+
|
|
476
|
+
def _offsets(self) -> List[float]:
|
|
477
|
+
pos = self._lead()
|
|
478
|
+
offs = []
|
|
479
|
+
gaps = self._gaps()
|
|
480
|
+
for b in range(len(self.rows)):
|
|
481
|
+
offs.append(pos)
|
|
482
|
+
pos += float(self.bar_thick) + (gaps[b] if b < len(gaps) else 0.0)
|
|
483
|
+
return offs
|
|
484
|
+
|
|
485
|
+
def _tick_n(self) -> int:
|
|
486
|
+
if self.horizontal:
|
|
487
|
+
top = max(sum(r) for r in self.rows)
|
|
488
|
+
return _tick_n(float(self.bar_len), self._fs(), False, self._tw(_fmt(round(top, 1)))[0])
|
|
489
|
+
return _tick_n(float(self.bar_len), self._fs())
|
|
490
|
+
|
|
491
|
+
def _ticks(self) -> List[float]:
|
|
492
|
+
return _nice_ticks(0.0, self._vmax(), self._tick_n())
|
|
493
|
+
|
|
494
|
+
def _extents(self) -> Dict[str, float]:
|
|
495
|
+
"""Margins around the plot area: left, right, top, bottom."""
|
|
496
|
+
fs = self._fs()
|
|
497
|
+
gap = self.theme.L(4.0)
|
|
498
|
+
m = {"l": 0.0, "r": 0.0, "t": 0.0, "b": 0.0}
|
|
499
|
+
cat_w = max((self._tw(c)[0] for c in self.categories), default=0.0)
|
|
500
|
+
cat_h = fs * 1.25 if self.categories else 0.0
|
|
501
|
+
if self.horizontal:
|
|
502
|
+
m["l"] = cat_w + gap if self.categories else 0.0
|
|
503
|
+
else:
|
|
504
|
+
m["b"] = cat_h + gap * 0.5 if self.categories else 0.0
|
|
505
|
+
if self.labels == "side":
|
|
506
|
+
hosts = self._side_hosts()
|
|
507
|
+
if self.horizontal:
|
|
508
|
+
if 0 in hosts:
|
|
509
|
+
m["t"] = fs * 1.25 * 1.0 + gap * 0.5 # names above the top bar
|
|
510
|
+
elif len(self.rows) - 1 in hosts:
|
|
511
|
+
m["r"] = self._side_w(hosts[len(self.rows) - 1]) + gap * 1.5
|
|
512
|
+
if self.axis:
|
|
513
|
+
tw = max(self._tw(_fmt(t))[0] for t in self._ticks())
|
|
514
|
+
if self.horizontal:
|
|
515
|
+
m["b"] += fs * 1.25 + gap * 0.5 + (fs * 1.25 if self.axis_label else 0.0)
|
|
516
|
+
else:
|
|
517
|
+
m["l"] += tw + gap + self.theme.L(1.0) + (fs * 1.25 + gap * 0.5 if self.axis_label else 0.0)
|
|
518
|
+
if self.labels == "legend": # swatch key as a column right of the plot
|
|
519
|
+
m["r"] += gap * 1.5 + max(it[2] for it in self._legend_items())
|
|
520
|
+
if self.errors and not self.horizontal:
|
|
521
|
+
m["t"] = max(m["t"], self.theme.L(1.0))
|
|
522
|
+
return m
|
|
523
|
+
|
|
524
|
+
def _gap(self) -> float:
|
|
525
|
+
"""Bar spacing, widened so category names under vertical bars never collide."""
|
|
526
|
+
g = float(self.bar_gap)
|
|
527
|
+
if not self.horizontal and self.categories:
|
|
528
|
+
cw = max(self._tw(c)[0] for c in self.categories)
|
|
529
|
+
g = max(g, cw - float(self.bar_thick) + self.theme.L(4.0))
|
|
530
|
+
return g
|
|
531
|
+
|
|
532
|
+
def _legend_items(self) -> List[Tuple[str, str, float]]:
|
|
533
|
+
sw = self._fs()
|
|
534
|
+
return [(s, c, sw + self.theme.L(2.0) + self._tw(s)[0]) for s, c in zip(self.segments, self.colors)]
|
|
535
|
+
|
|
536
|
+
def _legend_h(self) -> float:
|
|
537
|
+
return self._fs() * 1.25 * len(self.segments)
|
|
538
|
+
|
|
539
|
+
def _lead(self) -> float:
|
|
540
|
+
"""Inset of the first vertical bar so its category name clears the value axis."""
|
|
541
|
+
if self.horizontal or not self.categories or not self.axis:
|
|
542
|
+
return 0.0
|
|
543
|
+
return max(0.0, self._tw(self.categories[0])[0] / 2 - float(self.bar_thick) / 2
|
|
544
|
+
+ self.theme.L(1.0))
|
|
545
|
+
|
|
546
|
+
def _plot_size(self) -> Tuple[float, float]:
|
|
547
|
+
across = self._offsets()[-1] + float(self.bar_thick)
|
|
548
|
+
return (self.bar_len, across) if self.horizontal else (across, self.bar_len)
|
|
549
|
+
|
|
550
|
+
def _measure(self) -> Tuple[float, float]:
|
|
551
|
+
pw, ph = self._plot_size()
|
|
552
|
+
m = self._extents()
|
|
553
|
+
w = pw + m["l"] + m["r"]
|
|
554
|
+
h = ph + m["t"] + m["b"]
|
|
555
|
+
if self.labels == "legend":
|
|
556
|
+
h = max(h, self._legend_h() + m["t"])
|
|
557
|
+
if self.horizontal and self.labels == "side":
|
|
558
|
+
# segment names above a bar may extend past the plot on the right
|
|
559
|
+
w = max(w, m["l"] + self._side_label_extent())
|
|
560
|
+
return w, h
|
|
561
|
+
|
|
562
|
+
def _side_label_extent(self) -> float:
|
|
563
|
+
vmax = self._vmax() or 1.0
|
|
564
|
+
ext = 0.0
|
|
565
|
+
for b, ks in self._side_hosts().items():
|
|
566
|
+
row = self.rows[b]
|
|
567
|
+
for k in ks:
|
|
568
|
+
mid = (sum(row[:k]) + row[k] / 2) / vmax * self.bar_len
|
|
569
|
+
ext = max(ext, mid + self._tw(self.segments[k])[0] / 2)
|
|
570
|
+
return ext
|
|
571
|
+
|
|
572
|
+
def _plot_origin(self) -> Tuple[float, float, float, float]:
|
|
573
|
+
g = self.geom
|
|
574
|
+
pw, ph = self._plot_size()
|
|
575
|
+
m = self._extents()
|
|
576
|
+
mw, mh = self._measure()
|
|
577
|
+
body = ph + m["t"] + m["b"]
|
|
578
|
+
return g.x + (g.w - mw) / 2 + m["l"], g.y + (g.h - mh) / 2 + (mh - body) / 2 + m["t"], pw, ph
|
|
579
|
+
|
|
580
|
+
def segment_rect(self, bar: int, seg: int) -> Tuple[float, float, float, float]:
|
|
581
|
+
"""(x, y, w, h) of one segment (layout coordinates)."""
|
|
582
|
+
px, py, pw, ph = self._plot_origin()
|
|
583
|
+
vmax = self._vmax() or 1.0
|
|
584
|
+
row = self.rows[bar]
|
|
585
|
+
a = sum(row[:seg]) / vmax * self.bar_len
|
|
586
|
+
b = sum(row[: seg + 1]) / vmax * self.bar_len
|
|
587
|
+
off = self._offsets()[bar]
|
|
588
|
+
if self.horizontal:
|
|
589
|
+
return px + a, py + off, b - a, self.bar_thick
|
|
590
|
+
base = py + ph
|
|
591
|
+
return px + off, base - b, self.bar_thick, b - a
|
|
592
|
+
|
|
593
|
+
def bar_rect(self, bar: int) -> Tuple[float, float, float, float]:
|
|
594
|
+
rs = [self.segment_rect(bar, k) for k in range(len(self.segments))]
|
|
595
|
+
x0 = min(r[0] for r in rs)
|
|
596
|
+
y0 = min(r[1] for r in rs)
|
|
597
|
+
x1 = max(r[0] + r[2] for r in rs)
|
|
598
|
+
y1 = max(r[1] + r[3] for r in rs)
|
|
599
|
+
return x0, y0, x1 - x0, y1 - y0
|
|
600
|
+
|
|
601
|
+
def segment_port(self, bar: int, seg: int, side: str = "right"):
|
|
602
|
+
"""Port on segment ``seg`` of bar ``bar`` (left | right | top | bottom)."""
|
|
603
|
+
from .edges import Port
|
|
604
|
+
|
|
605
|
+
if not (0 <= bar < len(self.rows) and 0 <= seg < len(self.segments)):
|
|
606
|
+
raise IndexError("segment out of range")
|
|
607
|
+
return Port(self, kind="custom", side=side, index=bar * 1000 + seg)
|
|
608
|
+
|
|
609
|
+
def bar_port(self, bar: int, side: str = "top"):
|
|
610
|
+
"""Port on the whole bar ``bar``."""
|
|
611
|
+
from .edges import Port
|
|
612
|
+
|
|
613
|
+
if not 0 <= bar < len(self.rows):
|
|
614
|
+
raise IndexError("bar out of range")
|
|
615
|
+
return Port(self, kind="custom", side=side, index=-1 - bar)
|
|
616
|
+
|
|
617
|
+
def resolve_custom_port(self, side: str, idx: int = 0, at: float = 0.5):
|
|
618
|
+
if idx < 0:
|
|
619
|
+
x, y, w, h = self.bar_rect(-1 - idx)
|
|
620
|
+
else:
|
|
621
|
+
x, y, w, h = self.segment_rect(idx // 1000, idx % 1000)
|
|
622
|
+
return {"left": (x, y + h * at, "left"), "right": (x + w, y + h * at, "right"),
|
|
623
|
+
"top": (x + w * at, y, "up"), "bottom": (x + w * at, y + h, "down")}.get(
|
|
624
|
+
side, (x + w / 2, y + h / 2, "right"))
|
|
625
|
+
|
|
626
|
+
# -- drawing --------------------------------------------------------------------
|
|
627
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
628
|
+
from .render_svg import draw_line, draw_rect
|
|
629
|
+
|
|
630
|
+
th = self.theme
|
|
631
|
+
fs = self._fs()
|
|
632
|
+
px, py, pw, ph = self._plot_origin()
|
|
633
|
+
px, py = px + ox, py + oy
|
|
634
|
+
gap = th.L(4.0)
|
|
635
|
+
stroke = self.stroke or th.stroke
|
|
636
|
+
sw = th.stroke_width
|
|
637
|
+
vmax = self._vmax() or 1.0
|
|
638
|
+
if _grid_axes(self.grid)[1] or _grid_axes(self.grid)[0]: # value gridlines, behind bars
|
|
639
|
+
for t in self._ticks():
|
|
640
|
+
if t <= 0:
|
|
641
|
+
continue
|
|
642
|
+
if self.horizontal:
|
|
643
|
+
x = px + t / vmax * pw
|
|
644
|
+
draw_line(buf, x, py, x, py + ph, stroke=th.grid_color, stroke_width=sw)
|
|
645
|
+
else:
|
|
646
|
+
y = py + ph - t / vmax * ph
|
|
647
|
+
draw_line(buf, px, y, px + pw, y, stroke=th.grid_color, stroke_width=sw)
|
|
648
|
+
for b, row in enumerate(self.rows):
|
|
649
|
+
for k, v in enumerate(row):
|
|
650
|
+
if v <= 0:
|
|
651
|
+
continue
|
|
652
|
+
x, y, w, h = self.segment_rect(b, k)
|
|
653
|
+
draw_rect(buf, x + ox, y + oy, w, h, fill=self.colors[k], stroke=stroke, stroke_width=sw)
|
|
654
|
+
txt = None
|
|
655
|
+
if self.labels == "inside":
|
|
656
|
+
txt = self.segments[k]
|
|
657
|
+
elif self.values:
|
|
658
|
+
txt = _fmt(v)
|
|
659
|
+
if txt:
|
|
660
|
+
tw, _ = self._tw(txt)
|
|
661
|
+
if tw + th.L(3.0) <= w and fs * 1.1 <= h:
|
|
662
|
+
self._text(buf, txt, x + ox + w / 2, y + oy + h / 2, anchor="middle")
|
|
663
|
+
else:
|
|
664
|
+
from .notices import note
|
|
665
|
+
|
|
666
|
+
note(self, txt, "did not fit inside its segment and was not drawn "
|
|
667
|
+
"(use labels='side' / 'legend', or a longer / thicker bar)")
|
|
668
|
+
if self.errors:
|
|
669
|
+
for b, (lo, hi) in enumerate(self.errors):
|
|
670
|
+
if lo <= 0 and hi <= 0:
|
|
671
|
+
continue
|
|
672
|
+
x, y, w, h = self.bar_rect(b)
|
|
673
|
+
tot = sum(self.rows[b])
|
|
674
|
+
cap = min(w if not self.horizontal else h, float(self.bar_thick)) * 0.25
|
|
675
|
+
if self.horizontal:
|
|
676
|
+
a, z = (px + (tot - lo) / vmax * pw, px + (tot + hi) / vmax * pw)
|
|
677
|
+
_draw_errbar(buf, y + oy + h / 2, a, z, cap, stroke, sw, horizontal=True)
|
|
678
|
+
else:
|
|
679
|
+
a, z = (py + ph - (tot - lo) / vmax * ph, py + ph - (tot + hi) / vmax * ph)
|
|
680
|
+
_draw_errbar(buf, x + ox + w / 2, a, z, cap, stroke, sw)
|
|
681
|
+
# category labels
|
|
682
|
+
for b, c in enumerate(self.categories[: len(self.rows)]):
|
|
683
|
+
x, y, w, h = self.bar_rect(b)
|
|
684
|
+
if self.horizontal:
|
|
685
|
+
self._text(buf, c, px - gap, y + oy + h / 2, anchor="end")
|
|
686
|
+
else:
|
|
687
|
+
self._text(buf, c, x + ox + w / 2, py + ph + gap * 0.5 + fs * 0.6, anchor="middle")
|
|
688
|
+
# side labels: every segment named once, beside the bar that hosts it
|
|
689
|
+
if self.labels == "side":
|
|
690
|
+
for host, ks in sorted(self._side_hosts().items()):
|
|
691
|
+
mids, items = [], []
|
|
692
|
+
for k in ks:
|
|
693
|
+
x, y, w, h = self.segment_rect(host, k)
|
|
694
|
+
mids.append((x + w / 2) if self.horizontal else (y + h / 2))
|
|
695
|
+
items.append(k)
|
|
696
|
+
if self.horizontal:
|
|
697
|
+
x, y, w, h = self.bar_rect(host)
|
|
698
|
+
ty = y + oy - gap * 0.5 - fs * 0.6
|
|
699
|
+
for k, cx in zip(items, mids):
|
|
700
|
+
self._text(buf, self.segments[k], cx + ox, ty, anchor="middle")
|
|
701
|
+
else:
|
|
702
|
+
ys = _spread(mids, fs * 1.2, py - oy, py - oy + ph)
|
|
703
|
+
x, _y, w, _h = self.bar_rect(host)
|
|
704
|
+
lx = x + w + gap * 1.5
|
|
705
|
+
for k, cy0, cy in zip(items, mids, ys):
|
|
706
|
+
if self.grid: # knock gridlines out behind the name
|
|
707
|
+
tw_ = self._tw(self.segments[k])[0]
|
|
708
|
+
draw_rect(buf, lx + ox - th.L(1.0), cy + oy - fs * 0.6, tw_ + th.L(2.0),
|
|
709
|
+
fs * 1.2, fill="#ffffff", stroke="none", stroke_width=0)
|
|
710
|
+
if abs(cy - cy0) > 0.5: # leader to the segment it names
|
|
711
|
+
draw_line(buf, x + w + ox + th.L(0.5), cy0 + oy, lx + ox - th.L(1.0),
|
|
712
|
+
cy + oy, stroke=th.muted_color, stroke_width=sw)
|
|
713
|
+
self._text(buf, self.segments[k], lx + ox, cy + oy)
|
|
714
|
+
# value axis
|
|
715
|
+
if self.axis:
|
|
716
|
+
ticks = self._ticks()
|
|
717
|
+
tl = th.L(3.0)
|
|
718
|
+
if self.horizontal:
|
|
719
|
+
ay = py + ph + th.L(2.0)
|
|
720
|
+
draw_line(buf, px, ay, px + pw, ay, stroke=stroke, stroke_width=sw)
|
|
721
|
+
for t in ticks:
|
|
722
|
+
x = px + t / vmax * pw
|
|
723
|
+
draw_line(buf, x, ay, x, ay + tl, stroke=stroke, stroke_width=sw)
|
|
724
|
+
self._text(buf, _fmt(t), x, ay + tl + fs * 0.6, anchor="middle")
|
|
725
|
+
if self.axis_label:
|
|
726
|
+
self._text(buf, self.axis_label, px + pw / 2, ay + tl + fs * 1.85, anchor="middle")
|
|
727
|
+
else:
|
|
728
|
+
ax = px - th.L(2.0)
|
|
729
|
+
draw_line(buf, ax, py, ax, py + ph, stroke=stroke, stroke_width=sw)
|
|
730
|
+
for t in ticks:
|
|
731
|
+
y = py + ph - t / vmax * ph
|
|
732
|
+
draw_line(buf, ax - tl, y, ax, y, stroke=stroke, stroke_width=sw)
|
|
733
|
+
self._text(buf, _fmt(t), ax - tl - th.L(2.0), y, anchor="end")
|
|
734
|
+
if self.axis_label:
|
|
735
|
+
tw = max(self._tw(_fmt(t))[0] for t in ticks)
|
|
736
|
+
cx = ax - tl - th.L(2.0) - tw - gap * 0.5 - fs * 0.5
|
|
737
|
+
_rot_text(buf, th, self.axis_label, cx, py + ph / 2, fs)
|
|
738
|
+
if self.labels == "legend": # top segment first, like the stack
|
|
739
|
+
items = list(reversed(self._legend_items())) if not self.horizontal else self._legend_items()
|
|
740
|
+
x = px + pw + gap * 1.5
|
|
741
|
+
lh = fs * 1.25
|
|
742
|
+
y = py + ph / 2 - lh * (len(items) - 1) / 2
|
|
743
|
+
s = fs * 0.9
|
|
744
|
+
for name, col, _w in items:
|
|
745
|
+
draw_rect(buf, x, y - s / 2, s, s, fill=col, stroke=stroke, stroke_width=sw)
|
|
746
|
+
self._text(buf, name, x + s + th.L(2.0), y)
|
|
747
|
+
y += lh
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
class LineChart(_Chart):
|
|
751
|
+
"""Line chart: ``LineChart({"vLLM": [1, 3, 7], "ours": [1, 2, 3]}, x=[1, 2, 4])``.
|
|
752
|
+
|
|
753
|
+
series: mapping name → y values (or one list). x: shared x values (default 0..n-1).
|
|
754
|
+
markers: dot at every point. axis: x / y axes with ~3 round ticks each;
|
|
755
|
+
xlabel / ylabel: axis titles. labels: ``"end"`` puts each series name at its last point
|
|
756
|
+
(spread so names never collide), ``None`` hides them. colors: per series (default:
|
|
757
|
+
theme accents). ymin / ymax: value range (default: nice-rounded data range; see
|
|
758
|
+
``zero``). zero: linear y axis includes 0 — ``"auto"`` (default, theme
|
|
759
|
+
``chart_zero``: yes unless all data is above half its maximum), ``True`` or ``False``.
|
|
760
|
+
fill: shade the area under a single series. ``point_port(series, i)`` → a port on a
|
|
761
|
+
data point (for callouts / edges).
|
|
762
|
+
xscale / yscale: ``"linear"`` (default) or ``"log"`` (base 10; decade ticks 1, 10,
|
|
763
|
+
100, 1k …; values must be > 0). grid: light gridlines — ``True`` (= ``"y"``), ``"x"``,
|
|
764
|
+
``"both"``. errors: mapping series → per-point error, each a number (symmetric) or a
|
|
765
|
+
``(minus, plus)`` pair (asymmetric); drawn as capped whiskers in the series colour.
|
|
766
|
+
error_style: ``"bars"`` (default), ``"band"`` (a shaded band between the lower and
|
|
767
|
+
upper values, theme ``band_opacity``) or ``"both"``. Caps are at least theme
|
|
768
|
+
``err_cap`` wide and stick out beyond the markers, so errors smaller than a marker
|
|
769
|
+
(common on log axes at paper size) still read as a tight bar. Value axes get up to
|
|
770
|
+
~5 nice ticks when the plot is tall / wide enough (~3 at the default paper size).
|
|
771
|
+
|
|
772
|
+
v0.13.0: ``width=`` is the chart's *total* width in pt — axis ticks, the y label and
|
|
773
|
+
the end labels / legend included — so ``LineChart(..., width=70)`` really is 70 pt
|
|
774
|
+
wide; ``plot_width=`` sets the plot area alone (the old meaning of ``width=``).
|
|
775
|
+
``height=`` is the plot height. ``labels="legend"`` draws a line-swatch key instead
|
|
776
|
+
of end labels: ``legend="right"`` (default, a column right of the plot),
|
|
777
|
+
``"inside-top-left"`` or ``"inside-top-right"`` (inside the plot corner).
|
|
778
|
+
"""
|
|
779
|
+
|
|
780
|
+
plot_w = themed(110.0)
|
|
781
|
+
plot_h = themed(60.0)
|
|
782
|
+
|
|
783
|
+
def __init__(self, series: Any, *, x: Optional[Sequence[Number]] = None, markers: bool = True,
|
|
784
|
+
axis: bool = True, xlabel: Optional[str] = None, ylabel: Optional[str] = None,
|
|
785
|
+
labels: Optional[str] = "end", colors: Optional[Sequence[str]] = None,
|
|
786
|
+
ymin: Optional[float] = None, ymax: Optional[float] = None, fill: bool = False,
|
|
787
|
+
width: Optional[float] = None, height: Optional[float] = None,
|
|
788
|
+
font_size: Optional[float] = None, line_width: Optional[float] = None,
|
|
789
|
+
xscale: str = "linear", yscale: str = "linear", grid: Any = False,
|
|
790
|
+
errors: Optional[Mapping[str, Sequence[Any]]] = None,
|
|
791
|
+
error_style: str = "bars", zero: Any = None,
|
|
792
|
+
plot_width: Optional[float] = None, legend: Optional[str] = None,
|
|
793
|
+
**kwargs: Any) -> None:
|
|
794
|
+
super().__init__(**kwargs)
|
|
795
|
+
if legend not in (None, "right", "inside-top-left", "inside-top-right"):
|
|
796
|
+
raise ValueError("legend must be 'right', 'inside-top-left' or 'inside-top-right'")
|
|
797
|
+
self.legend_pos = legend or "right"
|
|
798
|
+
if width is not None and plot_width is not None:
|
|
799
|
+
raise TypeError("LineChart: pass width= (total) or plot_width= (plot area), not both")
|
|
800
|
+
self._outer_w = None if width is None else float(width)
|
|
801
|
+
if zero not in (None, "auto", True, False):
|
|
802
|
+
raise ValueError("zero must be 'auto', True or False")
|
|
803
|
+
self.zero = zero
|
|
804
|
+
if error_style not in ("bars", "band", "both"):
|
|
805
|
+
raise ValueError("error_style must be 'bars', 'band' or 'both'")
|
|
806
|
+
self.error_style = error_style
|
|
807
|
+
if xscale not in ("linear", "log") or yscale not in ("linear", "log"):
|
|
808
|
+
raise ValueError("xscale / yscale must be 'linear' or 'log'")
|
|
809
|
+
if not isinstance(series, Mapping):
|
|
810
|
+
series = {"": list(series)}
|
|
811
|
+
self.series = {str(k): [float(v) for v in vs] for k, vs in series.items()}
|
|
812
|
+
if not self.series or not any(self.series.values()):
|
|
813
|
+
raise ValueError("LineChart needs at least one non-empty series")
|
|
814
|
+
n = max(len(v) for v in self.series.values())
|
|
815
|
+
self.x = [float(v) for v in x] if x is not None else [float(i) for i in range(n)]
|
|
816
|
+
if labels not in ("end", "legend", None):
|
|
817
|
+
raise ValueError("labels must be 'end', 'legend' or None")
|
|
818
|
+
self.markers = bool(markers)
|
|
819
|
+
self.axis = bool(axis)
|
|
820
|
+
self.xlabel, self.ylabel = xlabel, ylabel
|
|
821
|
+
self.labels = labels
|
|
822
|
+
pal = list(colors or (self.theme.accents if len(self.series) > 1 else self.theme.accents[:1]))
|
|
823
|
+
self.colors = [pal[i % len(pal)] for i in range(len(self.series))]
|
|
824
|
+
self.ymin, self.ymax = ymin, ymax
|
|
825
|
+
self.fill = bool(fill)
|
|
826
|
+
self.plot_w = plot_width
|
|
827
|
+
self.plot_h = height
|
|
828
|
+
self.font_size = font_size
|
|
829
|
+
self.line_width = line_width
|
|
830
|
+
self.xscale, self.yscale = xscale, yscale
|
|
831
|
+
self.grid = grid
|
|
832
|
+
_grid_axes(grid)
|
|
833
|
+
errors = dict(errors or {})
|
|
834
|
+
unknown = [k for k in errors if str(k) not in self.series]
|
|
835
|
+
if unknown:
|
|
836
|
+
raise ValueError(f"errors for unknown series {unknown}")
|
|
837
|
+
self.errors = {str(k): _err_pairs(v, len(self.series[str(k)])) for k, v in errors.items()}
|
|
838
|
+
if xscale == "log" and min(self.x) <= 0:
|
|
839
|
+
raise ValueError("xscale='log' needs x > 0")
|
|
840
|
+
if yscale == "log" and min(v for vs in self.series.values() for v in vs) <= 0:
|
|
841
|
+
raise ValueError("yscale='log' needs y > 0")
|
|
842
|
+
|
|
843
|
+
def _ybounds(self) -> Tuple[List[float], List[float]]:
|
|
844
|
+
lows, highs = [], []
|
|
845
|
+
for name, vs in self.series.items():
|
|
846
|
+
es = self.errors.get(name) or [(0.0, 0.0)] * len(vs)
|
|
847
|
+
for v, (m, p) in zip(vs, es):
|
|
848
|
+
lo = v - m
|
|
849
|
+
lows.append(lo if (self.yscale != "log" or lo > 0) else v)
|
|
850
|
+
highs.append(v + p)
|
|
851
|
+
return lows, highs
|
|
852
|
+
|
|
853
|
+
def _yscale(self) -> Scale:
|
|
854
|
+
lows, highs = self._ybounds()
|
|
855
|
+
if self.yscale == "log":
|
|
856
|
+
lo = self.ymin if self.ymin is not None else min(lows)
|
|
857
|
+
else:
|
|
858
|
+
lo = self.ymin if self.ymin is not None else (
|
|
859
|
+
min(0.0, min(lows)) if self._with_zero(min(lows), max(highs)) else min(lows))
|
|
860
|
+
hi = self.ymax if self.ymax is not None else max(highs)
|
|
861
|
+
return Scale(lo, hi, self.yscale, nice=self.axis or self.yscale == "log",
|
|
862
|
+
n=_tick_n(float(self.plot_h), self._fs()),
|
|
863
|
+
fixed_lo=self.ymin is not None, fixed_hi=self.ymax is not None)
|
|
864
|
+
|
|
865
|
+
def _with_zero(self, lo: float, hi: float) -> bool:
|
|
866
|
+
"""Linear y axis: include 0? ``zero=True`` / ``False`` force it; ``"auto"`` (theme
|
|
867
|
+
``chart_zero``) keeps 0 for area fills, data that crosses / touches 0, and data
|
|
868
|
+
whose minimum is under half its maximum — data far from zero gets a nice
|
|
869
|
+
off-zero range instead (bar charts always keep 0)."""
|
|
870
|
+
z = self.zero if self.zero is not None else getattr(self.theme, "chart_zero", "auto")
|
|
871
|
+
if z is True or z is False:
|
|
872
|
+
return z
|
|
873
|
+
if self.fill or lo <= 0 or hi <= 0:
|
|
874
|
+
return True
|
|
875
|
+
return lo < 0.5 * hi
|
|
876
|
+
|
|
877
|
+
def _xscale(self) -> Scale:
|
|
878
|
+
lo, hi = min(self.x), max(self.x)
|
|
879
|
+
return Scale(lo, hi if hi > lo else (lo * 10 if self.xscale == "log" else lo + 1.0),
|
|
880
|
+
self.xscale, nice=False)
|
|
881
|
+
|
|
882
|
+
def _yrange(self) -> Tuple[float, float]:
|
|
883
|
+
sc = self._yscale()
|
|
884
|
+
return sc.lo, sc.hi
|
|
885
|
+
|
|
886
|
+
def _xrange(self) -> Tuple[float, float]:
|
|
887
|
+
sc = self._xscale()
|
|
888
|
+
return sc.lo, sc.hi
|
|
889
|
+
|
|
890
|
+
def _xticks(self) -> List[float]:
|
|
891
|
+
if len(self.x) <= 6:
|
|
892
|
+
return list(self.x)
|
|
893
|
+
if self.xscale == "log":
|
|
894
|
+
return self._xscale().ticks()
|
|
895
|
+
lo, hi = self._xrange()
|
|
896
|
+
lw = max(self._tw(_fmt(v))[0] for v in (lo, hi))
|
|
897
|
+
return _nice_ticks(lo, hi, _tick_n(float(self.plot_w), self._fs(), False, lw, hi=6))
|
|
898
|
+
|
|
899
|
+
def _margins(self) -> Dict[str, float]:
|
|
900
|
+
fs = self._fs()
|
|
901
|
+
gap = self.theme.L(3.0)
|
|
902
|
+
m = {"l": 0.0, "r": 0.0, "t": fs * 0.6 if self.labels else self.theme.L(2.0), "b": 0.0}
|
|
903
|
+
if self.axis:
|
|
904
|
+
ys = self._yscale()
|
|
905
|
+
m["l"] = max(self._tw(ys.fmt(t))[0] for t in ys.ticks()) + gap * 2
|
|
906
|
+
m["b"] = fs * 1.25 + gap * 1.5
|
|
907
|
+
if self.ylabel:
|
|
908
|
+
m["l"] += fs * 1.25 + gap * 0.5
|
|
909
|
+
if self.xlabel:
|
|
910
|
+
m["b"] += fs * 1.25
|
|
911
|
+
else:
|
|
912
|
+
m["l"] = m["b"] = self.theme.L(2.0)
|
|
913
|
+
if self.labels == "end" and any(self.series):
|
|
914
|
+
off = max(gap * 1.2, self._end_clear())
|
|
915
|
+
m["r"] = max(self._tw(k)[0] for k in self.series) + off + gap * 0.3 if any(k for k in self.series) else 0.0
|
|
916
|
+
elif self.labels == "legend" and any(k for k in self.series):
|
|
917
|
+
m["t"] = self.theme.L(2.0)
|
|
918
|
+
if self.legend_pos == "right":
|
|
919
|
+
m["r"] = gap * 1.5 + self._legend_w()
|
|
920
|
+
return m
|
|
921
|
+
|
|
922
|
+
def _legend_rows(self) -> List[Tuple[str, str]]:
|
|
923
|
+
return [(k, self.colors[i]) for i, k in enumerate(self.series) if k]
|
|
924
|
+
|
|
925
|
+
def _pt_abs(self, xv: float, yv: float, px: float, py: float) -> Tuple[float, float]:
|
|
926
|
+
return (px + self._xscale().norm(xv) * self.plot_w,
|
|
927
|
+
py + self.plot_h - self._yscale().norm(yv) * self.plot_h)
|
|
928
|
+
|
|
929
|
+
def _swatch(self) -> float:
|
|
930
|
+
return self.theme.L(10.0)
|
|
931
|
+
|
|
932
|
+
def _legend_w(self) -> float:
|
|
933
|
+
rows = self._legend_rows()
|
|
934
|
+
if not rows:
|
|
935
|
+
return 0.0
|
|
936
|
+
return self._swatch() + self.theme.L(2.5) + max(self._tw(k)[0] for k, _c in rows)
|
|
937
|
+
|
|
938
|
+
def _fit_width(self) -> None:
|
|
939
|
+
"""``width=`` (total) → plot width = width − axis / label margins."""
|
|
940
|
+
if self._outer_w is None:
|
|
941
|
+
return
|
|
942
|
+
from .notices import note
|
|
943
|
+
|
|
944
|
+
m = self._margins()
|
|
945
|
+
floor = self.theme.L(30.0)
|
|
946
|
+
want = self._outer_w - m["l"] - m["r"]
|
|
947
|
+
self.plot_w = max(floor, want)
|
|
948
|
+
if want < floor:
|
|
949
|
+
note(self, f"width={self._outer_w:g}",
|
|
950
|
+
f"leaves no room for the plot: axes + labels take {m['l'] + m['r']:.0f} pt, "
|
|
951
|
+
f"so the chart is {floor + m['l'] + m['r']:.0f} pt wide — use "
|
|
952
|
+
"labels='legend', shorter series names or a larger width=")
|
|
953
|
+
|
|
954
|
+
def _measure(self) -> Tuple[float, float]:
|
|
955
|
+
self._fit_width()
|
|
956
|
+
m = self._margins()
|
|
957
|
+
return self.plot_w + m["l"] + m["r"], self.plot_h + m["t"] + m["b"]
|
|
958
|
+
|
|
959
|
+
def _origin(self) -> Tuple[float, float]:
|
|
960
|
+
g = self.geom
|
|
961
|
+
m = self._margins()
|
|
962
|
+
mw, mh = self._measure()
|
|
963
|
+
return g.x + (g.w - mw) / 2 + m["l"], g.y + (g.h - mh) / 2 + m["t"]
|
|
964
|
+
|
|
965
|
+
def _pt(self, xv: float, yv: float) -> Tuple[float, float]:
|
|
966
|
+
px, py = self._origin()
|
|
967
|
+
return (px + self._xscale().norm(xv) * self.plot_w,
|
|
968
|
+
py + self.plot_h - self._yscale().norm(yv) * self.plot_h)
|
|
969
|
+
|
|
970
|
+
def point(self, series: Union[str, int], i: int) -> Tuple[float, float]:
|
|
971
|
+
"""Layout coordinates of data point ``i`` of ``series`` (name or index)."""
|
|
972
|
+
name = list(self.series)[series] if isinstance(series, int) else series
|
|
973
|
+
return self._pt(self.x[i], self.series[name][i])
|
|
974
|
+
|
|
975
|
+
def point_port(self, series: Union[str, int], i: int, side: str = "top"):
|
|
976
|
+
"""Port at a data point; ``side`` only sets the edge's approach direction."""
|
|
977
|
+
from .edges import Port
|
|
978
|
+
|
|
979
|
+
idx = series if isinstance(series, int) else list(self.series).index(series)
|
|
980
|
+
return Port(self, kind="custom", side=side, index=idx * 100000 + int(i))
|
|
981
|
+
|
|
982
|
+
def resolve_custom_port(self, side: str, idx: int = 0, at: float = 0.5):
|
|
983
|
+
x, y = self.point(idx // 100000, idx % 100000)
|
|
984
|
+
r = self.theme.L(2.5)
|
|
985
|
+
d = {"top": (0, -r, "up"), "bottom": (0, r, "down"), "left": (-r, 0, "left"),
|
|
986
|
+
"right": (r, 0, "right")}.get(side, (0, -r, "up"))
|
|
987
|
+
return x + d[0], y + d[1], d[2]
|
|
988
|
+
|
|
989
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
990
|
+
from .render_svg import draw_circle, draw_line, draw_path, draw_text
|
|
991
|
+
|
|
992
|
+
th = self.theme
|
|
993
|
+
fs = self._fs()
|
|
994
|
+
px, py = self._origin()
|
|
995
|
+
stroke = th.stroke
|
|
996
|
+
sw = th.stroke_width
|
|
997
|
+
lw = self.line_width if self.line_width is not None else th.edge_width
|
|
998
|
+
gap = th.L(3.0)
|
|
999
|
+
ysc, xsc = self._yscale(), self._xscale()
|
|
1000
|
+
gx, gy = _grid_axes(self.grid)
|
|
1001
|
+
if gy:
|
|
1002
|
+
for t in ysc.ticks():
|
|
1003
|
+
_x, y = self._pt(xsc.lo, t)
|
|
1004
|
+
if abs(y - (py + self.plot_h)) > 0.5:
|
|
1005
|
+
draw_line(buf, px + ox, y + oy, px + ox + self.plot_w, y + oy,
|
|
1006
|
+
stroke=th.grid_color, stroke_width=sw)
|
|
1007
|
+
if gx:
|
|
1008
|
+
for t in self._xticks():
|
|
1009
|
+
x, _y = self._pt(t, ysc.lo)
|
|
1010
|
+
if abs(x - px) > 0.5:
|
|
1011
|
+
draw_line(buf, x + ox, py + oy, x + ox, py + oy + self.plot_h,
|
|
1012
|
+
stroke=th.grid_color, stroke_width=sw)
|
|
1013
|
+
if self.axis:
|
|
1014
|
+
ax0, ay0 = px + ox, py + oy + self.plot_h
|
|
1015
|
+
draw_line(buf, ax0, ay0, ax0 + self.plot_w, ay0, stroke=stroke, stroke_width=sw)
|
|
1016
|
+
draw_line(buf, ax0, py + oy, ax0, ay0, stroke=stroke, stroke_width=sw)
|
|
1017
|
+
tl = th.L(2.5)
|
|
1018
|
+
for t in ysc.ticks():
|
|
1019
|
+
_x, y = self._pt(xsc.lo, t)
|
|
1020
|
+
draw_line(buf, ax0 - tl, y + oy, ax0, y + oy, stroke=stroke, stroke_width=sw)
|
|
1021
|
+
self._text(buf, ysc.fmt(t), ax0 - tl - th.L(2.0), y + oy, anchor="end")
|
|
1022
|
+
for t in self._xticks():
|
|
1023
|
+
x, _y = self._pt(t, ysc.lo)
|
|
1024
|
+
draw_line(buf, x + ox, ay0, x + ox, ay0 + tl, stroke=stroke, stroke_width=sw)
|
|
1025
|
+
self._text(buf, xsc.fmt(t), x + ox, ay0 + tl + fs * 0.7, anchor="middle")
|
|
1026
|
+
if self.xlabel:
|
|
1027
|
+
self._text(buf, self.xlabel, ax0 + self.plot_w / 2, ay0 + tl + fs * 1.95, anchor="middle")
|
|
1028
|
+
if self.ylabel:
|
|
1029
|
+
tw = max(self._tw(ysc.fmt(t))[0] for t in ysc.ticks())
|
|
1030
|
+
cx = ax0 - tl - th.L(2.0) - tw - gap * 0.5 - fs * 0.5
|
|
1031
|
+
_rot_text(buf, th, self.ylabel, cx, py + oy + self.plot_h / 2, fs)
|
|
1032
|
+
ends = []
|
|
1033
|
+
for k, (name, ys) in enumerate(self.series.items()):
|
|
1034
|
+
col = self.colors[k]
|
|
1035
|
+
pts = [self._pt(self.x[i], v) for i, v in enumerate(ys) if i < len(self.x)]
|
|
1036
|
+
pts = [(x + ox, y + oy) for x, y in pts]
|
|
1037
|
+
if not pts:
|
|
1038
|
+
continue
|
|
1039
|
+
d = "M " + " L ".join(f"{x:.2f},{y:.2f}" for x, y in pts)
|
|
1040
|
+
if self.fill and len(self.series) == 1:
|
|
1041
|
+
base = py + oy + self.plot_h
|
|
1042
|
+
area = d + f" L {pts[-1][0]:.2f},{base:.2f} L {pts[0][0]:.2f},{base:.2f} Z"
|
|
1043
|
+
draw_path(buf, area, fill=col, stroke="none", stroke_width=0, opacity=0.18)
|
|
1044
|
+
spans = [] # (x, y_lo, y_hi) per point with an error
|
|
1045
|
+
for i, (m_, p_) in enumerate(self.errors.get(name, [])):
|
|
1046
|
+
if i >= len(pts):
|
|
1047
|
+
continue
|
|
1048
|
+
v = ys[i]
|
|
1049
|
+
lo_v = v - m_ if (self.yscale != "log" or v - m_ > 0) else ysc.lo
|
|
1050
|
+
_x, y_lo = self._pt(self.x[i], lo_v)
|
|
1051
|
+
_x, y_hi = self._pt(self.x[i], v + p_)
|
|
1052
|
+
spans.append((pts[i][0], y_lo + oy, y_hi + oy, m_ > 0 or p_ > 0))
|
|
1053
|
+
if spans and self.error_style in ("band", "both") and len(spans) >= 2:
|
|
1054
|
+
up = " L ".join(f"{x:.2f},{yh:.2f}" for x, _yl, yh, _e in spans)
|
|
1055
|
+
dn = " L ".join(f"{x:.2f},{yl:.2f}" for x, yl, _yh, _e in reversed(spans))
|
|
1056
|
+
draw_path(buf, f"M {up} L {dn} Z", fill=col, stroke="none", stroke_width=0,
|
|
1057
|
+
opacity=getattr(th, "band_opacity", 0.18))
|
|
1058
|
+
draw_path(buf, d, fill="none", stroke=col, stroke_width=lw)
|
|
1059
|
+
mr = th.L(2.0)
|
|
1060
|
+
if self.error_style in ("bars", "both"):
|
|
1061
|
+
cap = _err_cap(th, (mr + th.L(getattr(th, "err_cap_out", 1.4)))
|
|
1062
|
+
if self.markers else 0.0)
|
|
1063
|
+
for x, y_lo, y_hi, has in spans:
|
|
1064
|
+
if has:
|
|
1065
|
+
_draw_errbar(buf, x, y_lo, y_hi, cap, col, sw)
|
|
1066
|
+
if self.markers:
|
|
1067
|
+
for x, y in pts:
|
|
1068
|
+
draw_circle(buf, x, y, mr, fill=col)
|
|
1069
|
+
ends.append((name, col, pts[-1]))
|
|
1070
|
+
if self.labels == "end":
|
|
1071
|
+
named = [(n, c, p) for n, c, p in ends if n]
|
|
1072
|
+
ys = _spread([p[1] for _n, _c, p in named], fs * 1.2, py + oy - fs, py + oy + self.plot_h + fs)
|
|
1073
|
+
# clear the last marker and its error cap
|
|
1074
|
+
off = max(gap * 1.2, self._end_clear())
|
|
1075
|
+
for (name, col, (x, y)), ly in zip(named, ys):
|
|
1076
|
+
self._text(buf, name, x + off, ly, color=col if col != th.text_color else None)
|
|
1077
|
+
elif self.labels == "legend":
|
|
1078
|
+
self._draw_line_legend(buf, px + ox, py + oy, lw)
|
|
1079
|
+
|
|
1080
|
+
def _draw_line_legend(self, buf: List[str], px: float, py: float, lw: float) -> None:
|
|
1081
|
+
from .render_svg import draw_circle, draw_line, draw_rect
|
|
1082
|
+
|
|
1083
|
+
th = self.theme
|
|
1084
|
+
rows = self._legend_rows()
|
|
1085
|
+
if not rows:
|
|
1086
|
+
return
|
|
1087
|
+
fs = self._fs()
|
|
1088
|
+
gap = th.L(3.0)
|
|
1089
|
+
rh = fs * 1.3
|
|
1090
|
+
sw_len = self._swatch()
|
|
1091
|
+
w, h = self._legend_w(), rh * len(rows)
|
|
1092
|
+
if self.legend_pos == "right":
|
|
1093
|
+
x0 = px + self.plot_w + gap * 1.5
|
|
1094
|
+
y0 = py + self.plot_h / 2 - h / 2
|
|
1095
|
+
else:
|
|
1096
|
+
pad = th.L(2.0)
|
|
1097
|
+
y0 = py + pad
|
|
1098
|
+
x0 = px + pad if self.legend_pos == "inside-top-left" else px + self.plot_w - pad - w
|
|
1099
|
+
draw_rect(buf, x0 - pad / 2, y0 - pad / 2, w + pad, h + pad, fill="#ffffff",
|
|
1100
|
+
stroke="none", stroke_width=0, opacity=0.85)
|
|
1101
|
+
hit = any(x0 - ox_ <= x <= x0 + w and y0 <= y <= y0 + h
|
|
1102
|
+
for name in self.series for i, v in enumerate(self.series[name])
|
|
1103
|
+
if i < len(self.x)
|
|
1104
|
+
for x, y, ox_ in [(*self._pt_abs(self.x[i], v, px, py), 0.0)])
|
|
1105
|
+
if hit:
|
|
1106
|
+
from .notices import note
|
|
1107
|
+
|
|
1108
|
+
note(self, "legend", "inside the plot covers data points; use the other "
|
|
1109
|
+
"inside corner or legend='right'", level="info")
|
|
1110
|
+
for i, (name, col) in enumerate(rows):
|
|
1111
|
+
cy = y0 + rh * (i + 0.5)
|
|
1112
|
+
draw_line(buf, x0, cy, x0 + sw_len, cy, stroke=col, stroke_width=lw)
|
|
1113
|
+
if self.markers:
|
|
1114
|
+
draw_circle(buf, x0 + sw_len / 2, cy, th.L(2.0), fill=col)
|
|
1115
|
+
self._text(buf, name, x0 + sw_len + th.L(2.5), cy)
|
|
1116
|
+
|
|
1117
|
+
def _end_clear(self) -> float:
|
|
1118
|
+
"""Distance from the last point to its end label: marker + error cap + a gap."""
|
|
1119
|
+
th = self.theme
|
|
1120
|
+
c = th.L(2.0) if self.markers else 0.0
|
|
1121
|
+
if self.errors and self.error_style in ("bars", "both"):
|
|
1122
|
+
c = max(c, _err_cap(th, (th.L(2.0) + th.L(getattr(th, "err_cap_out", 1.4)))
|
|
1123
|
+
if self.markers else 0.0))
|
|
1124
|
+
return c + th.L(2.0)
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
class GroupedBarChart(_Chart):
|
|
1128
|
+
"""Grouped (clustered) bars: ``GroupedBarChart({"A100": {"vLLM": 3, "ours": 5}, ...})``.
|
|
1129
|
+
|
|
1130
|
+
data: mapping category → mapping series → value, or a list of value lists (one per
|
|
1131
|
+
category) with ``series=`` names. categories: group names under each cluster (default:
|
|
1132
|
+
the mapping keys). labels: ``"legend"`` (swatch key column right of the plot, default)
|
|
1133
|
+
or ``None``. axis: value axis with round ticks (default on); axis_label: its title.
|
|
1134
|
+
yscale: ``"linear"`` (from 0) or ``"log"`` (decades; values > 0). grid: light value
|
|
1135
|
+
gridlines. errors: same shape as ``data`` — per bar a number or ``(minus, plus)``.
|
|
1136
|
+
values: print each value above its bar. colors: per series (default: theme palette).
|
|
1137
|
+
``bar_port(category, series, side)`` → a port on one bar (indices or names).
|
|
1138
|
+
horizontal (v0.9.0): bars grow to the right, category names on the left, the value
|
|
1139
|
+
axis at the bottom. The value axis gets up to ~5 nice ticks when it is long enough.
|
|
1140
|
+
|
|
1141
|
+
v0.12.0: negative values grow down (left) from the zero line. ``baseline=1.0``
|
|
1142
|
+
draws bars from that value instead of 0 (speed-ups: 0.8× grows down, 1.7× up).
|
|
1143
|
+
``ref=1.0`` / ``ref=(1.0, "baseline")`` / a list of those: dashed reference lines.
|
|
1144
|
+
``bar_colors=`` per-bar fills: a list per category (a colour, or a list per series)
|
|
1145
|
+
or ``{category: colour}`` / ``{(category, series): colour}``. ``value_format="{:.1f}×"``
|
|
1146
|
+
(or a callable) formats ``values=True`` labels; ``value_pos="inside"`` puts them inside
|
|
1147
|
+
the bar end (falling back outside, with a CF-TXT-011 info, when they don't fit).
|
|
1148
|
+
``legend="inside-top-left"`` / ``"inside-top-right"`` draws the swatch key inside
|
|
1149
|
+
the plot instead of in a column to the right.
|
|
1150
|
+
|
|
1151
|
+
v0.13.0: ``min_value=`` / ``max_value=`` pin the value-axis floor / top (bars are
|
|
1152
|
+
clipped to that range), e.g. ``min_value=5.75, max_value=6.05`` for a zoomed axis.
|
|
1153
|
+
A ``ref=`` equal to ``baseline`` dashes the baseline itself (it used to stay solid).
|
|
1154
|
+
"""
|
|
1155
|
+
|
|
1156
|
+
bar_len = themed(70.0)
|
|
1157
|
+
bar_thick = themed(9.0)
|
|
1158
|
+
bar_gap = themed(10.0)
|
|
1159
|
+
|
|
1160
|
+
def __init__(self, data: Any, *, series: Optional[Sequence[str]] = None,
|
|
1161
|
+
categories: Optional[Sequence[str]] = None, labels: Optional[str] = "legend",
|
|
1162
|
+
axis: bool = True, axis_label: Optional[str] = None, yscale: str = "linear",
|
|
1163
|
+
grid: Any = False, errors: Any = None, values: bool = False,
|
|
1164
|
+
colors: Optional[Sequence[str]] = None, max_value: Optional[float] = None,
|
|
1165
|
+
length: Optional[float] = None, thickness: Optional[float] = None,
|
|
1166
|
+
gap: Optional[float] = None, font_size: Optional[float] = None,
|
|
1167
|
+
stroke: Optional[str] = None, horizontal: bool = False,
|
|
1168
|
+
baseline: float = 0.0, ref: Any = None, bar_colors: Any = None,
|
|
1169
|
+
value_format: Any = None, value_pos: str = "outside",
|
|
1170
|
+
legend: Optional[str] = None, min_value: Optional[float] = None,
|
|
1171
|
+
**kwargs: Any) -> None:
|
|
1172
|
+
super().__init__(**kwargs)
|
|
1173
|
+
self.min_value = None if min_value is None else float(min_value)
|
|
1174
|
+
if self.min_value is not None and max_value is not None and self.min_value >= max_value:
|
|
1175
|
+
raise ValueError("GroupedBarChart: min_value must be below max_value")
|
|
1176
|
+
self.horizontal = bool(horizontal)
|
|
1177
|
+
if labels not in ("legend", None):
|
|
1178
|
+
raise ValueError("labels must be 'legend' or None")
|
|
1179
|
+
if legend not in (None, "right", "inside-top-left", "inside-top-right"):
|
|
1180
|
+
raise ValueError("legend must be 'right', 'inside-top-left' or 'inside-top-right'")
|
|
1181
|
+
if value_pos not in ("outside", "inside"):
|
|
1182
|
+
raise ValueError("value_pos must be 'outside' or 'inside'")
|
|
1183
|
+
self.legend_pos = legend or "right"
|
|
1184
|
+
self.baseline = float(baseline)
|
|
1185
|
+
self.value_format, self.value_pos = value_format, value_pos
|
|
1186
|
+
self.refs = _ref_list(ref)
|
|
1187
|
+
if yscale not in ("linear", "log"):
|
|
1188
|
+
raise ValueError("yscale must be 'linear' or 'log'")
|
|
1189
|
+
names: List[str] = list(series or [])
|
|
1190
|
+
cats: List[str] = list(categories or [])
|
|
1191
|
+
rows: List[List[float]] = []
|
|
1192
|
+
if isinstance(data, Mapping):
|
|
1193
|
+
for c, d in data.items():
|
|
1194
|
+
if not categories:
|
|
1195
|
+
cats.append(str(c))
|
|
1196
|
+
if isinstance(d, Mapping):
|
|
1197
|
+
for k in d:
|
|
1198
|
+
if str(k) not in names:
|
|
1199
|
+
names.append(str(k))
|
|
1200
|
+
for d in data.values():
|
|
1201
|
+
rows.append([float(d.get(k, 0.0)) for k in names] if isinstance(d, Mapping)
|
|
1202
|
+
else [float(v) for v in d])
|
|
1203
|
+
else:
|
|
1204
|
+
rows = [[float(v) for v in r] for r in data]
|
|
1205
|
+
if not rows or not any(any(r) for r in rows):
|
|
1206
|
+
raise ValueError("GroupedBarChart needs at least one value")
|
|
1207
|
+
ns = max(len(r) for r in rows)
|
|
1208
|
+
self.rows = [r + [0.0] * (ns - len(r)) for r in rows]
|
|
1209
|
+
self.series = (names + [f"s{i}" for i in range(len(names), ns)])[:ns]
|
|
1210
|
+
self.categories = cats
|
|
1211
|
+
if yscale == "log" and min(v for r in self.rows for v in r) <= 0:
|
|
1212
|
+
raise ValueError("yscale='log' needs values > 0")
|
|
1213
|
+
if yscale == "log" and self.baseline <= 0:
|
|
1214
|
+
self.baseline = 0.0 # log bars grow from the axis floor
|
|
1215
|
+
self.labels, self.axis, self.axis_label = labels, bool(axis), axis_label
|
|
1216
|
+
self.yscale, self.grid, self.values = yscale, grid, bool(values)
|
|
1217
|
+
_grid_axes(grid)
|
|
1218
|
+
self.errors: List[List[Tuple[float, float]]] = []
|
|
1219
|
+
if errors is not None:
|
|
1220
|
+
if isinstance(errors, Mapping):
|
|
1221
|
+
errs = [[(e.get(k) if isinstance(e, Mapping) else e) for k in self.series]
|
|
1222
|
+
if isinstance(e, Mapping) else list(e) for e in errors.values()]
|
|
1223
|
+
else:
|
|
1224
|
+
errs = [list(e) for e in errors]
|
|
1225
|
+
self.errors = [_err_pairs(e, ns) for e in errs] + \
|
|
1226
|
+
[[(0.0, 0.0)] * ns] * max(0, len(self.rows) - len(errs))
|
|
1227
|
+
pal = list(colors or self.theme.palette)
|
|
1228
|
+
self.colors = [pal[i % len(pal)] for i in range(ns)]
|
|
1229
|
+
self.bar_fills = _bar_fills(bar_colors, self.rows, self.categories, self.series,
|
|
1230
|
+
self.colors)
|
|
1231
|
+
self.max_value = max_value
|
|
1232
|
+
self.bar_len, self.bar_thick, self.bar_gap = length, thickness, gap
|
|
1233
|
+
self.font_size, self.stroke = font_size, stroke
|
|
1234
|
+
|
|
1235
|
+
# -- geometry -------------------------------------------------------------------
|
|
1236
|
+
def _scale(self) -> Scale:
|
|
1237
|
+
his = [v + (self.errors[c][s][1] if self.errors else 0.0)
|
|
1238
|
+
for c, r in enumerate(self.rows) for s, v in enumerate(r)]
|
|
1239
|
+
los = [v - (self.errors[c][s][0] if self.errors else 0.0)
|
|
1240
|
+
for c, r in enumerate(self.rows) for s, v in enumerate(r)]
|
|
1241
|
+
refs = [r for r, _l in self.refs]
|
|
1242
|
+
if self.yscale == "log":
|
|
1243
|
+
lo = min(x for x in los + [min(min(r) for r in self.rows)] + refs if x > 0)
|
|
1244
|
+
if self.baseline > 0:
|
|
1245
|
+
lo = min(lo, self.baseline)
|
|
1246
|
+
else:
|
|
1247
|
+
lo = min([self.baseline] + los + refs)
|
|
1248
|
+
if self.baseline == 0:
|
|
1249
|
+
lo = min(lo, 0.0)
|
|
1250
|
+
hi = float(self.max_value) if self.max_value is not None else max(his + refs + [self.baseline])
|
|
1251
|
+
if self.min_value is not None: # v0.13.0: explicit axis floor
|
|
1252
|
+
lo = self.min_value
|
|
1253
|
+
if self.yscale == "log" and lo <= 0:
|
|
1254
|
+
raise ValueError("min_value must be > 0 with yscale='log'")
|
|
1255
|
+
hi = max(hi, lo + 1e-9)
|
|
1256
|
+
if self.horizontal:
|
|
1257
|
+
n = _tick_n(float(self.bar_len), self._fs(), False, self._tw(_fmt_si(hi))[0])
|
|
1258
|
+
else:
|
|
1259
|
+
n = _tick_n(float(self.bar_len), self._fs())
|
|
1260
|
+
return Scale(lo, hi, self.yscale, nice=True, n=n, fixed_hi=self.max_value is not None,
|
|
1261
|
+
fixed_lo=self.min_value is not None)
|
|
1262
|
+
|
|
1263
|
+
def _inner(self) -> float:
|
|
1264
|
+
"""Space between bars of one group; widened so ``values=True`` labels never touch."""
|
|
1265
|
+
g = self.theme.L(1.0)
|
|
1266
|
+
if self.values and self.horizontal: # value labels beside bars: one text line each
|
|
1267
|
+
g = max(g, self._fs() * 1.05 - float(self.bar_thick))
|
|
1268
|
+
if self.values and not self.horizontal:
|
|
1269
|
+
sc = self._scale()
|
|
1270
|
+
vw = max(self._tw(t)[0] for r in self._value_texts() for t in r)
|
|
1271
|
+
g = max(g, vw - float(self.bar_thick) + self.theme.L(2.0))
|
|
1272
|
+
return g
|
|
1273
|
+
|
|
1274
|
+
def _group_w(self) -> float:
|
|
1275
|
+
n = len(self.series)
|
|
1276
|
+
return n * float(self.bar_thick) + (n - 1) * self._inner()
|
|
1277
|
+
|
|
1278
|
+
def _gap(self) -> float:
|
|
1279
|
+
g = float(self.bar_gap)
|
|
1280
|
+
if self.horizontal: # category names stacked on the left: one text line per group
|
|
1281
|
+
if self.categories:
|
|
1282
|
+
g = max(g, self._fs() * 1.25 - self._group_w() + self.theme.L(2.0))
|
|
1283
|
+
return g
|
|
1284
|
+
if self.categories:
|
|
1285
|
+
cw = max(self._tw(c)[0] for c in self.categories)
|
|
1286
|
+
g = max(g, cw - self._group_w() + self.theme.L(4.0))
|
|
1287
|
+
return g
|
|
1288
|
+
|
|
1289
|
+
def _lead(self) -> float:
|
|
1290
|
+
"""Inset of the first group so its category name / value labels clear the axis."""
|
|
1291
|
+
lead = self.theme.L(2.0)
|
|
1292
|
+
if self.horizontal:
|
|
1293
|
+
return lead
|
|
1294
|
+
if self.categories:
|
|
1295
|
+
lead = max(lead, self._tw(self.categories[0])[0] / 2 - self._group_w() / 2
|
|
1296
|
+
+ self.theme.L(1.0))
|
|
1297
|
+
if self.values:
|
|
1298
|
+
vw = self._tw(self._value_texts()[0][0])[0]
|
|
1299
|
+
lead = max(lead, (vw - float(self.bar_thick)) / 2 + self.theme.L(2.0))
|
|
1300
|
+
return lead
|
|
1301
|
+
|
|
1302
|
+
def _legend_items(self) -> List[Tuple[str, str, float]]:
|
|
1303
|
+
sw = self._fs()
|
|
1304
|
+
return [(s, c, sw + self.theme.L(2.0) + self._tw(s)[0]) for s, c in zip(self.series, self.colors)]
|
|
1305
|
+
|
|
1306
|
+
def _margins(self) -> Dict[str, float]:
|
|
1307
|
+
fs = self._fs()
|
|
1308
|
+
gap = self.theme.L(4.0)
|
|
1309
|
+
sc = self._scale()
|
|
1310
|
+
m = {"l": 0.0, "r": 0.0, "t": fs * 0.6, "b": 0.0}
|
|
1311
|
+
if self.horizontal:
|
|
1312
|
+
m["t"] = self.theme.L(2.0) + (fs * 1.3 if any(l for _r, l in self.refs) else 0.0)
|
|
1313
|
+
if self.categories:
|
|
1314
|
+
m["l"] = max(self._tw(c)[0] for c in self.categories) + gap * 0.75
|
|
1315
|
+
m["l"] += self._neg_room_h()
|
|
1316
|
+
if self.axis:
|
|
1317
|
+
m["b"] = fs * 1.25 + gap * 0.5 + (fs * 1.25 if self.axis_label else 0.0)
|
|
1318
|
+
m["r"] = self._tw(sc.fmt(sc.ticks()[-1]))[0] / 2
|
|
1319
|
+
if self.values:
|
|
1320
|
+
# how far the furthest value label (past its bar / whisker) runs beyond the
|
|
1321
|
+
# plot end — bars that stop short of the axis end need no extra room
|
|
1322
|
+
txt = self._value_texts()
|
|
1323
|
+
over = max([(sc.norm(v + (self.errors[c][s][1] if self.errors else 0.0)) - 1.0)
|
|
1324
|
+
* float(self.bar_len) + self.theme.L(3.0) + self._tw(txt[c][s])[0]
|
|
1325
|
+
for c, r in enumerate(self.rows) for s, v in enumerate(r)
|
|
1326
|
+
if not self._down(c, s) and self.value_pos != "inside"] + [0.0])
|
|
1327
|
+
m["r"] = max(m["r"], over)
|
|
1328
|
+
if self.labels == "legend" and not self.legend_pos.startswith("inside"):
|
|
1329
|
+
m["r"] += gap * 1.5 + max(it[2] for it in self._legend_items())
|
|
1330
|
+
return m
|
|
1331
|
+
if self.categories:
|
|
1332
|
+
m["b"] = fs * 1.25 + gap * 0.5
|
|
1333
|
+
m["b"] += self._neg_room()
|
|
1334
|
+
if self.axis:
|
|
1335
|
+
m["l"] = max(self._tw(sc.fmt(t))[0] for t in sc.ticks()) + gap + self.theme.L(1.0) + \
|
|
1336
|
+
(fs * 1.25 + gap * 0.5 if self.axis_label else 0.0)
|
|
1337
|
+
if self.values:
|
|
1338
|
+
m["t"] = max(m["t"], fs * 1.3)
|
|
1339
|
+
m["r"] = self._ref_label_w()
|
|
1340
|
+
if self.labels == "legend" and not self.legend_pos.startswith("inside"):
|
|
1341
|
+
m["r"] += gap * 1.5 + max(it[2] for it in self._legend_items())
|
|
1342
|
+
return m
|
|
1343
|
+
|
|
1344
|
+
def _plot_size(self) -> Tuple[float, float]:
|
|
1345
|
+
n = len(self.rows)
|
|
1346
|
+
across = self._lead() * 2 + n * self._group_w() + (n - 1) * self._gap()
|
|
1347
|
+
if self.horizontal:
|
|
1348
|
+
return float(self.bar_len), across
|
|
1349
|
+
return across, float(self.bar_len)
|
|
1350
|
+
|
|
1351
|
+
def _measure(self) -> Tuple[float, float]:
|
|
1352
|
+
pw, ph = self._plot_size()
|
|
1353
|
+
m = self._margins()
|
|
1354
|
+
h = ph + m["t"] + m["b"]
|
|
1355
|
+
if self.labels == "legend" and not self.legend_pos.startswith("inside"):
|
|
1356
|
+
h = max(h, self._fs() * 1.25 * len(self.series) + m["t"])
|
|
1357
|
+
return pw + m["l"] + m["r"], h
|
|
1358
|
+
|
|
1359
|
+
def _origin(self) -> Tuple[float, float]:
|
|
1360
|
+
g = self.geom
|
|
1361
|
+
m = self._margins()
|
|
1362
|
+
mw, mh = self._measure()
|
|
1363
|
+
pw, ph = self._plot_size()
|
|
1364
|
+
body = ph + m["t"] + m["b"]
|
|
1365
|
+
return g.x + (g.w - mw) / 2 + m["l"], g.y + (g.h - mh) / 2 + (mh - body) / 2 + m["t"]
|
|
1366
|
+
|
|
1367
|
+
def _idx(self, cat: Union[int, str], ser: Union[int, str]) -> Tuple[int, int]:
|
|
1368
|
+
c = self.categories.index(cat) if isinstance(cat, str) else int(cat)
|
|
1369
|
+
s = self.series.index(ser) if isinstance(ser, str) else int(ser)
|
|
1370
|
+
if not (0 <= c < len(self.rows) and 0 <= s < len(self.series)):
|
|
1371
|
+
raise IndexError("bar out of range")
|
|
1372
|
+
return c, s
|
|
1373
|
+
|
|
1374
|
+
def bar_rect(self, cat: Union[int, str], ser: Union[int, str]) -> Tuple[float, float, float, float]:
|
|
1375
|
+
"""(x, y, w, h) of one bar (layout coordinates)."""
|
|
1376
|
+
c, s = self._idx(cat, ser)
|
|
1377
|
+
px, py = self._origin()
|
|
1378
|
+
sc = self._scale()
|
|
1379
|
+
L = float(self.bar_len)
|
|
1380
|
+
b = self._base_n(sc)
|
|
1381
|
+
v = min(1.0, max(0.0, sc.norm(self.rows[c][s]))) # clipped to min_value / max_value
|
|
1382
|
+
if self.horizontal:
|
|
1383
|
+
y = py + self._lead() + c * (self._group_w() + self._gap()) + \
|
|
1384
|
+
s * (float(self.bar_thick) + self._inner())
|
|
1385
|
+
return px + min(b, v) * L, y, abs(v - b) * L, float(self.bar_thick)
|
|
1386
|
+
x = px + self._lead() + c * (self._group_w() + self._gap()) + s * (float(self.bar_thick) + self._inner())
|
|
1387
|
+
return x, py + L - max(b, v) * L, float(self.bar_thick), abs(v - b) * L
|
|
1388
|
+
|
|
1389
|
+
def _base_n(self, sc: Scale) -> float:
|
|
1390
|
+
"""Normalised position of the line bars grow from (0 / ``baseline`` / axis floor)."""
|
|
1391
|
+
if self.yscale == "log" and self.baseline <= 0:
|
|
1392
|
+
return 0.0
|
|
1393
|
+
return min(1.0, max(0.0, sc.norm(self.baseline)))
|
|
1394
|
+
|
|
1395
|
+
def _base_dash(self, sc: Scale) -> Optional[str]:
|
|
1396
|
+
"""v0.13.0: ``ref=`` on the baseline value dashes the baseline itself."""
|
|
1397
|
+
bn = self._base_n(sc)
|
|
1398
|
+
return "3,2" if any(abs(sc.norm(r) - bn) < 1e-6 for r, _l in self.refs) else None
|
|
1399
|
+
|
|
1400
|
+
def _down(self, c: int, s: int) -> bool:
|
|
1401
|
+
return self.rows[c][s] < self.baseline and not (self.yscale == "log" and self.baseline <= 0)
|
|
1402
|
+
|
|
1403
|
+
def bar_port(self, cat: Union[int, str], ser: Union[int, str], side: str = "top"):
|
|
1404
|
+
"""Port on one bar (``side`` left | right | top | bottom)."""
|
|
1405
|
+
from .edges import Port
|
|
1406
|
+
|
|
1407
|
+
c, s = self._idx(cat, ser)
|
|
1408
|
+
return Port(self, kind="custom", side=side, index=c * 1000 + s)
|
|
1409
|
+
|
|
1410
|
+
def value_coord(self, v: float) -> float:
|
|
1411
|
+
"""Layout coordinate of value ``v`` on the value axis (y; x when ``horizontal``)."""
|
|
1412
|
+
px, py = self._origin()
|
|
1413
|
+
L = float(self.bar_len)
|
|
1414
|
+
n = self._scale().norm(float(v))
|
|
1415
|
+
return px + n * L if self.horizontal else py + L - n * L
|
|
1416
|
+
|
|
1417
|
+
@property
|
|
1418
|
+
def baseline_y(self) -> float:
|
|
1419
|
+
"""Layout y of the line bars grow from (x when ``horizontal``); see :meth:`value_coord`."""
|
|
1420
|
+
sc = self._scale()
|
|
1421
|
+
px, py = self._origin()
|
|
1422
|
+
L = float(self.bar_len)
|
|
1423
|
+
b = self._base_n(sc)
|
|
1424
|
+
return px + b * L if self.horizontal else py + L - b * L
|
|
1425
|
+
|
|
1426
|
+
def value_anchor(self, v: float, side: str = "right", at: Optional[float] = None):
|
|
1427
|
+
"""Port at value ``v`` on the value axis, for edges / notes pointing at a level.
|
|
1428
|
+
|
|
1429
|
+
``side`` is where on the plot the port sits: ``left`` | ``right`` (plot edges at the
|
|
1430
|
+
height of ``v``; vertical charts) or ``top`` | ``bottom`` (horizontal charts).
|
|
1431
|
+
``at`` (0–1) slides it across the plot instead, e.g. ``at=0.5`` for the plot middle.
|
|
1432
|
+
"""
|
|
1433
|
+
from .edges import Port
|
|
1434
|
+
|
|
1435
|
+
self.__dict__.setdefault("_vanchors", []).append((float(v), at))
|
|
1436
|
+
return Port(self, kind="custom", side=side, index=-len(self._vanchors))
|
|
1437
|
+
|
|
1438
|
+
def resolve_custom_port(self, side: str, idx: int = 0, at: float = 0.5):
|
|
1439
|
+
if idx < 0: # value_anchor
|
|
1440
|
+
v, a2 = self._vanchors[-1 - idx]
|
|
1441
|
+
px, py = self._origin()
|
|
1442
|
+
pw, ph = self._plot_size()
|
|
1443
|
+
q = self.value_coord(v)
|
|
1444
|
+
if self.horizontal:
|
|
1445
|
+
f = a2 if a2 is not None else 0.0
|
|
1446
|
+
return (q, py + f * ph, "up") if side == "top" else \
|
|
1447
|
+
(q, py + ph - (a2 if a2 is not None else 0.0) * ph, "down")
|
|
1448
|
+
if a2 is not None:
|
|
1449
|
+
return px + a2 * pw, q, side if side in ("left", "right") else "right"
|
|
1450
|
+
return (px, q, "left") if side == "left" else (px + pw, q, "right")
|
|
1451
|
+
x, y, w, h = self.bar_rect(idx // 1000, idx % 1000)
|
|
1452
|
+
return {"left": (x, y + h * at, "left"), "right": (x + w, y + h * at, "right"),
|
|
1453
|
+
"top": (x + w * at, y, "up"), "bottom": (x + w * at, y + h, "down")}.get(
|
|
1454
|
+
side, (x + w / 2, y, "up"))
|
|
1455
|
+
|
|
1456
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
1457
|
+
from .render_svg import draw_line, draw_rect
|
|
1458
|
+
|
|
1459
|
+
th = self.theme
|
|
1460
|
+
fs = self._fs()
|
|
1461
|
+
px, py = self._origin()
|
|
1462
|
+
px, py = px + ox, py + oy
|
|
1463
|
+
pw, ph = self._plot_size()
|
|
1464
|
+
gap = th.L(4.0)
|
|
1465
|
+
stroke = self.stroke or th.stroke
|
|
1466
|
+
sw = th.stroke_width
|
|
1467
|
+
sc = self._scale()
|
|
1468
|
+
if self.horizontal:
|
|
1469
|
+
self._draw_h(buf, ox, oy, px, py, pw, ph, sc, stroke, sw, fs, gap)
|
|
1470
|
+
return
|
|
1471
|
+
vals = self._value_spots(ox, oy, px, py, pw, ph, sc) # (text, x, cy, anchor, colour)
|
|
1472
|
+
holes = [(cx - self._tw(t)[0] / 2, cy - self._tw(t)[1] / 2, self._tw(t)[0], self._tw(t)[1])
|
|
1473
|
+
for t, cx, cy, _a, _c in vals]
|
|
1474
|
+
holes += self._legend_holes(px, py, pw, ph)
|
|
1475
|
+
if any(_grid_axes(self.grid)):
|
|
1476
|
+
for t in sc.ticks():
|
|
1477
|
+
y = py + ph - sc.norm(t) * ph
|
|
1478
|
+
if abs(y - (py + ph)) > 0.5:
|
|
1479
|
+
_gapped_line(buf, px, y, px + pw, y, holes, stroke=th.grid_color,
|
|
1480
|
+
stroke_width=sw)
|
|
1481
|
+
self._draw_refs(buf, px, py, pw, ph, sc, holes, ox, oy)
|
|
1482
|
+
for c, row in enumerate(self.rows):
|
|
1483
|
+
for s, v in enumerate(row):
|
|
1484
|
+
x, y, w, h = self.bar_rect(c, s)
|
|
1485
|
+
if h > 0.05:
|
|
1486
|
+
draw_rect(buf, x + ox, y + oy, w, h, fill=self.bar_fills[c][s], stroke=stroke,
|
|
1487
|
+
stroke_width=sw)
|
|
1488
|
+
if self.errors:
|
|
1489
|
+
lo, hi = self.errors[c][s]
|
|
1490
|
+
if lo > 0 or hi > 0:
|
|
1491
|
+
lo_v = v - lo if (self.yscale != "log" or v - lo > 0) else sc.lo
|
|
1492
|
+
_draw_errbar(buf, x + ox + w / 2, py + ph - sc.norm(lo_v) * ph,
|
|
1493
|
+
py + ph - sc.norm(v + hi) * ph, self._cap(w), stroke, sw)
|
|
1494
|
+
for t, cx, cy, a, col in vals:
|
|
1495
|
+
self._text(buf, t, cx, cy, anchor=a, color=col)
|
|
1496
|
+
cat_y = py + ph + gap * 0.5 + fs * 0.6 + self._neg_room()
|
|
1497
|
+
for c, name in enumerate(self.categories[: len(self.rows)]):
|
|
1498
|
+
x0 = self.bar_rect(c, 0)[0]
|
|
1499
|
+
self._text(buf, name, x0 + ox + self._group_w() / 2, cat_y, anchor="middle")
|
|
1500
|
+
# baseline (zero / baseline= value) + value axis
|
|
1501
|
+
yb = py + ph - self._base_n(sc) * ph
|
|
1502
|
+
draw_line(buf, px, yb, px + pw, yb, stroke=stroke, stroke_width=sw,
|
|
1503
|
+
dasharray=self._base_dash(sc))
|
|
1504
|
+
if self.axis:
|
|
1505
|
+
tl = th.L(3.0)
|
|
1506
|
+
ax = px
|
|
1507
|
+
draw_line(buf, ax, py, ax, py + ph, stroke=stroke, stroke_width=sw)
|
|
1508
|
+
for t in sc.ticks():
|
|
1509
|
+
y = py + ph - sc.norm(t) * ph
|
|
1510
|
+
draw_line(buf, ax - tl, y, ax, y, stroke=stroke, stroke_width=sw)
|
|
1511
|
+
self._text(buf, sc.fmt(t), ax - tl - th.L(2.0), y, anchor="end")
|
|
1512
|
+
if self.axis_label:
|
|
1513
|
+
tw = max(self._tw(sc.fmt(t))[0] for t in sc.ticks())
|
|
1514
|
+
cx = ax - tl - th.L(2.0) - tw - gap * 0.5 - fs * 0.5
|
|
1515
|
+
_rot_text(buf, th, self.axis_label, cx, py + ph / 2, fs)
|
|
1516
|
+
if self.labels == "legend":
|
|
1517
|
+
if self.legend_pos.startswith("inside"):
|
|
1518
|
+
lx, ly, _w, _h = self._legend_box(px, py, pw, ph)
|
|
1519
|
+
self._draw_legend(buf, lx, ly + _h / 2, stroke, sw)
|
|
1520
|
+
else:
|
|
1521
|
+
self._draw_legend(buf, px + pw + self._ref_label_w() + gap * 1.5, py + ph / 2,
|
|
1522
|
+
stroke, sw)
|
|
1523
|
+
|
|
1524
|
+
# -- v0.12.0 helpers --------------------------------------------------------------
|
|
1525
|
+
def _neg_room(self) -> float:
|
|
1526
|
+
"""Extra room under the plot for value labels of bars that grow down."""
|
|
1527
|
+
if self.horizontal or not self.values or self.value_pos == "inside":
|
|
1528
|
+
return 0.0
|
|
1529
|
+
sc = self._scale()
|
|
1530
|
+
ph = float(self.bar_len)
|
|
1531
|
+
need = 0.0
|
|
1532
|
+
for c, row in enumerate(self.rows):
|
|
1533
|
+
for s, v in enumerate(row):
|
|
1534
|
+
if self._down(c, s):
|
|
1535
|
+
lo = v - (self.errors[c][s][0] if self.errors else 0.0)
|
|
1536
|
+
below = sc.norm(lo) * ph # distance of the bar end above the plot floor
|
|
1537
|
+
need = max(need, self._fs() * 1.25 - below)
|
|
1538
|
+
return max(0.0, need)
|
|
1539
|
+
|
|
1540
|
+
def _legend_box(self, px: float, py: float, pw: float, ph: float) -> Tuple[float, float, float, float]:
|
|
1541
|
+
items = self._legend_items()
|
|
1542
|
+
lh = self._fs() * 1.25
|
|
1543
|
+
w = max(it[2] for it in items)
|
|
1544
|
+
h = lh * len(items)
|
|
1545
|
+
pad = self.theme.L(3.0)
|
|
1546
|
+
left = px + pad + self.theme.L(2.0)
|
|
1547
|
+
right = px + pw - pad - w
|
|
1548
|
+
y = py + pad * 0.5
|
|
1549
|
+
first, second = (left, right) if self.legend_pos == "inside-top-left" else (right, left)
|
|
1550
|
+
if self._legend_clear(first, y, w, h, px, py):
|
|
1551
|
+
return first, y, w, h
|
|
1552
|
+
if self._legend_clear(second, y, w, h, px, py):
|
|
1553
|
+
return second, y, w, h
|
|
1554
|
+
from .notices import note
|
|
1555
|
+
|
|
1556
|
+
note(self, "legend", "inside the plot overlaps bars at both top corners; raise "
|
|
1557
|
+
"max_value= or use legend='right'", level="warn")
|
|
1558
|
+
return first, y, w, h
|
|
1559
|
+
|
|
1560
|
+
def _legend_clear(self, x: float, y: float, w: float, h: float, px: float, py: float) -> bool:
|
|
1561
|
+
"""True when a legend box at (x, y) clears every bar and value label."""
|
|
1562
|
+
dx, dy = px - self._origin()[0], py - self._origin()[1]
|
|
1563
|
+
p = self.theme.L(1.5)
|
|
1564
|
+
fs = self._fs()
|
|
1565
|
+
txt = self._value_texts()
|
|
1566
|
+
boxes = []
|
|
1567
|
+
for c, row in enumerate(self.rows):
|
|
1568
|
+
for s_ in range(len(row)):
|
|
1569
|
+
bx, by, bw, bh = self.bar_rect(c, s_)
|
|
1570
|
+
bx, by = bx + dx, by + dy
|
|
1571
|
+
if self.values and not self.horizontal and self.value_pos != "inside":
|
|
1572
|
+
by, bh = by - fs * 1.25, bh + fs * 1.25
|
|
1573
|
+
tw = self._tw(txt[c][s_])[0] # v0.12.0: the label may be wider than the bar
|
|
1574
|
+
if tw > bw:
|
|
1575
|
+
bx, bw = bx + bw / 2 - tw / 2, tw
|
|
1576
|
+
if self.values and self.horizontal and self.value_pos != "inside":
|
|
1577
|
+
bw += self._tw(txt[c][s_])[0] + self.theme.L(3.0)
|
|
1578
|
+
boxes.append((bx, by, bw, bh))
|
|
1579
|
+
return not any(bx < x + w + p and x - p < bx + bw and by < y + h + p and y - p < by + bh
|
|
1580
|
+
for bx, by, bw, bh in boxes)
|
|
1581
|
+
|
|
1582
|
+
def _legend_holes(self, px: float, py: float, pw: float, ph: float) -> List[Tuple[float, float, float, float]]:
|
|
1583
|
+
if self.labels != "legend" or not self.legend_pos.startswith("inside"):
|
|
1584
|
+
return []
|
|
1585
|
+
x, y, w, h = self._legend_box(px, py, pw, ph)
|
|
1586
|
+
p = self.theme.L(1.5)
|
|
1587
|
+
return [(x - p, y - p, w + 2 * p, h + 2 * p)]
|
|
1588
|
+
|
|
1589
|
+
def _ref_label_w(self) -> float:
|
|
1590
|
+
labs = [l for _r, l in self.refs if l]
|
|
1591
|
+
return max(self._tw(l)[0] for l in labs) + self.theme.L(3.0) if labs else 0.0
|
|
1592
|
+
|
|
1593
|
+
def _draw_refs(self, buf: List[str], px: float, py: float, pw: float, ph: float, sc: Scale,
|
|
1594
|
+
holes: List[Tuple[float, float, float, float]], ox: float = 0.0,
|
|
1595
|
+
oy: float = 0.0) -> None:
|
|
1596
|
+
"""Dashed reference lines across the plot, each label just outside the plot end
|
|
1597
|
+
(right of a horizontal line; above a vertical one) so bars never cover it."""
|
|
1598
|
+
th = self.theme
|
|
1599
|
+
fs = self._fs()
|
|
1600
|
+
bn = self._base_n(sc)
|
|
1601
|
+
for r, lab_ in self.refs:
|
|
1602
|
+
n = sc.norm(r)
|
|
1603
|
+
on_base = abs(n - bn) < 1e-6 # the baseline itself is drawn dashed (_base_dash)
|
|
1604
|
+
if self.horizontal:
|
|
1605
|
+
x = px + n * pw
|
|
1606
|
+
if lab_:
|
|
1607
|
+
self._text(buf, lab_, x, py - fs * 0.75, anchor="middle", color=th.muted_color)
|
|
1608
|
+
if not on_base:
|
|
1609
|
+
_gapped_line(buf, x, py, x, py + ph, holes, stroke=th.muted_color,
|
|
1610
|
+
stroke_width=th.stroke_width, dasharray="3,2")
|
|
1611
|
+
else:
|
|
1612
|
+
y = py + ph - n * ph
|
|
1613
|
+
if lab_:
|
|
1614
|
+
self._text(buf, lab_, px + pw + th.L(2.0), y, color=th.muted_color)
|
|
1615
|
+
if not on_base:
|
|
1616
|
+
_gapped_line(buf, px, y, px + pw, y, holes, stroke=th.muted_color,
|
|
1617
|
+
stroke_width=th.stroke_width, dasharray="3,2")
|
|
1618
|
+
|
|
1619
|
+
def _value_spots(self, ox: float, oy: float, px: float, py: float, pw: float, ph: float,
|
|
1620
|
+
sc: Scale) -> List[Tuple[str, float, float, str, Optional[str]]]:
|
|
1621
|
+
"""Where each ``values=True`` label goes: outside the bar end (past its whisker),
|
|
1622
|
+
or inside it with ``value_pos="inside"`` when it fits (else outside + a notice)."""
|
|
1623
|
+
from .notices import note
|
|
1624
|
+
from .primitives import _luma
|
|
1625
|
+
|
|
1626
|
+
if not self.values:
|
|
1627
|
+
return []
|
|
1628
|
+
th = self.theme
|
|
1629
|
+
fs = self._fs()
|
|
1630
|
+
txt = self._value_texts()
|
|
1631
|
+
out = []
|
|
1632
|
+
for c, row in enumerate(self.rows):
|
|
1633
|
+
for s, v in enumerate(row):
|
|
1634
|
+
x, y, w, h = self.bar_rect(c, s)
|
|
1635
|
+
x, y = x + ox, y + oy
|
|
1636
|
+
down = self._down(c, s)
|
|
1637
|
+
t = txt[c][s]
|
|
1638
|
+
tw = self._tw(t)[0]
|
|
1639
|
+
lo_e, hi_e = self.errors[c][s] if self.errors else (0.0, 0.0)
|
|
1640
|
+
if self.value_pos == "inside":
|
|
1641
|
+
fits = (tw + th.L(2.0) <= w and fs * 1.2 <= h) if not self.horizontal else \
|
|
1642
|
+
(tw + th.L(3.0) <= w and fs * 1.05 <= h)
|
|
1643
|
+
if fits:
|
|
1644
|
+
col = "#ffffff" if _luma(self.bar_fills[c][s]) < 0.5 else None
|
|
1645
|
+
if self.horizontal:
|
|
1646
|
+
if down:
|
|
1647
|
+
out.append((t, x + th.L(1.5), y + h / 2, "start", col))
|
|
1648
|
+
else:
|
|
1649
|
+
out.append((t, x + w - th.L(1.5), y + h / 2, "end", col))
|
|
1650
|
+
else:
|
|
1651
|
+
cy = (y + h - fs * 0.65) if down else (y + fs * 0.65)
|
|
1652
|
+
out.append((t, x + w / 2, cy, "middle", col))
|
|
1653
|
+
continue
|
|
1654
|
+
note(self, t, "did not fit inside its bar; drawn outside it", level="info")
|
|
1655
|
+
if self.horizontal:
|
|
1656
|
+
if down:
|
|
1657
|
+
end = px + sc.norm(v - lo_e) * pw if lo_e > 0 else x
|
|
1658
|
+
out.append((t, end - th.L(3.0), y + h / 2, "end", None))
|
|
1659
|
+
else:
|
|
1660
|
+
end = px + sc.norm(v + hi_e) * pw if hi_e > 0 else x + w
|
|
1661
|
+
out.append((t, end + th.L(3.0), y + h / 2, "start", None))
|
|
1662
|
+
else:
|
|
1663
|
+
if down:
|
|
1664
|
+
yy = py + ph - sc.norm(v - lo_e) * ph if lo_e > 0 else y + h
|
|
1665
|
+
out.append((t, x + w / 2, yy + fs * 0.6, "middle", None))
|
|
1666
|
+
else:
|
|
1667
|
+
yy = py + ph - sc.norm(v + hi_e) * ph if hi_e > 0 else y
|
|
1668
|
+
out.append((t, x + w / 2, yy - fs * 0.6, "middle", None))
|
|
1669
|
+
return out
|
|
1670
|
+
|
|
1671
|
+
def _value_texts(self) -> List[List[str]]:
|
|
1672
|
+
f = self.value_format
|
|
1673
|
+
if f is not None:
|
|
1674
|
+
fn = f if callable(f) else (lambda v, _f=str(f): _f.format(v))
|
|
1675
|
+
return [[str(fn(v)) for v in row] for row in self.rows]
|
|
1676
|
+
flat = _fmt_values([v for row in self.rows for v in row], self.yscale == "log")
|
|
1677
|
+
out, k = [], 0
|
|
1678
|
+
for row in self.rows:
|
|
1679
|
+
out.append(flat[k:k + len(row)])
|
|
1680
|
+
k += len(row)
|
|
1681
|
+
return out
|
|
1682
|
+
|
|
1683
|
+
def _cap(self, thick: float) -> float:
|
|
1684
|
+
return max(thick * 0.3, min(_err_cap(self.theme), thick * 0.45))
|
|
1685
|
+
|
|
1686
|
+
def _draw_legend(self, buf: List[str], x: float, cy: float, stroke: str, sw: float) -> None:
|
|
1687
|
+
from .render_svg import draw_rect
|
|
1688
|
+
|
|
1689
|
+
fs = self._fs()
|
|
1690
|
+
lh = fs * 1.25
|
|
1691
|
+
items = self._legend_items()
|
|
1692
|
+
y = cy - lh * (len(items) - 1) / 2
|
|
1693
|
+
s_ = fs * 0.9
|
|
1694
|
+
for name, col, _w in items:
|
|
1695
|
+
draw_rect(buf, x, y - s_ / 2, s_, s_, fill=col, stroke=stroke, stroke_width=sw)
|
|
1696
|
+
self._text(buf, name, x + s_ + self.theme.L(2.0), y)
|
|
1697
|
+
y += lh
|
|
1698
|
+
|
|
1699
|
+
def _draw_h(self, buf: List[str], ox: float, oy: float, px: float, py: float, pw: float,
|
|
1700
|
+
ph: float, sc: Scale, stroke: str, sw: float, fs: float, gap: float) -> None:
|
|
1701
|
+
"""horizontal=True: bars to the right, categories left, value axis below."""
|
|
1702
|
+
from .render_svg import draw_line, draw_rect
|
|
1703
|
+
|
|
1704
|
+
th = self.theme
|
|
1705
|
+
vals = self._value_spots(ox, oy, px, py, pw, ph, sc)
|
|
1706
|
+
holes = [((x if a == "start" else x - self._tw(t)[0] if a == "end" else x - self._tw(t)[0] / 2),
|
|
1707
|
+
cy - self._tw(t)[1] / 2, self._tw(t)[0], self._tw(t)[1])
|
|
1708
|
+
for t, x, cy, a, _c in vals]
|
|
1709
|
+
holes += self._legend_holes(px, py, pw, ph)
|
|
1710
|
+
if any(_grid_axes(self.grid, default="x")):
|
|
1711
|
+
for t in sc.ticks():
|
|
1712
|
+
x = px + sc.norm(t) * pw
|
|
1713
|
+
if abs(x - px) > 0.5:
|
|
1714
|
+
_gapped_line(buf, x, py, x, py + ph, holes, stroke=th.grid_color,
|
|
1715
|
+
stroke_width=sw)
|
|
1716
|
+
self._draw_refs(buf, px, py, pw, ph, sc, holes, ox, oy)
|
|
1717
|
+
for c, row in enumerate(self.rows):
|
|
1718
|
+
for s, v in enumerate(row):
|
|
1719
|
+
x, y, w, h = self.bar_rect(c, s)
|
|
1720
|
+
if w > 0.05:
|
|
1721
|
+
draw_rect(buf, x + ox, y + oy, w, h, fill=self.bar_fills[c][s], stroke=stroke,
|
|
1722
|
+
stroke_width=sw)
|
|
1723
|
+
if self.errors:
|
|
1724
|
+
lo, hi = self.errors[c][s]
|
|
1725
|
+
if lo > 0 or hi > 0:
|
|
1726
|
+
lo_v = v - lo if (self.yscale != "log" or v - lo > 0) else sc.lo
|
|
1727
|
+
_draw_errbar(buf, y + oy + h / 2, px + sc.norm(lo_v) * pw,
|
|
1728
|
+
px + sc.norm(v + hi) * pw, self._cap(h), stroke, sw,
|
|
1729
|
+
horizontal=True)
|
|
1730
|
+
for t, x, cy, a, col in vals:
|
|
1731
|
+
self._text(buf, t, x, cy, anchor=a, color=col)
|
|
1732
|
+
cat_x = px - gap * 0.75 - self._neg_room_h()
|
|
1733
|
+
for c, name in enumerate(self.categories[: len(self.rows)]):
|
|
1734
|
+
y0 = self.bar_rect(c, 0)[1]
|
|
1735
|
+
self._text(buf, name, cat_x, y0 + oy + self._group_w() / 2, anchor="end")
|
|
1736
|
+
xb = px + self._base_n(sc) * pw
|
|
1737
|
+
draw_line(buf, xb, py, xb, py + ph, stroke=stroke, stroke_width=sw,
|
|
1738
|
+
dasharray=self._base_dash(sc)) # baseline
|
|
1739
|
+
if self.axis:
|
|
1740
|
+
tl = th.L(3.0)
|
|
1741
|
+
ay = py + ph
|
|
1742
|
+
draw_line(buf, px, ay, px + pw, ay, stroke=stroke, stroke_width=sw)
|
|
1743
|
+
for t in sc.ticks():
|
|
1744
|
+
x = px + sc.norm(t) * pw
|
|
1745
|
+
draw_line(buf, x, ay, x, ay + tl, stroke=stroke, stroke_width=sw)
|
|
1746
|
+
self._text(buf, sc.fmt(t), x, ay + tl + fs * 0.7, anchor="middle")
|
|
1747
|
+
if self.axis_label:
|
|
1748
|
+
self._text(buf, self.axis_label, px + pw / 2, ay + tl + fs * 1.95, anchor="middle")
|
|
1749
|
+
if self.labels == "legend":
|
|
1750
|
+
if self.legend_pos.startswith("inside"):
|
|
1751
|
+
lx, ly, _w, _h = self._legend_box(px, py, pw, ph)
|
|
1752
|
+
self._draw_legend(buf, lx, ly + _h / 2, stroke, sw)
|
|
1753
|
+
return
|
|
1754
|
+
m = self._margins()
|
|
1755
|
+
xl = px + pw + m["r"] - (gap * 1.5 + max(it[2] for it in self._legend_items())) + gap * 1.5
|
|
1756
|
+
self._draw_legend(buf, xl, py + ph / 2, stroke, sw)
|
|
1757
|
+
|
|
1758
|
+
def _neg_room_h(self) -> float:
|
|
1759
|
+
"""horizontal: extra room left of the plot for labels of bars that grow left."""
|
|
1760
|
+
if not self.values or self.value_pos == "inside":
|
|
1761
|
+
return 0.0
|
|
1762
|
+
sc = self._scale()
|
|
1763
|
+
L = float(self.bar_len)
|
|
1764
|
+
txt = self._value_texts()
|
|
1765
|
+
need = 0.0
|
|
1766
|
+
for c, row in enumerate(self.rows):
|
|
1767
|
+
for s, v in enumerate(row):
|
|
1768
|
+
if self._down(c, s):
|
|
1769
|
+
lo = v - (self.errors[c][s][0] if self.errors else 0.0)
|
|
1770
|
+
need = max(need, self._tw(txt[c][s])[0] + self.theme.L(3.0) - sc.norm(lo) * L)
|
|
1771
|
+
# v0.12.0: a clear gap between a negative value label and the category names, so
|
|
1772
|
+
# "GSM8K −2.0" does not read as one phrase
|
|
1773
|
+
return need + self.theme.L(6.0) if need > 0 else 0.0
|
|
1774
|
+
|
|
1775
|
+
|
|
1776
|
+
class Sparkline(LineChart):
|
|
1777
|
+
"""Tiny axis-free trend line: ``Sparkline([3, 5, 4, 8, 7])`` (last point marked).
|
|
1778
|
+
|
|
1779
|
+
width / height default to a word-sized strip; ``color`` one colour; ``fill`` shades
|
|
1780
|
+
under the line; ``last=False`` hides the end dot.
|
|
1781
|
+
"""
|
|
1782
|
+
|
|
1783
|
+
plot_w = themed(40.0)
|
|
1784
|
+
plot_h = themed(12.0)
|
|
1785
|
+
|
|
1786
|
+
def __init__(self, values: Sequence[Number], *, color: Optional[str] = None, last: bool = True,
|
|
1787
|
+
fill: bool = False, width: Optional[float] = None, height: Optional[float] = None,
|
|
1788
|
+
**kwargs: Any) -> None:
|
|
1789
|
+
super().__init__({"": list(values)}, markers=False, axis=False, labels=None,
|
|
1790
|
+
colors=[color] if color else None, fill=fill, plot_width=width,
|
|
1791
|
+
height=height,
|
|
1792
|
+
ymin=min(values) if values else 0.0, **kwargs)
|
|
1793
|
+
self.last = bool(last)
|
|
1794
|
+
|
|
1795
|
+
def _margins(self) -> Dict[str, float]:
|
|
1796
|
+
r = self.theme.L(2.5)
|
|
1797
|
+
return {"l": r, "r": r, "t": r, "b": r}
|
|
1798
|
+
|
|
1799
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
1800
|
+
from .render_svg import draw_circle
|
|
1801
|
+
|
|
1802
|
+
super().draw(buf, ox, oy)
|
|
1803
|
+
if self.last:
|
|
1804
|
+
ys = list(self.series.values())[0]
|
|
1805
|
+
x, y = self._pt(self.x[len(ys) - 1], ys[-1])
|
|
1806
|
+
draw_circle(buf, x + ox, y + oy, self.theme.L(2.2), fill=self.colors[0])
|