fix(epg): re-centre the ribbon window in the same pass as a zoom

Review follow-up (Codex): a zoom (button, Ctrl/⌘ wheel, coalesced wheel
burst) or a group expansion changed the scale before the anchored
scrollLeft landed on a later frame, so the window was the previous
centre at the new scale until the next scroll event re-measured it. The
ribbon could flash empty or show the wrong section. The zoom controller
now hands the window the minute its anchored scroll will centre, and a
group expansion the group's centre, together with the new scale
(TimelineWindowController.centreOnMinute).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-07 20:51:34 +02:00
1 parent 2c53ac66b3
commit 5fc8ef3774
4 files changed
+85

No files matched your search

@@ -252,6 +252,70 @@ describe('EpgTimelineComponent ribbon windowing', () => {
expect(titles).not.toContain('Slot 96');
});
/** Render items overlapping the ribbon's px range at the current scale. */
function itemsIn(leftPx: number, widthPx: number): string[] {
return component
.renderItems()
.filter(
(entry) =>
entry.leftPx + entry.widthPx >= leftPx &&
entry.leftPx <= leftPx + widthPx
)
.map((entry) => entry.key);
}
// The anchored scroll lands on a later frame; the window must already
// cover what the ribbon will show at the new scale.
it('windows the anchored range in the same pass as a wheel zoom', () => {
render(mockSchedule());
const prevScale = component.scale();
const left = ribbon().scrollLeft;
// Zoom in to the maximum around the right edge of the ribbon.
const anchorPx = ribbonWidth - 20;
component.onRibbonWheel(
new WheelEvent('wheel', {
ctrlKey: true,
clientX: anchorPx,
deltaY: -1000,
})
);
fixture.detectChanges();
const nextScale = component.scale();
expect(nextScale).toBeGreaterThan(prevScale);
const anchoredLeft =
((left + anchorPx) / prevScale) * nextScale - anchorPx;
const windowed = component.ribbonWindow.items().map((e) => e.key);
expect(windowed).toEqual(
expect.arrayContaining(itemsIn(anchoredLeft, ribbonWidth))
);
});
it('windows an expanded group before the ribbon scrolls to it', () => {
render(mockSchedule());
const first = component
.blocks()
.find((block) => block.program.title === 'Slot 200');
const last = component
.blocks()
.find((block) => block.program.title === 'Slot 203');
if (!first || !last) throw new Error('missing slots');
jest.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(
() => undefined
);
component.onGroupExpand({
startMs: first.startMs,
stopMs: last.stopMs,
} as Parameters<EpgTimelineComponent['onGroupExpand']>[0]);
fixture.detectChanges();
expect(renderedTitles()).toEqual(
expect.arrayContaining(['Slot 200', 'Slot 203'])
);
expect(renderedTitles()).not.toContain('Slot 96');
});
it('does not re-render for a scroll within a quarter viewport', () => {
render(mockSchedule());
const before = component.ribbonWindow.items();
@@ -228,6 +228,20 @@ export class TimelineWindowController {
}
}
/**
* Re-centres the window on the ribbon minute a scale change is about to
* show, in the same pass as the new scale. The scroll event that would
* re-measure arrives only after the anchored `scrollLeft` lands on a
* later frame; until then the window would be the previous centre at the
* new scale, which can be far from what the ribbon shows.
*/
centreOnMinute(offsetMin: number): void {
this.viewport.set({
centreMs: this.ctx.axis().startMs + offsetMin * TIMELINE_MINUTE_MS,
widthPx: this.viewport().widthPx,
});
}
/** Follow the scroll position: re-centre once it moved far enough. */
measure(): void {
const scroller = this.scroller();
@@ -23,6 +23,8 @@ export interface TimelineZoomContext {
readonly ribbon: () => HTMLElement | undefined;
readonly scale: () => number;
readonly setScale: (scale: number) => void;
/** The ribbon minute the anchored scroll will centre, set with the scale. */
readonly centreOn?: (offsetMin: number) => void;
}
/**
@@ -97,6 +99,9 @@ export class TimelineZoomController {
const currentLeft = this.pendingScrollLeft ?? scroller.scrollLeft;
const anchorMin = (currentLeft + anchorPx) / prev;
this.pendingScrollLeft = anchorMin * next - anchorPx;
this.ctx.centreOn?.(
(this.pendingScrollLeft + scroller.clientWidth / 2) / next
);
if (this.frame === 0) {
this.frame = requestAnimationFrame(() => this.flushScroll());
}
@@ -145,6 +145,7 @@ export class EpgTimelineComponent {
ribbon: () => this.ribbon()?.nativeElement,
scale: () => this.scale(),
setScale: (scale) => this.scale.set(scale),
centreOn: (offsetMin) => this.ribbonWindow.centreOnMinute(offsetMin),
});
/** Ribbon scrolling + channel-select auto-focus, extracted from the view. */
@@ -347,6 +348,7 @@ export class EpgTimelineComponent {
const axis = this.axis();
const centreMs = (group.startMs + group.stopMs) / 2;
const offsetMin = (centreMs - axis.startMs) / TIMELINE_MINUTE_MS;
this.ribbonWindow.centreOnMinute(offsetMin);
this.scroll.scrollToOffset(offsetMin, 0.5);
}