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:
@@ -1051,5 +1051,8 @@
|
|||||||
"timeline_clear_selection": "Auswahl zurücksetzen",
|
"timeline_clear_selection": "Auswahl zurücksetzen",
|
||||||
"timeline_count_label": "Dok.",
|
"timeline_count_label": "Dok.",
|
||||||
"timeline_loading": "Lade Zeitachse…",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1051,5 +1051,8 @@
|
|||||||
"timeline_clear_selection": "Clear selection",
|
"timeline_clear_selection": "Clear selection",
|
||||||
"timeline_count_label": "docs",
|
"timeline_count_label": "docs",
|
||||||
"timeline_loading": "Loading timeline…",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1051,5 +1051,8 @@
|
|||||||
"timeline_clear_selection": "Borrar selección",
|
"timeline_clear_selection": "Borrar selección",
|
||||||
"timeline_count_label": "docs",
|
"timeline_count_label": "docs",
|
||||||
"timeline_loading": "Cargando cronología…",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as m from '$lib/paraglide/messages.js';
|
|||||||
import {
|
import {
|
||||||
fillDensityGaps,
|
fillDensityGaps,
|
||||||
aggregateToYears,
|
aggregateToYears,
|
||||||
|
clipBucketsToRange,
|
||||||
selectionBoundaryFrom,
|
selectionBoundaryFrom,
|
||||||
selectionBoundaryTo
|
selectionBoundaryTo
|
||||||
} from '$lib/document/timeline';
|
} from '$lib/document/timeline';
|
||||||
@@ -10,6 +11,7 @@ import type { components } from '$lib/generated/api';
|
|||||||
|
|
||||||
type MonthBucket = components['schemas']['MonthBucket'];
|
type MonthBucket = components['schemas']['MonthBucket'];
|
||||||
type SelectionEvent = { from: string; to: string };
|
type SelectionEvent = { from: string; to: string };
|
||||||
|
type ZoomEvent = { zoomFrom: string; zoomTo: string };
|
||||||
|
|
||||||
const BAR_AREA_HEIGHT = 80; // px — Leonie spec h-20
|
const BAR_AREA_HEIGHT = 80; // px — Leonie spec h-20
|
||||||
const ZERO_COUNT_BAR_HEIGHT = 2; // px — minimum visible signal for empty months
|
const ZERO_COUNT_BAR_HEIGHT = 2; // px — minimum visible signal for empty months
|
||||||
@@ -23,25 +25,44 @@ let {
|
|||||||
maxDate,
|
maxDate,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
onchange
|
zoomFrom = null,
|
||||||
|
zoomTo = null,
|
||||||
|
onchange,
|
||||||
|
onzoomchange
|
||||||
}: {
|
}: {
|
||||||
density: MonthBucket[] | null;
|
density: MonthBucket[] | null;
|
||||||
minDate: string | null;
|
minDate: string | null;
|
||||||
maxDate: string | null;
|
maxDate: string | null;
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
|
zoomFrom?: string | null;
|
||||||
|
zoomTo?: string | null;
|
||||||
onchange: (event: SelectionEvent) => void;
|
onchange: (event: SelectionEvent) => void;
|
||||||
|
onzoomchange?: (event: ZoomEvent | null) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const monthBuckets = $derived.by(() => {
|
const monthBuckets = $derived.by(() => {
|
||||||
if (density === null) return [];
|
if (density === null) return [];
|
||||||
return fillDensityGaps(density, minDate, maxDate);
|
const full = fillDensityGaps(density, minDate, maxDate);
|
||||||
|
return clipBucketsToRange(full, zoomFrom, zoomTo);
|
||||||
});
|
});
|
||||||
|
|
||||||
const filled = $derived(
|
const filled = $derived(
|
||||||
monthBuckets.length > MONTH_GRANULARITY_LIMIT ? aggregateToYears(monthBuckets) : monthBuckets
|
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 maxCount = $derived(Math.max(...filled.map((b) => b.count), 1));
|
||||||
|
|
||||||
const hasSelection = $derived(from !== '' || to !== '');
|
const hasSelection = $derived(from !== '' || to !== '');
|
||||||
@@ -166,17 +187,43 @@ function handleClick(index: number) {
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if hasSelection}
|
<div class="absolute top-2 right-2 flex items-center gap-1">
|
||||||
<button
|
{#if canZoomIn}
|
||||||
type="button"
|
<button
|
||||||
data-testid="timeline-clear"
|
type="button"
|
||||||
aria-label={m.timeline_clear_selection()}
|
data-testid="timeline-zoom-in"
|
||||||
onclick={clearSelection}
|
aria-label={m.timeline_zoom_in()}
|
||||||
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"
|
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}
|
🔍
|
||||||
|
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
describe('TimelineDensityFilter — drag-to-select-range', () => {
|
||||||
function pointerDown(el: HTMLElement) {
|
function pointerDown(el: HTMLElement) {
|
||||||
const event = new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0 });
|
const event = new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0 });
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
buildDensityUrl,
|
buildDensityUrl,
|
||||||
aggregateToYears,
|
aggregateToYears,
|
||||||
selectionBoundaryFrom,
|
selectionBoundaryFrom,
|
||||||
selectionBoundaryTo
|
selectionBoundaryTo,
|
||||||
|
clipBucketsToRange
|
||||||
} from './timeline';
|
} from './timeline';
|
||||||
|
|
||||||
describe('monthBoundaryFrom', () => {
|
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', () => {
|
describe('selectionBoundaryFrom / To', () => {
|
||||||
it('handles month labels (YYYY-MM)', () => {
|
it('handles month labels (YYYY-MM)', () => {
|
||||||
expect(selectionBoundaryFrom('1915-08')).toBe('1915-08-01');
|
expect(selectionBoundaryFrom('1915-08')).toBe('1915-08-01');
|
||||||
|
|||||||
@@ -56,6 +56,23 @@ export function fillDensityGaps(
|
|||||||
return sequence.map((month) => ({ month, count: counts.get(month) ?? 0 }));
|
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
|
* 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
|
* truncated to "YYYY" and counts are summed. Used when the date span is too
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ type FilterSnapshot = {
|
|||||||
dir: string;
|
dir: string;
|
||||||
tagQ: string;
|
tagQ: string;
|
||||||
tagOp: 'AND' | 'OR';
|
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.dir) params.set('dir', filters.dir);
|
||||||
if (filters.tagQ) params.set('tagQ', filters.tagQ);
|
if (filters.tagQ) params.set('tagQ', filters.tagQ);
|
||||||
if (filters.tagOp === 'OR') params.set('tagOp', 'OR');
|
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));
|
if (targetPage !== undefined && targetPage > 0) params.set('page', String(targetPage));
|
||||||
return params;
|
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
|
* Rebuilds the URL from the CURRENT local filter state. `page` is intentionally
|
||||||
* not carried over — any filter change implicitly resets back to page 0.
|
* 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({
|
const params = buildSearchParams({
|
||||||
q,
|
q,
|
||||||
from,
|
from,
|
||||||
@@ -91,7 +95,9 @@ function triggerSearch() {
|
|||||||
sort,
|
sort,
|
||||||
dir,
|
dir,
|
||||||
tagQ,
|
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 });
|
goto(`/documents?${params.toString()}`, { keepFocus: true, noScroll: true });
|
||||||
}
|
}
|
||||||
@@ -240,6 +246,8 @@ $effect(() => {
|
|||||||
density={data.density}
|
density={data.density}
|
||||||
minDate={data.minDate}
|
minDate={data.minDate}
|
||||||
maxDate={data.maxDate}
|
maxDate={data.maxDate}
|
||||||
|
zoomFrom={data.zoomFrom}
|
||||||
|
zoomTo={data.zoomTo}
|
||||||
from={from}
|
from={from}
|
||||||
to={to}
|
to={to}
|
||||||
onchange={(event) => {
|
onchange={(event) => {
|
||||||
@@ -247,6 +255,12 @@ $effect(() => {
|
|||||||
to = event.to;
|
to = event.to;
|
||||||
triggerSearch();
|
triggerSearch();
|
||||||
}}
|
}}
|
||||||
|
onzoomchange={(event) => {
|
||||||
|
triggerSearch({
|
||||||
|
zoomFrom: event?.zoomFrom ?? null,
|
||||||
|
zoomTo: event?.zoomTo ?? null
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -19,5 +19,8 @@ export const load: PageLoad = async ({ url, fetch, data }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const density = await fetchDensity(fetch, view, isDesktop, filters);
|
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 };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -176,4 +176,55 @@ describe('documents page — timeline density widget', () => {
|
|||||||
expect(url).toContain('from=1915-08-01');
|
expect(url).toContain('from=1915-08-01');
|
||||||
expect(url).toContain('to=1915-08-31');
|
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