111 lines
5.4 KiB
Python
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)
|