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:
@@ -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=');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user