From 25fe067c2e524a04ace79b35aa0616bb2f14cede Mon Sep 17 00:00:00 2001 From: Bowen Liang Date: Mon, 24 Aug 2026 04:01:47 +0800 Subject: [PATCH] fix: keep turn strip labels centered in their block's visible slice while scrolling --- src/client/components/trendChart.tsx | 58 ++++++++++++++++++++++++++-- src/client/styles.css | 3 ++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/client/components/trendChart.tsx b/src/client/components/trendChart.tsx index fe80e6a..e27887f 100644 --- a/src/client/components/trendChart.tsx +++ b/src/client/components/trendChart.tsx @@ -219,6 +219,21 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN grp.span += req.stepCount ?? 1 } + // Prefix geometry of the strip blocks in content px: the scroll handler + // re-centers each label inside its block's VISIBLE slice analytically, + // so it only measures the handful of labels currently on screen. + const turnOffsets: number[] = [] + const turnWidths: number[] = [] + { + let x = 0 + for (const grp of groups) { + const w = grp.agg ? BAR_W : grp.span * (BAR_W + BAR_GAP) - BAR_GAP + turnOffsets.push(x) + turnWidths.push(w) + x += w + BAR_GAP + } + } + // Default anchor: the newest bars at the RIGHT edge. The first layout // after mount scrolls unconditionally; a GRANULARITY SWITCH re-anchors // the same way (returning to step mode must show the newest bars, not @@ -246,6 +261,38 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN edgesRef.current = { left, right } setEdges({ left, right }) } + /** + * Keep each turn label centered within its block's VISIBLE slice, so a + * label never scrolls out of view while any part of its block remains + * on screen. Blocks narrower than their label stay put (the label is + * already ellipsized). Reads (offsetWidth) are gathered before writes + * (transform) to avoid layout thrash on the scroll path; out-of-view + * blocks need no measurement at all. + */ + const updateTurnLabels = (el: HTMLDivElement): void => { + const labels = el.querySelectorAll('.lc-turn-label') + const n = Math.min(labels.length, turnOffsets.length) + const sl = el.scrollLeft + const vr = sl + el.clientWidth + const writes: [HTMLElement, string][] = [] + for (let i = 0; i < n; i++) { + const off = turnOffsets[i] + const w = turnWidths[i] + const visL = Math.max(off, sl) + const visR = Math.min(off + w, vr) + let dx = 0 + if (visR > visL) { + const lw = labels[i].offsetWidth + if (lw < w) { + const center = (visL + visR) / 2 - off + dx = Math.min(Math.max(center, lw / 2), w - lw / 2) - w / 2 + } + } + const next = dx !== 0 ? `translateX(${dx}px)` : '' + if (labels[i].style.transform !== next) writes.push([labels[i], next]) + } + for (const [label, next] of writes) label.style.transform = next + } React.useLayoutEffect(() => { const el = scrollRef.current if (el === null) return @@ -271,6 +318,7 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN el.scrollLeft = el.scrollWidth } updateEdges(el) + updateTurnLabels(el) }) // Compact single-line hover tooltip (shown instantly by the custom @@ -305,7 +353,10 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN // bars and strip blocks OUTSIDE the active turn fade to 35%. className={'lc-chart-scroll' + (props.activeTurn !== null ? ' lc-chart-dim' : '')} ref={scrollRef} - onScroll={(e: ReactNS.UIEvent) => { updateEdges(e.currentTarget) }} + onScroll={(e: ReactNS.UIEvent) => { + updateEdges(e.currentTarget) + updateTurnLabels(e.currentTarget) + }} > {/* Edge fades: visible whenever more history sits beyond the viewport, so the horizontal scroll affordance is obvious. */} @@ -351,19 +402,18 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN // blocks span their steps' columns. The flex gap between blocks // mirrors the bar gap, so blocks always line up with the bars. const on = props.activeTurn === grp.turn - const blockW = grp.agg ? BAR_W : grp.span * (BAR_W + BAR_GAP) - BAR_GAP return ( { props.onHoverTurn(grp.turn) }} onClick={() => { props.onPickTurn(grp.turn) }} - >{`T${grp.turn}`} + >{`T${grp.turn}`} ) })} diff --git a/src/client/styles.css b/src/client/styles.css index 8ee862f..cf11a7b 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -207,6 +207,9 @@ .lc-turns { display: flex; gap: 2px; width: max-content; min-width: 100%; margin-top: 4px; } .lc-turn { flex: none; box-sizing: border-box; text-align: center; font-size: 10px; line-height: 14px; font-weight: 600; color: var(--dsw-alias-label-secondary); border-radius: 3px; height: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; transition: filter 120ms, opacity 120ms; } .lc-turn-on { filter: brightness(1.35); color: var(--dsw-alias-label-primary); } +/* The label is translated by JS to stay centered in the block's visible + slice; max-width keeps the block's ellipsis behavior for narrow blocks. */ +.lc-turn-label { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; } .lc-detail { margin-top: 12px; border-top: 1px solid var(--dsw-alias-border-l1); padding-top: 12px; } .lc-detail-head { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-bottom: 8px; color: var(--dsw-alias-label-secondary); } .lc-detail-head b { color: var(--dsw-alias-label-primary); }