Flatten budget-app contents into repo root
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user