diff --git a/scripts/test-client.mjs b/scripts/test-client.mjs index c21822a..b5b5132 100644 --- a/scripts/test-client.mjs +++ b/scripts/test-client.mjs @@ -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)') diff --git a/src/client.cts b/src/client.cts index b0cf906..8ec98b7 100644 --- a/src/client.cts +++ b/src/client.cts @@ -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(null) + const [tip, setTip] = React.useState<{ key: string; text: string; left: number } | null>(null) + const showTip = (e: ReactNS.MouseEvent, 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) => { showTip(e, p) }, + onMouseMove: (e: ReactNS.MouseEvent) => { 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); }',