From 67e0133f12e9a3b1e9a85b88a56df0747fcdc193 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 4 Oct 2026 11:30:31 +0200 Subject: [PATCH] perf(epg): render only the timeline programmes near the visible range (J3) Selecting a live channel rendered every programme block of its schedule (about 240 for the Xtream mock) while the stream was starting: about 6,000 of J3's 6,199 renderer.domMutationsToPlaying. The ribbon now renders the blocks, ticks and day dividers within half a viewport of the visible range; the track keeps the full schedule's width, so positions, the scrollbar and scroll-to-now are unchanged. A resize reported before the scroll-to-now must not re-centre the window (it once jumped to the schedule's start and back); resizes only widen it. Co-Authored-By: Claude Opus 5.5 --- .changes/epg-timeline-render-near-view.md | 8 + .../src/xtream-epg.e2e.ts | 41 ++- docs/architecture/iptvnator-ui-guidelines.md | 9 + .../epg-timeline-window.controller.spec.ts | 345 ++++++++++++++++++ .../epg-timeline-window.controller.ts | 275 ++++++++++++++ .../epg-timeline-zoom.controller.ts | 13 + .../epg-timeline/epg-timeline.component.html | 6 +- .../epg-timeline/epg-timeline.component.ts | 25 +- 8 files changed, 702 insertions(+), 20 deletions(-) create mode 100644 .changes/epg-timeline-render-near-view.md create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.ts diff --git a/.changes/epg-timeline-render-near-view.md b/.changes/epg-timeline-render-near-view.md new file mode 100644 index 000000000..76931f7ca --- /dev/null +++ b/.changes/epg-timeline-render-near-view.md @@ -0,0 +1,8 @@ +--- +type: perf +area: epg +--- + +Live channels start playing with less work competing for the first frame: the +programme guide below the player now draws only the programmes around the part +you are looking at, and fills in the rest as you scroll. diff --git a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts index dec87b886..a5a901a97 100644 --- a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts @@ -107,9 +107,10 @@ test('@epg @xtream @electron removes uploaded guide data and restores provider E await expect .poll(() => timelineBlockTitles(app.mainWindow)) .not.toContain('Temporary XMLTV Bulletin'); + // The programme on now: the ribbon renders the blocks around it. await expect .poll(() => timelineBlockTitles(app.mainWindow)) - .toContain(fixture.fullEpg[0].title); + .toContain(fixture.shortEpg[0].title); } finally { await closeElectronApp(app); await source.close(); @@ -281,14 +282,33 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) { app.mainWindow.locator('app-epg-timeline') ).toBeVisible({ timeout: 20000 }); - // The timeline renders the full multi-day window as blocks, - // sorted by start time (no per-day filtering — it scrolls). + // The timeline lays out the full multi-day window sorted by start + // time (no per-day filtering — it scrolls) and renders the blocks + // near the visible range: an ordered run around the current + // programme, and the last programme once scrolled to the end. const allTitles = [...fixture.fullEpg] .sort((a, b) => a.startTimestamp - b.startTimestamp) .map((listing) => listing.title); + await expect + .poll(async () => { + const titles = await timelineBlockTitles(app.mainWindow); + return ( + titles.includes(currentProgram.title) && + isContiguousRun(titles, allTitles) + ); + }) + .toBe(true); + await app.mainWindow + .locator('app-epg-timeline .epg-timeline__ribbon') + .evaluate((ribbon) => { + ribbon.scrollLeft = ribbon.scrollWidth; + }); await expect .poll(() => timelineBlockTitles(app.mainWindow)) - .toEqual(allTitles); + .toContain(allTitles[allTitles.length - 1]); + await app.mainWindow + .locator('app-epg-timeline .epg-timeline__jump') + .click(); // The current programme is highlighted as the "now" block. await expect( @@ -866,6 +886,19 @@ async function timelineBlockTitles( .then((titles) => titles.map((title) => title.trim()).filter(Boolean)); } +/** Whether `run` is a non-empty, in-order slice of `all`. */ +function isContiguousRun(run: string[], all: string[]): boolean { + if (run.length === 0) { + return false; + } + for (let start = 0; start + run.length <= all.length; start++) { + if (run.every((title, index) => all[start + index] === title)) { + return true; + } + } + return false; +} + async function getProgressWidthPercent( row: ReturnType ) { diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 7b57fb1d6..4f28241f1 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -370,6 +370,15 @@ remain local when the meaning is explicit. layouts keep the full EPG-only panel. - Keep the EPG content mounted while collapsed so current-program state can continue updating. +- The ribbon renders only the blocks, ticks and day dividers within half a + viewport of the visible range (`TimelineWindowController` in + `epg-timeline-window.controller.ts`); the track keeps the full schedule's + width, so positions, the scrollbar and scroll-to-now are unchanged. A + channel's full schedule is often a few hundred programmes, and rendering + them all competed with the stream's first frame (J3 in + [performance journeys](performance-journeys.md#j3-playback-start-playback-to-the-first-frame)). + Tests and features must not assume an off-screen programme is in the DOM: + scroll the ribbon to it first. ### Collapsible Live Sidebar 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 new file mode 100644 index 000000000..0725929f8 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.spec.ts @@ -0,0 +1,345 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject, of } from 'rxjs'; +import { EpgTimelineComponent } from './epg-timeline.component'; +import { + marksInRange, + renderItemsInRange, + TIMELINE_WINDOW_BUFFER_VIEWPORTS, + timelineWindowRange, + viewportFromScroller, + viewportNeedsWindow, +} from './epg-timeline-window.controller'; +import { + TIMELINE_MINUTE_MS, + TimelineRenderItem, + TimelineTick, +} from './epg-timeline.utils'; + +const SLOT_MIN = 30; + +function programAt(startOffsetMin: number, title: string): EpgProgram { + const start = new Date(Date.now() + startOffsetMin * TIMELINE_MINUTE_MS); + const stop = new Date(start.getTime() + SLOT_MIN * TIMELINE_MINUTE_MS); + return { + start: start.toISOString(), + stop: stop.toISOString(), + channel: 'ch', + title, + desc: null, + category: null, + }; +} + +/** + * The Xtream mock's shape: 240 half-hour slots, two days back, three ahead. + * Now falls in the middle of slot 96. + */ +function mockSchedule(): EpgProgram[] { + const firstOffsetMin = -2 * 24 * 60 - SLOT_MIN / 2; + return Array.from({ length: 240 }, (_, index) => + programAt(firstOffsetMin + index * SLOT_MIN, `Slot ${index}`) + ); +} + +function item(key: string, leftPx: number, widthPx: number) { + return { kind: 'group', key, leftPx, widthPx } as TimelineRenderItem; +} + +describe('timeline window helpers', () => { + const axis = { startMs: 0, endMs: 3 * 24 * 60 * TIMELINE_MINUTE_MS }; + + it('spans the viewport plus the buffer on both sides of the centre', () => { + const range = timelineWindowRange( + { centreMs: 1000 * TIMELINE_MINUTE_MS, widthPx: 800 }, + axis, + 2 + ); + const half = 800 * (0.5 + TIMELINE_WINDOW_BUFFER_VIEWPORTS); + expect(range).toEqual({ startPx: 2000 - half, endPx: 2000 + half }); + }); + + it('keeps the items overlapping the range, in order', () => { + const items = [ + item('before', 0, 90), + item('touching', 50, 60), + item('inside', 200, 50), + item('straddling', 280, 100), + item('after', 400, 50), + ]; + const kept = renderItemsInRange(items, { startPx: 100, endPx: 300 }); + expect(kept.map((entry) => entry.key)).toEqual([ + 'touching', + 'inside', + 'straddling', + ]); + }); + + it('keeps a mark whose label can still reach into the range', () => { + const marks: TimelineTick[] = [ + { ms: 1, offsetMin: 0 }, + { ms: 2, offsetMin: 400 }, + { ms: 3, offsetMin: 600 }, + { ms: 4, offsetMin: 1200 }, + ]; + const kept = marksInRange(marks, 1, { startPx: 500, endPx: 1000 }); + expect(kept.map((mark) => mark.ms)).toEqual([2, 3]); + }); + + it('measures the centre of the scrolled viewport in epoch time', () => { + expect(viewportFromScroller(1000, 400, axis, 2)).toEqual({ + centreMs: 600 * TIMELINE_MINUTE_MS, + widthPx: 400, + }); + }); + + it('re-windows after a quarter-viewport move or a wider ribbon only', () => { + const current = { centreMs: 0, widthPx: 800 }; + const at = (px: number, widthPx = 800) => ({ + centreMs: px * TIMELINE_MINUTE_MS, + widthPx, + }); + expect(viewportNeedsWindow(current, at(199), 1)).toBe(false); + expect(viewportNeedsWindow(current, at(-200), 1)).toBe(true); + expect(viewportNeedsWindow(current, at(0, 600), 1)).toBe(false); + expect(viewportNeedsWindow(current, at(0, 900), 1)).toBe(true); + }); +}); + +describe('EpgTimelineComponent ribbon windowing', () => { + let fixture: ComponentFixture; + let component: EpgTimelineComponent; + let ribbonWidth: number; + let frames: FrameRequestCallback[]; + + function flushFrames(): void { + while (frames.length > 0) { + frames.splice(0).forEach((callback) => callback(0)); + fixture.detectChanges(); + } + } + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [EpgTimelineComponent], + providers: [ + { + provide: MatDialog, + useValue: { + open: () => ({ afterClosed: () => of(undefined) }), + }, + }, + { + provide: TranslateService, + useValue: { + currentLang: 'en', + defaultLang: 'en', + onLangChange: new BehaviorSubject(null), + onTranslationChange: new BehaviorSubject(null), + onDefaultLangChange: new BehaviorSubject(null), + get: (key: string) => of(key), + instant: (key: string) => key, + }, + }, + ], + }); + ribbonWidth = 800; + jest.spyOn( + HTMLElement.prototype, + 'clientWidth', + 'get' + ).mockImplementation(() => ribbonWidth); + // Queue frame callbacks; `flushFrames` runs them like a frame would. + frames = []; + jest.spyOn(window, 'requestAnimationFrame').mockImplementation( + (callback) => frames.push(callback) + ); + jest.spyOn(window, 'cancelAnimationFrame').mockImplementation( + () => undefined + ); + HTMLElement.prototype.scrollTo = function ( + this: HTMLElement, + options?: ScrollToOptions | number + ) { + if (typeof options === 'object' && options.left !== undefined) { + this.scrollLeft = options.left; + } + } as HTMLElement['scrollTo']; + + fixture = TestBed.createComponent(EpgTimelineComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function render(programs: EpgProgram[]): void { + fixture.componentRef.setInput('programs', programs); + fixture.detectChanges(); + flushFrames(); + } + + function ribbon(): HTMLElement { + return fixture.nativeElement.querySelector('.epg-timeline__ribbon'); + } + + function renderedTitles(): string[] { + return Array.from( + fixture.nativeElement.querySelectorAll( + '.epg-timeline__block-title' + ) as NodeListOf + ).map((title) => title.textContent?.trim() ?? ''); + } + + function scrollRibbonTo(left: number): void { + ribbon().scrollLeft = left; + ribbon().dispatchEvent(new Event('scroll')); + flushFrames(); + } + + function blockLeftPx(title: string): number { + const block = component + .renderItems() + .find( + (entry) => + entry.kind === 'block' && + entry.block.program.title === title + ); + if (!block) { + throw new Error(`no render item for ${title}`); + } + return block.leftPx; + } + + it('renders the programmes around now, not the whole schedule', () => { + render(mockSchedule()); + + const titles = renderedTitles(); + expect(component.renderItems()).toHaveLength(240); + expect(titles.length).toBeGreaterThan(0); + // Twice the ribbon width (800px) of 52.5px blocks, plus the edges. + expect(titles.length).toBeLessThanOrEqual(34); + expect(titles).toContain('Slot 96'); + expect(titles).not.toContain('Slot 0'); + expect(titles).not.toContain('Slot 239'); + // The track keeps the width of the whole schedule. + const track = fixture.nativeElement.querySelector( + 'app-epg-timeline-track' + ) as HTMLElement; + expect(track.style.width).toBe(`${component.trackWidthPx()}px`); + }); + + it('keeps the on-now block, including its live state', () => { + render(mockSchedule()); + + const nowTitle = fixture.nativeElement.querySelector( + '.epg-timeline__block.is-now .epg-timeline__block-title' + ) as HTMLElement; + expect(nowTitle.textContent?.trim()).toBe('Slot 96'); + }); + + it('renders the blocks of a range the user scrolls to', () => { + render(mockSchedule()); + + scrollRibbonTo(blockLeftPx('Slot 230') - ribbonWidth / 2); + + const titles = renderedTitles(); + expect(titles).toContain('Slot 230'); + expect(titles).toContain('Slot 239'); + expect(titles).not.toContain('Slot 96'); + }); + + it('does not re-render for a scroll within a quarter viewport', () => { + render(mockSchedule()); + const before = component.ribbonWindow.items(); + + scrollRibbonTo(ribbon().scrollLeft + ribbonWidth / 8); + + expect(component.ribbonWindow.items()).toBe(before); + }); + + it('keeps the selected programme highlighted after scrolling away and back', () => { + render(mockSchedule()); + const selectedTitle = 'Slot 97'; + const selected = Array.from( + fixture.nativeElement.querySelectorAll( + '.epg-timeline__block' + ) as NodeListOf + ).find( + (block) => + block + .querySelector('.epg-timeline__block-title') + ?.textContent?.trim() === selectedTitle + ) as HTMLElement; + selected.click(); + fixture.detectChanges(); + const start = ribbon().scrollLeft; + + scrollRibbonTo(blockLeftPx('Slot 230')); + expect(renderedTitles()).not.toContain(selectedTitle); + scrollRibbonTo(start); + + const highlighted = fixture.nativeElement.querySelector( + '.epg-timeline__block.is-selected .epg-timeline__block-title' + ) as HTMLElement; + expect(highlighted.textContent?.trim()).toBe(selectedTitle); + }); + + it('marks a far-away catch-up programme as playing once it is in view', () => { + const schedule = mockSchedule(); + render(schedule); + fixture.componentRef.setInput('activeProgram', schedule[2]); + fixture.detectChanges(); + expect(renderedTitles()).not.toContain('Slot 2'); + + scrollRibbonTo(blockLeftPx('Slot 2')); + + const playing = fixture.nativeElement.querySelector( + '.epg-timeline__block.is-playing .epg-timeline__block-title' + ) as HTMLElement; + expect(playing.textContent?.trim()).toBe('Slot 2'); + }); + + it('widens the window when the ribbon grows', () => { + render(mockSchedule()); + const before = renderedTitles().length; + + ribbonWidth = 2400; + component.ribbonWindow.measureWidth(); + fixture.detectChanges(); + + expect(renderedTitles().length).toBeGreaterThan(before); + expect(renderedTitles()).toContain('Slot 96'); + }); + + it('keeps the window on now when a resize reports before the focus scroll', () => { + // The observer's first report can precede the auto-focus scroll, + // while the ribbon is still at its left edge: it must not move the + // window to the start of the schedule. + fixture.componentRef.setInput('programs', mockSchedule()); + fixture.detectChanges(); + expect(ribbon().scrollLeft).toBe(0); + + ribbonWidth = 1200; + component.ribbonWindow.measureWidth(); + fixture.detectChanges(); + + expect(renderedTitles()).toContain('Slot 96'); + expect(renderedTitles()).not.toContain('Slot 0'); + }); + + it('starts over around now when another channel loads', () => { + render(mockSchedule()); + scrollRibbonTo(blockLeftPx('Slot 230')); + expect(renderedTitles()).not.toContain('Slot 96'); + + render( + mockSchedule().map((program) => ({ ...program, channel: 'other' })) + ); + + expect(renderedTitles()).toContain('Slot 96'); + }); +}); 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 new file mode 100644 index 000000000..0319d96b4 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.ts @@ -0,0 +1,275 @@ +import { + computed, + effect, + ElementRef, + inject, + linkedSignal, + Signal, +} from '@angular/core'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { getTodayEpgDateKey, parseEpgDateKey } from '../epg-date'; +import { programsFocusKey } from './epg-timeline-scroll.controller'; +import { + TIMELINE_MINUTE_MS, + TimelineAxis, + TimelineBlock, + TimelineDayDivider, + TimelineRenderItem, + TimelineTick, +} from './epg-timeline.utils'; + +// ───────────────────────── ribbon windowing ───────────────────────── +// A channel's schedule is often a few hundred programmes over several days, +// but the ribbon shows a few hours. Rendering every block costs thousands of +// DOM nodes the moment a channel is selected, while its stream is starting +// (J3 in docs/architecture/performance-journeys.md). Only blocks, ticks and +// dividers near the visible range are rendered; the track keeps its full +// width, so scrolling, the scrollbar and every position are unchanged. + +/** Viewport widths rendered beyond each edge of the visible ribbon. */ +export const TIMELINE_WINDOW_BUFFER_VIEWPORTS = 0.5; +/** + * Scrolling re-windows only once the centre moved this share of a viewport. + * Below the buffer, so the visible range is always rendered between steps. + */ +export const TIMELINE_WINDOW_STEP_FRACTION = 0.25; +/** Room for a tick or day label that starts left of the window. */ +const TIMELINE_LABEL_ALLOWANCE_PX = 160; + +/** The centre of the rendered range (epoch ms) and the viewport width it covers. */ +export interface TimelineViewport { + readonly centreMs: number; + readonly widthPx: number; +} + +export interface TimelinePxRange { + readonly startPx: number; + readonly endPx: number; +} + +export function timelineWindowRange( + viewport: TimelineViewport, + axis: TimelineAxis, + scale: number +): TimelinePxRange { + const centrePx = + ((viewport.centreMs - axis.startMs) / TIMELINE_MINUTE_MS) * scale; + const halfPx = viewport.widthPx * (0.5 + TIMELINE_WINDOW_BUFFER_VIEWPORTS); + return { startPx: centrePx - halfPx, endPx: centrePx + halfPx }; +} + +/** Render items overlapping the range, in their original order. */ +export function renderItemsInRange( + items: readonly TimelineRenderItem[], + range: TimelinePxRange +): TimelineRenderItem[] { + return items.filter( + (item) => + item.leftPx + item.widthPx >= range.startPx && + item.leftPx <= range.endPx + ); +} + +/** Ticks or day dividers whose label can reach into the range. */ +export function marksInRange( + marks: readonly T[], + scale: number, + range: TimelinePxRange +): T[] { + return marks.filter((mark) => { + const leftPx = mark.offsetMin * scale; + return ( + leftPx >= range.startPx - TIMELINE_LABEL_ALLOWANCE_PX && + leftPx <= range.endPx + ); + }); +} + +export function viewportFromScroller( + scrollLeft: number, + clientWidth: number, + axis: TimelineAxis, + scale: number +): TimelineViewport { + const centreMin = (scrollLeft + clientWidth / 2) / scale; + return { + centreMs: axis.startMs + centreMin * TIMELINE_MINUTE_MS, + widthPx: clientWidth, + }; +} + +/** + * Whether a measured viewport needs a new window: the centre moved far enough + * or the ribbon got wider than the covered width. A narrower ribbon keeps the + * (larger) window, so a resize never removes blocks still in view. + */ +export function viewportNeedsWindow( + current: TimelineViewport, + next: TimelineViewport, + scale: number +): boolean { + if (next.widthPx > current.widthPx) { + return true; + } + const movedPx = + (Math.abs(next.centreMs - current.centreMs) / TIMELINE_MINUTE_MS) * + scale; + return movedPx >= current.widthPx * TIMELINE_WINDOW_STEP_FRACTION; +} + +/** The timeline component's signals the controller reads. */ +export interface TimelineWindowHost { + readonly ribbon: Signal | undefined>; + readonly programs: Signal; + readonly axis: Signal; + readonly blocks: Signal; + readonly scale: Signal; + readonly nowMs: Signal; + readonly viewDayKey: Signal; + readonly renderItems: Signal; + readonly ticks: Signal; + readonly dividers: Signal; +} + +/** + * Picks the ribbon content to render. Before the ribbon reports a scroll + * position, the window is centred where the auto-focus lands (the programme + * on now, or noon of a viewed non-today day) and spans the timeline host's + * width (the browser window's before the host is laid out), an upper bound + * of the ribbon's. A new channel or a remounted ribbon starts over from that + * estimate. Construct it in an injection context (a component field): it + * reads the host element and observes the ribbon's size. + */ +export class TimelineWindowController { + private measureFrame = 0; + private readonly hostElement = inject>(ElementRef); + + private readonly identity = computed( + () => ({ + ribbon: this.scroller(), + key: programsFocusKey(this.ctx.programs()), + }), + { + equal: (left, right) => + left.ribbon === right.ribbon && left.key === right.key, + } + ); + private readonly measured = linkedSignal({ + source: this.identity, + computation: () => null, + }); + private readonly viewport = computed( + () => this.measured() ?? this.initialViewport() + ); + private readonly range = computed(() => + timelineWindowRange(this.viewport(), this.ctx.axis(), this.ctx.scale()) + ); + + readonly items = computed(() => + renderItemsInRange(this.ctx.renderItems(), this.range()) + ); + readonly ticks = computed(() => + marksInRange(this.ctx.ticks(), this.ctx.scale(), this.range()) + ); + readonly dividers = computed(() => + marksInRange(this.ctx.dividers(), this.ctx.scale(), this.range()) + ); + + constructor(private readonly ctx: TimelineWindowHost) { + effect((onCleanup) => { + const scroller = this.scroller(); + if (scroller) { + onCleanup(this.observe(scroller)); + } + }); + } + + /** Re-measure on the next frame (scroll); repeated calls coalesce. */ + scheduleMeasure(): void { + if (this.measureFrame) { + return; + } + this.measureFrame = requestAnimationFrame(() => { + this.measureFrame = 0; + this.measure(); + }); + } + + /** Follow the ribbon's scrolling and size; returns the cleanup. */ + private observe(ribbon: HTMLElement): () => void { + const onScroll = () => this.scheduleMeasure(); + ribbon.addEventListener('scroll', onScroll, { passive: true }); + const observer = + typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(() => this.measureWidth()); + observer?.observe(ribbon); + return () => { + ribbon.removeEventListener('scroll', onScroll); + observer?.disconnect(); + cancelAnimationFrame(this.measureFrame); + this.measureFrame = 0; + }; + } + + /** + * A resize widens the window around its current centre and never moves + * it: the observer's first report can come before the auto-focus scroll, + * while the ribbon is still at its left edge. + */ + measureWidth(): void { + const widthPx = this.scroller()?.clientWidth ?? 0; + const current = this.viewport(); + if (widthPx > current.widthPx) { + this.measured.set({ centreMs: current.centreMs, widthPx }); + } + } + + /** Follow the scroll position: re-centre once it moved far enough. */ + measure(): void { + const scroller = this.scroller(); + if (!scroller || scroller.clientWidth <= 0) { + return; + } + const scale = this.ctx.scale(); + const next = viewportFromScroller( + scroller.scrollLeft, + scroller.clientWidth, + this.ctx.axis(), + scale + ); + if (viewportNeedsWindow(this.viewport(), next, scale)) { + this.measured.set(next); + } + } + + private scroller(): HTMLElement | undefined { + return this.ctx.ribbon()?.nativeElement; + } + + private initialViewport(): TimelineViewport { + const hostWidth = this.hostElement.nativeElement.clientWidth; + const widthPx = + hostWidth > 0 + ? hostWidth + : typeof window === 'undefined' + ? 0 + : window.innerWidth; + const dayKey = this.ctx.viewDayKey(); + if (dayKey !== getTodayEpgDateKey()) { + return { + centreMs: + parseEpgDateKey(dayKey).getTime() + + 12 * 60 * TIMELINE_MINUTE_MS, + widthPx, + }; + } + const current = this.ctx.blocks().find((block) => block.when === 'now'); + return { + centreMs: current + ? (current.startMs + current.stopMs) / 2 + : this.ctx.nowMs(), + widthPx, + }; + } +} 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 5a236f0d6..498fe2c15 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 @@ -3,8 +3,21 @@ import { TIMELINE_WHEEL_ZOOM_RATE, TIMELINE_ZOOM_MAX, TIMELINE_ZOOM_MIN, + TimelineZoomLevel, } from './epg-timeline-render.util'; +/** Translation key naming a zoom level in the toolbar tooltip. */ +export function timelineZoomLabelKey(level: TimelineZoomLevel): string { + switch (level) { + case 'day': + return 'EPG.TIMELINE.ZOOM_DAY'; + case 'hours': + return 'EPG.TIMELINE.ZOOM_HOURS'; + default: + return 'EPG.TIMELINE.ZOOM_DETAIL'; + } +} + export interface TimelineZoomContext { /** The horizontal scroller hosting the track (undefined until rendered). */ readonly ribbon: () => HTMLElement | undefined; diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html index 6819e63e8..13381f94a 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html @@ -209,9 +209,9 @@ (wheel)="onRibbonWheel($event)" > (null); /** Day centred in the ribbon, seeded from the controlled `selectedDate` so * a non-today date survives (re)mount and follows host changes. */ - private readonly viewDayKey = linkedSignal(() => { + readonly viewDayKey = linkedSignal(() => { const key = this.selectedDate()?.trim(); return key ? key : getTodayEpgDateKey(); }); @@ -200,6 +204,8 @@ export class EpgTimelineComponent { buildTimelineTicks(this.axis(), timelineTickStepForScale(this.scale())) ); readonly dividers = computed(() => buildTimelineDayDividers(this.axis())); + /** Only the ribbon content near the visible range is rendered. */ + readonly ribbonWindow = new TimelineWindowController(this); readonly trackWidthPx = computed(() => { const axis = this.axis(); return ( @@ -215,16 +221,9 @@ export class EpgTimelineComponent { readonly zoomLevel = computed(() => timelineZoomLevelForScale(this.scale()) ); - readonly zoomLabelKey = computed(() => { - switch (this.zoomLevel()) { - case 'day': - return 'EPG.TIMELINE.ZOOM_DAY'; - case 'hours': - return 'EPG.TIMELINE.ZOOM_HOURS'; - default: - return 'EPG.TIMELINE.ZOOM_DETAIL'; - } - }); + readonly zoomLabelKey = computed(() => + timelineZoomLabelKey(this.zoomLevel()) + ); /** `zoom_out` reads as "overview"; both finer bands share `zoom_in`. */ readonly zoomIcon = computed(() => this.zoomLevel() === 'day' ? 'zoom_out' : 'zoom_in'