Files

111 lines
5.4 KiB
Python

"""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'<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" class="chart">']
for t in ticks:
parts.append(f'<line x1="{x(t):.1f}" x2="{x(t):.1f}" y1="{top}" y2="{height - bottom}" stroke="{GRID}"/>')
parts.append(f'<text x="{x(t):.1f}" y="{height - bottom + 18}" font-size="12" text-anchor="middle" fill="{MUTED}">{escape(fmt_tick(t))}</text>')
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'<text x="{label_w - 10}" y="{cy + dy:.1f}" font-size="{fs}" text-anchor="end" fill="{INK}">{escape(chunk)}</text>')
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'<rect x="{label_w}" y="{by:.1f}" width="{w:.1f}" height="{bar_h:.1f}" fill="{colour}"/>')
if fmt_value and row_h >= 26:
vfs = min(12, bar_h + 1)
parts.append(f'<text x="{label_w + w + 5:.1f}" y="{by + bar_h / 2 + vfs / 3:.1f}" font-size="{vfs:.1f}" fill="{MUTED}">{escape(fmt_value(value))}</text>')
parts.append(f'<line x1="{label_w}" x2="{label_w}" y1="{top}" y2="{height - bottom}" stroke="{MUTED}"/>')
parts.append("</svg>")
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'<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" class="chart">']
for t in ticks:
parts.append(f'<line x1="{x(t):.1f}" x2="{x(t):.1f}" y1="{top}" y2="{height - bottom}" stroke="{GRID}"/>')
parts.append(f'<text x="{x(t):.1f}" y="{height - bottom + 18}" font-size="12" text-anchor="middle" fill="{MUTED}">{"+" if t > 0 else ""}{t:g}%</text>')
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'<text x="{label_w - 10}" y="{cy + 4.5:.1f}" font-size="13" text-anchor="end" fill="{INK}">{escape(_wrap(label, 38, 1)[0])}</text>')
parts.append(f'<rect x="{x0:.1f}" y="{cy - bar / 2:.1f}" width="{max(x1 - x0, 1):.1f}" height="{bar:.1f}" fill="{colour}"/>')
tx, anchor = (x1 + 6, "start") if value >= 0 else (x0 - 6, "end")
parts.append(f'<text x="{tx:.1f}" y="{cy + 4.5:.1f}" font-size="12.5" font-weight="bold" text-anchor="{anchor}" fill="{INK}">{escape(fmt_value(value))}</text>')
parts.append(f'<line x1="{x(0):.1f}" x2="{x(0):.1f}" y1="{top}" y2="{height - bottom}" stroke="{MUTED}"/>')
parts.append("</svg>")
return "".join(parts)