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
167 lines
8.9 KiB
JavaScript
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() };
|
|
});
|