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