mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(epg): compact timeline toolbar with icon-only Now and cycling zoom (#1557)
* feat(epg): compact timeline toolbar with icon-only Now and cycling zoom Replace the 96px zoom range slider with a single icon button that cycles day overview → by hour → detailed, make the Now button icon-only (label in tooltip + aria-label), and add Ctrl/⌘ + wheel zoom around the cursor on the ribbon (preventDefault keeps Chromium from page-zooming the same gesture). Zoom math moves into TimelineZoomController so the component stays under the max-lines limit. The channel/programme heading no longer carries a 60% width cap and takes the space the controls free up. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): coalesce wheel-zoom anchoring and make the e2e zoom reset deterministic - TimelineZoomController keeps a logical pending scrollLeft across a burst of wheel/pinch events and applies it in one animation frame, so every event anchors on the same minute instead of the stale DOM position - the e2e settles on the detail preset from whichever band the wheel landed in (contrast checks need the block's time line, hidden at narrower tiers) - zoom tooltip advertises ⌘/Ctrl per the repo's shortcut convention Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
61b06b9f31
commit
ef2418a2c5
30 files changed
+580
-123
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: feature
|
||||
area: epg
|
||||
---
|
||||
|
||||
The EPG timeline toolbar is more compact: "Now" is an icon button, and the zoom slider is replaced by a single button that cycles day overview → by hour → detailed. Ctrl/⌘ + scroll (or a trackpad pinch) over the timeline zooms smoothly around the cursor. The channel and programme title now use all the space the controls free up.
|
||||
@@ -78,25 +78,60 @@ test('@epg @xtream @electron opens the programme dialog from a timeline block an
|
||||
.first();
|
||||
await expect(nowBlock).toBeVisible();
|
||||
|
||||
// Zooming re-renders the ribbon: block widths grow with px/minute.
|
||||
const zoomInput = timeline.locator(
|
||||
'.epg-timeline__zoom input[type="range"]'
|
||||
// The zoom button cycles hours → detail → day; block widths follow
|
||||
// px/minute, so "detail" must render the same block wider than "day".
|
||||
const zoomButton = timeline.locator('.epg-timeline__zoom');
|
||||
await expect(zoomButton).toBeVisible();
|
||||
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'hours');
|
||||
|
||||
const blockWidth = async () =>
|
||||
(await nowBlock.boundingBox())?.width ?? 0;
|
||||
|
||||
await zoomButton.click();
|
||||
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'detail');
|
||||
const detailZoomWidth = await blockWidth();
|
||||
|
||||
await zoomButton.click();
|
||||
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'day');
|
||||
const dayZoomWidth = await blockWidth();
|
||||
expect(detailZoomWidth).toBeGreaterThan(dayZoomWidth);
|
||||
|
||||
// Ctrl + wheel over the ribbon fine-tunes the zoom (and must not
|
||||
// trigger Chromium's page zoom, which would scale the whole window).
|
||||
const ribbon = timeline.locator('.epg-timeline__ribbon');
|
||||
await ribbon.hover({ position: { x: 200, y: 40 } });
|
||||
const pageZoomBefore = await app.mainWindow.evaluate(
|
||||
() => window.devicePixelRatio
|
||||
);
|
||||
await expect(zoomInput).toBeVisible();
|
||||
await app.mainWindow.keyboard.down('Control');
|
||||
await app.mainWindow.mouse.wheel(0, -300);
|
||||
await app.mainWindow.keyboard.up('Control');
|
||||
await expect
|
||||
.poll(blockWidth, { timeout: 5000 })
|
||||
.toBeGreaterThan(dayZoomWidth);
|
||||
// Chromium page zoom scales devicePixelRatio; the ribbon must have
|
||||
// swallowed the gesture instead.
|
||||
expect(
|
||||
await app.mainWindow.evaluate(() => window.devicePixelRatio)
|
||||
).toBe(pageZoomBefore);
|
||||
|
||||
const blockWidthAt = async (zoom: 'min' | 'max') => {
|
||||
await zoomInput.evaluate((element, target) => {
|
||||
const input = element as HTMLInputElement;
|
||||
input.value = target === 'min' ? input.min : input.max;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}, zoom);
|
||||
const box = await nowBlock.boundingBox();
|
||||
return box?.width ?? 0;
|
||||
// The contrast checks below read the block's time line, which the
|
||||
// narrower tiers hide, so settle on the "detail" preset. The
|
||||
// wheel-tuned scale may sit in any band (Chromium scales the delivered
|
||||
// delta), so cycle from wherever it landed rather than assuming a
|
||||
// fixed number of clicks.
|
||||
const clicksToDetail: Record<string, number> = {
|
||||
detail: 0,
|
||||
day: 2,
|
||||
hours: 1,
|
||||
};
|
||||
|
||||
const minZoomWidth = await blockWidthAt('min');
|
||||
const maxZoomWidth = await blockWidthAt('max');
|
||||
expect(maxZoomWidth).toBeGreaterThan(minZoomWidth);
|
||||
const landedLevel =
|
||||
(await zoomButton.getAttribute('data-zoom-level')) ?? '';
|
||||
expect(Object.keys(clicksToDetail)).toContain(landedLevel);
|
||||
for (let i = 0; i < clicksToDetail[landedLevel]; i += 1) {
|
||||
await zoomButton.click();
|
||||
}
|
||||
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'detail');
|
||||
|
||||
for (const theme of ['light', 'dark', 'light'] as const) {
|
||||
await applyTheme(app.mainWindow, theme);
|
||||
|
||||
@@ -837,6 +837,7 @@
|
||||
"ZOOM_DAY": "نظرة عامة على اليوم",
|
||||
"ZOOM_HOURS": "حسب الساعة",
|
||||
"ZOOM_DETAIL": "تفصيلي",
|
||||
"ZOOM_TOOLTIP": "التكبير: {{level}} · ⌘/Ctrl + التمرير للضبط الدقيق",
|
||||
"GROUP_SHORT": "برامج قصيرة",
|
||||
"GROUP_EXPAND": "عرض ›"
|
||||
}
|
||||
|
||||
@@ -837,6 +837,7 @@
|
||||
"ZOOM_DAY": "نظرة عامة على النهار",
|
||||
"ZOOM_HOURS": "بالساعة",
|
||||
"ZOOM_DETAIL": "مفصّل",
|
||||
"ZOOM_TOOLTIP": "التكبير: {{level}} · ⌘/Ctrl + التمرير باش تضبط",
|
||||
"GROUP_SHORT": "برامج قصيرة",
|
||||
"GROUP_EXPAND": "وري ›"
|
||||
}
|
||||
|
||||
@@ -837,6 +837,7 @@
|
||||
"ZOOM_DAY": "Агляд дня",
|
||||
"ZOOM_HOURS": "Па гадзінах",
|
||||
"ZOOM_DETAIL": "Падрабязна",
|
||||
"ZOOM_TOOLTIP": "Маштаб: {{level}} · ⌘/Ctrl + кола для дакладнай наладкі",
|
||||
"GROUP_SHORT": "кароткія перадачы",
|
||||
"GROUP_EXPAND": "паказаць ›"
|
||||
}
|
||||
|
||||
@@ -837,6 +837,7 @@
|
||||
"ZOOM_DAY": "Tagesübersicht",
|
||||
"ZOOM_HOURS": "Stundenweise",
|
||||
"ZOOM_DETAIL": "Detailliert",
|
||||
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Strg + Scrollen zum Feinabstimmen",
|
||||
"GROUP_SHORT": "kurze Sendungen",
|
||||
"GROUP_EXPAND": "anzeigen ›"
|
||||
}
|
||||
|
||||
@@ -837,6 +837,7 @@
|
||||
"ZOOM_DAY": "Επισκόπηση ημέρας",
|
||||
"ZOOM_HOURS": "Ανά ώρα",
|
||||
"ZOOM_DETAIL": "Λεπτομερής",
|
||||
"ZOOM_TOOLTIP": "Ζουμ: {{level}} · ⌘/Ctrl + κύλιση για λεπτομερή ρύθμιση",
|
||||
"GROUP_SHORT": "σύντ. προγρ.",
|
||||
"GROUP_EXPAND": "εμφάνιση ›"
|
||||
}
|
||||
|
||||
@@ -837,6 +837,7 @@
|
||||
"ZOOM_DAY": "Day overview",
|
||||
"ZOOM_HOURS": "By hour",
|
||||
"ZOOM_DETAIL": "Detailed",
|
||||
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Ctrl + scroll to fine-tune",
|
||||
"GROUP_SHORT": "short progs",
|
||||
"GROUP_EXPAND": "show ›"
|
||||
}
|
||||
|
||||
@@ -837,6 +837,7 @@
|
||||
"ZOOM_DAY": "Vista del día",
|
||||
"ZOOM_HOURS": "Por hora",
|
||||
"ZOOM_DETAIL": "Detallado",
|
||||
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Ctrl + rueda para ajustar",
|
||||
"GROUP_SHORT": "progs. cortos",
|
||||
"GROUP_EXPAND": "ver ›"
|
||||
}
|
||||
|
||||
@@ -837,6 +837,7 @@
|
||||
"ZOOM_DAY": "Vue de la journée",
|
||||
"ZOOM_HOURS": "Par heure",
|
||||
"ZOOM_DETAIL": "Détaillé",
|
||||
"ZOOM_TOOLTIP": "Zoom : {{level}} · ⌘/Ctrl + molette pour ajuster",
|
||||
"GROUP_SHORT": "progs courts",
|
||||
"GROUP_EXPAND": "afficher ›"
|
||||
}
|
||||
|
||||
@@ -837,6 +837,7 @@
|
||||
"ZOOM_DAY": "Napi áttekintés",
|
||||
"ZOOM_HOURS": "Óránként",
|
||||
"ZOOM_DETAIL": "Részletes",
|
||||
"ZOOM_TOOLTIP": "Nagyítás: {{level}} · ⌘/Ctrl + görgetés a finomhangoláshoz",
|
||||
"GROUP_SHORT": "rövid műsorok",
|
||||
"GROUP_EXPAND": "megjelenítés ›"
|
||||
}
|
||||
|
||||
@@ -837,6 +837,7 @@
|
||||
"ZOOM_DAY": "Panoramica giorno",
|
||||
"ZOOM_HOURS": "Per ora",
|
||||
"ZOOM_DETAIL": "Dettagliato",
|
||||
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Ctrl + rotella per regolare",
|
||||
"GROUP_SHORT": "prog. brevi",
|
||||
"GROUP_EXPAND": "mostra ›"
|
||||
}
|
||||
|
||||
@@ -837,6 +837,7 @@
|
||||
"ZOOM_DAY": "1日表示",
|
||||
"ZOOM_HOURS": "時間単位",
|
||||
"ZOOM_DETAIL": "詳細",
|
||||
"ZOOM_TOOLTIP": "ズーム: {{level}} · ⌘/Ctrl + スクロールで微調整",
|
||||
"GROUP_SHORT": "短い番組",
|
||||
"GROUP_EXPAND": "表示 ›"
|
||||
}
|
||||
|
||||
@@ -837,6 +837,7 @@
|
||||
"ZOOM_DAY": "하루 보기",
|
||||
"ZOOM_HOURS": "시간별",
|
||||
"ZOOM_DETAIL": "상세",
|
||||
"ZOOM_TOOLTIP": "확대/축소: {{level}} · ⌘/Ctrl + 스크롤로 미세 조정",
|
||||
"GROUP_SHORT": "짧은 프로그램",
|
||||
"GROUP_EXPAND": "보기 ›"
|
||||
}
|
||||
|
||||
@@ -837,6 +837,7 @@
|
||||
"ZOOM_DAY": "Dagoverzicht",
|
||||
"ZOOM_HOURS": "Per uur",
|
||||
"ZOOM_DETAIL": "Gedetailleerd",
|
||||
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Ctrl + scrollen om fijn af te stellen",
|
||||
"GROUP_SHORT": "korte progr.",
|
||||
"GROUP_EXPAND": "toon ›"
|
||||
}
|
||||
|
||||
@@ -837,6 +837,7 @@
|
||||
"ZOOM_DAY": "Przegląd dnia",
|
||||
"ZOOM_HOURS": "Co godzinę",
|
||||
"ZOOM_DETAIL": "Szczegółowo",
|
||||
"ZOOM_TOOLTIP": "Powiększenie: {{level}} · ⌘/Ctrl + kółko, aby dostroić",
|
||||
"GROUP_SHORT": "krótkie progr.",
|
||||
"GROUP_EXPAND": "pokaż ›"
|
||||
}
|
||||
|
||||
@@ -837,6 +837,7 @@
|
||||
"ZOOM_DAY": "Visão geral do dia",
|
||||
"ZOOM_HOURS": "Por hora",
|
||||
"ZOOM_DETAIL": "Detalhado",
|
||||
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Ctrl + roda para ajustar",
|
||||
"GROUP_SHORT": "progs. curtos",
|
||||
"GROUP_EXPAND": "mostrar ›"
|
||||
}
|
||||
|
||||
@@ -837,6 +837,7 @@
|
||||
"ZOOM_DAY": "Обзор дня",
|
||||
"ZOOM_HOURS": "По часам",
|
||||
"ZOOM_DETAIL": "Детально",
|
||||
"ZOOM_TOOLTIP": "Масштаб: {{level}} · ⌘/Ctrl + колесо для точной настройки",
|
||||
"GROUP_SHORT": "короткие передачи",
|
||||
"GROUP_EXPAND": "показать ›"
|
||||
}
|
||||
|
||||
@@ -837,6 +837,7 @@
|
||||
"ZOOM_DAY": "Gün özeti",
|
||||
"ZOOM_HOURS": "Saatlik",
|
||||
"ZOOM_DETAIL": "Detaylı",
|
||||
"ZOOM_TOOLTIP": "Yakınlaştırma: {{level}} · İnce ayar için ⌘/Ctrl + kaydırma",
|
||||
"GROUP_SHORT": "kısa progr.",
|
||||
"GROUP_EXPAND": "göster ›"
|
||||
}
|
||||
|
||||
@@ -837,6 +837,7 @@
|
||||
"ZOOM_DAY": "全天概览",
|
||||
"ZOOM_HOURS": "按小时",
|
||||
"ZOOM_DETAIL": "详细",
|
||||
"ZOOM_TOOLTIP": "缩放:{{level}} · ⌘/Ctrl + 滚轮微调",
|
||||
"GROUP_SHORT": "短节目",
|
||||
"GROUP_EXPAND": "展开 ›"
|
||||
}
|
||||
|
||||
@@ -837,6 +837,7 @@
|
||||
"ZOOM_DAY": "日總覽",
|
||||
"ZOOM_HOURS": "依小時",
|
||||
"ZOOM_DETAIL": "詳細",
|
||||
"ZOOM_TOOLTIP": "縮放:{{level}} · ⌘/Ctrl + 滾輪微調",
|
||||
"GROUP_SHORT": "短節目",
|
||||
"GROUP_EXPAND": "顯示 ›"
|
||||
}
|
||||
|
||||
@@ -836,12 +836,22 @@ activation, and the details dialog behave identically to the timeline.
|
||||
(**vertical title**, no time) → `micro` (just a marker); (C) a **hover/focus
|
||||
popover** revealing the full title + time + description for any non-`wide`
|
||||
block (it flips above the block when the panel is near the screen bottom);
|
||||
(D) a px-per-minute **zoom** slider (tick density adapts via
|
||||
`timelineTickStepForScale`); and (E) **grouping** of ≥4 consecutive short
|
||||
(D) a px-per-minute **zoom** (tick density adapts via
|
||||
`timelineTickStepForScale`): the toolbar's icon-only zoom button cycles
|
||||
three presets — day overview (`1`) → by hour (`1.75`, the default) →
|
||||
detailed (`3.4`, the scale a group chip expands to) — snapping a
|
||||
wheel-tuned scale to its band's successor (`TIMELINE_ZOOM_LEVELS`,
|
||||
`nextTimelineZoomScale`), while Ctrl/⌘ + wheel (and trackpad pinch) over
|
||||
the ribbon zooms continuously around the cursor within
|
||||
`TIMELINE_ZOOM_MIN..MAX` and `preventDefault`s so Chromium never page-zooms
|
||||
the same gesture; the current level lives in the tooltip/`aria-label` and
|
||||
a `data-zoom-level` attribute; and (E) **grouping** of ≥4 consecutive short
|
||||
(<10 min) programmes into one dashed "N short" chip when zoomed out
|
||||
(`scale < TIMELINE_GROUP_ZOOM_MAX`), expanded by clicking it. The ribbon
|
||||
canvas lives in the child `app-epg-timeline-track`; the parent owns the
|
||||
scroller, toolbar (incl. the zoom slider) and state.
|
||||
scroller, toolbar (icon-only "Now" + zoom buttons, both labelled through
|
||||
tooltip + `aria-label`, so the channel/programme heading takes every pixel
|
||||
the fixed-width controls leave) and state.
|
||||
- **Panel height & titles.** Block titles wrap onto as many lines as the card
|
||||
height allows and are clipped (not single-line ellipsis); the foot ("ON NOW"
|
||||
tag / "Watch") stays pinned at the bottom. With an inline player the guide is
|
||||
@@ -1452,7 +1462,6 @@ resolve another retained source sharing that channel ID. Legacy programmes with
|
||||
unknown (`NULL`) ownership are conservatively left alone; the existing database
|
||||
initialization backfill handles rows whose channel still identifies their owner.
|
||||
|
||||
|
||||
Renderer reconciliation fences lookups before its first asynchronous step.
|
||||
Imports wait for serialized reconciliation (including playlist migration), then
|
||||
filter against its committed owner set. Completion increments the data revision again
|
||||
|
||||
@@ -11,10 +11,30 @@ import { TIMELINE_MINUTE_MS, TimelineBlock } from './epg-timeline.utils';
|
||||
export const TIMELINE_MIN_BLOCK_WIDTH_PX = 40;
|
||||
/** Gap subtracted from each block so neighbours don't touch. */
|
||||
export const TIMELINE_BLOCK_GAP_PX = 4;
|
||||
/** Zoom (px/min) slider bounds. */
|
||||
/** Zoom (px/min) bounds for the Ctrl/⌘ + wheel fine-tune gesture. */
|
||||
export const TIMELINE_ZOOM_MIN = 0.8;
|
||||
export const TIMELINE_ZOOM_MAX = 6;
|
||||
export const TIMELINE_ZOOM_STEP = 0.1;
|
||||
/**
|
||||
* Multiplicative zoom per wheel pixel: one mouse notch (~100px) is about
|
||||
* ×1.22, and a trackpad pinch (a few px per event) stays smooth.
|
||||
*/
|
||||
export const TIMELINE_WHEEL_ZOOM_RATE = 0.002;
|
||||
|
||||
export type TimelineZoomLevel = 'day' | 'hours' | 'detail';
|
||||
|
||||
/**
|
||||
* The three zoom presets the toolbar button cycles through. `hours` is the
|
||||
* default scale; `detail` matches the zoom a group chip expands to, so the
|
||||
* cycle keeps reading consistently after an expand.
|
||||
*/
|
||||
export const TIMELINE_ZOOM_LEVELS: readonly {
|
||||
readonly level: TimelineZoomLevel;
|
||||
readonly scale: number;
|
||||
}[] = [
|
||||
{ level: 'day', scale: 1 },
|
||||
{ level: 'hours', scale: 1.75 },
|
||||
{ level: 'detail', scale: 3.4 },
|
||||
];
|
||||
/** Below this zoom, dense runs of short programmes collapse into a group chip. */
|
||||
export const TIMELINE_GROUP_ZOOM_MAX = 1.3;
|
||||
/** Zoom applied when a group chip is expanded. */
|
||||
@@ -69,6 +89,24 @@ export function tierFor(widthPx: number): TimelineTier {
|
||||
return 'micro';
|
||||
}
|
||||
|
||||
/** Which preset band a (possibly wheel-tuned) scale falls into. */
|
||||
export function timelineZoomLevelForScale(scale: number): TimelineZoomLevel {
|
||||
if (scale < TIMELINE_GROUP_ZOOM_MAX) return 'day';
|
||||
if (scale < 3) return 'hours';
|
||||
return 'detail';
|
||||
}
|
||||
|
||||
/** The preset after the current band, wrapping detail → day. */
|
||||
export function nextTimelineZoomScale(scale: number): number {
|
||||
const current = timelineZoomLevelForScale(scale);
|
||||
const index = TIMELINE_ZOOM_LEVELS.findIndex(
|
||||
(entry) => entry.level === current
|
||||
);
|
||||
const next =
|
||||
TIMELINE_ZOOM_LEVELS[(index + 1) % TIMELINE_ZOOM_LEVELS.length];
|
||||
return next.scale;
|
||||
}
|
||||
|
||||
/** Adaptive tick spacing (minutes) — denser as the user zooms in. */
|
||||
export function timelineTickStepForScale(scale: number): number {
|
||||
if (scale < 2.2) return 120;
|
||||
@@ -151,7 +189,8 @@ export function buildTimelineRenderItems(
|
||||
if (run.length >= TIMELINE_GROUP_MIN_RUN) {
|
||||
const first = run[0];
|
||||
const last = run[run.length - 1];
|
||||
const spanPx = ((last.stopMs - first.startMs) / TIMELINE_MINUTE_MS) * scale;
|
||||
const spanPx =
|
||||
((last.stopMs - first.startMs) / TIMELINE_MINUTE_MS) * scale;
|
||||
items.push({
|
||||
kind: 'group',
|
||||
key: `group-${first.key}-${last.key}`,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { TimelineZoomController } from './epg-timeline-zoom.controller';
|
||||
import {
|
||||
TIMELINE_ZOOM_MAX,
|
||||
TIMELINE_ZOOM_MIN,
|
||||
} from './epg-timeline-render.util';
|
||||
|
||||
function fakeScroller(clientWidth: number, scrollLeft: number): HTMLElement {
|
||||
return {
|
||||
clientWidth,
|
||||
scrollLeft,
|
||||
getBoundingClientRect: () => ({ left: 100 }),
|
||||
} as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
describe('TimelineZoomController', () => {
|
||||
let scale: number;
|
||||
let scroller: HTMLElement | undefined;
|
||||
let controller: TimelineZoomController;
|
||||
let rafCallbacks: FrameRequestCallback[];
|
||||
|
||||
beforeEach(() => {
|
||||
scale = 2;
|
||||
scroller = fakeScroller(600, 1200);
|
||||
rafCallbacks = [];
|
||||
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
rafCallbacks.push(cb);
|
||||
return rafCallbacks.length;
|
||||
});
|
||||
controller = new TimelineZoomController({
|
||||
ribbon: () => scroller,
|
||||
scale: () => scale,
|
||||
setScale: (next) => (scale = next),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
function flushFrames(): void {
|
||||
for (const cb of rafCallbacks.splice(0)) cb(0);
|
||||
}
|
||||
|
||||
it('keeps the viewport centre minute fixed on a programmatic zoom', () => {
|
||||
// centre = (1200 + 300) / 2 px/min = 750 min
|
||||
controller.zoomTo(4);
|
||||
flushFrames();
|
||||
expect(scale).toBe(4);
|
||||
expect(scroller?.scrollLeft).toBe(750 * 4 - 300);
|
||||
});
|
||||
|
||||
it('clamps to the zoom bounds and ignores non-finite input', () => {
|
||||
controller.zoomTo(99);
|
||||
expect(scale).toBe(TIMELINE_ZOOM_MAX);
|
||||
controller.zoomTo(-1);
|
||||
expect(scale).toBe(TIMELINE_ZOOM_MIN);
|
||||
controller.zoomTo(Number.NaN);
|
||||
expect(scale).toBe(TIMELINE_ZOOM_MIN);
|
||||
});
|
||||
|
||||
it('does nothing when the clamped scale is unchanged', () => {
|
||||
controller.zoomTo(2);
|
||||
expect(rafCallbacks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('zooms around the cursor on Ctrl + wheel', () => {
|
||||
// cursor 150px into the 600px viewport → anchor 0.25
|
||||
const event = new WheelEvent('wheel', {
|
||||
deltaY: -100,
|
||||
ctrlKey: true,
|
||||
clientX: 250,
|
||||
cancelable: true,
|
||||
});
|
||||
controller.onWheel(event);
|
||||
flushFrames();
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(scale).toBeCloseTo(2 * Math.exp(0.2), 6);
|
||||
// minute under the cursor before: (1200 + 150) / 2 = 675
|
||||
expect(scroller?.scrollLeft).toBeCloseTo(675 * scale - 150, 6);
|
||||
});
|
||||
|
||||
it('keeps the cursor minute anchored across a burst of wheel events', () => {
|
||||
const burst = () =>
|
||||
new WheelEvent('wheel', {
|
||||
deltaY: -50,
|
||||
ctrlKey: true,
|
||||
clientX: 250,
|
||||
cancelable: true,
|
||||
});
|
||||
// Three events before any frame runs: the DOM scrollLeft is stale for
|
||||
// the 2nd and 3rd, so the anchor must come from the logical position.
|
||||
controller.onWheel(burst());
|
||||
controller.onWheel(burst());
|
||||
controller.onWheel(burst());
|
||||
expect(rafCallbacks).toHaveLength(1); // coalesced into one frame
|
||||
flushFrames();
|
||||
// minute under the cursor before the burst: (1200 + 150) / 2 = 675
|
||||
expect(scale).toBeCloseTo(2 * Math.exp(0.3), 6);
|
||||
expect(scroller?.scrollLeft).toBeCloseTo(675 * scale - 150, 6);
|
||||
});
|
||||
|
||||
it('re-anchors on the DOM position once a frame has flushed', () => {
|
||||
controller.zoomTo(4);
|
||||
flushFrames();
|
||||
expect(scroller?.scrollLeft).toBe(750 * 4 - 300);
|
||||
controller.zoomTo(2);
|
||||
flushFrames();
|
||||
expect(scroller?.scrollLeft).toBe(750 * 2 - 300);
|
||||
});
|
||||
|
||||
it('leaves plain wheel events alone', () => {
|
||||
const event = new WheelEvent('wheel', {
|
||||
deltaY: -100,
|
||||
cancelable: true,
|
||||
});
|
||||
controller.onWheel(event);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(scale).toBe(2);
|
||||
});
|
||||
|
||||
it('zooms without a rendered ribbon', () => {
|
||||
scroller = undefined;
|
||||
controller.cycle();
|
||||
expect(scale).toBe(3.4);
|
||||
expect(rafCallbacks).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
nextTimelineZoomScale,
|
||||
TIMELINE_WHEEL_ZOOM_RATE,
|
||||
TIMELINE_ZOOM_MAX,
|
||||
TIMELINE_ZOOM_MIN,
|
||||
} from './epg-timeline-render.util';
|
||||
|
||||
export interface TimelineZoomContext {
|
||||
/** The horizontal scroller hosting the track (undefined until rendered). */
|
||||
readonly ribbon: () => HTMLElement | undefined;
|
||||
readonly scale: () => number;
|
||||
readonly setScale: (scale: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ribbon zoom (px per minute) owned by the toolbar button and the
|
||||
* Ctrl/⌘ + wheel gesture. Every change keeps one ribbon minute anchored on
|
||||
* screen: the viewport centre for button/programmatic zooms, the cursor for
|
||||
* wheel zooms.
|
||||
*/
|
||||
export class TimelineZoomController {
|
||||
/**
|
||||
* Scroll position the next animation frame will apply. Rapid wheel/pinch
|
||||
* events arrive faster than frames, so the DOM `scrollLeft` still reflects
|
||||
* the previous scale by the time the next event computes its anchor; the
|
||||
* logical position keeps every event anchored on the same minute.
|
||||
*/
|
||||
private pendingScrollLeft: number | null = null;
|
||||
private frame = 0;
|
||||
|
||||
constructor(private readonly ctx: TimelineZoomContext) {}
|
||||
|
||||
/** Clamp + apply a scale, keeping the viewport centre stable. */
|
||||
zoomTo(value: number): void {
|
||||
this.applyScale(value, 0.5);
|
||||
}
|
||||
|
||||
/** Toolbar zoom button: step to the next preset (day → hours → detail). */
|
||||
cycle(): void {
|
||||
this.applyScale(nextTimelineZoomScale(this.ctx.scale()), 0.5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ctrl/⌘ + wheel (and trackpad pinch, which Chromium reports the same
|
||||
* way) zooms around the cursor. Plain wheel keeps scrolling.
|
||||
* `preventDefault` also stops Chromium's page zoom for the same gesture.
|
||||
*/
|
||||
onWheel(event: WheelEvent): void {
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const scroller = this.ctx.ribbon();
|
||||
const anchor = scroller
|
||||
? (event.clientX - scroller.getBoundingClientRect().left) /
|
||||
scroller.clientWidth
|
||||
: 0.5;
|
||||
const factor = Math.exp(-event.deltaY * TIMELINE_WHEEL_ZOOM_RATE);
|
||||
this.applyScale(this.ctx.scale() * factor, anchor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the scale so the ribbon minute under `anchorFrac` (0 = left edge,
|
||||
* 1 = right edge of the viewport) stays put.
|
||||
*/
|
||||
private applyScale(value: number, anchorFrac: number): void {
|
||||
const requested = Number(value);
|
||||
const prev = this.ctx.scale();
|
||||
const next = Number.isFinite(requested)
|
||||
? Math.min(
|
||||
TIMELINE_ZOOM_MAX,
|
||||
Math.max(TIMELINE_ZOOM_MIN, requested)
|
||||
)
|
||||
: prev;
|
||||
if (next === prev) {
|
||||
return;
|
||||
}
|
||||
const scroller = this.ctx.ribbon();
|
||||
this.ctx.setScale(next);
|
||||
if (!scroller) {
|
||||
return;
|
||||
}
|
||||
const anchorPx = scroller.clientWidth * anchorFrac;
|
||||
const currentLeft = this.pendingScrollLeft ?? scroller.scrollLeft;
|
||||
const anchorMin = (currentLeft + anchorPx) / prev;
|
||||
this.pendingScrollLeft = anchorMin * next - anchorPx;
|
||||
if (this.frame === 0) {
|
||||
this.frame = requestAnimationFrame(() => this.flushScroll());
|
||||
}
|
||||
}
|
||||
|
||||
/** One frame applies the last coalesced position for a burst of events. */
|
||||
private flushScroll(): void {
|
||||
this.frame = 0;
|
||||
const left = this.pendingScrollLeft;
|
||||
this.pendingScrollLeft = null;
|
||||
const scroller = this.ctx.ribbon();
|
||||
if (scroller && left !== null) {
|
||||
scroller.scrollLeft = left;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,10 @@
|
||||
summary()?.title
|
||||
}}</span>
|
||||
@if (progress() !== null) {
|
||||
<span class="epg-timeline__summary-bar" aria-hidden="true">
|
||||
<span
|
||||
class="epg-timeline__summary-bar"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i [style.width.%]="progress()"></i>
|
||||
</span>
|
||||
}
|
||||
@@ -99,29 +102,26 @@
|
||||
@if (showRibbonControls()) {
|
||||
<button
|
||||
type="button"
|
||||
class="epg-timeline__jump"
|
||||
class="epg-timeline__iconbtn epg-timeline__jump"
|
||||
(click)="jumpToNow()"
|
||||
[matTooltip]="'EPG.TIMELINE.JUMP_NOW' | translate"
|
||||
[attr.aria-label]="'EPG.TIMELINE.JUMP_NOW' | translate"
|
||||
>
|
||||
<mat-icon>my_location</mat-icon>
|
||||
{{ 'EPG.TIMELINE.NOW' | translate }}
|
||||
</button>
|
||||
<label
|
||||
class="epg-timeline__zoom"
|
||||
[matTooltip]="zoomLabelKey() | translate"
|
||||
@let zoomTooltip =
|
||||
'EPG.TIMELINE.ZOOM_TOOLTIP'
|
||||
| translate: { level: (zoomLabelKey() | translate) };
|
||||
<button
|
||||
type="button"
|
||||
class="epg-timeline__iconbtn epg-timeline__zoom"
|
||||
[attr.data-zoom-level]="zoomLevel()"
|
||||
(click)="cycleZoom()"
|
||||
[matTooltip]="zoomTooltip"
|
||||
[attr.aria-label]="zoomTooltip"
|
||||
>
|
||||
<mat-icon>zoom_in</mat-icon>
|
||||
<input
|
||||
#zoomInput
|
||||
type="range"
|
||||
[min]="zoomMin"
|
||||
[max]="zoomMax"
|
||||
[step]="zoomStep"
|
||||
[value]="scale()"
|
||||
(input)="onZoom(zoomInput.valueAsNumber)"
|
||||
[attr.aria-label]="'EPG.TIMELINE.ZOOM' | translate"
|
||||
/>
|
||||
</label>
|
||||
<mat-icon>{{ zoomIcon() }}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (showDateStepper()) {
|
||||
<div class="epg-timeline__stepper">
|
||||
@@ -145,7 +145,8 @@
|
||||
}
|
||||
</span>
|
||||
<small>{{
|
||||
viewDate() | date: 'EEE, d MMM' : '' : currentLocale()
|
||||
viewDate()
|
||||
| date: 'EEE, d MMM' : '' : currentLocale()
|
||||
}}</small>
|
||||
</div>
|
||||
<button
|
||||
@@ -166,13 +167,17 @@
|
||||
@if (state === 'ribbon' && !archivePlaybackAvailable()) {
|
||||
<div class="epg-timeline__notice" role="note">
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>{{ 'EPG.TIMELINE.SCHEDULE_ONLY_NOTICE' | translate }}</span>
|
||||
<span>{{
|
||||
'EPG.TIMELINE.SCHEDULE_ONLY_NOTICE' | translate
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@switch (state) {
|
||||
@case ('loading') {
|
||||
<div class="epg-timeline__ribbon epg-timeline__ribbon--skeleton">
|
||||
<div
|
||||
class="epg-timeline__ribbon epg-timeline__ribbon--skeleton"
|
||||
>
|
||||
@for (width of skeletonWidths; track $index) {
|
||||
<div
|
||||
class="epg-timeline__sk"
|
||||
@@ -186,6 +191,7 @@
|
||||
#ribbon
|
||||
class="epg-timeline__ribbon app-scrollbar"
|
||||
(scroll)="onRibbonScroll()"
|
||||
(wheel)="onRibbonWheel($event)"
|
||||
>
|
||||
<app-epg-timeline-track
|
||||
[items]="renderItems()"
|
||||
|
||||
@@ -53,8 +53,10 @@
|
||||
padding: 4px 6px;
|
||||
border-radius: 7px;
|
||||
font: inherit;
|
||||
/* Shrinks only when the toolbar overflows: the channel + programme title
|
||||
take every pixel the fixed-width controls leave (no hard width cap). */
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 60%;
|
||||
|
||||
&:hover {
|
||||
background: $surface-2;
|
||||
@@ -161,32 +163,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
.epg-timeline__jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
/* icon-only toolbar buttons: "Now" + zoom level (label lives in the tooltip
|
||||
and aria-label, so the channel/programme title keeps the toolbar width) */
|
||||
.epg-timeline__iconbtn {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0 13px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
background: $surface-2;
|
||||
border: 1px solid $line-strong;
|
||||
color: $text-secondary;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $text-primary;
|
||||
border-color: $accent-blue;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $accent-blue;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.epg-timeline__zoom[data-zoom-level='detail'] {
|
||||
color: $accent-text;
|
||||
}
|
||||
|
||||
.epg-timeline__stepper {
|
||||
@@ -339,32 +349,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── zoom slider ── */
|
||||
.epg-timeline__zoom {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: $surface-2;
|
||||
border: 1px solid $line;
|
||||
color: $text-tertiary;
|
||||
flex: 0 0 auto;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
input[type='range'] {
|
||||
width: 96px;
|
||||
accent-color: $accent-text;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── ribbon ── */
|
||||
.epg-timeline__ribbon {
|
||||
flex: 1 1 auto;
|
||||
|
||||
@@ -40,7 +40,9 @@ describe('EpgTimelineComponent', () => {
|
||||
providers: [
|
||||
{
|
||||
provide: MatDialog,
|
||||
useValue: { open: () => ({ afterClosed: () => of(undefined) }) },
|
||||
useValue: {
|
||||
open: () => ({ afterClosed: () => of(undefined) }),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
@@ -225,6 +227,60 @@ describe('EpgTimelineComponent', () => {
|
||||
expect(component.renderItems().length).toBe(before);
|
||||
});
|
||||
|
||||
it('cycles the zoom button through day → hours → detail → day', () => {
|
||||
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
|
||||
expect(component.zoomLevel()).toBe('hours'); // default scale
|
||||
|
||||
component.cycleZoom();
|
||||
expect(component.zoomLevel()).toBe('detail');
|
||||
expect(component.zoomIcon()).toBe('zoom_in');
|
||||
|
||||
component.cycleZoom();
|
||||
expect(component.zoomLevel()).toBe('day');
|
||||
expect(component.zoomIcon()).toBe('zoom_out');
|
||||
|
||||
component.cycleZoom();
|
||||
expect(component.zoomLevel()).toBe('hours');
|
||||
expect(component.zoomLabelKey()).toBe('EPG.TIMELINE.ZOOM_HOURS');
|
||||
});
|
||||
|
||||
it('snaps a wheel-tuned scale to the next preset band when cycling', () => {
|
||||
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
|
||||
component.onZoom(2.6); // still "hours" band, but off-preset
|
||||
expect(component.zoomLevel()).toBe('hours');
|
||||
component.cycleZoom();
|
||||
expect(component.zoomLevel()).toBe('detail');
|
||||
expect(component.scale()).toBe(3.4);
|
||||
});
|
||||
|
||||
function wheel(init: WheelEventInit): WheelEvent {
|
||||
return new WheelEvent('wheel', { cancelable: true, ...init });
|
||||
}
|
||||
|
||||
it('zooms the ribbon with Ctrl/⌘ + wheel and blocks page zoom', () => {
|
||||
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
|
||||
const before = component.scale();
|
||||
|
||||
const zoomIn = wheel({ deltaY: -100, ctrlKey: true });
|
||||
component.onRibbonWheel(zoomIn);
|
||||
expect(zoomIn.defaultPrevented).toBe(true);
|
||||
expect(component.scale()).toBeGreaterThan(before);
|
||||
|
||||
const zoomOut = wheel({ deltaY: 300, metaKey: true });
|
||||
component.onRibbonWheel(zoomOut);
|
||||
expect(component.scale()).toBeLessThan(before);
|
||||
expect(component.scale()).toBeGreaterThanOrEqual(component.zoomMin);
|
||||
});
|
||||
|
||||
it('leaves a plain wheel to native scrolling', () => {
|
||||
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
|
||||
const before = component.scale();
|
||||
const plain = wheel({ deltaY: -100 });
|
||||
component.onRibbonWheel(plain);
|
||||
expect(plain.defaultPrevented).toBe(false);
|
||||
expect(component.scale()).toBe(before);
|
||||
});
|
||||
|
||||
it('expands a group chip by zooming in', () => {
|
||||
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
|
||||
component.onGroupExpand({
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
EpgTimelineEmptyStateComponent,
|
||||
} from './epg-timeline-empty-state.component';
|
||||
import { TimelineScrollController } from './epg-timeline-scroll.controller';
|
||||
import { TimelineZoomController } from './epg-timeline-zoom.controller';
|
||||
import { EpgTimelineTrackComponent } from './epg-timeline-track.component';
|
||||
import {
|
||||
buildTimelineAxis,
|
||||
@@ -59,10 +60,10 @@ import {
|
||||
TIMELINE_MINUTE_MS,
|
||||
TIMELINE_ZOOM_MAX,
|
||||
TIMELINE_ZOOM_MIN,
|
||||
TIMELINE_ZOOM_STEP,
|
||||
TimelineBlock,
|
||||
TimelineRenderGroup,
|
||||
timelineTickStepForScale,
|
||||
timelineZoomLevelForScale,
|
||||
} from './epg-timeline.utils';
|
||||
|
||||
type RenderState = 'loading' | 'ribbon' | EpgTimelineEmptyReason;
|
||||
@@ -115,7 +116,6 @@ export class EpgTimelineComponent {
|
||||
readonly scale = signal(TIMELINE_DEFAULT_SCALE);
|
||||
readonly zoomMin = TIMELINE_ZOOM_MIN;
|
||||
readonly zoomMax = TIMELINE_ZOOM_MAX;
|
||||
readonly zoomStep = TIMELINE_ZOOM_STEP;
|
||||
readonly skeletonWidths = [120, 170, 150, 200, 140, 180];
|
||||
|
||||
private readonly nowMs = signal(Date.now());
|
||||
@@ -127,6 +127,13 @@ export class EpgTimelineComponent {
|
||||
return key ? key : getTodayEpgDateKey();
|
||||
});
|
||||
|
||||
/** Zoom button + Ctrl/⌘ wheel, anchored so a ribbon minute stays put. */
|
||||
private readonly zoom = new TimelineZoomController({
|
||||
ribbon: () => this.ribbon()?.nativeElement,
|
||||
scale: () => this.scale(),
|
||||
setScale: (scale) => this.scale.set(scale),
|
||||
});
|
||||
|
||||
/** Ribbon scrolling + channel-select auto-focus, extracted from the view. */
|
||||
private readonly scroll = new TimelineScrollController({
|
||||
ribbon: () => this.ribbon()?.nativeElement,
|
||||
@@ -196,12 +203,23 @@ export class EpgTimelineComponent {
|
||||
((this.nowMs() - axis.startMs) / TIMELINE_MINUTE_MS) * this.scale()
|
||||
);
|
||||
});
|
||||
readonly zoomLevel = computed(() =>
|
||||
timelineZoomLevelForScale(this.scale())
|
||||
);
|
||||
readonly zoomLabelKey = computed(() => {
|
||||
const scale = this.scale();
|
||||
if (scale < TIMELINE_GROUP_ZOOM_MAX) return 'EPG.TIMELINE.ZOOM_DAY';
|
||||
if (scale < 3) return 'EPG.TIMELINE.ZOOM_HOURS';
|
||||
return 'EPG.TIMELINE.ZOOM_DETAIL';
|
||||
switch (this.zoomLevel()) {
|
||||
case 'day':
|
||||
return 'EPG.TIMELINE.ZOOM_DAY';
|
||||
case 'hours':
|
||||
return 'EPG.TIMELINE.ZOOM_HOURS';
|
||||
default:
|
||||
return 'EPG.TIMELINE.ZOOM_DETAIL';
|
||||
}
|
||||
});
|
||||
/** `zoom_out` reads as "overview"; both finer bands share `zoom_in`. */
|
||||
readonly zoomIcon = computed(() =>
|
||||
this.zoomLevel() === 'day' ? 'zoom_out' : 'zoom_in'
|
||||
);
|
||||
readonly nowLabel = computed(() => formatClockTime(this.nowMs()));
|
||||
|
||||
readonly viewDate = computed(() => parseEpgDateKey(this.viewDayKey()));
|
||||
@@ -301,24 +319,19 @@ export class EpgTimelineComponent {
|
||||
this.collapsedChange.emit(!this.collapsed());
|
||||
}
|
||||
|
||||
/** Clamp + apply a scale, keeping the viewport centre stable. */
|
||||
onZoom(value: number): void {
|
||||
const requested = Number(value);
|
||||
const next = Number.isFinite(requested)
|
||||
? Math.min(this.zoomMax, Math.max(this.zoomMin, requested))
|
||||
: this.scale();
|
||||
// Keep the viewport centre stable across a zoom change.
|
||||
const scroller = this.ribbon()?.nativeElement;
|
||||
const prev = this.scale();
|
||||
const centreMin = scroller
|
||||
? (scroller.scrollLeft + scroller.clientWidth / 2) / prev
|
||||
: null;
|
||||
this.scale.set(next);
|
||||
if (scroller && centreMin !== null) {
|
||||
requestAnimationFrame(() => {
|
||||
scroller.scrollLeft =
|
||||
centreMin * next - scroller.clientWidth / 2;
|
||||
});
|
||||
}
|
||||
this.zoom.zoomTo(value);
|
||||
}
|
||||
|
||||
/** Toolbar zoom button: next preset (day → hours → detail → day). */
|
||||
cycleZoom(): void {
|
||||
this.zoom.cycle();
|
||||
}
|
||||
|
||||
/** Ctrl/⌘ + wheel over the ribbon zooms around the cursor. */
|
||||
onRibbonWheel(event: WheelEvent): void {
|
||||
this.zoom.onWheel(event);
|
||||
}
|
||||
|
||||
onGroupExpand(group: TimelineRenderGroup): void {
|
||||
|
||||
@@ -9,12 +9,16 @@ import {
|
||||
dayKeyAtOffset,
|
||||
hasProgramsForDateKey,
|
||||
nearestDateKeyWithPrograms,
|
||||
nextTimelineZoomScale,
|
||||
TIMELINE_GROUP_EXPAND_ZOOM,
|
||||
TIMELINE_MINUTE_MS,
|
||||
TIMELINE_MIN_BLOCK_WIDTH_PX,
|
||||
TIMELINE_ZOOM_LEVELS,
|
||||
TimelineRenderBlock,
|
||||
TimelineRenderGroup,
|
||||
tierFor,
|
||||
timelineTickStepForScale,
|
||||
timelineZoomLevelForScale,
|
||||
} from './epg-timeline.utils';
|
||||
|
||||
function localIso(
|
||||
@@ -66,15 +70,15 @@ describe('epg-timeline.utils', () => {
|
||||
|
||||
describe('classifyTimelineWhen', () => {
|
||||
it('classifies past, now and future relative to now', () => {
|
||||
expect(classifyTimelineWhen(NOW - 7200000, NOW - 3600000, NOW)).toBe(
|
||||
'past'
|
||||
);
|
||||
expect(
|
||||
classifyTimelineWhen(NOW - 7200000, NOW - 3600000, NOW)
|
||||
).toBe('past');
|
||||
expect(classifyTimelineWhen(NOW - 60000, NOW + 60000, NOW)).toBe(
|
||||
'now'
|
||||
);
|
||||
expect(classifyTimelineWhen(NOW + 3600000, NOW + 7200000, NOW)).toBe(
|
||||
'future'
|
||||
);
|
||||
expect(
|
||||
classifyTimelineWhen(NOW + 3600000, NOW + 7200000, NOW)
|
||||
).toBe('future');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,18 +95,30 @@ describe('epg-timeline.utils', () => {
|
||||
const block = blocks[0];
|
||||
expect(block.when).toBe('now');
|
||||
expect(block.durationMin).toBe(120);
|
||||
const expectedOffset = (block.startMs - axis.startMs) / TIMELINE_MINUTE_MS;
|
||||
const expectedOffset =
|
||||
(block.startMs - axis.startMs) / TIMELINE_MINUTE_MS;
|
||||
expect(block.offsetMin).toBeCloseTo(expectedOffset, 5);
|
||||
});
|
||||
|
||||
it('sorts blocks chronologically', () => {
|
||||
const programs = [
|
||||
program(localIso(2026, 6, 28, 18), localIso(2026, 6, 28, 19), 'late'),
|
||||
program(localIso(2026, 6, 28, 8), localIso(2026, 6, 28, 9), 'early'),
|
||||
program(
|
||||
localIso(2026, 6, 28, 18),
|
||||
localIso(2026, 6, 28, 19),
|
||||
'late'
|
||||
),
|
||||
program(
|
||||
localIso(2026, 6, 28, 8),
|
||||
localIso(2026, 6, 28, 9),
|
||||
'early'
|
||||
),
|
||||
];
|
||||
const axis = buildTimelineAxis(programs, NOW);
|
||||
const blocks = buildTimelineBlocks(programs, axis, NOW);
|
||||
expect(blocks.map((b) => b.program.title)).toEqual(['early', 'late']);
|
||||
expect(blocks.map((b) => b.program.title)).toEqual([
|
||||
'early',
|
||||
'late',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,7 +183,8 @@ describe('epg-timeline.utils', () => {
|
||||
[program(localIso(2026, 6, 28, 10), localIso(2026, 6, 28, 11))],
|
||||
NOW
|
||||
);
|
||||
const offsetForNoon = (12 * 60 * TIMELINE_MINUTE_MS) / TIMELINE_MINUTE_MS;
|
||||
const offsetForNoon =
|
||||
(12 * 60 * TIMELINE_MINUTE_MS) / TIMELINE_MINUTE_MS;
|
||||
expect(dayKeyAtOffset(axis, offsetForNoon)).toBe('2026-06-28');
|
||||
});
|
||||
|
||||
@@ -177,7 +194,9 @@ describe('epg-timeline.utils', () => {
|
||||
program(localIso(2026, 6, 30, 10), localIso(2026, 6, 30, 11)),
|
||||
];
|
||||
// Jun 30 10:00 (~1d22h away) is closer to Jun 28 12:00 than Jun 25.
|
||||
expect(nearestDateKeyWithPrograms(programs, NOW)).toBe('2026-06-30');
|
||||
expect(nearestDateKeyWithPrograms(programs, NOW)).toBe(
|
||||
'2026-06-30'
|
||||
);
|
||||
});
|
||||
|
||||
it('keys, positions and picks days in display time when an offset is set', () => {
|
||||
@@ -189,8 +208,12 @@ describe('epg-timeline.utils', () => {
|
||||
'Late'
|
||||
),
|
||||
];
|
||||
expect(hasProgramsForDateKey(programs, '2026-06-29', 60)).toBe(false);
|
||||
expect(hasProgramsForDateKey(programs, '2026-06-30', 60)).toBe(true);
|
||||
expect(hasProgramsForDateKey(programs, '2026-06-29', 60)).toBe(
|
||||
false
|
||||
);
|
||||
expect(hasProgramsForDateKey(programs, '2026-06-30', 60)).toBe(
|
||||
true
|
||||
);
|
||||
expect(nearestDateKeyWithPrograms(programs, NOW, 60)).toBe(
|
||||
'2026-06-30'
|
||||
);
|
||||
@@ -205,6 +228,35 @@ describe('epg-timeline.utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('zoom presets', () => {
|
||||
it('maps every preset scale back to its own level', () => {
|
||||
for (const { level, scale } of TIMELINE_ZOOM_LEVELS) {
|
||||
expect(timelineZoomLevelForScale(scale)).toBe(level);
|
||||
}
|
||||
});
|
||||
|
||||
it('classifies off-preset scales by band', () => {
|
||||
expect(timelineZoomLevelForScale(0.8)).toBe('day');
|
||||
expect(timelineZoomLevelForScale(1.29)).toBe('day');
|
||||
expect(timelineZoomLevelForScale(1.3)).toBe('hours');
|
||||
expect(timelineZoomLevelForScale(2.99)).toBe('hours');
|
||||
expect(timelineZoomLevelForScale(3)).toBe('detail');
|
||||
expect(timelineZoomLevelForScale(6)).toBe('detail');
|
||||
});
|
||||
|
||||
it('cycles to the next preset and wraps from detail to day', () => {
|
||||
expect(nextTimelineZoomScale(1)).toBe(1.75);
|
||||
expect(nextTimelineZoomScale(1.75)).toBe(3.4);
|
||||
expect(nextTimelineZoomScale(3.4)).toBe(1);
|
||||
// an expanded group chip sits on the detail preset, so the cycle
|
||||
// continues from there instead of re-entering "detail"
|
||||
expect(nextTimelineZoomScale(TIMELINE_GROUP_EXPAND_ZOOM)).toBe(1);
|
||||
// wheel-tuned values snap to the band's successor
|
||||
expect(nextTimelineZoomScale(2.6)).toBe(3.4);
|
||||
expect(nextTimelineZoomScale(5)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('short-programme strategy', () => {
|
||||
it('classifies content tiers by rendered width', () => {
|
||||
expect(tierFor(200)).toBe('wide');
|
||||
@@ -220,11 +272,19 @@ describe('epg-timeline.utils', () => {
|
||||
expect(timelineTickStepForScale(6)).toBe(15);
|
||||
});
|
||||
|
||||
function blocksFor(durations: number[]): ReturnType<typeof buildTimelineBlocks> {
|
||||
function blocksFor(
|
||||
durations: number[]
|
||||
): ReturnType<typeof buildTimelineBlocks> {
|
||||
let startMin = 6 * 60; // 06:00
|
||||
const programs = durations.map((dur) => {
|
||||
const p = program(
|
||||
localIso(2026, 6, 28, Math.floor(startMin / 60), startMin % 60),
|
||||
localIso(
|
||||
2026,
|
||||
6,
|
||||
28,
|
||||
Math.floor(startMin / 60),
|
||||
startMin % 60
|
||||
),
|
||||
localIso(
|
||||
2026,
|
||||
6,
|
||||
@@ -278,7 +338,10 @@ describe('epg-timeline.utils', () => {
|
||||
eh: number,
|
||||
em: number
|
||||
): ReturnType<typeof program> =>
|
||||
program(localIso(2026, 6, 28, sh, sm), localIso(2026, 6, 28, eh, em));
|
||||
program(
|
||||
localIso(2026, 6, 28, sh, sm),
|
||||
localIso(2026, 6, 28, eh, em)
|
||||
);
|
||||
const programs = [
|
||||
mk(11, 38, 11, 43),
|
||||
mk(11, 43, 11, 48),
|
||||
|
||||
Reference in new issue
Block a user