diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 2525b177..9fd30f16 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -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; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 5669ce90..0f2e5cd1 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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); diff --git a/internal/gui/tests/usage-speed.test.cjs b/internal/gui/tests/usage-speed.test.cjs index f87b7d88..44d04adc 100644 --- a/internal/gui/tests/usage-speed.test.cjs +++ b/internal/gui/tests/usage-speed.test.cjs @@ -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 });