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

@@ -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>

View File

@@ -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,

View File

@@ -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/);
});
});

View File

@@ -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

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 () => {