diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.spec.ts index 3c6d89321..c5702c5a7 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.spec.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.spec.ts @@ -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[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(); diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.ts index db33558dc..77c2ff918 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.ts @@ -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(); diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-zoom.controller.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-zoom.controller.ts index 498fe2c15..7c6488e36 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-zoom.controller.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-zoom.controller.ts @@ -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()); } diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts index 5c25079c4..26297d043 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts @@ -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); }