feat(client): link the stats legend rows to their donut slices on hover

Hovering a legend row or a ring segment now lights both: the shared
hover key dims the other slices to the stacked bars' 0.35 idiom and
tints the active row. A hover with no painted arc (a zero slice) tints
the row but leaves the ring at rest. The legend names bolden, and the
token buckets get clearer names and colors: Cached Input / 缓存输入,
Uncached Input / 未缓存输入 (violet, no longer blue-next-to-blue with
Output), Cache Hit, Cache Write.
This commit is contained in:
bowenliang123 committed 2026-09-01 15:02:59 +08:00
1 parent 426f07926b
commit a658dc4fea
10 files changed
+177 -44

No files matched your search

+15 -1
View File
@@ -26,6 +26,10 @@ export interface DonutProps {
centerSub?: ReactNS.ReactNode
/** Outer size in px (default 118). */
size?: number
/** The hovered slice key — the legend row ↔ segment hover link. */
hoverKey?: string | null
/** Hover relay; absent renders the ring inert. */
onHoverKey?: (key: string | null) => void
}
export function makeDonut(kit: ViewKit): (props: DonutProps) => ReactNS.ReactElement {
@@ -50,14 +54,23 @@ export function makeDonut(kit: ViewKit): (props: DonutProps) => ReactNS.ReactEle
acc += pct
}
}
// The ring dims only for a hover that lands on a painted arc — a legend
// row without a segment (a zero slice) leaves the ring at rest.
const hovering = props.hoverKey !== null && props.hoverKey !== undefined
&& arcs.some(a => a.key === props.hoverKey)
return (
<div className="lc-donut" style={{ width: size, height: size }}>
<div
className={'lc-donut' + (hovering ? ' lc-donut-dim' : '')}
style={{ width: size, height: size }}
onMouseLeave={() => { if (props.onHoverKey !== undefined) props.onHoverKey(null) }}
>
<svg viewBox="0 0 42 42" width={size} height={size} aria-hidden="true">
{arcs.length === 0
? <circle className="lc-donut-track" cx="21" cy="21" r="15.9155" fill="none" strokeWidth="4" />
: arcs.map(a => (
<circle
key={a.key}
className={'lc-donut-seg' + (props.hoverKey === a.key ? ' lc-donut-seg-on' : '')}
cx="21"
cy="21"
r="15.9155"
@@ -66,6 +79,7 @@ export function makeDonut(kit: ViewKit): (props: DonutProps) => ReactNS.ReactEle
strokeWidth="4"
strokeDasharray={`${a.pct} ${100 - a.pct}`}
strokeDashoffset={a.offset}
onMouseEnter={() => { if (props.onHoverKey !== undefined) props.onHoverKey(a.key) }}
/>
))}
</svg>
+24 -6
View File
@@ -3,8 +3,10 @@
* per slice — the primary line pairs the color dot and the name with the
* bold share closing a fixed right column, and the secondary line drops the
* absolute quantity (plus a qualifier like the call count) under the name in
* muted small print. No tracks or bars — the donut IS the proportion chart;
* the rows are its legend. Preformatted strings in, dumb markup out.
* muted small print. Hovering a row lights it and its donut segment together
* (the shared hover key), and vice versa. No tracks or bars — the donut IS
* the proportion chart; the rows are its legend. Preformatted strings in,
* dumb markup out.
*/
import type * as ReactNS from 'react'
@@ -24,13 +26,29 @@ export interface SliceRow {
dim?: boolean
}
export function makeSliceList(kit: ViewKit): (props: { rows: SliceRow[] }) => ReactNS.ReactElement {
export function makeSliceList(kit: ViewKit): (props: {
rows: SliceRow[]
/** The hovered slice key — the legend row ↔ segment hover link. */
hoverKey?: string | null
/** Hover relay; absent renders the rows inert. */
onHoverKey?: (key: string | null) => void
}) => ReactNS.ReactElement {
void kit
return function SliceList(props: { rows: SliceRow[] }): ReactNS.ReactElement {
return function SliceList(props: {
rows: SliceRow[]
hoverKey?: string | null
onHoverKey?: (key: string | null) => void
}): ReactNS.ReactElement {
return (
<div className="lc-sl">
<div className="lc-sl" onMouseLeave={() => { if (props.onHoverKey !== undefined) props.onHoverKey(null) }}>
{props.rows.map(r => (
<div key={r.key} className={'lc-sl-row' + (r.dim ? ' lc-sl-row-dim' : '')}>
<div
key={r.key}
className={'lc-sl-row'
+ (r.dim ? ' lc-sl-row-dim' : '')
+ (props.hoverKey !== undefined && props.hoverKey === r.key ? ' lc-sl-row-on' : '')}
onMouseEnter={() => { if (props.onHoverKey !== undefined) props.onHoverKey(r.key) }}
>
<div className="lc-sl-main">
<i className="lc-sl-dot" style={{ background: r.color }} />
<span className="lc-sl-label" title={r.label}>{r.label}</span>
+7 -8
View File
@@ -17,14 +17,9 @@ import { React } from '../react'
import { makeSliceList } from './sliceList'
import type { SliceRow } from './sliceList'
import type { DonutSegment } from './donut'
import type { DonutProps, DonutSegment } from './donut'
export function makeStatsTiming(kit: ViewKit, Donut: (props: {
segments: DonutSegment[]
centerTop: ReactNS.ReactNode
centerSub?: ReactNS.ReactNode
size?: number
}) => ReactNS.ReactElement): (props: {
export function makeStatsTiming(kit: ViewKit, Donut: (props: DonutProps) => ReactNS.ReactElement): (props: {
timing: TimingTotals | null
locale: string
}) => ReactNS.ReactElement {
@@ -32,6 +27,8 @@ export function makeStatsTiming(kit: ViewKit, Donut: (props: {
const SliceList = makeSliceList(kit)
return function StatsTiming(props: { timing: TimingTotals | null; locale: string }): ReactNS.ReactElement {
const lang: 'zh' | 'en' = props.locale === 'zh' ? 'zh' : 'en'
// The legend row ↔ donut segment hover link (shared key, set from either side).
const [hoverKey, setHoverKey] = React.useState<string | null>(null)
const timing = props.timing
const wall = timing !== null && Number.isFinite(timing.wallMs) && timing.wallMs > 0 ? timing.wallMs : 0
let segments: DonutSegment[] = []
@@ -91,8 +88,10 @@ export function makeStatsTiming(kit: ViewKit, Donut: (props: {
size={96}
centerTop={wall > 0 ? fmtDuration(wall, lang) : '—'}
centerSub={t('timing.total')}
hoverKey={hoverKey}
onHoverKey={setHoverKey}
/>
<SliceList rows={rows} />
<SliceList rows={rows} hoverKey={hoverKey} onHoverKey={setHoverKey} />
</div>
)}
</div>
+8 -9
View File
@@ -18,19 +18,16 @@ import { React } from '../react'
import { makeSliceList } from './sliceList'
import type { SliceRow } from './sliceList'
import type { DonutSegment } from './donut'
import type { DonutProps } from './donut'
export function makeStatsTokens(kit: ViewKit, Donut: (props: {
segments: DonutSegment[]
centerTop: ReactNS.ReactNode
centerSub?: ReactNS.ReactNode
size?: number
}) => ReactNS.ReactElement): (props: {
export function makeStatsTokens(kit: ViewKit, Donut: (props: DonutProps) => ReactNS.ReactElement): (props: {
usage: TokenUsage | null
}) => ReactNS.ReactElement {
const { t, fmt, fmtShare } = kit
const SliceList = makeSliceList(kit)
return function StatsTokens(props: { usage: TokenUsage | null }): ReactNS.ReactElement {
// The legend row ↔ donut segment hover link (shared key, set from either side).
const [hoverKey, setHoverKey] = React.useState<string | null>(null)
const reads = props.usage !== null ? numOf(props.usage.cacheReadTokens) : 0
const writes = props.usage !== null ? numOf(props.usage.cacheWriteTokens) : 0
const uncached = props.usage !== null ? numOf(props.usage.uncachedInputTokens) : 0
@@ -44,7 +41,7 @@ export function makeStatsTokens(kit: ViewKit, Donut: (props: {
const buckets: { key: string; color: string; label: string; note?: string; value: number }[] = [
{ key: 'read', color: '#22c55e', label: t('tokens.cacheRead'), value: reads },
{ key: 'write', color: '#f59e0b', label: t('tokens.cacheWrite'), value: writes },
{ key: 'uncached', color: '#6366f1', label: t('tokens.uncached'), value: uncached },
{ key: 'uncached', color: '#a855f7', label: t('tokens.uncached'), value: uncached },
{ key: 'output', color: '#3b82f6', label: t('tokens.output'), note: t('tokens.outputNote'), value: output },
]
const shown = billed > 0 ? buckets.filter(b => b.value > 0) : buckets
@@ -65,8 +62,10 @@ export function makeStatsTokens(kit: ViewKit, Donut: (props: {
size={96}
centerTop={hit === null ? '—' : `${hit}%`}
centerSub={t('tokens.cacheHit')}
hoverKey={hoverKey}
onHoverKey={setHoverKey}
/>
<SliceList rows={rows} />
<SliceList rows={rows} hoverKey={hoverKey} onHoverKey={setHoverKey} />
</div>
</div>
)
+6 -6
View File
@@ -38,9 +38,9 @@ export const DICT_ZH: Record<string, string> = {
'tokens.title': 'Token 统计',
'tokens.hint': '供应商上报的累计计费用量',
'tokens.cacheHit': '缓存命中',
'tokens.cacheRead': '缓存读取',
'tokens.cacheRead': '缓存输入',
'tokens.cacheWrite': '缓存写入',
'tokens.uncached': '未命中输入',
'tokens.uncached': '未缓存输入',
'tokens.output': '输出',
'tokens.outputNote': '含思考',
'plugin.title': '插件信息',
@@ -249,10 +249,10 @@ export const DICT_EN: Record<string, string> = {
'timing.empty': 'No timing data yet — durations appear as the conversation runs',
'tokens.title': 'Token Stats',
'tokens.hint': 'Cumulative provider-reported usage',
'tokens.cacheHit': 'Cache hit',
'tokens.cacheRead': 'Cache read',
'tokens.cacheWrite': 'Cache write',
'tokens.uncached': 'Uncached input',
'tokens.cacheHit': 'Cache Hit',
'tokens.cacheRead': 'Cached Input',
'tokens.cacheWrite': 'Cache Write',
'tokens.uncached': 'Uncached Input',
'tokens.output': 'Output',
'tokens.outputNote': 'incl. reasoning',
'plugin.title': 'Plugin Info',
+11 -2
View File
@@ -46,6 +46,12 @@
.lc-donut { position: relative; flex: none; }
.lc-donut svg { display: block; }
.lc-donut-track { stroke: rgba(128,128,128,0.18); }
/* The legend row ↔ segment hover link: hovering either side dims the ring's
other slices to 0.35 (the stacked bars' idiom) while the active one stays
solid; the center figures stay at rest. */
.lc-donut-seg { transition: opacity 120ms ease; }
.lc-donut-dim .lc-donut-seg { opacity: 0.35; }
.lc-donut-dim .lc-donut-seg-on { opacity: 1; }
.lc-donut-center { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; pointer-events: none; text-align: center; padding: 0 7px; box-sizing: border-box; }
.lc-donut-center b { line-height: 1.15; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.lc-donut-center span { color: var(--dsw-alias-label-secondary); white-space: nowrap; }
@@ -58,11 +64,14 @@
a last resort; a zero-figure row dims — the ring already carries the share.
*/
.lc-sl { display: flex; flex-direction: column; gap: 6px; min-width: 0; overflow: hidden; }
.lc-sl-row { display: flex; flex-direction: column; gap: 1px; font-size: 12px; min-width: 0; }
/* The pill tint spreads 6px past the text column (negative margins) so the
hovered row reads as one lit surface without shifting the columns. */
.lc-sl-row { display: flex; flex-direction: column; gap: 1px; font-size: 12px; min-width: 0; border-radius: 6px; padding: 1px 6px; margin: 0 -6px; transition: background-color 120ms ease; }
.lc-sl-row-on { background: var(--dsw-alias-interactive-bg-hover); }
.lc-sl-row-dim { opacity: 0.55; }
.lc-sl-main { display: flex; align-items: center; gap: 6px; min-width: 0; }
.lc-sl-dot { flex: none; width: 8px; height: 8px; border-radius: 2px; }
.lc-sl-label { flex: 1 1 auto; min-width: 0; line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lc-sl-label { flex: 1 1 auto; min-width: 0; line-height: 1.25; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lc-sl-pct { flex: none; min-width: 34px; text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
.lc-sl-sub { margin-left: 14px; color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 1.3; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lc-pi-grid { display: grid; grid-template-columns: 1fr; gap: 8px; }
+44 -2
View File
@@ -4,13 +4,20 @@
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { h } from '../../../src/client/react'
import { h, React } from '../../../src/client/react'
import { makeDonut } from '../../../src/client/components/donut'
import { makeKit, mount, query, queryAll, text } from '../helpers/kit'
import type { DonutSegment } from '../../../src/client/components/donut'
import { hover, makeKit, mount, query, queryAll, text, unhover } from '../helpers/kit'
const kit = makeKit()
const Donut = makeDonut(kit)
/** A parent that really holds the hover key, so hovering re-renders the ring. */
function HoverHarness(props: { segments: DonutSegment[] }) {
const [hoverKey, setHoverKey] = React.useState<string | null>(null)
return h(Donut, { segments: props.segments, centerTop: 'x', hoverKey, onHoverKey: setHoverKey })
}
describe('Donut', () => {
test('segments render one circle each, in order, with accumulating offsets', async () => {
const m = await mount(h(Donut, {
@@ -70,3 +77,38 @@ describe('Donut', () => {
await m.unmount()
})
})
describe('Donut hover link', () => {
const SEGS: DonutSegment[] = [
{ key: 'a', color: '#ff0000', value: 30 },
{ key: 'b', color: '#00ff00', value: 70 },
]
test('hovering an arc dims the ring and lights it; leaving the ring clears both', async () => {
const m = await mount(h(HoverHarness, { segments: SEGS }))
const arcs = queryAll(m.container, '.lc-donut-seg')
const cls = (el: Element): string => el.getAttribute('class') ?? ''
await hover(arcs[0])
assert.ok(query(m.container, '.lc-donut').className.includes('lc-donut-dim'))
assert.ok(cls(arcs[0]).includes('lc-donut-seg-on'))
assert.ok(!cls(arcs[1]).includes('lc-donut-seg-on'))
await unhover(query(m.container, '.lc-donut'))
assert.ok(!query(m.container, '.lc-donut').className.includes('lc-donut-dim'))
assert.ok(!cls(arcs[0]).includes('lc-donut-seg-on'))
await m.unmount()
})
test('a hover key with no painted arc leaves the ring at rest', async () => {
const m = await mount(h(Donut, { segments: SEGS, centerTop: 'x', hoverKey: 'ghost' }))
assert.ok(!query(m.container, '.lc-donut').className.includes('lc-donut-dim'))
await m.unmount()
})
test('hover events without a relay stay inert', async () => {
const m = await mount(h(Donut, { segments: SEGS, centerTop: 'x' }))
await hover(query(m.container, '.lc-donut-seg'))
await unhover(query(m.container, '.lc-donut'))
assert.ok(!query(m.container, '.lc-donut').className.includes('lc-donut-dim'))
await m.unmount()
})
})
+32 -2
View File
@@ -4,13 +4,20 @@
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { h } from '../../../src/client/react'
import { h, React } from '../../../src/client/react'
import { makeSliceList } from '../../../src/client/components/sliceList'
import { makeKit, mount, queryAll, text } from '../helpers/kit'
import type { SliceRow } from '../../../src/client/components/sliceList'
import { hover, makeKit, mount, query, queryAll, text, unhover } from '../helpers/kit'
const kit = makeKit()
const SliceList = makeSliceList(kit)
/** A parent that really holds the hover key, so hovering re-renders the rows. */
function HoverHarness(props: { rows: SliceRow[] }) {
const [hoverKey, setHoverKey] = React.useState<string | null>(null)
return h(SliceList, { rows: props.rows, hoverKey, onHoverKey: setHoverKey })
}
describe('SliceList', () => {
test('rows render the primary line and the secondary count line in order', async () => {
const m = await mount(h(SliceList, {
@@ -53,4 +60,27 @@ describe('SliceList', () => {
assert.ok(queryAll(m.container, '.lc-sl').length === 1)
await m.unmount()
})
test('hovering a row lights it; leaving the list clears it', async () => {
const rowsIn: SliceRow[] = [
{ key: 'a', color: '#ff0000', label: 'Alpha', pct: '84%', count: '204.9k' },
{ key: 'b', color: '#00ff00', label: 'Beta', pct: '<1%', count: '3m 20s · 9 runs' },
]
const m = await mount(h(HoverHarness, { rows: rowsIn }))
const rows = queryAll(m.container, '.lc-sl-row')
await hover(rows[1])
assert.ok(rows[1].className.includes('lc-sl-row-on'))
assert.ok(!rows[0].className.includes('lc-sl-row-on'))
await unhover(query(m.container, '.lc-sl'))
assert.ok(!rows[1].className.includes('lc-sl-row-on'))
await m.unmount()
})
test('hover events without a relay stay inert', async () => {
const m = await mount(h(SliceList, { rows: [{ key: 'a', color: '#ff0000', label: 'Alpha', pct: '1%', count: '1' }] }))
await hover(query(m.container, '.lc-sl-row'))
await unhover(query(m.container, '.lc-sl'))
assert.equal(queryAll(m.container, '.lc-sl-row-on').length, 0)
await m.unmount()
})
})
+10 -1
View File
@@ -9,7 +9,7 @@ import { h } from '../../../src/client/react'
import { makeDonut } from '../../../src/client/components/donut'
import { makeStatsTiming } from '../../../src/client/components/statsTiming'
import type { TimingTotals } from '../../../src/shared/types'
import { makeKit, mount, query, queryAll, text } from '../helpers/kit'
import { makeKit, mount, query, queryAll, text, hover } from '../helpers/kit'
const kit = makeKit()
const kitZh = makeKit('zh')
@@ -74,6 +74,15 @@ describe('StatsTiming', () => {
assert.equal(queryAll(m.container, '.lc-donut circle').length, 2)
// The zero overhead row dims whole.
assert.deepEqual(rowOf(m.container, 2), { pct: '0%', label: 'Overhead', count: '—', dim: true })
// The hover link: the zero-overhead row tints itself but leaves the ring
// at rest (no painted arc to light); a painted row dims the ring.
const rows = queryAll(m.container, '.lc-sl-row')
await hover(rows[2])
assert.ok(rows[2].className.includes('lc-sl-row-on'))
assert.ok(!query(m.container, '.lc-donut').className.includes('lc-donut-dim'))
await hover(rows[1])
assert.ok(query(m.container, '.lc-donut').className.includes('lc-donut-dim'))
assert.ok((queryAll(m.container, '.lc-donut-seg')[1]?.getAttribute('class') ?? '').includes('lc-donut-seg-on'))
await m.unmount()
})
+20 -7
View File
@@ -8,7 +8,7 @@ import { h } from '../../../src/client/react'
import { makeDonut } from '../../../src/client/components/donut'
import { makeStatsTokens } from '../../../src/client/components/statsTokens'
import type { TokenUsage } from '../../../src/shared/types'
import { makeKit, mount, query, queryAll } from '../helpers/kit'
import { makeKit, mount, query, queryAll, hover, unhover } from '../helpers/kit'
const kit = makeKit()
const StatsTokens = makeStatsTokens(kit, makeDonut(kit))
@@ -30,24 +30,37 @@ describe('StatsTokens', () => {
// Prompt-side hit = 300 / (100 + 300 + 0) = 75.00% (the chat line's formula);
// the rows normalize over the whole billed total (450).
assert.equal(query(m.container, '.lc-donut-center b').textContent, '75.00%')
assert.equal(query(m.container, '.lc-donut-center span').textContent, 'Cache hit')
assert.equal(query(m.container, '.lc-donut-center span').textContent, 'Cache Hit')
// Zero buckets stay hidden: the always-zero DeepSeek cache write drops out.
assert.equal(queryAll(m.container, '.lc-sl-row').length, 3)
assert.deepEqual(rowOf(m.container, 0), { pct: '67%', label: 'Cache read', count: '300' })
assert.deepEqual(rowOf(m.container, 1), { pct: '22%', label: 'Uncached input', count: '100' })
assert.deepEqual(rowOf(m.container, 0), { pct: '67%', label: 'Cached Input', count: '300' })
assert.deepEqual(rowOf(m.container, 1), { pct: '22%', label: 'Uncached Input', count: '100' })
assert.deepEqual(rowOf(m.container, 2), { pct: '11%', label: 'Output', count: '50 · incl. reasoning' })
await m.unmount()
})
test('hovering a legend row lights its donut segment and the row itself', async () => {
const m = await mount(h(StatsTokens, { usage: USAGE }))
const rows = queryAll(m.container, '.lc-sl-row')
await hover(rows[0])
assert.ok(query(m.container, '.lc-donut').className.includes('lc-donut-dim'))
assert.ok((queryAll(m.container, '.lc-donut-seg')[0]?.getAttribute('class') ?? '').includes('lc-donut-seg-on'))
assert.ok(rows[0].className.includes('lc-sl-row-on'))
await unhover(rows[0])
assert.ok(!query(m.container, '.lc-donut').className.includes('lc-donut-dim'))
assert.ok(!rows[0].className.includes('lc-sl-row-on'))
await m.unmount()
})
test('non-zero cache writes render as a fourth bucket', async () => {
const written: TokenUsage = { uncachedInputTokens: 100, outputTokens: 50, cacheReadTokens: 300, cacheWriteTokens: 50 }
const m = await mount(h(StatsTokens, { usage: written }))
// Hit = 300 / (100 + 300 + 50) = 66.66%; rows normalize over billed 500.
assert.equal(query(m.container, '.lc-donut-center b').textContent, '66.66%')
assert.equal(queryAll(m.container, '.lc-sl-row').length, 4)
assert.deepEqual(rowOf(m.container, 0), { pct: '60%', label: 'Cache read', count: '300' })
assert.deepEqual(rowOf(m.container, 1), { pct: '10%', label: 'Cache write', count: '50' })
assert.deepEqual(rowOf(m.container, 2), { pct: '20%', label: 'Uncached input', count: '100' })
assert.deepEqual(rowOf(m.container, 0), { pct: '60%', label: 'Cached Input', count: '300' })
assert.deepEqual(rowOf(m.container, 1), { pct: '10%', label: 'Cache Write', count: '50' })
assert.deepEqual(rowOf(m.container, 2), { pct: '20%', label: 'Uncached Input', count: '100' })
assert.deepEqual(rowOf(m.container, 3), { pct: '10%', label: 'Output', count: '50 · incl. reasoning' })
await m.unmount()
})