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:
1 parent
0fb669175b
commit
3bcf71b003
3 files changed
+58
-5
No files matched your search
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user