feat(ui): granularity switches re-anchor the history at the newest bars

Returning from turn to step mode kept the stale left edge (the narrow
turn chart had clamped the scroll position), so the newest bars were off
screen. The layout effect now detects a granularity change and re-runs the
first-mount anchor — both directions land on the right edge. Plain
re-renders (polls) still respect a scrolled-away position.
This commit is contained in:
bowenliang123 committed 2026-08-14 23:49:19 +08:00
1 parent 7eaec6d70b
commit 895ebbee1a
3 files changed
+36 -5

No files matched your search

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

+22 -1
View File
@@ -140,8 +140,9 @@ const statefulReact = {
},
useLayoutEffect(fn) {
// Store the callback in a hook slot so tests can drive the layout effect.
// Always overwrite: real React runs the LATEST render's closure.
const i = hookCursor++
if (currentHooks[i] === undefined) currentHooks[i] = { effect: fn }
currentHooks[i] = { effect: fn }
return currentHooks[i]
},
}
@@ -456,6 +457,26 @@ tr = renderView()
assert.equal(byClass(tr, 'lc-chart-fade-l').length, 1, 'left fade shown once anchored at the newest bars')
assert.equal(byClass(tr, 'lc-chart-fade-r').length, 0, 'no right fade at the end')
// ---- granularity switches re-anchor at the newest bars ----
// (the turn->step report: returning to step must show the right edge)
const latestEffect = () => hookStates.get(trendKey).find(s => s && typeof s.effect === 'function')
scrollEl.scrollLeft = 0 // stale left edge from the narrow turn chart
const granTurnBtn = byClass(tr, 'lc-gran-btn')[1]
granTurnBtn.args[1].onClick() // step -> turn
tr = renderView()
latestEffect().effect()
assert.equal(scrollEl.scrollLeft, 800, 'switching to turn re-anchors at the newest bars')
scrollEl.scrollLeft = 0
const granStepBtn = byClass(tr, 'lc-gran-btn')[0]
granStepBtn.args[1].onClick() // turn -> step
tr = renderView()
latestEffect().effect()
assert.equal(scrollEl.scrollLeft, 800, 'switching back to step re-anchors at the newest bars')
// a plain re-render (poll) without a switch must NOT yank a scrolled-away view
scrollEl.scrollLeft = 200
latestEffect().effect()
assert.equal(scrollEl.scrollLeft, 200, 'plain re-renders still respect the scroll position')
// ---- message list: newest first, with timestamps when available ----
const nodeRows = byClass(tr, 'lc-node')
assert.equal(nodeRows.length, 2, 'message rows rendered')
+14 -4
View File
@@ -288,6 +288,8 @@ interface TrendChartProps {
hoveredSeq: number | null
/** The turn currently highlighted (from the turn strip or a hovered bar). */
activeTurn: number | null
/** Display granularity; a switch re-anchors the chart at the newest bars. */
granularity: 'step' | 'turn'
onSelect: (seq: number | null) => void
onHover: (seq: number | null) => void
onHoverTurn: (turn: number | null) => void
@@ -457,12 +459,15 @@ function makeView(ctx: ClientCtx, t: Translate): (props: ContextViewProps) => Re
}
// Default anchor: the newest bars at the RIGHT edge. The first layout
// after mount scrolls unconditionally; afterwards the chart only sticks
// to the end while the user is already near it (scrolling away is
// respected). useLayoutEffect avoids a first-paint flash at the left.
// Edge fades stay in sync with the scroll position.
// after mount scrolls unconditionally; a GRANULARITY SWITCH re-anchors
// the same way (returning to step mode must show the newest bars, not
// the stale left edge from the narrow turn chart); otherwise the chart
// only sticks to the end while the user is already near it (scrolling
// away is respected). useLayoutEffect avoids a first-paint flash at the
// left. Edge fades stay in sync with the scroll position.
const scrollRef = React.useRef<HTMLDivElement | null>(null)
const scrolledOnce = React.useRef(false)
const lastGranRef = React.useRef(props.granularity)
const [edges, setEdges] = React.useState<{ left: boolean; right: boolean }>({ left: false, right: false })
const updateEdges = (el: HTMLDivElement): void => {
const left = el.scrollLeft > 4
@@ -472,6 +477,10 @@ function makeView(ctx: ClientCtx, t: Translate): (props: ContextViewProps) => Re
React.useLayoutEffect(() => {
const el = scrollRef.current
if (el === null) return
if (props.granularity !== lastGranRef.current) {
lastGranRef.current = props.granularity
scrolledOnce.current = false // re-anchor on every granularity switch
}
if (!scrolledOnce.current) {
scrolledOnce.current = true
el.scrollLeft = el.scrollWidth
@@ -763,6 +772,7 @@ function makeView(ctx: ClientCtx, t: Translate): (props: ContextViewProps) => Re
selectedSeq: pinnedReq ? pinnedReq.seq : null,
hoveredSeq,
activeTurn,
granularity,
onSelect: setSelectedSeq,
onHover: setHoveredSeq,
onHoverTurn: setHoverTurn,