feat(documents): zoom-in tool for the timeline (#385)

Adds a zoom action that narrows the visible timeline range to the current
selection so the user can drill from year-level back into month-level
density. Zoom state lives in the URL (zoomFrom / zoomTo) so it survives
reload and is shareable.

- New `clipBucketsToRange(buckets, from, to)` helper applied before the
  >240-month year-aggregate decision, so a zoomed window flips back to
  month bars automatically when the clip narrows the range enough.
- `TimelineDensityFilter` gains `zoomFrom`, `zoomTo`, and `onzoomchange`
  props. Zoom button shown only when a selection exists and we aren't
  already zoomed; reset-zoom shown only when zoomed. Both placed in a
  shared right-edge action cluster alongside the × clear button.
- `+page.ts` reads zoomFrom/zoomTo from the URL and forwards them as
  props. `+page.svelte` extends FilterSnapshot + buildSearchParams, and
  triggerSearch accepts an optional zoom override so the onzoomchange
  callback can write the new pair (or clear them) atomically.
- 7 new component tests + 2 new page-integration tests cover the
  visibility rules and URL writes.
- 4 new unit tests for `clipBucketsToRange`.
- 3 new i18n keys (zoom in / zoom reset / drag aria-live) across de/en/es.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-07 23:23:38 +02:00
parent bd81ff81f9
commit a6123e1867
10 changed files with 282 additions and 20 deletions

View File

@@ -1051,5 +1051,8 @@
"timeline_clear_selection": "Auswahl zurücksetzen",
"timeline_count_label": "Dok.",
"timeline_loading": "Lade Zeitachse…",
"timeline_filtered_count": "{count} Dokumente im Zeitraum"
"timeline_filtered_count": "{count} Dokumente im Zeitraum",
"timeline_zoom_in": "Zeitraum vergrößern",
"timeline_zoom_reset": "Zurück zur Übersicht",
"timeline_dragging_aria_live": "Zeitraum {from} bis {to} ausgewählt"
}

View File

@@ -1051,5 +1051,8 @@
"timeline_clear_selection": "Clear selection",
"timeline_count_label": "docs",
"timeline_loading": "Loading timeline…",
"timeline_filtered_count": "{count} documents in range"
"timeline_filtered_count": "{count} documents in range",
"timeline_zoom_in": "Zoom in",
"timeline_zoom_reset": "Reset zoom",
"timeline_dragging_aria_live": "Range {from} to {to} selected"
}

View File

@@ -1051,5 +1051,8 @@
"timeline_clear_selection": "Borrar selección",
"timeline_count_label": "docs",
"timeline_loading": "Cargando cronología…",
"timeline_filtered_count": "{count} documentos en el rango"
"timeline_filtered_count": "{count} documentos en el rango",
"timeline_zoom_in": "Acercar",
"timeline_zoom_reset": "Restablecer zoom",
"timeline_dragging_aria_live": "Rango {from} a {to} seleccionado"
}

View File

