# 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 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'