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 482de63da..eb203605b 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 @@ -345,6 +345,26 @@ describe('EpgTimelineComponent ribbon windowing', () => { expect(renderedTitles()).toContain('Slot 239'); }); + // A time offset can carry the first programme across midnight: the axis + // origin and every track position move under an unchanged scrollLeft, + // and no scroll event follows. + it('re-measures the ribbon when the axis origin moves', () => { + render(midnightSchedule()); + const startBefore = component.axis().startMs; + const left = ribbon().scrollLeft; + + fixture.componentRef.setInput('offsetMinutes', -720); + fixture.detectChanges(); + + expect(component.axis().startMs).not.toBe(startBefore); + expect(ribbon().scrollLeft).toBe(left); + const visible = itemsIn(left, ribbonWidth); + expect(visible.length).toBeGreaterThan(0); + expect(component.ribbonWindow.items().map((e) => e.key)).toEqual( + expect.arrayContaining(visible) + ); + }); + it('windows an expanded group before the ribbon scrolls to it', () => { render(mockSchedule()); const first = component 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 77c2ff918..61307a6a6 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 @@ -149,20 +149,36 @@ export class TimelineWindowController { () => ({ ribbon: this.scroller(), key: programsFocusKey(this.ctx.programs()), + axisStartMs: this.ctx.axis().startMs, }), { equal: (left, right) => - left.ribbon === right.ribbon && left.key === right.key, + left.ribbon === right.ribbon && + left.key === right.key && + left.axisStartMs === right.axisStartMs, } ); /** * Estimated once per channel or ribbon mount, then moved only by the * scroll and resize measurements: a live estimate would follow the 30 s - * now tick and the centred day away from what the ribbon shows. + * now tick and the centred day away from what the ribbon shows. A moved + * axis origin (a time offset carrying the first programme across + * midnight) shifts every track position under an unchanged `scrollLeft`, + * with no scroll event, so the viewport is measured from the ribbon again. */ - private readonly viewport = linkedSignal({ + private readonly viewport = linkedSignal< + { ribbon: HTMLElement | undefined; key: string; axisStartMs: number }, + TimelineViewport + >({ source: this.identity, - computation: () => untracked(() => this.initialViewport()), + computation: (source, previous) => + untracked(() => + previous && + previous.source.ribbon === source.ribbon && + previous.source.key === source.key + ? (this.measuredViewport() ?? previous.value) + : this.initialViewport() + ), }); private readonly range = computed(() => timelineWindowRange(this.viewport(), this.ctx.axis(), this.ctx.scale()) @@ -260,6 +276,20 @@ export class TimelineWindowController { } } + /** The viewport the ribbon shows now; null before it is laid out. */ + private measuredViewport(): TimelineViewport | null { + const scroller = this.scroller(); + if (!scroller || scroller.clientWidth <= 0) { + return null; + } + return viewportFromScroller( + scroller.scrollLeft, + scroller.clientWidth, + this.ctx.axis(), + this.ctx.scale() + ); + } + private scroller(): HTMLElement | undefined { return this.ctx.ribbon()?.nativeElement; }