"""Inline SVG charts, so the same markup renders in the browser and in WeasyPrint.""" import math from html import escape BLUE = "#2f6fd6" ORANGE = "#e8632b" INK = "#1d1d1f" MUTED = "#6b6b70" GRID = "#e4e4e8" def nice_ticks(lo: float, hi: float, count: int = 8) -> list[float]: if hi <= lo: hi = lo + 1 raw = (hi - lo) / count mag = 10 ** math.floor(math.log10(raw)) step = next(m * mag for m in (1, 2, 2.5, 5, 10) if m * mag >= raw) start = math.floor(lo / step) * step ticks, v = [], start while v <= hi + step * 0.001: ticks.append(round(v, 10)) v += step if ticks[-1] < hi: ticks.append(ticks[-1] + step) return ticks def _wrap(text: str, width: int = 30, lines: int = 2) -> list[str]: words, out, cur = text.split(), [], "" for w in words: if len(cur) + len(w) + (1 if cur else 0) <= width: cur = f"{cur} {w}".strip() else: out.append(cur) cur = w out.append(cur) out = [o for o in out if o] if len(out) > lines: out = out[:lines] out[-1] = out[-1][: width - 1].rstrip() + "…" return [o if len(o) <= width else o[: width - 1] + "…" for o in out] or [""] def grouped_bars(rows: list[tuple[str, float, float]], fmt_tick, fmt_value=None, width: int = 900, height: int = 490) -> str: """Horizontal bars, two per row (prior in blue, adjusted in orange), sized to fill width x height.""" if not rows: return "" label_w = 250 top, bottom = 8, 30 row_h = min(130.0, (height - top - bottom) / len(rows)) bar_h = min(row_h * 0.34, 36) plot_w = width - label_w - (80 if fmt_value else 24) height = round(top + bottom + row_h * len(rows)) hi = max(max(p, a) for _, p, a in rows) or 1 ticks = nice_ticks(0, hi, 8) x = lambda v: label_w + plot_w * v / ticks[-1] parts = [f''] for t in ticks: parts.append(f'') parts.append(f'{escape(fmt_tick(t))}') for i, (label, prior, adjusted) in enumerate(rows): y0 = top + i * row_h cy = y0 + row_h / 2 fs = 13 if row_h >= 22 else 11.5 wrapped = _wrap(label, 34, 2 if row_h >= 30 else 1) for j, chunk in enumerate(wrapped): dy = (j - (len(wrapped) - 1) / 2) * (fs + 2) + fs / 3 parts.append(f'{escape(chunk)}') for k, (value, colour) in enumerate(((prior, BLUE), (adjusted, ORANGE))): by = cy - bar_h - 1 + k * (bar_h + 2) w = max(x(max(value, 0)) - label_w, 0) parts.append(f'') if fmt_value and row_h >= 26: vfs = min(12, bar_h + 1) parts.append(f'{escape(fmt_value(value))}') parts.append(f'') parts.append("") return "".join(parts) def change_bars(rows: list[tuple[str, float]], fmt_value, width: int = 900, height: int = 520) -> str: """Ranked % change per category; increases orange, decreases blue.""" if not rows: return "" label_w, top, bottom = 270, 8, 30 row_h = min(60.0, (height - top - bottom) / len(rows)) bar = min(row_h * 0.6, 28) plot_w = width - label_w - 90 height = round(top + bottom + row_h * len(rows)) lo = min(0.0, min(v for _, v in rows)) hi = max(0.0, max(v for _, v in rows)) or 1 ticks = nice_ticks(lo, hi, 8) span = ticks[-1] - ticks[0] x = lambda v: label_w + plot_w * (v - ticks[0]) / span parts = [f''] for t in ticks: parts.append(f'') parts.append(f'{"+" if t > 0 else ""}{t:g}%') for i, (label, value) in enumerate(rows): cy = top + i * row_h + row_h / 2 x0, x1 = sorted((x(0), x(value))) colour = ORANGE if value > 0 else BLUE parts.append(f'{escape(_wrap(label, 38, 1)[0])}') parts.append(f'') tx, anchor = (x1 + 6, "start") if value >= 0 else (x0 - 6, "end") parts.append(f'{escape(fmt_value(value))}') parts.append(f'') parts.append("") return "".join(parts)