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/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])