feat(shared): add Sparkline primitive for fixed-series density bars

A minimal presentational bar series (one bar per value, heights scaled to the
max, faint floor for empty buckets). Lives in shared so both the timeline
density strip and the document chart can use it. REQ-012 (supports).

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-13 19:26:11 +02:00
parent 4e119f098d
commit 607112afc2
2 changed files with 66 additions and 0 deletions

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');
});
});