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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-04 11:30:31 +02:00
1 parent e8b181fcea
commit 67e0133f12
8 files changed
+702 -20

No files matched your search

@@ -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.
@@ -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<typeof channelItemByTitle>
) {
@@ -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
@@ -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<EpgTimelineComponent>;
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<HTMLElement>
).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<HTMLElement>
).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');
});
});
@@ -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<T extends TimelineTick | TimelineDayDivider>(
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<ElementRef<HTMLElement> | undefined>;
readonly programs: Signal<readonly EpgProgram[]>;
readonly axis: Signal<TimelineAxis>;
readonly blocks: Signal<readonly TimelineBlock[]>;
readonly scale: Signal<number>;
readonly nowMs: Signal<number>;
readonly viewDayKey: Signal<string>;
readonly renderItems: Signal<readonly TimelineRenderItem[]>;
readonly ticks: Signal<readonly TimelineTick[]>;
readonly dividers: Signal<readonly TimelineDayDivider[]>;
}
/**
* 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<HTMLElement>>(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<unknown, TimelineViewport | null>({
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,
};
}
}
@@ -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;
@@ -209,9 +209,9 @@
(wheel)="onRibbonWheel($event)"
>
<app-epg-timeline-track
[items]="renderItems()"
[ticks]="ticks()"
[dividers]="dividers()"
[items]="ribbonWindow.items()"
[ticks]="ribbonWindow.ticks()"
[dividers]="ribbonWindow.dividers()"
[scale]="scale()"
[trackWidthPx]="trackWidthPx()"
[playheadLeftPx]="playheadLeftPx()"
@@ -46,7 +46,11 @@ import {
EpgTimelineEmptyStateComponent,
} from './epg-timeline-empty-state.component';
import { TimelineScrollController } from './epg-timeline-scroll.controller';
import { TimelineZoomController } from './epg-timeline-zoom.controller';
import { TimelineWindowController } from './epg-timeline-window.controller';
import {
TimelineZoomController,
timelineZoomLabelKey,
} from './epg-timeline-zoom.controller';
import { EpgTimelineTrackComponent } from './epg-timeline-track.component';
import {
buildTimelineAxis,
@@ -127,11 +131,11 @@ export class EpgTimelineComponent {
readonly zoomMax = TIMELINE_ZOOM_MAX;
readonly skeletonWidths = [120, 170, 150, 200, 140, 180];
private readonly nowMs = signal(Date.now());
readonly nowMs = signal(Date.now());
readonly selectedKey = signal<string | null>(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'