mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
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:
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'
|
||||
|
||||
Reference in new issue
Block a user