feat(timeline): global /zeitstrahl timeline (Concept A) — #779 #831
38
frontend/src/lib/shared/primitives/Sparkline.svelte
Normal file
38
frontend/src/lib/shared/primitives/Sparkline.svelte
Normal 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>
|
||||
28
frontend/src/lib/shared/primitives/Sparkline.svelte.spec.ts
Normal file
28
frontend/src/lib/shared/primitives/Sparkline.svelte.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user