gui: the speed chart's marks that leave their column get a leader line (#1018)

The Speed trend's column for a day is that day's own speed — every reply's
tokens over every reply's decode time — while each model's mark is drawn at
its own speed. A model faster than the day it is in leaves its mark above
the column, in nothing, reading as a stray dash (huoranxuanyuan, #860): with
five models the fastest is nearly always above the day's own, so this is the
chart's usual look, not an edge case.

Each mark that leaves its column now gets a dotted leader line down to the
column's top, so it reads as that column's; the line stops at the top when
the mark is within a pixel of it, or it would be drawn back down into the
column. The column itself is drawn in the track's colour rather than the
grey the stacked charts use for "Other", so it reads as the track the marks
sit on. The colour goes through style, not the presentation attribute:
var() substitutes in a style and not in an attribute.

box.emphasize() strokes a line and fills a rect: the marks' leader lines are
lines, so dimming a day or emphasizing a model sets their stroke where it
sets the marks' fill, and the two read the same.

usage-speed.test.cjs's hour(0) fixture now has the day's own speed under
glm-fast's, so the case asserts the leader line — that it is drawn, that it
runs down the mark's middle from the column's top to the mark's foot, that
it is dotted, that the column is the track's colour, and that the colour
resolves (it goes through style). It fails on origin/main, where the mark is
drawn with no line at all.

Verified: node --check; go vet; go build (windows); the new assertions
failing on origin/main's app.js and passing on this one; usage-speed 6/6 and
usage-ledger-chart 18/18 in Chromium and WebKit, en and zh; 560px with
nothing past the view and no sideways scroll.

Co-authored-by: wizzy-yang <wizzy-yang@users.noreply.github.com>
This commit is contained in:
wizzy-yangandwizzy-yang authored and GitHub committed 2026-10-06 20:23:11 +08:00
1 parent 0fb669175b
commit 3bcf71b003
3 files changed
+58 -5

No files matched your search

+6 -2
View File
@@ -807,8 +807,12 @@ body.window .field.solo { grid-column: auto; }
.led-chart .axis { fill: var(--muted); font-size: 10.5px; }
.led-chart .hot { fill: var(--pill); }
.led-chart rect.col { transition: opacity .12s; }
/* a speed chart's column is all the replies', each one told apart a mark on it */
.led-chart rect.col.all { fill-opacity: .55; }
/* a speed chart's column is the point's own speed: a track the marks sit on,
at full strength so it reads as the track and not as a fainter series */
.led-chart rect.col.all { fill-opacity: 1; }
/* a mark faster than its point's own leaves the column: the leader line ties
it back, so it reads as this column's and not as a stray dash */
.led-chart line.stem { stroke-dasharray: 2 2; transition: opacity .12s; }
.led-chart .led-day { cursor: pointer; outline: none; }
.led-chart .led-day:focus-visible { stroke: var(--accent); stroke-width: 1; }
.led-chart .tip { position: absolute; top: 2px; z-index: 2; min-width: 172px; max-width: 260px; padding: 8px 10px 7px; background: var(--pop-bg); border-radius: 9px; box-shadow: var(--shadow); font-size: 11.5px; pointer-events: none; }
+23 -2
View File
@@ -14892,10 +14892,28 @@ function drawLedColumns(box, l, split, metric, compact, chooseDay, focusDay) {
};
const by = p.by?.[split] || {};
if (speed) {
if (totals[i] > 0) { draw("\0all", "var(--faint)", totals[i]); segs.at(-1).classList.add("all"); }
// the point's own speed, as a track the models' marks sit on: not a
// share of anything, and not the grey "Other" of the stacked charts
if (totals[i] > 0) { draw("\0all", "var(--pill)", totals[i]); segs.at(-1).classList.add("all"); }
for (const c of top) {
const v = ledSpeed(by[c.id]);
if (!(v > 0)) continue;
// a model faster than the point's own leaves its mark above the
// column, in nothing: a leader line ties the two, so the mark reads
// as this column's rather than as a stray dash (huoranxuanyuan, #860).
// The colour goes through style, not the attribute: var() substitutes
// in a style and not in a presentation attribute. The line stops at
// the column's top when the mark is within a pixel of it, or it would
// be drawn back down into the column
if (v > totals[i]) {
const top = Y(totals[i]);
const stem = sv("line", {
x1: x + bw / 2, x2: x + bw / 2, y1: top, y2: Math.min(Y(v) + 1.5, top),
class: "col stem", "data-k": c.id, "data-day": p.time.slice(0, 10), "data-color": c.color,
}, { stroke: c.color, strokeWidth: 1, strokeOpacity: ".45" });
g.append(stem);
segs.push(stem);
}
const r = sv("rect", { x, width: bw, y: Y(v) - 1.5, height: 3, rx: 1.5, class: "col mark", "data-k": c.id, "data-day": p.time.slice(0, 10), "data-color": c.color }, { fill: c.color });
g.append(r);
segs.push(r);
@@ -14954,8 +14972,11 @@ function drawLedColumns(box, l, split, metric, compact, chooseDay, focusDay) {
box.emphasize = (key) => {
for (const r of segs) {
const dimDay = l.day && r.dataset.day !== l.day;
const color = dimDay ? "var(--faint)" : r.dataset.color;
r.style.opacity = dimDay || key != null && r.dataset.k !== key ? ".22" : "";
r.style.fill = dimDay ? "var(--faint)" : r.dataset.color;
// a mark's leader line is a line, which is stroked, not filled
if (r.tagName === "line") r.style.stroke = color;
else r.style.fill = color;
}
};
box.emphasize(null);
+29 -1
View File
@@ -45,7 +45,11 @@ function ledger(period) {
by: { model: [FAST, SLOW] }, bucket: "hour",
series: [
{ label: "", time: hour(1), calls: 14, input: 1, output: 1890, ...{ timed: 14, ttft_ms: 8400, decode_ms: 25200, decode_out: 1890 }, by: { model: { "glm-fast": part({ ...FAST, calls: 14, output: 1890, timed: 14, ttft_ms: 8400, decode_ms: 25200, decode_out: 1890 }) } } },
{ label: "", time: hour(0), calls: 15, input: 1, output: 1910, ...{ timed: 14, ttft_ms: 8400, decode_ms: 25400, decode_out: 1910 }, by: { model: { "glm-fast": part({ ...FAST, calls: 14, output: 1890, timed: 13, ttft_ms: 7800, decode_ms: 23400, decode_out: 1755 }), "kimi-slow": part(SLOW) } } },
// hour(0): the hour's own is 1910 tokens in 27.4 s (69.7 tok/s) while
// glm-fast answered 1755 in 23.4 s (75.0), so its mark leaves the column
// and gets a leader line; kimi-slow (10) stays under it. hour(1) is the
// hour's own equal to the one model that answered in it, so no line
{ label: "", time: hour(0), calls: 15, input: 1, output: 1910, ...{ timed: 14, ttft_ms: 8400, decode_ms: 27400, decode_out: 1910 }, by: { model: { "glm-fast": part({ ...FAST, calls: 14, output: 1890, timed: 13, ttft_ms: 7800, decode_ms: 23400, decode_out: 1755 }), "kimi-slow": part(SLOW) } } },
],
agents: [{ id: "claude", name: "Claude Code", icon: "claudecode-color" }],
};
@@ -144,6 +148,30 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
assert.equal((await p.locator("#ledRank .rk").nth(1).locator(".rk-val").textContent()).trim(), w.slow);
assert.equal(await p.locator("#ledChart rect.col.mark").count(), 3, "a mark for each model at each hour it answered");
assert.equal(await p.locator("#ledChart rect.col.all").count(), 2, "and the hour's own");
// a model faster than the hour's own leaves its mark above the column:
// a dotted leader line ties the two, so the mark reads as this hour's
// rather than as a stray dash (huoranxuanyuan, #860). The hour(0)
// fixture is the one where that happens: 1890 tokens in 23.4 s is
// 80.8 tok/s for glm-fast against the hour's own 1910 in 25.4 s,
// 75.2, while kimi-slow answers 10
const stems = p.locator("#ledChart line.stem");
assert.equal(await stems.count(), 1, "a leader line for the mark that leaves its column");
const stem = await stems.first().evaluate((l) => ({ x1: l.x1.baseVal.value, x2: l.x2.baseVal.value, y1: l.y1.baseVal.value, y2: l.y2.baseVal.value }));
const mark = await p.locator("#ledChart rect.col.mark").nth(1).evaluate((r) => ({ x: r.x.baseVal.value, y: r.y.baseVal.value, w: r.width.baseVal.value, h: r.height.baseVal.value }));
const col = await p.locator("#ledChart rect.col.all").nth(1).evaluate((r) => ({ y: r.y.baseVal.value, color: r.dataset.color, style: r.style.fill }));
assert(Math.abs(stem.x1 - (mark.x + mark.w / 2)) < 0.51 && Math.abs(stem.x2 - stem.x1) < 0.01, "the line runs down the mark's middle");
assert(Math.abs(stem.y1 - col.y) < 0.01, "and starts at its column's top");
assert(Math.abs(stem.y2 - (mark.y + mark.h)) < 0.01, "and ends at the mark's foot");
assert(stem.y1 > stem.y2, "downward: the mark is above its column");
assert.equal(await stems.first().evaluate((l) => getComputedStyle(l).strokeDasharray), "2px, 2px", "dotted");
// the column is the track the marks sit on, not the stacked charts'
// "Other" grey, and the colour goes through style, where var()
// substitutes — not through the attribute, where it does not
assert.equal(col.color, "var(--pill)", "the track's colour, named");
assert.notEqual(col.style, "", "set through style");
const fill = await p.locator("#ledChart rect.col.all").nth(1).evaluate((r) => getComputedStyle(r).fill);
assert(fill.startsWith("rgb"), "the style resolves to a colour, not the var() text: " + fill);
assert.equal(fill, await p.locator("#ledChart rect.col.all").nth(0).evaluate((r) => getComputedStyle(r).fill), "both hours' tracks read the same");
// a wide window: the Agents and Settings rows don't stretch across it
await p.setViewportSize({ width: 1670, height: 1060 });