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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user