From a658dc4feaa0aa5037092e6a99b2f1463bf658bd Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Tue, 1 Sep 2026 15:02:59 +0800 Subject: [PATCH] feat(client): link the stats legend rows to their donut slices on hover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/client/components/donut.tsx | 16 ++++++- src/client/components/sliceList.tsx | 30 +++++++++++--- src/client/components/statsTiming.tsx | 15 ++++--- src/client/components/statsTokens.tsx | 17 ++++---- src/client/i18n.ts | 12 +++--- src/client/styles/stats.css | 13 +++++- tests/client/components/donut.spec.ts | 46 ++++++++++++++++++++- tests/client/components/sliceList.spec.ts | 34 ++++++++++++++- tests/client/components/statsTiming.spec.ts | 11 ++++- tests/client/components/statsTokens.spec.ts | 27 ++++++++---- 10 files changed, 177 insertions(+), 44 deletions(-) diff --git a/src/client/components/donut.tsx b/src/client/components/donut.tsx index 67e86f1..4398ee7 100644 --- a/src/client/components/donut.tsx +++ b/src/client/components/donut.tsx @@ -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 ( -
+
{ if (props.onHoverKey !== undefined) props.onHoverKey(null) }} + > diff --git a/src/client/components/sliceList.tsx b/src/client/components/sliceList.tsx index 1b5108c..3ab4691 100644 --- a/src/client/components/sliceList.tsx +++ b/src/client/components/sliceList.tsx @@ -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 ( -
+
{ if (props.onHoverKey !== undefined) props.onHoverKey(null) }}> {props.rows.map(r => ( -
+
{ if (props.onHoverKey !== undefined) props.onHoverKey(r.key) }} + >
{r.label} diff --git a/src/client/components/statsTiming.tsx b/src/client/components/statsTiming.tsx index 37727bd..9c23bde 100644 --- a/src/client/components/statsTiming.tsx +++ b/src/client/components/statsTiming.tsx @@ -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(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} /> - +
)}
diff --git a/src/client/components/statsTokens.tsx b/src/client/components/statsTokens.tsx index c9d5c86..911e90e 100644 --- a/src/client/components/statsTokens.tsx +++ b/src/client/components/statsTokens.tsx @@ -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(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} /> - +
) diff --git a/src/client/i18n.ts b/src/client/i18n.ts index 911bd5b..f178cac 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -38,9 +38,9 @@ export const DICT_ZH: Record = { '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 = { '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', diff --git a/src/client/styles/stats.css b/src/client/styles/stats.css index bb7fa1c..5c0556a 100644 --- a/src/client/styles/stats.css +++ b/src/client/styles/stats.css @@ -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; } diff --git a/tests/client/components/donut.spec.ts b/tests/client/components/donut.spec.ts index 187c4a0..4ad4e60 100644 --- a/tests/client/components/donut.spec.ts +++ b/tests/client/components/donut.spec.ts @@ -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(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() + }) +}) diff --git a/tests/client/components/sliceList.spec.ts b/tests/client/components/sliceList.spec.ts index fd355f0..cc8fe92 100644 --- a/tests/client/components/sliceList.spec.ts +++ b/tests/client/components/sliceList.spec.ts @@ -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(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() + }) }) diff --git a/tests/client/components/statsTiming.spec.ts b/tests/client/components/statsTiming.spec.ts index b0f2500..bb6474a 100644 --- a/tests/client/components/statsTiming.spec.ts +++ b/tests/client/components/statsTiming.spec.ts @@ -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() }) diff --git a/tests/client/components/statsTokens.spec.ts b/tests/client/components/statsTokens.spec.ts index 2085fc6..1b620cf 100644 --- a/tests/client/components/statsTokens.spec.ts +++ b/tests/client/components/statsTokens.spec.ts @@ -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() })