Files
Claude 355951c9b1 UI kit: chart.line, tags.editor and tags.cloud elements
Needed by the LoRA training window (loss graph, tag editing, tag frequencies).
Demos, behaviour tests and kit reference included.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MpnF7GwNGMbngU11wjym7P
2026-10-17 14:40:39 +00:00

167 lines
8.9 KiB
JavaScript

// Line chart: a loss curve, a metric over time.
//
// The lines are an SVG stretched over the box (preserveAspectRatio none, strokes
// that do not scale), the numbers around it are HTML, so text stays crisp at any
// width. Data is cut down to a few hundred drawn points by keeping each bucket's
// lowest and highest, which is what keeps a spike visible.
import { define } from "../internals/register.js";
import { el, svg } from "../internals/el.js";
import { setText } from "../internals/text.js";
const W = 600, H = 200; // the drawing's own units; the box it is stretched to is CSS
const MAX_POINTS = 600;
const TONES = ["accent", "good", "warn", "muted"];
const HEIGHTS = ["sm", "md", "lg"];
/** Finite points only, as [{x, y}]. `values` are spaced 0, 1, 2…; logY drops what a log cannot show. */
function pointsOf(series, logY) {
const raw = Array.isArray(series.points) ? series.points : (series.values || []).map((y, x) => ({ x, y }));
return raw.filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y) && (!logY || p.y > 0)).sort((a, b) => a.x - b.x);
}
/** At most MAX_POINTS points: each bucket keeps its lowest and highest point, in x order. */
function thin(points) {
if (points.length <= MAX_POINTS) return points;
const size = Math.ceil(points.length / (MAX_POINTS / 2)), out = [];
for (let i = 0; i < points.length; i += size) {
const bucket = points.slice(i, i + size);
const lo = bucket.reduce((a, p) => (p.y < a.y ? p : a)), hi = bucket.reduce((a, p) => (p.y > a.y ? p : a));
out.push(...(lo === hi ? [lo] : lo.x < hi.x ? [lo, hi] : [hi, lo]));
}
return out;
}
/** Round, readable tick values covering [lo, hi]. */
function niceTicks(lo, hi, want = 4) {
const span = hi - lo, raw = span / want, mag = 10 ** Math.floor(Math.log10(raw));
const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= raw);
const out = [];
for (let v = Math.ceil(lo / step) * step; v <= hi + step * 1e-9; v += step) out.push(Number(v.toPrecision(12)));
return out;
}
/** 1, 2 and 5 times a power of ten inside [lo, hi] (a log axis); only the powers when that is too many. */
function logTicks(lo, hi) {
const out = [];
for (let k = Math.floor(Math.log10(lo)); k <= Math.ceil(Math.log10(hi)); k++) {
for (const m of [1, 2, 5]) { const v = m * 10 ** k; if (v >= lo && v <= hi) out.push(v); }
}
return out.length > 8 ? out.filter((v) => Math.log10(v) % 1 === 0) : out;
}
export function format(n) {
if (n === 0) return "0";
const a = Math.abs(n);
return a >= 10000 || a < 0.01 ? n.toExponential(1).replace("e+", "e") : String(Number(n.toPrecision(3)));
}
/** The point at or just before x, found by bisection (points are in x order). */
function nearest(points, x) {
let lo = 0, hi = points.length - 1;
while (lo < hi) { const mid = (lo + hi) >> 1; if (points[mid].x < x) lo = mid + 1; else hi = mid; }
return lo > 0 && Math.abs(points[lo - 1].x - x) < Math.abs(points[lo].x - x) ? points[lo - 1] : points[lo];
}
define("chart", "line", ({ series = [], xLabel, yLabel, height = "md", empty = "No data yet", label, logY = false } = {}) => {
if (!HEIGHTS.includes(height)) throw new TypeError(`chart.line height must be one of ${HEIGHTS.join(", ")}.`);
let model = []; // [{ spec, points, drawn }]
const lines = svg("g");
const cursor = svg("line", { class: "cx-chart-cursor", "vector-effect": "non-scaling-stroke" });
const plot = svg("svg", { class: "cx-chart-svg", viewBox: `0 0 ${W} ${H}`, preserveAspectRatio: "none", "aria-hidden": "true" });
plot.append(lines, cursor);
const yAxis = el("div", { cls: "cx-chart-yaxis" });
const xAxis = el("div", { cls: "cx-chart-xaxis" });
const grid = el("div", { cls: "cx-chart-grid" }); // horizontal rules, as boxes so they stay hairlines
const field = el("div", { cls: "cx-chart-field", children: [grid, plot] });
const legend = el("div", { cls: "cx-chart-legend" });
const readout = el("p", { cls: "cx-chart-readout" });
const note = el("p", { cls: "cx-chart-empty", text: empty });
const axes = [yLabel ? el("span", { cls: "cx-chart-axis-name", text: yLabel }) : null, xLabel ? el("span", { cls: "cx-chart-axis-name cx-chart-axis-x", text: xLabel }) : null];
const body = el("div", { cls: "cx-chart-body", children: [axes[0], yAxis, field, xAxis, axes[1]] });
const node = el("div", { cls: ["cx-chart", `cx-chart-${height}`], attrs: { role: "group", "aria-label": label }, children: [body, legend, readout, note] });
const lookup = (v, lo, hi) => (logY ? (Math.log10(v) - Math.log10(lo)) / (Math.log10(hi) - Math.log10(lo)) : (v - lo) / (hi - lo));
let extent = null;
const tickBox = (cls, value, fraction, axis) => {
const t = el("span", { cls, text: format(value) });
t.style.setProperty(axis === "y" ? "bottom" : "left", `${(fraction * 100).toFixed(2)}%`);
return t;
};
const latest = () => model.filter((m) => m.points.length).map((m) => `${m.spec.name || "value"} ${format(m.points[m.points.length - 1].y)}`).join(" · ");
const reading = (x) => {
const parts = model.filter((m) => m.points.length && !m.spec.faint).map((m) => `${m.spec.name || "value"} ${format(nearest(m.points, x).y)}`);
const at = model.find((m) => m.points.length);
return `${xLabel || "x"} ${format(at ? nearest(at.points, x).x : x)} · ${parts.join(" · ")}`;
};
function draw() {
lines.replaceChildren(); yAxis.replaceChildren(); xAxis.replaceChildren(); grid.replaceChildren(); legend.replaceChildren();
const live = model.filter((m) => m.points.length);
node.classList.toggle("cx-chart-none", !live.length);
if (!live.length) { extent = null; setText(readout, ""); cursor.setAttribute("x1", "-1"); cursor.setAttribute("x2", "-1"); return; }
const all = live.flatMap((m) => m.points);
const range = (key) => all.reduce(([lo, hi], p) => [Math.min(lo, p[key]), Math.max(hi, p[key])], [Infinity, -Infinity]);
let [x0, x1] = range("x");
let [y0, y1] = range("y");
if (x0 === x1) { x0 -= 1; x1 += 1; }
if (y0 === y1) { const pad = logY ? y0 * 0.5 : Math.abs(y0) * 0.1 || 1; y0 -= pad; y1 += pad; }
const yTicks = logY ? logTicks(y0, y1) : niceTicks(y0, y1);
if (!logY && yTicks.length) { y0 = Math.min(y0, yTicks[0]); y1 = Math.max(y1, yTicks[yTicks.length - 1]); }
const xTicks = niceTicks(x0, x1, 5);
extent = { x0, x1 };
const px = (x) => ((x - x0) / (x1 - x0)) * W, py = (y) => H - lookup(y, y0, y1) * H;
for (const m of model.filter((q) => q.points.length)) {
const cls = ["cx-chart-line", `cx-chart-${m.spec.tone || "accent"}`, m.spec.faint ? "cx-chart-faint" : null, m.spec.dashed ? "cx-chart-dashed" : null].filter(Boolean).join(" ");
const d = m.drawn.length === 1 ? `M${px(m.drawn[0].x).toFixed(1)} ${py(m.drawn[0].y).toFixed(1)}h0` // a lone point is a round dot
: m.drawn.map((p, i) => `${i ? "L" : "M"}${px(p.x).toFixed(1)} ${py(p.y).toFixed(1)}`).join("");
lines.append(svg("path", { d, class: cls, "vector-effect": "non-scaling-stroke", fill: "none" }));
}
for (const v of yTicks) {
const f = lookup(v, y0, y1);
if (f < -1e-9 || f > 1 + 1e-9) continue;
yAxis.append(tickBox("cx-chart-tick", v, f, "y"));
const rule = el("span", { cls: "cx-chart-rule" }); rule.style.setProperty("bottom", `${(f * 100).toFixed(2)}%`); grid.append(rule);
}
for (const v of xTicks) xAxis.append(tickBox("cx-chart-tick", v, (v - x0) / (x1 - x0), "x"));
for (const m of model) {
if (!m.spec.name) continue;
legend.append(el("span", { cls: "cx-chart-key", children: [el("span", { cls: ["cx-chart-swatch", `cx-chart-${m.spec.tone || "accent"}`, m.spec.faint ? "cx-chart-faint" : null], attrs: { "aria-hidden": "true" } }), el("span", { text: m.spec.name })] }));
}
setText(readout, latest());
}
const at = (e) => {
const box = plot.getBoundingClientRect();
if (!extent || !box.width) return null;
return extent.x0 + Math.min(1, Math.max(0, (e.clientX - box.left) / box.width)) * (extent.x1 - extent.x0);
};
plot.addEventListener("pointermove", (e) => {
const x = at(e);
if (x === null) return;
const c = ((x - extent.x0) / (extent.x1 - extent.x0)) * W;
cursor.setAttribute("x1", String(c)); cursor.setAttribute("x2", String(c)); cursor.setAttribute("y1", "0"); cursor.setAttribute("y2", String(H));
setText(readout, reading(x));
});
plot.addEventListener("pointerleave", () => {
cursor.setAttribute("x1", "-1"); cursor.setAttribute("x2", "-1");
setText(readout, latest());
});
function setSeries(next) {
model = (next || []).map((spec) => {
if (spec.tone && !TONES.includes(spec.tone)) throw new TypeError(`chart.line tone must be one of ${TONES.join(", ")}.`);
const points = pointsOf(spec, logY);
return { spec, points, drawn: thin(points) };
});
draw();
}
setSeries(series);
return { node, setSeries, setEmpty: (text) => setText(note, text), destroy: () => node.remove() };
});