mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
perf(epg): render only the timeline programmes near the visible range (J3) (#1817)
* 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> * test(e2e): sweep the EPG ribbon to see every programme Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(epg): estimate the ribbon window once per channel or mount The live estimate followed the centred day and the 30 s now tick while the ribbon was not yet scrolled: a small scroll across midnight re-centred the window on the next day's noon and left the visible range empty, and the host width was re-read (a forced layout) on every tick. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): keyboard Tab reaches programmes beyond the rendered EPG range Greptile flagged that windowing the ribbon could strand keyboard users at the last rendered block. Focusing a block scrolls it into view, which re-windows before the next key press; the new test walks ten unrendered programmes past the range with Tab and with Shift+Tab, and fails if focus ever leaves the ribbon. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(epg): re-centre the ribbon window in the same pass as a zoom Review follow-up (Codex): a zoom (button, Ctrl/⌘ wheel, coalesced wheel burst) or a group expansion changed the scale before the anchored scrollLeft landed on a later frame, so the window was the previous centre at the new scale until the next scroll event re-measured it. The ribbon could flash empty or show the wrong section. The zoom controller now hands the window the minute its anchored scroll will centre, and a group expansion the group's centre, together with the new scale (TimelineWindowController.centreOnMinute). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(epg): window the scroll position a zoom can actually reach Review follow-up (Greptile, Codex): a zoom-out anchored right of centre at the ribbon's start computed a negative scroll position. The window centred on it, the browser kept scrollLeft at 0, and with the position unchanged no scroll event re-windowed, so the visible right-hand part stayed empty. The centre now uses the position clamped at 0, and once the frame applies scrollLeft the window re-centres on what the browser kept, which also covers the clamp at the end of the track. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(epg): close the programme popover when the window drops its block Review follow-up (Codex): the ribbon window can remove a focused narrow block on scroll, and a removed node fires no focusout, so the fixed tooltip kept showing a programme no longer on screen. The popover is now a linkedSignal over the rendered items that keeps its state only while its block (by key) is still rendered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(epg): re-measure the ribbon window when the axis origin moves Review follow-up (Codex): a time offset that carries the first programme across midnight moves the axis origin and every track position while scrollLeft and the ribbon stay put, so no scroll event fires and the window kept its epoch-time centre at a different pixel position. The window identity now includes the axis start; when only that changes, the viewport is measured from the ribbon instead of re-estimated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
11 files changed
+1121
-27
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.
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
fetchXtreamEpgFixture,
|
||||
fetchXtreamLiveFixture,
|
||||
fetchStalkerCategoryFixture,
|
||||
} from './portal-mock-fixtures';
|
||||
|
||||
@@ -463,3 +464,100 @@ test('@epg @stalker @theme @electron applies live themes to the shared Stalker g
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
/** Title of the programme block holding keyboard focus, if any. */
|
||||
async function focusedTimelineBlockTitle(page: Page): Promise<string | null> {
|
||||
return page.evaluate(() => {
|
||||
const block = document.activeElement?.closest(
|
||||
'app-epg-timeline .epg-timeline__block'
|
||||
);
|
||||
return (
|
||||
block
|
||||
?.querySelector('.epg-timeline__block-title')
|
||||
?.textContent?.trim() ?? null
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Two frames: the focus scroll's measure, then the re-rendered blocks. */
|
||||
async function nextFrames(page: Page): Promise<void> {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
test('@epg @xtream @electron reaches programmes beyond the rendered range with Tab', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
// The ribbon renders only the programmes near the visible range; keyboard
|
||||
// focus scrolls a block into view, which renders its neighbours before
|
||||
// the next key press. A generated schedule (240 half-hour slots) is far
|
||||
// wider than that range.
|
||||
test.setTimeout(180000);
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const credentials = { username: 'minimal', password: 'minimal' };
|
||||
const fixture = await fetchXtreamLiveFixture(request, credentials);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await app.mainWindow.route('https://test-streams.mux.dev/**', () => {
|
||||
// Keep the external demo request pending; guide data is local.
|
||||
});
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: 'Xtream Timeline Keyboard',
|
||||
...credentials,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
||||
const channelRow = channelItemByTitle(
|
||||
app.mainWindow,
|
||||
fixture.items[0]?.name ?? ''
|
||||
).first();
|
||||
await expect(channelRow).toBeVisible({ timeout: 20000 });
|
||||
await channelRow.click();
|
||||
|
||||
const timeline = app.mainWindow.locator('app-epg-timeline');
|
||||
const nowBlock = timeline.locator('.epg-timeline__block.is-now');
|
||||
await expect(nowBlock).toBeVisible({ timeout: 20000 });
|
||||
const titles = timeline.locator('.epg-timeline__block-title');
|
||||
const initial = new Set(
|
||||
(await titles.allInnerTexts()).map((title) => title.trim())
|
||||
);
|
||||
expect(initial.size).toBeLessThan(120);
|
||||
|
||||
// Walk well past the rendered range in both directions: ten
|
||||
// programmes that were not in the DOM when the walk started.
|
||||
for (const key of ['Tab', 'Shift+Tab']) {
|
||||
// The previous walk moved the window away from now.
|
||||
await timeline.locator('.epg-timeline__jump').click();
|
||||
await expect(nowBlock).toBeVisible();
|
||||
await nowBlock.focus();
|
||||
const reached = new Set<string>();
|
||||
for (let press = 0; press < 400 && reached.size < 10; press++) {
|
||||
await app.mainWindow.keyboard.press(key);
|
||||
await nextFrames(app.mainWindow);
|
||||
const inRibbon = await app.mainWindow.evaluate(
|
||||
() =>
|
||||
!!document.activeElement?.closest(
|
||||
'app-epg-timeline .epg-timeline__ribbon'
|
||||
)
|
||||
);
|
||||
expect(inRibbon, `${key} press ${press} left the ribbon`).toBe(
|
||||
true
|
||||
);
|
||||
const title = await focusedTimelineBlockTitle(app.mainWindow);
|
||||
if (title && !initial.has(title)) {
|
||||
reached.add(title);
|
||||
}
|
||||
}
|
||||
expect(reached.size, `${key} programmes beyond the range`).toBe(10);
|
||||
}
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
@@ -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,28 @@ 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 every programme once the ribbon is swept.
|
||||
const allTitles = [...fixture.fullEpg]
|
||||
.sort((a, b) => a.startTimestamp - b.startTimestamp)
|
||||
.map((listing) => listing.title);
|
||||
await expect
|
||||
.poll(() => timelineBlockTitles(app.mainWindow))
|
||||
.toEqual(allTitles);
|
||||
.poll(async () => {
|
||||
const titles = await timelineBlockTitles(app.mainWindow);
|
||||
return (
|
||||
titles.includes(currentProgram.title) &&
|
||||
isContiguousRun(titles, allTitles)
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
expect(await sweepTimelineTitles(app.mainWindow)).toEqual(
|
||||
new Set(allTitles)
|
||||
);
|
||||
await app.mainWindow
|
||||
.locator('app-epg-timeline .epg-timeline__jump')
|
||||
.click();
|
||||
|
||||
// The current programme is highlighted as the "now" block.
|
||||
await expect(
|
||||
@@ -866,6 +881,56 @@ async function timelineBlockTitles(
|
||||
.then((titles) => titles.map((title) => title.trim()).filter(Boolean));
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll the ribbon from its start to its end in half-viewport steps and
|
||||
* collect every programme title rendered on the way.
|
||||
*/
|
||||
async function sweepTimelineTitles(
|
||||
page: Parameters<typeof channelItemByTitle>[0]
|
||||
): Promise<Set<string>> {
|
||||
const ribbon = page.locator('app-epg-timeline .epg-timeline__ribbon');
|
||||
const seen = new Set<string>();
|
||||
let left = 0;
|
||||
for (;;) {
|
||||
const { scrollLeft, maxLeft, step } = await ribbon.evaluate(
|
||||
(element, target) => {
|
||||
element.scrollLeft = target;
|
||||
return {
|
||||
scrollLeft: element.scrollLeft,
|
||||
maxLeft: element.scrollWidth - element.clientWidth,
|
||||
step: Math.max(1, element.clientWidth / 2),
|
||||
};
|
||||
},
|
||||
left
|
||||
);
|
||||
// Two frames: the scroll's measure, then the re-rendered blocks.
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve))
|
||||
)
|
||||
);
|
||||
(await timelineBlockTitles(page)).forEach((title) => seen.add(title));
|
||||
if (scrollLeft >= maxLeft) {
|
||||
return seen;
|
||||
}
|
||||
left = scrollLeft + step;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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>
|
||||
) {
|
||||
|
||||
@@ -385,6 +385,18 @@ 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. Keyboard access must keep working: focusing
|
||||
a block scrolls it into view, which renders its neighbours before the next
|
||||
Tab (`epg-timeline-interaction.e2e.ts` walks past the rendered range both
|
||||
ways).
|
||||
|
||||
### Collapsible Live Sidebar
|
||||
|
||||
|
||||
@@ -82,11 +82,52 @@ describe('EpgTimelineTrackComponent', () => {
|
||||
expect(component.popover()).toBeNull();
|
||||
});
|
||||
|
||||
// The ribbon window can remove a focused block on scroll; a removed node
|
||||
// fires no focusout, so the popover must follow the rendered items.
|
||||
it('closes the popover when its block leaves the rendered items', () => {
|
||||
const item = renderBlock({ tier: 'narrow' });
|
||||
const other = renderBlock({ key: 'k2', tier: 'narrow' });
|
||||
fixture.componentRef.setInput('items', [item, other]);
|
||||
fixture.detectChanges();
|
||||
component.onBlockEnter(item, {
|
||||
currentTarget: {
|
||||
getBoundingClientRect: () => ({
|
||||
left: 100,
|
||||
width: 40,
|
||||
top: 60,
|
||||
bottom: 200,
|
||||
}),
|
||||
},
|
||||
} as unknown as Event);
|
||||
|
||||
fixture.componentRef.setInput('items', [item]);
|
||||
fixture.detectChanges();
|
||||
expect(component.popover()?.key).toBe('k1');
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
'[role="tooltip"]'
|
||||
)
|
||||
).not.toBeNull();
|
||||
|
||||
fixture.componentRef.setInput('items', [other]);
|
||||
fixture.detectChanges();
|
||||
expect(component.popover()).toBeNull();
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
'[role="tooltip"]'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('never shows a popover for a wide block', () => {
|
||||
const item = renderBlock({ tier: 'wide' });
|
||||
component.onBlockEnter(item, {
|
||||
currentTarget: {
|
||||
getBoundingClientRect: () => ({ left: 0, width: 200, bottom: 0 }),
|
||||
getBoundingClientRect: () => ({
|
||||
left: 0,
|
||||
width: 200,
|
||||
bottom: 0,
|
||||
}),
|
||||
},
|
||||
} as unknown as Event);
|
||||
expect(component.popover()).toBeNull();
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
computed,
|
||||
HostBinding,
|
||||
input,
|
||||
linkedSignal,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
} from './epg-timeline.utils';
|
||||
|
||||
interface PopoverState {
|
||||
/** The render item it describes. */
|
||||
readonly key: string;
|
||||
readonly title: string;
|
||||
readonly desc: string | null;
|
||||
readonly startMs: number;
|
||||
@@ -60,7 +62,20 @@ export class EpgTimelineTrackComponent {
|
||||
readonly infoClick = output<TimelineBlock>();
|
||||
readonly groupExpand = output<TimelineRenderGroup>();
|
||||
|
||||
readonly popover = signal<PopoverState | null>(null);
|
||||
/**
|
||||
* Cleared when its block leaves the rendered items: the ribbon window can
|
||||
* remove a focused block on scroll, and a removed node fires no
|
||||
* `focusout`, so the tooltip would stay on a programme no longer shown.
|
||||
*/
|
||||
readonly popover = linkedSignal<TimelineRenderItem[], PopoverState | null>({
|
||||
source: this.items,
|
||||
computation: (items, previous) => {
|
||||
const shown = previous?.value;
|
||||
return shown && items.some((item) => item.key === shown.key)
|
||||
? shown
|
||||
: null;
|
||||
},
|
||||
});
|
||||
|
||||
@HostBinding('style.width.px') get hostWidth(): number {
|
||||
return this.trackWidthPx();
|
||||
@@ -116,6 +131,7 @@ export class EpgTimelineTrackComponent {
|
||||
const below = rect.bottom + 8;
|
||||
const flipAbove = below + estHeight > window.innerHeight;
|
||||
this.popover.set({
|
||||
key: item.key,
|
||||
title: item.block.program.title,
|
||||
desc: item.block.program.desc,
|
||||
startMs: item.block.startMs,
|
||||
@@ -128,9 +144,7 @@ export class EpgTimelineTrackComponent {
|
||||
rect.left + rect.width / 2 - width / 2
|
||||
)
|
||||
),
|
||||
top: flipAbove
|
||||
? Math.max(12, rect.top - estHeight - 8)
|
||||
: below,
|
||||
top: flipAbove ? Math.max(12, rect.top - estHeight - 8) : below,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
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}`)
|
||||
);
|
||||
}
|
||||
|
||||
/** Half-hour slots from local midnight two days back: the axis start. */
|
||||
function midnightSchedule(): EpgProgram[] {
|
||||
const midnight = new Date(Date.now() - 2 * 24 * 60 * TIMELINE_MINUTE_MS);
|
||||
midnight.setHours(0, 0, 0, 0);
|
||||
const startOffsetMin =
|
||||
(midnight.getTime() - Date.now()) / TIMELINE_MINUTE_MS;
|
||||
return Array.from({ length: 240 }, (_, index) =>
|
||||
programAt(startOffsetMin + 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');
|
||||
});
|
||||
|
||||
/** 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))
|
||||
);
|
||||
});
|
||||
|
||||
// At the ribbon's start the anchored position is negative; the browser
|
||||
// keeps scrollLeft at 0 and fires no scroll event to re-window.
|
||||
it('windows the attainable range for a zoom-out at the ribbon start', () => {
|
||||
render(midnightSchedule());
|
||||
// From the deepest zoom, so the unclamped centre would be far off.
|
||||
component.onZoom(6);
|
||||
flushFrames();
|
||||
scrollRibbonTo(0);
|
||||
component.onRibbonWheel(
|
||||
new WheelEvent('wheel', {
|
||||
ctrlKey: true,
|
||||
clientX: ribbonWidth - 20,
|
||||
// 6 → about 1.4: above the grouping zoom, so blocks stay
|
||||
// separate and a missing one shows.
|
||||
deltaY: 727,
|
||||
})
|
||||
);
|
||||
fixture.detectChanges();
|
||||
expect(component.scale()).toBeGreaterThan(1.3);
|
||||
const visible = itemsIn(0, ribbonWidth);
|
||||
expect(visible.length).toBeGreaterThan(0);
|
||||
|
||||
const windowed = component.ribbonWindow.items().map((e) => e.key);
|
||||
expect(windowed).toEqual(expect.arrayContaining(visible));
|
||||
});
|
||||
|
||||
it('re-windows on the scroll position the browser applied', () => {
|
||||
render(mockSchedule());
|
||||
const end = blockLeftPx('Slot 239');
|
||||
component.onZoom(component.scale() * 2);
|
||||
// The browser clamps the anchored position to the end of the track.
|
||||
const scroller = ribbon();
|
||||
let applied = scroller.scrollLeft;
|
||||
Object.defineProperty(scroller, 'scrollLeft', {
|
||||
configurable: true,
|
||||
get: () => applied,
|
||||
set: () => (applied = end * 2),
|
||||
});
|
||||
flushFrames();
|
||||
|
||||
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
|
||||
.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<EpgTimelineComponent['onGroupExpand']>[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();
|
||||
|
||||
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('keeps the window where the ribbon is when a small scroll commits another day', () => {
|
||||
// A scroll under the re-window step can still cross midnight; the
|
||||
// committed day must not re-centre the window on its noon.
|
||||
render(mockSchedule());
|
||||
const tomorrow = new Date(Date.now() + 24 * 60 * TIMELINE_MINUTE_MS);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
component.viewDayKey.set(
|
||||
`${tomorrow.getFullYear()}-${pad(tomorrow.getMonth() + 1)}-${pad(tomorrow.getDate())}`
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(renderedTitles()).toContain('Slot 96');
|
||||
});
|
||||
|
||||
it('does not follow the now tick away from an unscrolled ribbon', () => {
|
||||
render(mockSchedule());
|
||||
|
||||
component.nowMs.set(Date.now() + 12 * 60 * TIMELINE_MINUTE_MS);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(renderedTitles()).toContain('Slot 96');
|
||||
});
|
||||
|
||||
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,322 @@
|
||||
import {
|
||||
computed,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
linkedSignal,
|
||||
Signal,
|
||||
untracked,
|
||||
} 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()),
|
||||
axisStartMs: this.ctx.axis().startMs,
|
||||
}),
|
||||
{
|
||||
equal: (left, right) =>
|
||||
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. 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<
|
||||
{ ribbon: HTMLElement | undefined; key: string; axisStartMs: number },
|
||||
TimelineViewport
|
||||
>({
|
||||
source: this.identity,
|
||||
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())
|
||||
);
|
||||
|
||||
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.viewport.set({ centreMs: current.centreMs, widthPx });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
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.viewport.set(next);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
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,13 +3,28 @@ 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;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,6 +99,12 @@ export class TimelineZoomController {
|
||||
const currentLeft = this.pendingScrollLeft ?? scroller.scrollLeft;
|
||||
const anchorMin = (currentLeft + anchorPx) / prev;
|
||||
this.pendingScrollLeft = anchorMin * next - anchorPx;
|
||||
// The browser clamps scrollLeft at 0: a zoom-out anchored right of
|
||||
// centre at the ribbon's start lands there, not left of it.
|
||||
this.ctx.centreOn?.(
|
||||
(Math.max(0, this.pendingScrollLeft) + scroller.clientWidth / 2) /
|
||||
next
|
||||
);
|
||||
if (this.frame === 0) {
|
||||
this.frame = requestAnimationFrame(() => this.flushScroll());
|
||||
}
|
||||
@@ -97,6 +118,12 @@ export class TimelineZoomController {
|
||||
const scroller = this.ctx.ribbon();
|
||||
if (scroller && left !== null) {
|
||||
scroller.scrollLeft = left;
|
||||
// Where the browser put it: the end of the track clamps too, and
|
||||
// an unchanged position fires no scroll event to re-window.
|
||||
this.ctx.centreOn?.(
|
||||
(scroller.scrollLeft + scroller.clientWidth / 2) /
|
||||
this.ctx.scale()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -141,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. */
|
||||
@@ -200,6 +205,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 +222,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'
|
||||
@@ -348,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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user