From 5d92f5a32b8a610e372bd6d60ac320dfc26025f8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 8 May 2026 08:54:48 +0200 Subject: [PATCH] refactor(documents): rework timeline UX after live testing (#385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the discrete zoom-in button with a Graylog-style drag-to-zoom range selector and adds X/Y axis labels so the chart is readable. Drag interaction - Pointerdown on a bar attaches document-level pointermove/pointerup/ pointercancel listeners; pointermove maps clientX to a bar index via the row's bounding rect, so the mint-bordered window expands smoothly even when the cursor leaves the bar or the chart entirely. - pointerup commits filter + zoom atomically. Same-bar release on a year bar (year-aggregated mode) zooms into that year's months; same-bar release on a month bar emits filter-only. - setPointerCapture removed — it was suppressing pointerenter on sibling bars and preventing the drag window from expanding. - Bar buttons are now h-full so the entire 80 px column is the hit target, not just the visible bar height. Axis labels - Y-axis: max-count and 0 labels left of the bar area. - X-axis: tickIndicesFor() picks decadal years for long ranges, evenly spaced months for short year-zoom views, January boundaries for multi-year month ranges. formatTickLabel() drops the year when the visible range is a single year so 12-month zooms read "Jan Feb Mär…". Co-Authored-By: Claude Opus 4.7 --- .../lib/document/TimelineDensityFilter.svelte | 239 +++++++++++++----- .../TimelineDensityFilter.svelte.spec.ts | 158 +++++++++--- frontend/src/lib/document/timeline.spec.ts | 85 ++++++- frontend/src/lib/document/timeline.ts | 58 +++++ frontend/src/routes/documents/+page.svelte | 11 +- .../src/routes/documents/page.svelte.spec.ts | 22 +- 6 files changed, 454 insertions(+), 119 deletions(-) diff --git a/frontend/src/lib/document/TimelineDensityFilter.svelte b/frontend/src/lib/document/TimelineDensityFilter.svelte index 67b8d824..6b32cbd1 100644 --- a/frontend/src/lib/document/TimelineDensityFilter.svelte +++ b/frontend/src/lib/document/TimelineDensityFilter.svelte @@ -5,12 +5,22 @@ import { aggregateToYears, clipBucketsToRange, selectionBoundaryFrom, - selectionBoundaryTo + selectionBoundaryTo, + tickIndicesFor, + formatTickLabel } from '$lib/document/timeline'; +import { getLocale } from '$lib/paraglide/runtime'; import type { components } from '$lib/generated/api'; type MonthBucket = components['schemas']['MonthBucket']; -type SelectionEvent = { from: string; to: string }; +// Drag emits filter + zoom atomically (Graylog-style range selector). +// Single click and clear emit filter only — zoom fields are absent. +type SelectionEvent = { + from: string; + to: string; + zoomFrom?: string | null; + zoomTo?: string | null; +}; type ZoomEvent = { zoomFrom: string; zoomTo: string }; const BAR_AREA_HEIGHT = 80; // px — Leonie spec h-20 @@ -52,12 +62,6 @@ const filled = $derived( ); const isZoomed = $derived(zoomFrom !== null && zoomTo !== null); -const canZoomIn = $derived(hasSelection && !isZoomed); - -function zoomIn() { - if (from === '' || to === '') return; - onzoomchange?.({ zoomFrom: from, zoomTo: to }); -} function resetZoom() { onzoomchange?.(null); @@ -69,6 +73,7 @@ const hasSelection = $derived(from !== '' || to !== ''); let dragStartIndex: number | null = $state(null); let dragEndIndex: number | null = $state(null); +let rowEl: HTMLDivElement | undefined = $state(); // Set when a pointerup just emitted a selection so the synthesized click that // follows mouse interaction is suppressed (we'd otherwise emit twice). Keyboard // Enter/Space on a focused button still fires `click` without preceding @@ -107,15 +112,76 @@ function isInDragPreview(index: number): boolean { return index >= dragLowIndex && index <= dragHighIndex; } -function emitSelection(startIndex: number, endIndex: number) { +function emitSelection(startIndex: number, endIndex: number, includeZoom: boolean) { const lo = Math.min(startIndex, endIndex); const hi = Math.max(startIndex, endIndex); const startLabel = filled[lo]?.month; const endLabel = filled[hi]?.month; if (!startLabel || !endLabel) return; - onchange({ - from: selectionBoundaryFrom(startLabel), - to: selectionBoundaryTo(endLabel) + const selFrom = selectionBoundaryFrom(startLabel); + const selTo = selectionBoundaryTo(endLabel); + if (includeZoom) { + onchange({ from: selFrom, to: selTo, zoomFrom: selFrom, zoomTo: selTo }); + } else { + onchange({ from: selFrom, to: selTo }); + } +} + +// Maps a viewport X-coordinate to a bar index by measuring the row, so +// pointermove during drag works even when the cursor leaves the original bar +// or the graph entirely. Pointer capture isn't usable here because it would +// re-target click and suppress pointerenter on sibling bars. +function indexFromClientX(clientX: number): number | null { + if (!rowEl || filled.length === 0) return null; + const rect = rowEl.getBoundingClientRect(); + const x = clientX - rect.left; + if (x < 0) return 0; + if (x >= rect.width) return filled.length - 1; + const barWidth = rect.width / filled.length; + return Math.min(filled.length - 1, Math.max(0, Math.floor(x / barWidth))); +} + +function handleDocumentMove(e: PointerEvent) { + const idx = indexFromClientX(e.clientX); + if (idx !== null) dragEndIndex = idx; +} + +function handleDocumentUp() { + cleanupDragListeners(); + finalizeDrag(); +} + +function handleDocumentCancel() { + cleanupDragListeners(); + dragStartIndex = null; + dragEndIndex = null; +} + +function cleanupDragListeners() { + document.removeEventListener('pointermove', handleDocumentMove); + document.removeEventListener('pointerup', handleDocumentUp); + document.removeEventListener('pointercancel', handleDocumentCancel); +} + +function finalizeDrag() { + if (dragStartIndex === null || dragEndIndex === null) return; + const start = dragStartIndex; + const end = dragEndIndex; + dragStartIndex = null; + dragEndIndex = null; + const isRangeDrag = start !== end; + const startLabel = filled[start]?.month; + // Range drag → atomic zoom + filter. + // Same-bar release on a year bar → zoom into that year's months. + // Same-bar release on a month bar → filter only. + const includeZoom = isRangeDrag || (!!startLabel && isYearLabel(startLabel)); + emitSelection(start, end, includeZoom); + // Suppress the synthesized click that follows pointerup so we don't + // double-emit. Keyboard Enter/Space fires click without a preceding + // pointerup and stays the keyboard accessibility surface. + suppressClick = true; + queueMicrotask(() => { + suppressClick = false; }); } @@ -123,7 +189,9 @@ function handlePointerDown(event: PointerEvent, index: number) { if (event.button !== 0) return; dragStartIndex = index; dragEndIndex = index; - (event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId); + document.addEventListener('pointermove', handleDocumentMove); + document.addEventListener('pointerup', handleDocumentUp); + document.addEventListener('pointercancel', handleDocumentCancel); } function handlePointerEnter(index: number) { @@ -131,20 +199,8 @@ function handlePointerEnter(index: number) { dragEndIndex = index; } -function handlePointerUp() { - if (dragStartIndex === null || dragEndIndex === null) return; - emitSelection(dragStartIndex, dragEndIndex); - dragStartIndex = null; - dragEndIndex = null; - suppressClick = true; - queueMicrotask(() => { - suppressClick = false; - }); -} - -function handlePointerCancel() { - dragStartIndex = null; - dragEndIndex = null; +function isYearLabel(label: string): boolean { + return label.length === 4; } function handleClick(index: number) { @@ -152,8 +208,28 @@ function handleClick(index: number) { suppressClick = false; return; } - emitSelection(index, index); + const label = filled[index]?.month; + // Click on a year bar zooms into that year so the user can see month-level + // density. Click on a month bar just filters. + const includeZoom = !!label && isYearLabel(label); + emitSelection(index, index, includeZoom); } + +const dragWindowLeftPct = $derived.by(() => { + if (!isDragging || dragLowIndex === null || filled.length === 0) return 0; + return (dragLowIndex / filled.length) * 100; +}); +const dragWindowRightPct = $derived.by(() => { + if (!isDragging || dragHighIndex === null || filled.length === 0) return 100; + return ((filled.length - dragHighIndex - 1) / filled.length) * 100; +}); + +const tickIndices = $derived(tickIndicesFor(filled)); +const omitTickYear = $derived.by(() => { + if (filled.length === 0 || filled[0].month.length === 4) return false; + const firstYear = filled[0].month.slice(0, 4); + return filled.every((b) => b.month.slice(0, 4) === firstYear); +}); {#if density !== null} @@ -163,43 +239,71 @@ function handleClick(index: number) { aria-label={m.timeline_aria_label()} class="relative rounded-sm border border-line bg-surface px-3 pt-3 pb-2 shadow-sm" > -
- {#each filled as bucket, i (bucket.month)} - - {/each} + {#each filled as bucket, i (bucket.month)} + + {/each} + {#if isDragging} +
+ {/if} +
+ + +
- {#if canZoomIn} - - {/if} {#if isZoomed}