feat: Diagramme als handgeschriebenes SVG

Ohne Chart-Bibliothek: die Diagramme sind schlicht, sollen sich an die
Design-Tokens halten und in beiden Farbschemata funktionieren.

Die Serienfarben sind abgedunkelte Signaturfarben. Sie bestehen in beiden
Modi die Pruefungen auf Helligkeitsband, Saettigung, Farbfehlsichtigkeit
(schlechtestes Paar 12,1 unter Deuteranopie) und Kontrast; die vollen
Signaturtoene waeren auf dunklem Grund zu hell.

Markenfarben der Dienste kommen nur zum Zug, wenn sie auf hellem und dunklem
Grund mindestens 3:1 erreichen — sonst faellt der Balken je nach Farbschema
in den Hintergrund.
This commit is contained in:
Florian Wilde
2026-09-01 19:54:15 +02:00
parent b53ee828ba
commit 0e51df7bad
+373
View File
@@ -0,0 +1,373 @@
"use client";
import { useState } from "react";
import { clsx } from "clsx";
import { formatMinutes } from "@/lib/labels";
/**
* Diagramme der Statistikseite.
*
* Handgeschriebenes SVG statt einer Chart-Bibliothek: die Diagramme sind
* schlicht, sollen sich an die Design-Tokens halten und in beiden Farbschemata
* funktionieren — dafür lohnt keine Abhängigkeit mit eigenem Versionsrhythmus.
*
* Farbwahl: Die beiden Serienfarben sind abgedunkelte Varianten der
* Signaturfarben. Sie erfüllen in **beiden** Modi die Prüfungen auf
* Helligkeitsband, Farbsättigung, Farbfehlsichtigkeit (schlechtestes Paar
* ΔE 12,1 unter Deuteranopie) und Kontrast zum Untergrund. Die vollen
* Signaturtöne wären auf dunklem Grund zu hell und fielen durch.
*/
export const SERIES_COLORS = {
episodes: "#c47a18",
movies: "#d92d78",
} as const;
/** Ein Ton für Balken, die nur eine Größe zeigen (keine Identität). */
const SINGLE_HUE = "#c47a18";
// ---------------------------------------------------------------------------
// Säulen je Monat
// ---------------------------------------------------------------------------
export type MonthlyPointData = {
label: string;
episodes: number;
movies: number;
minutes: number;
};
/** Rundet die Achsenspitze auf einen glatten Wert auf. */
function niceMax(value: number): number {
if (value <= 0) return 4;
const magnitude = 10 ** Math.floor(Math.log10(value));
for (const step of [1, 2, 2.5, 5, 10]) {
const candidate = step * magnitude;
if (candidate >= value) return candidate;
}
return 10 * magnitude;
}
export function MonthlyChart({ points }: { points: MonthlyPointData[] }) {
const [active, setActive] = useState<number | null>(null);
const max = niceMax(Math.max(1, ...points.map((point) => point.episodes + point.movies)));
const ticks = [0, max / 2, max];
// Breites Seitenverhältnis: das SVG füllt die Karte und bleibt trotzdem flach.
const width = 1100;
const height = 260;
const padding = { top: 12, right: 8, bottom: 28, left: 38 };
const plotWidth = width - padding.left - padding.right;
const plotHeight = height - padding.top - padding.bottom;
const band = plotWidth / Math.max(1, points.length);
const barWidth = Math.min(24, band - 10);
const scale = (value: number) => (value / max) * plotHeight;
const total = points.reduce((sum, point) => sum + point.episodes + point.movies, 0);
if (total === 0) {
return (
<p className="py-8 text-center text-sm text-[var(--fl-faint)]">
In den letzten zwölf Monaten wurde noch nichts abgehakt.
</p>
);
}
return (
<figure className="m-0">
<Legend
items={[
{ label: "Episoden", color: SERIES_COLORS.episodes },
{ label: "Filme", color: SERIES_COLORS.movies },
]}
/>
<div className="relative mt-2 overflow-x-auto">
<svg
viewBox={`0 0 ${width} ${height}`}
className="h-auto w-full min-w-[32rem]"
role="img"
// Bewusst aria-label statt eines <title>-Kindes: React behandelt
// <title> als Dokument-Metadatum und hebt es aus dem SVG heraus —
// das brach die Hydration.
aria-label={`Gesehene Episoden und Filme je Monat in den letzten ${points.length} Monaten`}
onMouseLeave={() => setActive(null)}
>
{ticks.map((tick) => {
const y = padding.top + plotHeight - scale(tick);
return (
<g key={tick}>
<line
x1={padding.left}
x2={width - padding.right}
y1={y}
y2={y}
stroke="var(--fl-border)"
strokeWidth="1"
/>
<text
x={padding.left - 8}
y={y + 4}
textAnchor="end"
className="fill-[var(--fl-faint)] text-[11px] tabular-nums"
>
{Math.round(tick)}
</text>
</g>
);
})}
{points.map((point, index) => {
const x = padding.left + index * band + (band - barWidth) / 2;
const episodeHeight = scale(point.episodes);
const movieHeight = scale(point.movies);
const baseline = padding.top + plotHeight;
const isActive = active === index;
return (
<g
key={point.label + index}
onMouseEnter={() => setActive(index)}
onFocus={() => setActive(index)}
tabIndex={0}
className="outline-none"
>
{/* Großzügige Trefferfläche — der Balken selbst ist schmal. */}
<rect
x={padding.left + index * band}
y={padding.top}
width={band}
height={plotHeight}
fill={isActive ? "var(--fl-surface-2)" : "transparent"}
opacity={isActive ? 0.6 : 0}
/>
{point.episodes > 0 && (
<rect
x={x}
y={baseline - episodeHeight}
width={barWidth}
height={episodeHeight}
rx={point.movies > 0 ? 0 : 4}
fill={SERIES_COLORS.episodes}
/>
)}
{point.movies > 0 && (
<rect
x={x}
// 2 px Fuge in Untergrundfarbe zwischen den Segmenten
y={baseline - episodeHeight - movieHeight - (point.episodes > 0 ? 2 : 0)}
width={barWidth}
height={movieHeight}
rx={4}
fill={SERIES_COLORS.movies}
/>
)}
<text
x={padding.left + index * band + band / 2}
y={height - 8}
textAnchor="middle"
className={clsx(
"text-[11px]",
isActive ? "fill-[var(--fl-text)]" : "fill-[var(--fl-faint)]",
)}
>
{point.label}
</text>
</g>
);
})}
</svg>
{active !== null && points[active] && (
<Tooltip
title={points[active].label}
rows={[
{ label: "Episoden", value: String(points[active].episodes), color: SERIES_COLORS.episodes },
{ label: "Filme", value: String(points[active].movies), color: SERIES_COLORS.movies },
{ label: "Schauzeit", value: formatMinutes(points[active].minutes) },
]}
position={active / Math.max(1, points.length - 1)}
/>
)}
</div>
<figcaption className="sr-only">
<table>
<caption>Gesehene Episoden und Filme je Monat</caption>
<thead>
<tr>
<th scope="col">Monat</th>
<th scope="col">Episoden</th>
<th scope="col">Filme</th>
<th scope="col">Schauzeit</th>
</tr>
</thead>
<tbody>
{points.map((point, index) => (
<tr key={point.label + index}>
<th scope="row">{point.label}</th>
<td>{point.episodes}</td>
<td>{point.movies}</td>
<td>{formatMinutes(point.minutes)}</td>
</tr>
))}
</tbody>
</table>
</figcaption>
</figure>
);
}
function Tooltip({
title,
rows,
position,
}: {
title: string;
rows: { label: string; value: string; color?: string }[];
position: number;
}) {
return (
<div
role="status"
className="pointer-events-none absolute top-0 z-10 min-w-[9rem] -translate-x-1/2 rounded-xl border border-[var(--fl-border)] bg-[var(--fl-surface-solid)] px-3 py-2 text-xs shadow-[var(--fl-shadow)]"
style={{ left: `${12 + position * 76}%` }}
>
<p className="mb-1 font-semibold">{title}</p>
{rows.map((row) => (
<p key={row.label} className="flex items-center gap-1.5 text-[var(--fl-muted)]">
{row.color && (
<span
aria-hidden
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: row.color }}
/>
)}
<span className="flex-1">{row.label}</span>
<span className="font-medium tabular-nums text-[var(--fl-text)]">{row.value}</span>
</p>
))}
</div>
);
}
function Legend({ items }: { items: { label: string; color: string }[] }) {
return (
<ul className="flex flex-wrap gap-4 text-xs text-[var(--fl-muted)]">
{items.map((item) => (
<li key={item.label} className="flex items-center gap-1.5">
<span
aria-hidden
className="h-2.5 w-2.5 rounded-sm"
style={{ backgroundColor: item.color }}
/>
{item.label}
</li>
))}
</ul>
);
}
// ---------------------------------------------------------------------------
// Liegende Balken für Verteilungen
// ---------------------------------------------------------------------------
export type DistributionRow = {
label: string;
minutes: number;
count: number;
/** Markenfarbe eines Dienstes; wird nur genutzt, wenn sie kontrastreich genug ist. */
color?: string | null;
};
/** Relative Leuchtdichte nach WCAG — für die Kontrastprüfung der Markenfarben. */
function luminance(hex: string): number | null {
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
if (!match?.[1]) return null;
const value = Number.parseInt(match[1], 16);
const channels = [(value >> 16) & 255, (value >> 8) & 255, value & 255].map((raw) => {
const c = raw / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;
}
function contrast(hex: string, surfaceLuminance: number): number | null {
const value = luminance(hex);
if (value === null) return null;
const lighter = Math.max(value, surfaceLuminance);
const darker = Math.min(value, surfaceLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
/** Leuchtdichte der beiden Untergründe (--fl-bg in dunkel und hell). */
const SURFACE_DARK = 0.0055;
const SURFACE_LIGHT = 0.9;
/**
* Eine Markenfarbe wird nur benutzt, wenn sie auf **beiden** Untergründen
* mindestens 3:1 erreicht. So bleibt die Wahl unabhängig vom eingestellten
* Farbschema — sonst müsste die Komponente den Theme-Wechsel mitverfolgen und
* liefe Gefahr, beim ersten Rendern etwas anderes zu zeigen als der Server.
*/
function safeBrandColor(hex: string | null | undefined): string | null {
if (!hex) return null;
const dark = contrast(hex, SURFACE_DARK);
const light = contrast(hex, SURFACE_LIGHT);
if (dark === null || light === null) return null;
return dark >= 3 && light >= 3 ? hex : null;
}
export function DistributionBars({
rows,
emptyText,
useBrandColors = false,
}: {
rows: DistributionRow[];
emptyText: string;
/**
* Streamingdienste tragen ihre Markenfarbe — das ist ihre Identität und
* zieht sich durchs ganze UI. Zu dunkle oder zu blasse Marken fallen
* automatisch auf den geprüften Standardton zurück, sonst verschwände der
* Balken je nach Farbschema.
*/
useBrandColors?: boolean;
}) {
if (rows.length === 0) {
return <p className="py-6 text-center text-sm text-[var(--fl-faint)]">{emptyText}</p>;
}
const max = Math.max(...rows.map((row) => row.minutes), 1);
return (
<ul className="space-y-2.5">
{rows.map((row) => {
const brand = useBrandColors ? safeBrandColor(row.color) : null;
const color = brand ?? SINGLE_HUE;
return (
<li key={row.label}>
<div className="mb-1 flex items-baseline justify-between gap-3 text-sm">
<span className="min-w-0 truncate text-[var(--fl-text)]">{row.label}</span>
<span className="shrink-0 tabular-nums text-[var(--fl-muted)]">
{formatMinutes(row.minutes)}
</span>
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-[var(--fl-surface-2)]">
<div
className="h-full rounded-full transition-[width] duration-500"
style={{
width: `${Math.max(2, (row.minutes / max) * 100)}%`,
backgroundColor: color,
}}
/>
</div>
</li>
);
})}
</ul>
);
}