From 0e51df7bad57ee296c52777ce5f0933f1ac14988 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Tue, 1 Sep 2026 19:54:15 +0200 Subject: [PATCH] feat: Diagramme als handgeschriebenes SVG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/stats/Charts.tsx | 373 ++++++++++++++++++++++++++++++++ 1 file changed, 373 insertions(+) create mode 100644 src/components/stats/Charts.tsx diff --git a/src/components/stats/Charts.tsx b/src/components/stats/Charts.tsx new file mode 100644 index 0000000..8fb6cce --- /dev/null +++ b/src/components/stats/Charts.tsx @@ -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(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 ( +

+ In den letzten zwölf Monaten wurde noch nichts abgehakt. +

+ ); + } + + return ( +
+ + +
+ -Kindes: React behandelt + // 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 ( + + + + {Math.round(tick)} + + + ); + })} + + {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 ( + setActive(index)} + onFocus={() => setActive(index)} + tabIndex={0} + className="outline-none" + > + {/* Großzügige Trefferfläche — der Balken selbst ist schmal. */} + + + {point.episodes > 0 && ( + 0 ? 0 : 4} + fill={SERIES_COLORS.episodes} + /> + )} + {point.movies > 0 && ( + 0 ? 2 : 0)} + width={barWidth} + height={movieHeight} + rx={4} + fill={SERIES_COLORS.movies} + /> + )} + + + {point.label} + + + ); + })} + + + {active !== null && points[active] && ( + + )} +
+ +
+ + + + + + + + + + + + {points.map((point, index) => ( + + + + + + + ))} + +
Gesehene Episoden und Filme je Monat
MonatEpisodenFilmeSchauzeit
{point.label}{point.episodes}{point.movies}{formatMinutes(point.minutes)}
+
+
+ ); +} + +function Tooltip({ + title, + rows, + position, +}: { + title: string; + rows: { label: string; value: string; color?: string }[]; + position: number; +}) { + return ( +
+

{title}

+ {rows.map((row) => ( +

+ {row.color && ( + + )} + {row.label} + {row.value} +

+ ))} +
+ ); +} + +function Legend({ items }: { items: { label: string; color: string }[] }) { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// 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

{emptyText}

; + } + + const max = Math.max(...rows.map((row) => row.minutes), 1); + + return ( + + ); +}