fix(epg): window the scroll position a zoom can actually reach

Review follow-up (Greptile, Codex): a zoom-out anchored right of centre
at the ribbon's start computed a negative scroll position. The window
centred on it, the browser kept scrollLeft at 0, and with the position
unchanged no scroll event re-windowed, so the visible right-hand part
stayed empty. The centre now uses the position clamped at 0, and once
the frame applies scrollLeft the window re-centres on what the browser
kept, which also covers the clamp at the end of the track.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-07 21:05:35 +02:00
1 parent 5fc8ef3774
commit 623ffc672a
2 files changed
+64 -1

No files matched your search

@@ -44,6 +44,17 @@ function mockSchedule(): EpgProgram[] {
);
}
/** Half-hour slots from local midnight two days back: the axis start. */
function midnightSchedule(): EpgProgram[] {
const midnight = new Date(Date.now() - 2 * 24 * 60 * TIMELINE_MINUTE_MS);
midnight.setHours(0, 0, 0, 0);
const startOffsetMin =
(midnight.getTime() - Date.now()) / TIMELINE_MINUTE_MS;
return Array.from({ length: 240 }, (_, index) =>
programAt(startOffsetMin + index * SLOT_MIN, `Slot ${index}`)
);
}
function item(key: string, leftPx: number, widthPx: number) {
return { kind: 'group', key, leftPx, widthPx } as TimelineRenderItem;
}
@@ -291,6 +302,49 @@ describe('EpgTimelineComponent ribbon windowing', () => {
);
});
// At the ribbon's start the anchored position is negative; the browser
// keeps scrollLeft at 0 and fires no scroll event to re-window.
it('windows the attainable range for a zoom-out at the ribbon start', () => {
render(midnightSchedule());
// From the deepest zoom, so the unclamped centre would be far off.
component.onZoom(6);
flushFrames();
scrollRibbonTo(0);
component.onRibbonWheel(
new WheelEvent('wheel', {
ctrlKey: true,
clientX: ribbonWidth - 20,
// 6 → about 1.4: above the grouping zoom, so blocks stay
// separate and a missing one shows.
deltaY: 727,
})
);
fixture.detectChanges();
expect(component.scale()).toBeGreaterThan(1.3);
const visible = itemsIn(0, ribbonWidth);
expect(visible.length).toBeGreaterThan(0);
const windowed = component.ribbonWindow.items().map((e) => e.key);
expect(windowed).toEqual(expect.arrayContaining(visible));
});
it('re-windows on the scroll position the browser applied', () => {
render(mockSchedule());
const end = blockLeftPx('Slot 239');
component.onZoom(component.scale() * 2);
// The browser clamps the anchored position to the end of the track.
const scroller = ribbon();
let applied = scroller.scrollLeft;
Object.defineProperty(scroller, 'scrollLeft', {
configurable: true,
get: () => applied,
set: () => (applied = end * 2),
});
flushFrames();
expect(renderedTitles()).toContain('Slot 239');
});
it('windows an expanded group before the ribbon scrolls to it', () => {
render(mockSchedule());
const first = component
@@ -99,8 +99,11 @@ export class TimelineZoomController {
const currentLeft = this.pendingScrollLeft ?? scroller.scrollLeft;
const anchorMin = (currentLeft + anchorPx) / prev;
this.pendingScrollLeft = anchorMin * next - anchorPx;
// The browser clamps scrollLeft at 0: a zoom-out anchored right of
// centre at the ribbon's start lands there, not left of it.
this.ctx.centreOn?.(
(this.pendingScrollLeft + scroller.clientWidth / 2) / next
(Math.max(0, this.pendingScrollLeft) + scroller.clientWidth / 2) /
next
);
if (this.frame === 0) {
this.frame = requestAnimationFrame(() => this.flushScroll());
@@ -115,6 +118,12 @@ export class TimelineZoomController {
const scroller = this.ctx.ribbon();
if (scroller && left !== null) {
scroller.scrollLeft = left;
// Where the browser put it: the end of the track clamps too, and
// an unchanged position fires no scroll event to re-window.
this.ctx.centreOn?.(
(scroller.scrollLeft + scroller.clientWidth / 2) /
this.ctx.scale()
);
}
}
}