feat(timeline): global /zeitstrahl timeline (Concept A) — #779 #831

Merged
marcel merged 22 commits from feat/issue-779-zeitstrahl into main 2026-06-13 21:56:46 +02:00
2 changed files with 66 additions and 0 deletions
Showing only changes of commit 607112afc2 - Show all commits

View File

@@ -0,0 +1,38 @@
<script lang="ts">
/**
* A minimal fixed-series bar sparkline: one bar per value, heights scaled to the
* largest value. Presentational only — callers supply the already-bucketed
* counts. Used by the timeline density strip; reusable by the document chart.
*/
let {
values,
label,
class: className = ''
}: { values: number[]; label?: string; class?: string } = $props();
const max = $derived(Math.max(1, ...values));
// Empty buckets keep a faint floor so the series reads as a continuous axis
// rather than disappearing to nothing.
const MIN_HEIGHT_PCT = 4;
function heightPct(value: number): number {
if (value <= 0) return MIN_HEIGHT_PCT;
return Math.max(MIN_HEIGHT_PCT, (value / max) * 100);
}
</script>
<div
class="flex h-8 items-end gap-[1.5px] {className}"
role="img"
aria-label={label}
aria-hidden={label ? undefined : 'true'}
>
{#each values as value, i (i)}
<div
data-testid="sparkline-bar"
class="flex-1 rounded-[1px] bg-brand-mint"
style="height: {heightPct(value)}%"
></div>
{/each}
</div>

View File

@@ -0,0 +1,28 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import Sparkline from './Sparkline.svelte';
afterEach(() => cleanup());
describe('Sparkline', () => {
it('renders one bar per value', () => {
render(Sparkline, { values: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] });
const bars = document.querySelectorAll('[data-testid="sparkline-bar"]');
expect(bars).toHaveLength(12);
});
it('scales bar heights relative to the largest value', () => {
render(Sparkline, { values: [5, 10, 0] });
const bars = document.querySelectorAll<HTMLElement>('[data-testid="sparkline-bar"]');
const h = (i: number) => parseFloat(bars[i].style.height);
// 10 is the max → tallest; 5 is half of the max's height; 0 is the shortest.
expect(h(1)).toBeGreaterThan(h(0));
expect(h(0)).toBeGreaterThan(h(2));
});
it('exposes an accessible label when provided', () => {
render(Sparkline, { values: [1, 2, 3], label: 'Monatsdichte' });
const img = document.querySelector('[role="img"]');
expect(img?.getAttribute('aria-label')).toBe('Monatsdichte');
});
});