From ef2418a2c548f4bb871adac868c3c876492015de Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:34:28 +0200 Subject: [PATCH] feat(epg): compact timeline toolbar with icon-only Now and cycling zoom (#1557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Claude Fable 5.1 --- .changes/epg-timeline-compact-controls.md | 6 + .../src/epg-timeline-interaction.e2e.ts | 67 +++++++--- apps/web/src/assets/i18n/ar.json | 1 + apps/web/src/assets/i18n/ary.json | 1 + apps/web/src/assets/i18n/by.json | 1 + apps/web/src/assets/i18n/de.json | 1 + apps/web/src/assets/i18n/el.json | 1 + apps/web/src/assets/i18n/en.json | 1 + apps/web/src/assets/i18n/es.json | 1 + apps/web/src/assets/i18n/fr.json | 1 + apps/web/src/assets/i18n/hu.json | 1 + apps/web/src/assets/i18n/it.json | 1 + apps/web/src/assets/i18n/ja.json | 1 + apps/web/src/assets/i18n/ko.json | 1 + apps/web/src/assets/i18n/nl.json | 1 + apps/web/src/assets/i18n/pl.json | 1 + apps/web/src/assets/i18n/pt.json | 1 + apps/web/src/assets/i18n/ru.json | 1 + apps/web/src/assets/i18n/tr.json | 1 + apps/web/src/assets/i18n/zh.json | 1 + apps/web/src/assets/i18n/zhtw.json | 1 + docs/architecture/m3u-playlist-module.md | 17 ++- .../epg-timeline/epg-timeline-render.util.ts | 45 ++++++- .../epg-timeline-zoom.controller.spec.ts | 125 ++++++++++++++++++ .../epg-timeline-zoom.controller.ts | 102 ++++++++++++++ .../epg-timeline/epg-timeline.component.html | 48 ++++--- .../epg-timeline/epg-timeline.component.scss | 60 +++------ .../epg-timeline.component.spec.ts | 58 +++++++- .../epg-timeline/epg-timeline.component.ts | 59 +++++---- .../epg-timeline/epg-timeline.utils.spec.ts | 97 +++++++++++--- 30 files changed, 580 insertions(+), 123 deletions(-) create mode 100644 .changes/epg-timeline-compact-controls.md create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-timeline-zoom.controller.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-timeline-zoom.controller.ts diff --git a/.changes/epg-timeline-compact-controls.md b/.changes/epg-timeline-compact-controls.md new file mode 100644 index 000000000..6acf207c3 --- /dev/null +++ b/.changes/epg-timeline-compact-controls.md @@ -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. diff --git a/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts b/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts index e89f5e484..034825f12 100644 --- a/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts +++ b/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts @@ -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 = { + 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); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 2c70c7777..0ceee228e 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -837,6 +837,7 @@ "ZOOM_DAY": "نظرة عامة على اليوم", "ZOOM_HOURS": "حسب الساعة", "ZOOM_DETAIL": "تفصيلي", + "ZOOM_TOOLTIP": "التكبير: {{level}} · ⌘/Ctrl + التمرير للضبط الدقيق", "GROUP_SHORT": "برامج قصيرة", "GROUP_EXPAND": "عرض ›" } diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 07193fe59..3e0964d6e 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -837,6 +837,7 @@ "ZOOM_DAY": "نظرة عامة على النهار", "ZOOM_HOURS": "بالساعة", "ZOOM_DETAIL": "مفصّل", + "ZOOM_TOOLTIP": "التكبير: {{level}} · ⌘/Ctrl + التمرير باش تضبط", "GROUP_SHORT": "برامج قصيرة", "GROUP_EXPAND": "وري ›" } diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 7dbd9e2cd..e61367805 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -837,6 +837,7 @@ "ZOOM_DAY": "Агляд дня", "ZOOM_HOURS": "Па гадзінах", "ZOOM_DETAIL": "Падрабязна", + "ZOOM_TOOLTIP": "Маштаб: {{level}} · ⌘/Ctrl + кола для дакладнай наладкі", "GROUP_SHORT": "кароткія перадачы", "GROUP_EXPAND": "паказаць ›" } diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 206ac9850..b1dbcbd5c 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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 ›" } diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 3af3e99ac..1b32e374f 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -837,6 +837,7 @@ "ZOOM_DAY": "Επισκόπηση ημέρας", "ZOOM_HOURS": "Ανά ώρα", "ZOOM_DETAIL": "Λεπτομερής", + "ZOOM_TOOLTIP": "Ζουμ: {{level}} · ⌘/Ctrl + κύλιση για λεπτομερή ρύθμιση", "GROUP_SHORT": "σύντ. προγρ.", "GROUP_EXPAND": "εμφάνιση ›" } diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 9497932b3..ec0111811 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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 ›" } diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 1801d2fa5..578d9275e 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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 ›" } diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index c8f823727..3c11651e5 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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 ›" } diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 178d6ee2b..d031e6794 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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 ›" } diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 6da189902..b7a049cc1 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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 ›" } diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 79be8eb91..40514202a 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -837,6 +837,7 @@ "ZOOM_DAY": "1日表示", "ZOOM_HOURS": "時間単位", "ZOOM_DETAIL": "詳細", + "ZOOM_TOOLTIP": "ズーム: {{level}} · ⌘/Ctrl + スクロールで微調整", "GROUP_SHORT": "短い番組", "GROUP_EXPAND": "表示 ›" } diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index f99f0f9d8..3566b1a42 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -837,6 +837,7 @@ "ZOOM_DAY": "하루 보기", "ZOOM_HOURS": "시간별", "ZOOM_DETAIL": "상세", + "ZOOM_TOOLTIP": "확대/축소: {{level}} · ⌘/Ctrl + 스크롤로 미세 조정", "GROUP_SHORT": "짧은 프로그램", "GROUP_EXPAND": "보기 ›" } diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 8179c2432..63a5798e6 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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 ›" } diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index fc4e60eeb..8388b00f7 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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ż ›" } diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 8d8d06406..072e838ba 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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 ›" } diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 6bf700511..9c0550a57 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -837,6 +837,7 @@ "ZOOM_DAY": "Обзор дня", "ZOOM_HOURS": "По часам", "ZOOM_DETAIL": "Детально", + "ZOOM_TOOLTIP": "Масштаб: {{level}} · ⌘/Ctrl + колесо для точной настройки", "GROUP_SHORT": "короткие передачи", "GROUP_EXPAND": "показать ›" } diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index daeb836e3..237dc8e4a 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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 ›" } diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 54d0dc95e..4ab122512 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -837,6 +837,7 @@ "ZOOM_DAY": "全天概览", "ZOOM_HOURS": "按小时", "ZOOM_DETAIL": "详细", + "ZOOM_TOOLTIP": "缩放:{{level}} · ⌘/Ctrl + 滚轮微调", "GROUP_SHORT": "短节目", "GROUP_EXPAND": "展开 ›" } diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 1d91c7e24..57035cd01 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -837,6 +837,7 @@ "ZOOM_DAY": "日總覽", "ZOOM_HOURS": "依小時", "ZOOM_DETAIL": "詳細", + "ZOOM_TOOLTIP": "縮放:{{level}} · ⌘/Ctrl + 滾輪微調", "GROUP_SHORT": "短節目", "GROUP_EXPAND": "顯示 ›" } diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index a8aecd194..39abd87b7 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -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 diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-render.util.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-render.util.ts index a7afd196b..469cbe85f 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-render.util.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-render.util.ts @@ -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}`, diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-zoom.controller.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-zoom.controller.spec.ts new file mode 100644 index 000000000..e1a7d7752 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-zoom.controller.spec.ts @@ -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); + }); +}); diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-zoom.controller.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-zoom.controller.ts new file mode 100644 index 000000000..5a236f0d6 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-zoom.controller.ts @@ -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; + } + } +} diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html index 1b3990b90..3559e2870 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html @@ -55,7 +55,10 @@ summary()?.title }} @if (progress() !== null) { -