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
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:
@@ -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);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#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"
|
||||
>
|
||||
<div class="flex h-20 items-end" style="height: {BAR_AREA_HEIGHT}px;">
|
||||
{#each filled as bucket, i (bucket.month)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="timeline-bar"
|
||||
aria-label="{bucket.month} · {bucket.count}"
|
||||
aria-pressed={isSelected(bucket.month)}
|
||||
onpointerdown={(e) => handlePointerDown(e, i)}
|
||||
onpointerenter={() => handlePointerEnter(i)}
|
||||
onpointerup={handlePointerUp}
|
||||
onpointercancel={handlePointerCancel}
|
||||
onclick={() => handleClick(i)}
|
||||
class="bar group flex min-w-px flex-1 cursor-pointer items-end justify-center bg-transparent p-0 transition-colors"
|
||||
class:selected={isSelected(bucket.month)}
|
||||
class:in-drag-preview={isInDragPreview(i)}
|
||||
<div class="flex">
|
||||
<div
|
||||
class="flex flex-col justify-between pr-1.5 text-right font-sans text-[10px] leading-none text-ink-3"
|
||||
style="height: {BAR_AREA_HEIGHT}px;"
|
||||
aria-hidden="true"
|
||||
data-testid="timeline-y-axis"
|
||||
>
|
||||
<span>{maxCount}</span>
|
||||
<span>0</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div
|
||||
bind:this={rowEl}
|
||||
class="relative flex items-end border-b border-line"
|
||||
style="height: {BAR_AREA_HEIGHT}px;"
|
||||
>
|
||||
<span
|
||||
class="bar-fill block w-full rounded-t-[2px]"
|
||||
style="height: {barHeight(bucket.count)}px;"
|
||||
></span>
|
||||
</button>
|
||||
{/each}
|
||||
{#each filled as bucket, i (bucket.month)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="timeline-bar"
|
||||
aria-label="{bucket.month} · {bucket.count}"
|
||||
aria-pressed={isSelected(bucket.month)}
|
||||
onpointerdown={(e) => handlePointerDown(e, i)}
|
||||
onpointerenter={() => handlePointerEnter(i)}
|
||||
onclick={() => handleClick(i)}
|
||||
class="bar group flex h-full min-w-px flex-1 cursor-pointer items-end justify-center bg-transparent p-0 transition-colors"
|
||||
class:selected={isSelected(bucket.month)}
|
||||
class:in-drag-preview={isInDragPreview(i)}
|
||||
>
|
||||
<span
|
||||
class="bar-fill block w-full rounded-t-[2px]"
|
||||
style="height: {barHeight(bucket.count)}px;"
|
||||
></span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if isDragging}
|
||||
<div
|
||||
class="drag-window"
|
||||
data-testid="timeline-drag-window"
|
||||
style="left: {dragWindowLeftPct}%; right: {dragWindowRightPct}%;"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative mt-1 h-3 font-sans text-[10px] leading-none text-ink-3"
|
||||
aria-hidden="true"
|
||||
data-testid="timeline-x-axis"
|
||||
>
|
||||
{#each tickIndices as idx (filled[idx]?.month)}
|
||||
{@const tickLeftPct = ((idx + 0.5) / filled.length) * 100}
|
||||
<span
|
||||
class="absolute -translate-x-1/2 whitespace-nowrap"
|
||||
data-testid="timeline-x-tick"
|
||||
style="left: {tickLeftPct}%;"
|
||||
>
|
||||
{formatTickLabel(filled[idx].month, getLocale(), omitTickYear)}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
@@ -256,4 +360,17 @@ function handleClick(index: number) {
|
||||
background-color: var(--palette-mint, #a1dcd8);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Graylog-style range selector window: left/right borders mark the dragged
|
||||
range, tinted body fills the area. pointer-events:none keeps the bars below
|
||||
reachable so pointermove still fires their pointerenter. */
|
||||
.drag-window {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(161, 220, 216, 0.22);
|
||||
border-left: 2px solid var(--palette-mint, #a1dcd8);
|
||||
border-right: 2px solid var(--palette-mint, #a1dcd8);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -46,6 +46,66 @@ describe('TimelineDensityFilter — visibility', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — axes', () => {
|
||||
it('renders a Y-axis showing the maximum bar count and zero', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 12 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-10-31'
|
||||
})
|
||||
);
|
||||
|
||||
const yAxis = document.querySelector('[data-testid="timeline-y-axis"]') as HTMLElement;
|
||||
expect(yAxis).not.toBeNull();
|
||||
expect(yAxis.textContent).toContain('12');
|
||||
expect(yAxis.textContent).toContain('0');
|
||||
});
|
||||
|
||||
it('renders X-axis ticks at January boundaries for long month ranges', async () => {
|
||||
const buckets: MonthBucket[] = [];
|
||||
for (let m = 8; m <= 12; m++)
|
||||
buckets.push({ month: `1914-${String(m).padStart(2, '0')}`, count: 1 });
|
||||
for (let m = 1; m <= 12; m++)
|
||||
buckets.push({ month: `1915-${String(m).padStart(2, '0')}`, count: 1 });
|
||||
for (let m = 1; m <= 2; m++)
|
||||
buckets.push({ month: `1916-${String(m).padStart(2, '0')}`, count: 1 });
|
||||
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({ density: buckets, minDate: '1914-08-01', maxDate: '1916-02-29' })
|
||||
);
|
||||
|
||||
const ticks = document.querySelectorAll('[data-testid="timeline-x-tick"]');
|
||||
expect(ticks.length).toBe(2);
|
||||
expect(Array.from(ticks).map((t) => t.textContent?.trim())).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('1915'), expect.stringContaining('1916')])
|
||||
);
|
||||
});
|
||||
|
||||
it('renders X-axis ticks for year-aggregated bars (every 10 years for ~50yr range)', async () => {
|
||||
const buckets: MonthBucket[] = [];
|
||||
for (let year = 1900; year <= 1949; 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: '1949-12-31' })
|
||||
);
|
||||
|
||||
const ticks = document.querySelectorAll('[data-testid="timeline-x-tick"]');
|
||||
const labels = Array.from(ticks).map((t) => t.textContent?.trim());
|
||||
expect(labels).toEqual(['1900', '1910', '1920', '1930', '1940']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — bars', () => {
|
||||
it('renders one bar per month within the range, including zero-count gaps', async () => {
|
||||
render(
|
||||
@@ -150,7 +210,7 @@ describe('TimelineDensityFilter — year-granularity fallback', () => {
|
||||
expect(firstLabel?.startsWith('1900 ·')).toBe(true);
|
||||
});
|
||||
|
||||
it('clicking a year bar emits Jan 1 to Dec 31 boundary dates', async () => {
|
||||
it('clicking a year bar zooms into that year (filter + zoom atomic)', async () => {
|
||||
const onchange = vi.fn();
|
||||
const buckets: MonthBucket[] = [];
|
||||
for (let year = 1900; year <= 1920; year++) {
|
||||
@@ -171,47 +231,21 @@ describe('TimelineDensityFilter — year-granularity fallback', () => {
|
||||
) as NodeListOf<HTMLElement>;
|
||||
bars[5].dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
expect(onchange).toHaveBeenCalledWith({ from: '1905-01-01', to: '1905-12-31' });
|
||||
expect(onchange).toHaveBeenCalledWith({
|
||||
from: '1905-01-01',
|
||||
to: '1905-12-31',
|
||||
zoomFrom: '1905-01-01',
|
||||
zoomTo: '1905-12-31'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
describe('TimelineDensityFilter — zoom', () => {
|
||||
it('does not show the zoom-in button (drag replaces it as the zoom gesture)', 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();
|
||||
@@ -278,7 +312,7 @@ describe('TimelineDensityFilter — drag-to-select-range', () => {
|
||||
el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1, button: 0 }));
|
||||
}
|
||||
|
||||
it('dragging from bar A to bar B emits a single onchange with the spanning range', async () => {
|
||||
it('dragging from bar A to bar B emits a single onchange with filter + zoom (atomic)', async () => {
|
||||
const onchange = vi.fn();
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
@@ -302,7 +336,12 @@ describe('TimelineDensityFilter — drag-to-select-range', () => {
|
||||
pointerUp(bars[2]);
|
||||
|
||||
expect(onchange).toHaveBeenCalledTimes(1);
|
||||
expect(onchange).toHaveBeenCalledWith({ from: '1915-08-01', to: '1915-10-31' });
|
||||
expect(onchange).toHaveBeenCalledWith({
|
||||
from: '1915-08-01',
|
||||
to: '1915-10-31',
|
||||
zoomFrom: '1915-08-01',
|
||||
zoomTo: '1915-10-31'
|
||||
});
|
||||
});
|
||||
|
||||
it('dragging from a later bar to an earlier bar still emits ascending boundaries', async () => {
|
||||
@@ -328,10 +367,15 @@ describe('TimelineDensityFilter — drag-to-select-range', () => {
|
||||
pointerEnter(bars[0]);
|
||||
pointerUp(bars[0]);
|
||||
|
||||
expect(onchange).toHaveBeenCalledWith({ from: '1915-08-01', to: '1915-10-31' });
|
||||
expect(onchange).toHaveBeenCalledWith({
|
||||
from: '1915-08-01',
|
||||
to: '1915-10-31',
|
||||
zoomFrom: '1915-08-01',
|
||||
zoomTo: '1915-10-31'
|
||||
});
|
||||
});
|
||||
|
||||
it('pressing+releasing on the same bar still selects that single bar', async () => {
|
||||
it('pressing+releasing on the same bar selects that single month without zoom', async () => {
|
||||
const onchange = vi.fn();
|
||||
render(TimelineDensityFilter, makeProps({ onchange }));
|
||||
|
||||
@@ -345,6 +389,42 @@ describe('TimelineDensityFilter — drag-to-select-range', () => {
|
||||
expect(onchange).toHaveBeenCalledWith({ from: '1915-09-01', to: '1915-09-30' });
|
||||
});
|
||||
|
||||
it('renders a drag-window overlay between drag start and current position', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 3 },
|
||||
{ month: '1915-11', count: 4 }
|
||||
],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-11-30'
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
|
||||
expect(document.querySelector('[data-testid="timeline-drag-window"]')).toBeNull();
|
||||
|
||||
pointerDown(bars[0]);
|
||||
pointerEnter(bars[2]);
|
||||
await tick();
|
||||
|
||||
const win = document.querySelector('[data-testid="timeline-drag-window"]') as HTMLElement;
|
||||
expect(win).not.toBeNull();
|
||||
// 4 bars total, drag covers indices 0..2 → left 0%, right 25%.
|
||||
expect(win.style.left).toBe('0%');
|
||||
expect(win.style.right).toBe('25%');
|
||||
|
||||
pointerUp(bars[2]);
|
||||
await tick();
|
||||
expect(document.querySelector('[data-testid="timeline-drag-window"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks bars in the active drag range with the in-drag-preview class', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
aggregateToYears,
|
||||
selectionBoundaryFrom,
|
||||
selectionBoundaryTo,
|
||||
clipBucketsToRange
|
||||
clipBucketsToRange,
|
||||
tickIndicesFor,
|
||||
formatTickLabel
|
||||
} from './timeline';
|
||||
|
||||
describe('monthBoundaryFrom', () => {
|
||||
@@ -284,3 +286,84 @@ describe('fetchDensity', () => {
|
||||
expect(result).toEqual({ density: [], minDate: null, maxDate: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('tickIndicesFor', () => {
|
||||
it('returns no indices for an empty bucket list', () => {
|
||||
expect(tickIndicesFor([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('picks years divisible by 25 when the year span exceeds 120', () => {
|
||||
const buckets = Array.from({ length: 150 }, (_, i) => ({
|
||||
month: String(1875 + i),
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
const labels = ticks.map((i) => buckets[i].month);
|
||||
expect(labels).toEqual(['1875', '1900', '1925', '1950', '1975', '2000']);
|
||||
});
|
||||
|
||||
it('picks years divisible by 10 for medium ranges (~50 years)', () => {
|
||||
const buckets = Array.from({ length: 50 }, (_, i) => ({
|
||||
month: String(1900 + i),
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
const labels = ticks.map((i) => buckets[i].month);
|
||||
expect(labels).toEqual(['1900', '1910', '1920', '1930', '1940']);
|
||||
});
|
||||
|
||||
it('picks January boundaries for long month ranges', () => {
|
||||
const buckets = [
|
||||
{ month: '1914-08', count: 1 },
|
||||
{ month: '1914-09', count: 1 },
|
||||
{ month: '1914-10', count: 1 },
|
||||
{ month: '1914-11', count: 1 },
|
||||
{ month: '1914-12', count: 1 },
|
||||
{ month: '1915-01', count: 1 },
|
||||
{ month: '1915-02', count: 1 },
|
||||
{ month: '1915-03', count: 1 },
|
||||
{ month: '1915-04', count: 1 },
|
||||
{ month: '1915-05', count: 1 },
|
||||
{ month: '1915-06', count: 1 },
|
||||
{ month: '1915-07', count: 1 },
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 1 },
|
||||
{ month: '1915-10', count: 1 },
|
||||
{ month: '1915-11', count: 1 },
|
||||
{ month: '1915-12', count: 1 },
|
||||
{ month: '1916-01', count: 1 },
|
||||
{ month: '1916-02', count: 1 }
|
||||
];
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
expect(ticks.map((i) => buckets[i].month)).toEqual(['1915-01', '1916-01']);
|
||||
});
|
||||
|
||||
it('falls back to evenly spaced ticks for short month ranges (12 months)', () => {
|
||||
const buckets = Array.from({ length: 12 }, (_, i) => ({
|
||||
month: `1905-${String(i + 1).padStart(2, '0')}`,
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
expect(ticks.length).toBeGreaterThanOrEqual(5);
|
||||
expect(ticks.length).toBeLessThanOrEqual(7);
|
||||
expect(ticks[0]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTickLabel', () => {
|
||||
it('returns the year string unchanged for year labels', () => {
|
||||
expect(formatTickLabel('1905', 'en-US')).toBe('1905');
|
||||
});
|
||||
|
||||
it('formats month labels with the year by default', () => {
|
||||
const result = formatTickLabel('1905-06', 'en-US');
|
||||
expect(result).toMatch(/Jun/);
|
||||
expect(result).toMatch(/1905/);
|
||||
});
|
||||
|
||||
it('omits the year when omitYear is true', () => {
|
||||
const result = formatTickLabel('1905-06', 'en-US', true);
|
||||
expect(result).toMatch(/Jun/);
|
||||
expect(result).not.toMatch(/1905/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,6 +102,64 @@ export function selectionBoundaryTo(label: string): string {
|
||||
return monthBoundaryTo(label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks bucket indices that should get an X-axis tick label. The strategy adapts
|
||||
* to whether bars are years or months and how many are visible:
|
||||
* - Year bars: pick years divisible by a step that scales with range length
|
||||
* (every 25 yrs for >120 bars, every 20 / 10 / 5 / 1 below).
|
||||
* - Month bars: prefer January boundaries (year breaks). For ≤18 bars (e.g.
|
||||
* one year zoomed in to months), fall back to evenly spaced ticks so we
|
||||
* show ~6 labels even when no January boundary exists.
|
||||
*/
|
||||
export function tickIndicesFor(filled: MonthBucket[]): number[] {
|
||||
if (filled.length === 0) return [];
|
||||
const isYearMode = filled[0].month.length === 4;
|
||||
const indices: number[] = [];
|
||||
|
||||
if (isYearMode) {
|
||||
const years = filled.length;
|
||||
const step =
|
||||
years > 120 ? 25 : years > 60 ? 20 : years > 30 ? 10 : years > 12 ? 5 : years > 6 ? 2 : 1;
|
||||
for (let i = 0; i < filled.length; i++) {
|
||||
const year = parseInt(filled[i].month, 10);
|
||||
if (year % step === 0) indices.push(i);
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
if (filled.length <= 18) {
|
||||
const step = Math.max(1, Math.round(filled.length / 6));
|
||||
for (let i = 0; i < filled.length; i += step) indices.push(i);
|
||||
return indices;
|
||||
}
|
||||
|
||||
// Long month range — pick January boundaries (year breaks).
|
||||
for (let i = 0; i < filled.length; i++) {
|
||||
if (filled[i].month.endsWith('-01')) indices.push(i);
|
||||
}
|
||||
// Fallback if there's no January in the visible range (rare): even spacing.
|
||||
if (indices.length === 0) {
|
||||
const step = Math.max(1, Math.round(filled.length / 6));
|
||||
for (let i = 0; i < filled.length; i += step) indices.push(i);
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a bucket month label ("YYYY" or "YYYY-MM") for the X-axis. When
|
||||
* `omitYear` is true the year is dropped so a 12-month zoomed view reads as
|
||||
* "Jan", "Feb", … without repetition.
|
||||
*/
|
||||
export function formatTickLabel(label: string, locale?: string, omitYear = false): string {
|
||||
if (label.length === 4) return label;
|
||||
const [yearStr, monthStr] = label.split('-');
|
||||
const date = new Date(parseInt(yearStr, 10), parseInt(monthStr, 10) - 1, 1);
|
||||
const opts: Intl.DateTimeFormatOptions = omitYear
|
||||
? { month: 'short' }
|
||||
: { month: 'short', year: 'numeric' };
|
||||
return new Intl.DateTimeFormat(locale, opts).format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of /documents URL params that should narrow the density chart.
|
||||
* Date bounds (`from`/`to`) are intentionally excluded — see
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user