fix(documents): collapse timeline to year bars when range > 240 months (#385)
Surfaced during proofshot: the production archive spans 1873 → 2023 (≈1809 month bars). With flex-1 + gap-px on a 1280 px container, every pixel was consumed by gaps and bars rendered at 0 px width — visible as "empty box, no bars". Fix: - Add aggregateToYears(buckets) that sums month counts per year and returns YYYY-keyed entries. - Add selectionBoundaryFrom/To that handle both YYYY and YYYY-MM labels (Jan 1 → Dec 31 for years, first → last day for months). - Component switches to year granularity when the gap-filled month sequence exceeds 240 entries (~20 years), keeping each bar clickable. - Drop the gap-px between bars and add min-w-px so sub-pixel rounding still leaves something visible. 5 new tests cover aggregation, boundary helpers, and the component-level year-mode + click behaviour. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { fillDensityGaps, monthBoundaryFrom, monthBoundaryTo } from '$lib/document/timeline';
|
||||
import {
|
||||
fillDensityGaps,
|
||||
aggregateToYears,
|
||||
selectionBoundaryFrom,
|
||||
selectionBoundaryTo
|
||||
} from '$lib/document/timeline';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type MonthBucket = components['schemas']['MonthBucket'];
|
||||
@@ -8,6 +13,9 @@ type SelectionEvent = { from: string; to: string };
|
||||
|
||||
const BAR_AREA_HEIGHT = 80; // px — Leonie spec h-20
|
||||
const ZERO_COUNT_BAR_HEIGHT = 2; // px — minimum visible signal for empty months
|
||||
// Above this threshold, month bars compress to sub-pixel widths in the flex
|
||||
// row; we collapse to year granularity so each bar stays clickable.
|
||||
const MONTH_GRANULARITY_LIMIT = 240;
|
||||
|
||||
let {
|
||||
density,
|
||||
@@ -25,11 +33,15 @@ let {
|
||||
onchange: (event: SelectionEvent) => void;
|
||||
} = $props();
|
||||
|
||||
const filled = $derived.by(() => {
|
||||
const monthBuckets = $derived.by(() => {
|
||||
if (density === null) return [];
|
||||
return fillDensityGaps(density, minDate, maxDate);
|
||||
});
|
||||
|
||||
const filled = $derived(
|
||||
monthBuckets.length > MONTH_GRANULARITY_LIMIT ? aggregateToYears(monthBuckets) : monthBuckets
|
||||
);
|
||||
|
||||
const maxCount = $derived(Math.max(...filled.map((b) => b.count), 1));
|
||||
|
||||
const hasSelection = $derived(from !== '' || to !== '');
|
||||
@@ -39,18 +51,18 @@ function barHeight(count: number): number {
|
||||
return Math.max(ZERO_COUNT_BAR_HEIGHT, (count / maxCount) * BAR_AREA_HEIGHT);
|
||||
}
|
||||
|
||||
function selectMonth(month: string) {
|
||||
onchange({ from: monthBoundaryFrom(month), to: monthBoundaryTo(month) });
|
||||
function selectBar(label: string) {
|
||||
onchange({ from: selectionBoundaryFrom(label), to: selectionBoundaryTo(label) });
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
onchange({ from: '', to: '' });
|
||||
}
|
||||
|
||||
function isSelected(month: string): boolean {
|
||||
function isSelected(label: string): boolean {
|
||||
if (!hasSelection) return false;
|
||||
const monthFrom = monthBoundaryFrom(month);
|
||||
return monthFrom >= from && monthFrom <= to;
|
||||
const labelFrom = selectionBoundaryFrom(label);
|
||||
return labelFrom >= from && labelFrom <= to;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -61,15 +73,15 @@ function isSelected(month: string): boolean {
|
||||
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 gap-px" style="height: {BAR_AREA_HEIGHT}px;">
|
||||
<div class="flex h-20 items-end" style="height: {BAR_AREA_HEIGHT}px;">
|
||||
{#each filled as bucket (bucket.month)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="timeline-bar"
|
||||
aria-label="{bucket.month} · {bucket.count}"
|
||||
aria-pressed={isSelected(bucket.month)}
|
||||
onclick={() => selectMonth(bucket.month)}
|
||||
class="bar group flex flex-1 cursor-pointer items-end justify-center bg-transparent p-0 transition-colors"
|
||||
onclick={() => selectBar(bucket.month)}
|
||||
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)}
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -130,3 +130,46 @@ describe('TimelineDensityFilter — selection', () => {
|
||||
expect(clearBtn.getAttribute('aria-label')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — year-granularity fallback', () => {
|
||||
it('collapses to year buckets when the month sequence exceeds the limit', async () => {
|
||||
// 21 years × 12 months = 252 entries — above the 240 month limit.
|
||||
const buckets: MonthBucket[] = [];
|
||||
for (let year = 1900; year <= 1920; year++) {
|
||||
buckets.push({ month: `${year}-06`, count: year - 1899 });
|
||||
}
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({ density: buckets, minDate: '1900-01-01', maxDate: '1920-12-31' })
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll('[data-testid="timeline-bar"]');
|
||||
expect(bars.length).toBe(21);
|
||||
const firstLabel = bars[0].getAttribute('aria-label');
|
||||
expect(firstLabel?.startsWith('1900 ·')).toBe(true);
|
||||
});
|
||||
|
||||
it('clicking a year bar emits Jan 1 to Dec 31 boundary dates', async () => {
|
||||
const onchange = vi.fn();
|
||||
const buckets: MonthBucket[] = [];
|
||||
for (let year = 1900; year <= 1920; year++) {
|
||||
buckets.push({ month: `${year}-06`, count: 5 });
|
||||
}
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: buckets,
|
||||
minDate: '1900-01-01',
|
||||
maxDate: '1920-12-31',
|
||||
onchange
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
bars[5].dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
expect(onchange).toHaveBeenCalledWith({ from: '1905-01-01', to: '1905-12-31' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
monthBoundaryTo,
|
||||
buildMonthSequence,
|
||||
fillDensityGaps,
|
||||
fetchDensity
|
||||
fetchDensity,
|
||||
aggregateToYears,
|
||||
selectionBoundaryFrom,
|
||||
selectionBoundaryTo
|
||||
} from './timeline';
|
||||
|
||||
describe('monthBoundaryFrom', () => {
|
||||
@@ -108,6 +111,47 @@ describe('fillDensityGaps', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateToYears', () => {
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(aggregateToYears([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('sums counts within the same year', () => {
|
||||
const result = aggregateToYears([
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
]);
|
||||
expect(result).toEqual([{ month: '1915', count: 15 }]);
|
||||
});
|
||||
|
||||
it('produces one bucket per distinct year, sorted chronologically', () => {
|
||||
const result = aggregateToYears([
|
||||
{ month: '1916-01', count: 3 },
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1916-04', count: 7 },
|
||||
{ month: '1914-12', count: 1 }
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
{ month: '1914', count: 1 },
|
||||
{ month: '1915', count: 5 },
|
||||
{ month: '1916', count: 10 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectionBoundaryFrom / To', () => {
|
||||
it('handles month labels (YYYY-MM)', () => {
|
||||
expect(selectionBoundaryFrom('1915-08')).toBe('1915-08-01');
|
||||
expect(selectionBoundaryTo('1915-08')).toBe('1915-08-31');
|
||||
});
|
||||
|
||||
it('handles year labels (YYYY)', () => {
|
||||
expect(selectionBoundaryFrom('1915')).toBe('1915-01-01');
|
||||
expect(selectionBoundaryTo('1915')).toBe('1915-12-31');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchDensity', () => {
|
||||
it('skips fetch and returns null density on mobile', async () => {
|
||||
const fetch = vi.fn();
|
||||
|
||||
@@ -56,6 +56,35 @@ export function fillDensityGaps(
|
||||
return sequence.map((month) => ({ month, count: counts.get(month) ?? 0 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* long for month-granular bars to render at a clickable size.
|
||||
*/
|
||||
export function aggregateToYears(buckets: MonthBucket[]): MonthBucket[] {
|
||||
const totals = new Map<string, number>();
|
||||
for (const b of buckets) {
|
||||
const year = b.month.slice(0, 4);
|
||||
totals.set(year, (totals.get(year) ?? 0) + b.count);
|
||||
}
|
||||
return Array.from(totals.entries())
|
||||
.map(([year, count]) => ({ month: year, count }))
|
||||
.sort((a, b) => a.month.localeCompare(b.month));
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundary helpers for selection. Accept either "YYYY-MM" (month) or "YYYY"
|
||||
* (year) and return the matching LocalDate string.
|
||||
*/
|
||||
export function selectionBoundaryFrom(label: string): string {
|
||||
return label.length === 4 ? `${label}-01-01` : `${label}-01`;
|
||||
}
|
||||
|
||||
export function selectionBoundaryTo(label: string): string {
|
||||
if (label.length === 4) return `${label}-12-31`;
|
||||
return monthBoundaryTo(label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the density data for the timeline widget. Mobile (sm: breakpoint and below)
|
||||
* and calendar view both skip the request entirely — the widget isn't rendered
|
||||
|
||||
Reference in New Issue
Block a user