@@ -3,6 +3,7 @@ import * as m from '$lib/paraglide/messages.js';
import {
fillDensityGaps,
aggregateToYears,
clipBucketsToRange,
selectionBoundaryFrom,
selectionBoundaryTo
} from '$lib/document/timeline';
@@ -10,6 +11,7 @@ import type { components } from '$lib/generated/api';
type MonthBucket = components['schemas']['MonthBucket'];
type SelectionEvent = { from: string; to: string };
type ZoomEvent = { zoomFrom: string; zoomTo: string };
const BAR_AREA_HEIGHT = 80; // px — Leonie spec h-20
const ZERO_COUNT_BAR_HEIGHT = 2; // px — minimum visible signal for empty months
@@ -23,25 +25,44 @@ let {
maxDate,
from,
to,
onchange
zoomFrom = null,
zoomTo = null,
onchange,
onzoomchange
}: {
density: MonthBucket[] | null;
minDate: string | null;
maxDate: string | null;
from: string;
to: string;
zoomFrom?: string | null;
zoomTo?: string | null;
onchange: (event: SelectionEvent) => void;
onzoomchange?: (event: ZoomEvent | null) => void;
} = $props();
const monthBuckets = $derived.by(() => {
if (density === null) return [];
return fillDensityGaps(density, minDate, maxDate);
const full = fillDensityGaps(density, minDate, maxDate);
return clipBucketsToRange(full, zoomFrom, zoomTo);
});
const filled = $derived(
monthBuckets.length > MONTH_GRANULARITY_LIMIT ? aggregateToYears(monthBuckets) : monthBuckets
);
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);
}
const maxCount = $derived(Math.max(...filled.map((b) => b.count), 1));
const hasSelection = $derived(from !== '' || to !== '');
@@ -166,17 +187,43 @@ function handleClick(index: number) {
{/each}
</div>
{#if hasSelection}
<button
type="button"
data-testid="timeline-clear"
aria-label={m.timeline_clear_selection()}
onclick={clearSelection}
class="hover:text-ink-1 absolute top-2 right-2 inline-flex h-6 w-6 items-center justify-center rounded-full text-ink-3 hover:bg-canvas"
>
×
</button>
{/if}
<div class="absolute top-2 right-2 flex items-center gap-1">
{#if canZoomIn}
<button
type="button"
data-testid="timeline-zoom-in"
aria-label={m.timeline_zoom_in()}
title={m.timeline_zoom_in()}
onclick={zoomIn}
class="hover:text-ink-1 inline-flex h-6 items-center justify-center gap-1 rounded-sm px-2 text-xs text-ink-3 hover:bg-canvas"
>
🔍
</button>
{/if}
{#if isZoomed}
<button
type="button"
data-testid="timeline-zoom-reset"
aria-label={m.timeline_zoom_reset()}
title={m.timeline_zoom_reset()}
onclick={resetZoom}
class="hover:text-ink-1 inline-flex h-6 items-center justify-center gap-1 rounded-sm px-2 text-xs text-ink-3 hover:bg-canvas"
>
</button>
{/if}
{#if hasSelection}
<button
type="button"
data-testid="timeline-clear"
aria-label={m.timeline_clear_selection()}
onclick={clearSelection}
class="hover:text-ink-1 inline-flex h-6 w-6 items-center justify-center rounded-full text-ink-3 hover:bg-canvas"
>
×
</button>
{/if}
</div>
</div>
{/if}

View File

@@ -175,6 +175,95 @@ describe('TimelineDensityFilter — year-granularity fallback', () => {
});
});
describe('TimelineDensityFilter — zoom-in', () => {
it('does not show the zoom button when there is no selection', async () => {
render(TimelineDensityFilter, makeProps());
expect(document.querySelector('[data-testid="timeline-zoom-in"]')).toBeNull();
});
it('shows the zoom button when from/to are set and not yet zoomed', async () => {
render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30' }));
await expect.element(page.getByTestId('timeline-zoom-in')).toBeInTheDocument();
});
it('hides the zoom button when zoomFrom/zoomTo are already set', async () => {
render(
TimelineDensityFilter,
makeProps({
from: '1915-08-01',
to: '1915-09-30',
zoomFrom: '1915-08-01',
zoomTo: '1915-09-30'
})
);
expect(document.querySelector('[data-testid="timeline-zoom-in"]')).toBeNull();
});
it('clicking the zoom button emits onzoomchange with the current selection', async () => {
const onzoomchange = vi.fn();
render(
TimelineDensityFilter,
makeProps({ from: '1915-08-01', to: '1915-09-30', onzoomchange })
);
const zoomBtn = document.querySelector('[data-testid="timeline-zoom-in"]') as HTMLButtonElement;
zoomBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onzoomchange).toHaveBeenCalledWith({ zoomFrom: '1915-08-01', zoomTo: '1915-09-30' });
});
it('shows the reset-zoom button only when zoomed', async () => {
render(TimelineDensityFilter, makeProps({ zoomFrom: '1915-08-01', zoomTo: '1915-09-30' }));
await expect.element(page.getByTestId('timeline-zoom-reset')).toBeInTheDocument();
});
it('clicking reset-zoom emits onzoomchange(null)', async () => {
const onzoomchange = vi.fn();
render(
TimelineDensityFilter,
makeProps({ zoomFrom: '1915-08-01', zoomTo: '1915-09-30', onzoomchange })
);
const resetBtn = document.querySelector(
'[data-testid="timeline-zoom-reset"]'
) as HTMLButtonElement;
resetBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onzoomchange).toHaveBeenCalledWith(null);
});
it('when zoomed, only bars within the zoom range are rendered', async () => {
// 21-year span normally collapses to year mode (>240 months handled
// elsewhere). Zooming in to a 3-month window should restore month bars.
const buckets: MonthBucket[] = [];
for (let year = 1900; year <= 1920; year++) {
for (let month = 1; month <= 12; month++) {
buckets.push({
month: `${year}-${String(month).padStart(2, '0')}`,
count: 1
});
}
}
render(
TimelineDensityFilter,
makeProps({
density: buckets,
minDate: '1900-01-01',
maxDate: '1920-12-31',
zoomFrom: '1910-06-01',
zoomTo: '1910-08-31'
})
);
const bars = document.querySelectorAll('[data-testid="timeline-bar"]');
// 3 months in zoom range
expect(bars.length).toBe(3);
expect(bars[0].getAttribute('aria-label')?.startsWith('1910-06 ·')).toBe(true);
expect(bars[2].getAttribute('aria-label')?.startsWith('1910-08 ·')).toBe(true);
});
});
describe('TimelineDensityFilter — drag-to-select-range', () => {
function pointerDown(el: HTMLElement) {
const event = new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0 });

View File

@@ -8,7 +8,8 @@ import {
buildDensityUrl,
aggregateToYears,
selectionBoundaryFrom,
selectionBoundaryTo
selectionBoundaryTo,
clipBucketsToRange
} from './timeline';
describe('monthBoundaryFrom', () => {
@@ -141,6 +142,37 @@ describe('aggregateToYears', () => {
});
});
describe('clipBucketsToRange', () => {
const buckets = [
{ month: '1915-08', count: 5 },
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 },
{ month: '1915-11', count: 3 }
];
it('returns the original buckets when range bounds are null', () => {
expect(clipBucketsToRange(buckets, null, null)).toBe(buckets);
});
it('keeps only buckets whose month falls within the range', () => {
expect(clipBucketsToRange(buckets, '1915-09-01', '1915-10-31')).toEqual([
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 }
]);
});
it('returns an empty array when the range excludes everything', () => {
expect(clipBucketsToRange(buckets, '1916-01-01', '1916-12-31')).toEqual([]);
});
it('treats partial dates correctly when bounds cross month boundaries', () => {
expect(clipBucketsToRange(buckets, '1915-09-15', '1915-10-15')).toEqual([
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 }
]);
});
});
describe('selectionBoundaryFrom / To', () => {
it('handles month labels (YYYY-MM)', () => {
expect(selectionBoundaryFrom('1915-08')).toBe('1915-08-01');

View File

@@ -56,6 +56,23 @@ export function fillDensityGaps(
return sequence.map((month) => ({ month, count: counts.get(month) ?? 0 }));
}
/**
* Returns only the month buckets whose YYYY-MM falls inside the provided
* `[fromInclusive, toInclusive]` ISO date range. When either bound is null the
* input array is returned unchanged. Used by the timeline's zoom-in tool to
* narrow the visible bars without refetching data.
*/
export function clipBucketsToRange(
buckets: MonthBucket[],
fromInclusive: string | null,
toInclusive: string | null
): MonthBucket[] {
if (!fromInclusive || !toInclusive) return buckets;
const fromMonth = fromInclusive.slice(0, 7);
const toMonth = toInclusive.slice(0, 7);
return buckets.filter((b) => b.month >= fromMonth && b.month <= toMonth);
}
/**
* Aggregates month-granular buckets into one entry per year. Month strings are
* truncated to "YYYY" and counts are summed. Used when the date span is too

View File

@@ -51,6 +51,8 @@ type FilterSnapshot = {
dir: string;
tagQ: string;
tagOp: 'AND' | 'OR';
zoomFrom?: string | null;
zoomTo?: string | null;
};
/**
@@ -72,6 +74,8 @@ function buildSearchParams(filters: FilterSnapshot, targetPage?: number): Svelte
if (filters.dir) params.set('dir', filters.dir);
if (filters.tagQ) params.set('tagQ', filters.tagQ);
if (filters.tagOp === 'OR') params.set('tagOp', 'OR');
if (filters.zoomFrom) params.set('zoomFrom', filters.zoomFrom);
if (filters.zoomTo) params.set('zoomTo', filters.zoomTo);
if (targetPage !== undefined && targetPage > 0) params.set('page', String(targetPage));
return params;
}
@@ -80,7 +84,7 @@ function buildSearchParams(filters: FilterSnapshot, targetPage?: number): Svelte
* Rebuilds the URL from the CURRENT local filter state. `page` is intentionally
* not carried over — any filter change implicitly resets back to page 0.
*/
function triggerSearch() {
function triggerSearch(zoomOverride?: { zoomFrom: string | null; zoomTo: string | null }) {
const params = buildSearchParams({
q,
from,
@@ -91,7 +95,9 @@ function triggerSearch() {
sort,
dir,
tagQ,
tagOp: tagOperator
tagOp: tagOperator,
zoomFrom: zoomOverride ? zoomOverride.zoomFrom : data.zoomFrom,
zoomTo: zoomOverride ? zoomOverride.zoomTo : data.zoomTo
});
goto(`/documents?${params.toString()}`, { keepFocus: true, noScroll: true });
}
@@ -240,6 +246,8 @@ $effect(() => {
density={data.density}
minDate={data.minDate}
maxDate={data.maxDate}
zoomFrom={data.zoomFrom}
zoomTo={data.zoomTo}
from={from}
to={to}
onchange={(event) => {
@@ -247,6 +255,12 @@ $effect(() => {
to = event.to;
triggerSearch();
}}
onzoomchange={(event) => {
triggerSearch({
zoomFrom: event?.zoomFrom ?? null,
zoomTo: event?.zoomTo ?? null
});
}}
/>
</div>

View File

@@ -19,5 +19,8 @@ export const load: PageLoad = async ({ url, fetch, data }) => {
};
const density = await fetchDensity(fetch, view, isDesktop, filters);
return { ...data, ...density };
const zoomFrom = url.searchParams.get('zoomFrom');
const zoomTo = url.searchParams.get('zoomTo');
return { ...data, ...density, zoomFrom, zoomTo };
};

View File

@@ -176,4 +176,55 @@ describe('documents page — timeline density widget', () => {
expect(url).toContain('from=1915-08-01');
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();
render(Page, {
data: makeData({
density: [
{ month: '1915-08', count: 3 },
{ month: '1915-09', count: 2 }
],
minDate: '1915-08-01',
maxDate: '1915-09-30',
from: '1915-08-01',
to: '1915-09-30'
})
});
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');
});
it('clicking reset-zoom drops zoomFrom/zoomTo from the URL', async () => {
const { goto } = await import('$app/navigation');
vi.mocked(goto).mockClear();
render(Page, {
data: makeData({
density: [{ month: '1915-08', count: 3 }],
minDate: '1915-08-01',
maxDate: '1915-08-31',
zoomFrom: '1915-08-01',
zoomTo: '1915-08-31'
})
});
const resetBtn = document.querySelector(
'[data-testid="timeline-zoom-reset"]'
) as HTMLButtonElement;
resetBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(goto).toHaveBeenCalledOnce();
const [url] = vi.mocked(goto).mock.calls[0];
expect(url).not.toContain('zoomFrom=');
expect(url).not.toContain('zoomTo=');
});
});