refactor(documents): rework timeline UX after live testing (#385)
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m29s
CI / OCR Service Tests (push) Successful in 48s
CI / Backend Unit Tests (push) Failing after 3m46s
CI / Unit & Component Tests (pull_request) Failing after 4m31s
CI / OCR Service Tests (pull_request) Successful in 38s
CI / Backend Unit Tests (pull_request) Failing after 3m31s

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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-08 08:54:48 +02:00
parent a6123e1867
commit 5d92f5a32b
6 changed files with 454 additions and 119 deletions

View File

@@ -253,7 +253,16 @@ $effect(() => {
onchange={(event) => {
from = event.from;
to = event.to;
triggerSearch();
// Drag commits filter + zoom atomically (Graylog-style range selector).
// Single click and clear omit zoomFrom/zoomTo so existing zoom is preserved.
if ('zoomFrom' in event) {
triggerSearch({
zoomFrom: event.zoomFrom ?? null,
zoomTo: event.zoomTo ?? null
});
} else {
triggerSearch();
}
}}
onzoomchange={(event) => {
triggerSearch({

View File

@@ -177,30 +177,18 @@ describe('documents page — timeline density widget', () => {
expect(url).toContain('to=1915-08-31');
});
it('clicking the zoom-in button writes zoomFrom/zoomTo URL params', async () => {
const { goto } = await import('$app/navigation');
vi.mocked(goto).mockClear();
it('the standalone zoom-in button no longer exists (drag replaces it)', async () => {
render(Page, {
data: makeData({
density: [
{ month: '1915-08', count: 3 },
{ month: '1915-09', count: 2 }
],
density: [{ month: '1915-08', count: 3 }],
minDate: '1915-08-01',
maxDate: '1915-09-30',
maxDate: '1915-08-31',
from: '1915-08-01',
to: '1915-09-30'
to: '1915-08-31'
})
});
const zoomBtn = document.querySelector('[data-testid="timeline-zoom-in"]') as HTMLButtonElement;
zoomBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(goto).toHaveBeenCalledOnce();
const [url] = vi.mocked(goto).mock.calls[0];
expect(url).toContain('zoomFrom=1915-08-01');
expect(url).toContain('zoomTo=1915-09-30');
expect(document.querySelector('[data-testid="timeline-zoom-in"]')).toBeNull();
});
it('clicking reset-zoom drops zoomFrom/zoomTo from the URL', async () => {