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">
|
<script lang="ts">
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
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';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
type MonthBucket = components['schemas']['MonthBucket'];
|
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 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
|
||||||
|
// 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 {
|
let {
|
||||||
density,
|
density,
|
||||||
@@ -25,11 +33,15 @@ let {
|
|||||||
onchange: (event: SelectionEvent) => void;
|
onchange: (event: SelectionEvent) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const filled = $derived.by(() => {
|
const monthBuckets = $derived.by(() => {
|
||||||
if (density === null) return [];
|
if (density === null) return [];
|
||||||
return fillDensityGaps(density, minDate, maxDate);
|
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 maxCount = $derived(Math.max(...filled.map((b) => b.count), 1));
|
||||||
|
|
||||||
const hasSelection = $derived(from !== '' || to !== '');
|
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);
|
return Math.max(ZERO_COUNT_BAR_HEIGHT, (count / maxCount) * BAR_AREA_HEIGHT);
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectMonth(month: string) {
|
function selectBar(label: string) {
|
||||||
onchange({ from: monthBoundaryFrom(month), to: monthBoundaryTo(month) });
|
onchange({ from: selectionBoundaryFrom(label), to: selectionBoundaryTo(label) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
onchange({ from: '', to: '' });
|
onchange({ from: '', to: '' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSelected(month: string): boolean {
|
function isSelected(label: string): boolean {
|
||||||
if (!hasSelection) return false;
|
if (!hasSelection) return false;
|
||||||
const monthFrom = monthBoundaryFrom(month);
|
const labelFrom = selectionBoundaryFrom(label);
|
||||||
return monthFrom >= from && monthFrom <= to;
|
return labelFrom >= from && labelFrom <= to;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -61,15 +73,15 @@ function isSelected(month: string): boolean {
|
|||||||
aria-label={m.timeline_aria_label()}
|
aria-label={m.timeline_aria_label()}
|
||||||
class="relative rounded-sm border border-line bg-surface px-3 pt-3 pb-2 shadow-sm"
|
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)}
|
{#each filled as bucket (bucket.month)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="timeline-bar"
|
data-testid="timeline-bar"
|
||||||
aria-label="{bucket.month} · {bucket.count}"
|
aria-label="{bucket.month} · {bucket.count}"
|
||||||
aria-pressed={isSelected(bucket.month)}
|
aria-pressed={isSelected(bucket.month)}
|
||||||
onclick={() => selectMonth(bucket.month)}
|
onclick={() => selectBar(bucket.month)}
|
||||||
class="bar group flex flex-1 cursor-pointer items-end justify-center bg-transparent p-0 transition-colors"
|
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:selected={isSelected(bucket.month)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -130,3 +130,46 @@ describe('TimelineDensityFilter — selection', () => {
|
|||||||
expect(clearBtn.getAttribute('aria-label')).toBeTruthy();
|
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,
|
monthBoundaryTo,
|
||||||
buildMonthSequence,
|
buildMonthSequence,
|
||||||
fillDensityGaps,
|
fillDensityGaps,
|
||||||
fetchDensity
|
fetchDensity,
|
||||||
|
aggregateToYears,
|
||||||
|
selectionBoundaryFrom,
|
||||||
|
selectionBoundaryTo
|
||||||
} from './timeline';
|
} from './timeline';
|
||||||
|
|
||||||
describe('monthBoundaryFrom', () => {
|
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', () => {
|
describe('fetchDensity', () => {
|
||||||
it('skips fetch and returns null density on mobile', async () => {
|
it('skips fetch and returns null density on mobile', async () => {
|
||||||
const fetch = vi.fn();
|
const fetch = vi.fn();
|
||||||
|
|||||||
@@ -56,6 +56,35 @@ export function fillDensityGaps(
|
|||||||
return sequence.map((month) => ({ month, count: counts.get(month) ?? 0 }));
|
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)
|
* 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
|
* and calendar view both skip the request entirely — the widget isn't rendered
|
||||||
|
|||||||
Reference in New Issue
Block a user