feat(ui): themed hover tooltip on the composition bar segments

The Current composition stacked bar (and the request-detail mini bar)
now shows a theme-native tooltip on hover per segment — category, token
count, and share of the total — positioned at the pointer and clamped
inside the bar. Replaces the native title attribute; the wrapper keeps
the tooltip outside the rounded/clipped stack.
This commit is contained in:
bowenliang123 committed 2026-08-14 19:25:06 +08:00
1 parent f8ac2f45cb
commit ed63b7f0d8
2 files changed
+54 -12

No files matched your search

+15 -1
View File
@@ -276,4 +276,18 @@ ctxSlots[3][1](null) // leave the plot
tr = renderView()
assert.match(detailStep(tr), /T3/, 'leaving the plot reverts the detail to the newest request')
console.log('✔ chart render test passed (fixed-width bars, scroll container, turn ranges, hover linking)')
// ---- overview stacked bar: themed hover tooltip per segment ----
const overviewStack = byClass(tr, 'lc-stacked').find(s => s.args[1].style.height === '16px')
assert.ok(overviewStack, 'overview stacked bar present')
const segment = overviewStack.args.slice(2).flat().find(s => s !== null)
assert.ok(segment, 'overview has segments')
assert.equal(segment.args[1].title, undefined, 'native title replaced by the custom tooltip')
assert.equal(typeof segment.args[1].onMouseEnter, 'function', 'segments carry onMouseEnter')
segment.args[1].onMouseEnter({ clientX: 120 }) // fake pointer; ref is null in tests -> centered fallback
tr = renderView()
const tip = byClass(tr, 'lc-bar-tip')
assert.equal(tip.length, 1, 'hovering a segment shows the tooltip')
assert.match(textOf(tip[0]), /\(10%\)/, 'tooltip shows the segment share of the total')
assert.equal(typeof tip[0].args[1].style.left, 'string', 'tooltip is positioned along the pointer')
console.log('✔ chart render test passed (fixed-width bars, scroll container, turn ranges, hover linking, overview tooltip)')
+39 -11
View File
@@ -292,17 +292,43 @@ function makeView(ctx: ClientCtx, t: Translate): (props: ContextViewProps) => Re
let total = 0
for (const p of props.parts) total += p.value
const scale = props.max !== undefined && props.max > total ? props.max : total
return h('div', { className: 'lc-stacked', style: { height: (props.height || 14) + 'px' } },
total <= 0
? null
: props.parts.map(p => {
if (!p.value) return null
return h('div', {
key: p.key,
title: catLabel(p.key) + ' ' + fmt(p.value) + ' (' + Math.round(p.value / total * 100) + '%)',
style: { width: (p.value / scale * 100) + '%', background: p.color },
})
}))
// Themed hover tooltip per segment: shows the category, its tokens, and
// its share of the parts' total. The tooltip is positioned at the
// pointer's x within the bar (clamped) and floats above the stack — the
// wrapper keeps it outside the rounded/clipped stack itself.
const wrapRef = React.useRef<HTMLDivElement | null>(null)
const [tip, setTip] = React.useState<{ key: string; text: string; left: number } | null>(null)
const showTip = (e: ReactNS.MouseEvent<HTMLDivElement>, p: PartsPart): void => {
const rect = wrapRef.current?.getBoundingClientRect()
const left = rect ? e.clientX - rect.left : 50
const width = rect?.width ?? 200
setTip({
key: p.key,
text: catLabel(p.key) + ' ' + fmt(p.value) + ' (' + Math.round(p.value / total * 100) + '%)',
left: Math.max(48, Math.min(left, width - 48)),
})
}
return h('div', { className: 'lc-stacked-wrap' },
h('div', {
className: 'lc-stacked',
style: { height: (props.height || 14) + 'px' },
ref: wrapRef,
onMouseLeave: () => { setTip(null) },
},
total <= 0
? null
: props.parts.map(p => {
if (!p.value) return null
return h('div', {
key: p.key,
style: { width: (p.value / scale * 100) + '%', background: p.color },
onMouseEnter: (e: ReactNS.MouseEvent<HTMLDivElement>) => { showTip(e, p) },
onMouseMove: (e: ReactNS.MouseEvent<HTMLDivElement>) => { showTip(e, p) },
})
})),
tip ? h('div', { className: 'lc-bar-tip', style: { left: tip.left + 'px' } }, tip.text) : null)
}
function Legend(props: { parts: PartsPart[] }): ReactNS.ReactElement {
@@ -602,7 +628,9 @@ const STYLES = [
'.lc-overview-num { margin-bottom: 8px; }',
'.lc-overview-num b { font-size: 20px; }',
'.lc-overview-num span { color: var(--dsw-alias-label-secondary); }',
'.lc-stacked-wrap { position: relative; width: 100%; }',
'.lc-stacked { display: flex; width: 100%; border-radius: 5px; overflow: hidden; background: rgba(128,128,128,0.18); }',
'.lc-bar-tip { position: absolute; bottom: calc(100% + 6px); transform: translateX(-50%); z-index: 5; white-space: nowrap; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 3px 8px; font-size: 12px; color: var(--dsw-alias-label-primary); box-shadow: 0 2px 8px rgba(0,0,0,0.18); pointer-events: none; }',
'.lc-stacked > div { height: 100%; }',
'.lc-legend { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 10px; }',
'.lc-chip { display: inline-flex; align-items: center; gap: 5px; color: var(--dsw-alias-label-primary); }',