# SPDX-License-Identifier: MIT """Tiny inline-SVG chart writer for Engineering Notes. Emits markup that notes.css styles and notes.js makes interactive: thin 2 px lines, 8 px markers with a surface ring, solid hairline grid, one y-axis, selective end labels, per-column hit targets with tooltips, and a table-view twin for every chart (see `table`). `inject(page, name, markup)` replaces the block between and so figures are rebuilt from data at author time and the published page stays static. """ from __future__ import annotations import html import math import re from pathlib import Path def esc(s) -> str: return html.escape(str(s), quote=True) def fmt_num(v: float) -> str: if v == 0: return "0" a = abs(v) if a >= 1e6 and a == int(a) and a % 1e6 == 0: return f"{v / 1e6:g}M" if a >= 1000: return f"{v:,.0f}" if a >= 1: return f"{v:g}" return f"{v:g}" class Scale: def __init__(self, lo, hi, a, b, log=False): self.lo, self.hi, self.a, self.b, self.log = lo, hi, a, b, log def __call__(self, v): f = math.log10 if self.log else (lambda z: z) return self.a + (f(v) - f(self.lo)) / (f(self.hi) - f(self.lo)) * (self.b - self.a) class Frame: """Plot area, axes and grid. Charts draw into `out`.""" def __init__(self, width, height, label, margins=(58, 132, 26, 46)): self.w, self.h = width, height self.ml, self.mr, self.mt, self.mb = margins self.pw = width - self.ml - self.mr self.ph = height - self.mt - self.mb self.out = [f''] def x_scale(self, lo, hi, log=False): self.x = Scale(lo, hi, self.ml, self.ml + self.pw, log) return self.x def y_scale(self, lo, hi, log=False): self.y = Scale(lo, hi, self.mt + self.ph, self.mt, log) return self.y def grid_y(self, ticks, fmt=fmt_num): o = self.out o.append('') for t in ticks: o.append(f'') o.append("") for t in ticks: o.append(f'{esc(fmt(t))}') def grid_x(self, ticks, fmt=fmt_num, lines=True): o = self.out if lines: o.append('') for t in ticks: o.append(f'') o.append("") for t in ticks: o.append(f'{esc(fmt(t))}') def x_labels(self, labels): for i, lab in labels: self.out.append(f'{esc(lab)}') def baseline(self): y = self.mt + self.ph self.out.append(f'') def titles(self, x_title="", y_title=""): if x_title: self.out.append(f'{esc(x_title)}') if y_title: self.out.append(f'{esc(y_title)}') def note(self, x, y, text, anchor="start", cls="muted"): self.out.append(f'{esc(text)}') def vline(self, xv, text="", cls="axis"): x = self.x(xv) self.out.append(f'') if text: self.note(x + 5, self.mt + 11, text) def hline(self, yv, text="", cls="axis"): y = self.y(yv) self.out.append(f'') if text: self.note(self.ml + 6, y - 6, text) def cursor(self): self.out.append(f'') def svg(self): return "\n".join(self.out + [""]) def draw_lines(fr: Frame, xs, series, end_labels=True, hover_marks=True): """Lines sharing x positions; end markers; nudged end labels.""" ends = [] for s in series: pts = [(fr.x(x), fr.y(v)) for x, v in zip(xs, s["values"]) if v is not None] d = "M" + " L".join(f"{a:.1f},{b:.1f}" for a, b in pts) fr.out.append(f'') ex, ey = pts[-1] fr.out.append(f'') if end_labels and s.get("label", True): ends.append([ey, s.get("end", s["name"]), ex, ey]) ends.sort() for k in range(1, len(ends)): if ends[k][0] - ends[k - 1][0] < 14: ends[k][0] = ends[k - 1][0] + 14 for ly, name, ex, ey in ends: if abs(ly - ey) > 2: fr.out.append(f'') fr.out.append(f'{esc(name)}') def column_hits(fr: Frame, xs, series, title, fmt, hover_marks=True): """One full-height hit column per x (midpoints between neighbours).""" px = [fr.x(x) for x in xs] for i, x in enumerate(xs): if hover_marks: for s in series: v = s["values"][i] if v is not None: fr.out.append(f'') left = fr.ml if i == 0 else (px[i - 1] + px[i]) / 2 right = fr.ml + fr.pw if i == len(xs) - 1 else (px[i] + px[i + 1]) / 2 rows = ";".join(f'{s["slot"]}|{s["name"]}|{fmt(s["values"][i])}' for s in series if s["values"][i] is not None) fr.out.append( f'' ) def point_hit(fr: Frame, cx, cy, col, title, rows, r=12): fr.out.append( f'' ) def columns(fr: Frame, cats, values, slot=1, fmt=fmt_num, title=lambda i, c: c, highlight=None, slots=None, metric="Value"): """Vertical columns from the baseline, <=24 px wide, 4 px rounded cap.""" n = len(cats) band = fr.pw / n w = min(24, band * 0.6) y0 = fr.mt + fr.ph for i, (c, v) in enumerate(zip(cats, values)): cx = fr.ml + band * (i + 0.5) top = fr.y(v) h = y0 - top r = min(4, h) s = slots[i] if slots else (slot if highlight is None or i in highlight else 0) d = (f"M{cx - w / 2:.1f},{y0} L{cx - w / 2:.1f},{top + r:.1f} Q{cx - w / 2:.1f},{top:.1f} {cx - w / 2 + r:.1f},{top:.1f} " f"L{cx + w / 2 - r:.1f},{top:.1f} Q{cx + w / 2:.1f},{top:.1f} {cx + w / 2:.1f},{top + r:.1f} L{cx + w / 2:.1f},{y0} Z") fr.out.append(f'') fr.out.append(f'{esc(fmt(v))}') fr.out.append(f'{esc(c)}') fr.out.append( f'' ) def legend(series: list[dict]) -> str: items = "".join(f'{esc(s["name"])}' for s in series) return f'
{items}
' def table(caption: str, head: list[str], rows: list[list], numeric_from: int = 1) -> str: def cls(j): return ' class="n"' if j >= numeric_from else "" th = "".join(f'{esc(h)}' for j, h in enumerate(head)) body = [] for r in rows: cells = [f'{esc(r[0])}'] + [f"{esc(c)}" for j, c in enumerate(r[1:])] body.append("" + "".join(cells) + "") return ( f'
' f"{th}{''.join(body)}
{esc(caption)}
" ) def figure(*, code: str, plot: str, caption: str, legend_html: str = "", data_html: str = "", wide=False, fid="") -> str: """The standard figure frame: LCARS bar, plot, legend, caption, table view.""" data = f"
Table view{data_html}
" if data_html else "" idattr = f' id="{fid}"' if fid else "" return ( f'
' f'
' f"{esc(code)}
" f'
{plot}
{legend_html}' f"
{caption}
{data}
" ) def inject(page: Path, name: str, markup: str) -> None: text = page.read_text(encoding="utf-8") pat = re.compile(rf"()(.*?)()", re.S) if not pat.search(text): raise SystemExit(f"marker fig:{name} not found in {page}") text = pat.sub(lambda m: m.group(1) + "\n" + markup + "\n" + m.group(3), text) page.write_text(text, encoding="utf-8")