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

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