Files
Dashboard/features/dashboard/utils/chartTheme.ts
T
dejanandClaude Opus 5 0875ae2688 feat(carbon): Carbon-Konformität nachprüfbar machen statt behaupten
Recherche an der Quelle statt an der Dokumentation. Zwei Ergebnisse:

1. Carbon regelt Datenvisualisierung nur teilweise. Farben, Gitter, Achsen,
   Tooltip und Tickanzahl sind verbindlich festgelegt — Paddings, Margins und
   Dashboard-Raster dagegen NICHT. Die Carbon-Seiten zu Chart-Anatomie und
   Dashboards tragen ausdrücklich den Hinweis "this guidance is a work in
   progress" und enthalten keine Zahlenwerte. Unsere Abstandswerte stammen
   deshalb aus der Carbon-Spacing-Skala und der Planung mit dem Branchenpartner,
   nicht aus der Data-Viz-Norm. Das steht jetzt so in DESIGN-SYSTEM.md §0.

2. Eine echte Abweichung gefunden: Der Tooltip ist seit Carbon v11 hell
   (--cds-layer-02 #ffffff auf --cds-text-primary #161616, abgesetzt allein
   durch den Schatten). Unser Theme führte den dunklen v10-Stand (#393939) und
   berief sich dabei auf das ausgelieferte Stylesheet — die Angabe war veraltet.
   Korrigiert.

Neu:
- docs/carbon-referenz.json — kanonische Werte mit Quelle, Version und
  Prüfdatum, gezogen aus @carbon/charts 1.23.0 styles.min.css und
  configuration.ts. Bewusste Abweichungen (Balkenbreite 48 statt 16, feste
  Kategoriefarben statt n-Serien-Paletten) sind dort begründet.
- scripts/verify-carbon.mjs + npm run verify:carbon — gleicht chartTheme.ts
  gegen die Referenz ab und bricht bei Drift ab.
- docs/PRUEFFRAGEN.md — Prüffragen für die Gestaltungsabnahme mit
  nachprüfbarem Ja/Nein, plus Leseliste (FT Visual Vocabulary, IBCS,
  Cleveland & McGill, Few, Datawrapper Academy).

Die 14er-Palette wurde gegen die CSS-Variablen --cds-charts-14-1-* geprüft und
stimmt exakt; ebenso Gitter #e0e0e0, Achse #8d8d8d und Achsentext #525252.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
2026-08-29 17:53:51 +02:00

292 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Zentrales Chart-Theme — abgeleitet aus IBM Carbon Data Visualization.
*
* Die Werte stammen aus dem ausgelieferten Stylesheet von @carbon/charts
* (dist/styles.css, Light Theme):
* Gridlines #e0e0e0 (--cds-layer-accent-01)
* Achse #8d8d8d (--cds-border-strong-01)
* Text primär #161616 (--cds-text-primary)
* Text sek. #525252 (--cds-text-secondary)
* Tooltip #ffffff (--cds-layer-02) auf #161616 Text, Schatten statt Rahmen
*
* Warum kein bunter "Marketing"-Farbsatz: Die Carbon-Palette ist auf
* Unterscheidbarkeit und Kontrast optimiert und in dieser Reihenfolge
* getestet. Reihenfolge deshalb nie umsortieren, nur von vorne nehmen.
*
* Siehe docs/DESIGN-SYSTEM.md.
*/
/** Carbon "14-color" categorical palette, Light Theme, in Originalreihenfolge. */
export const KATEGORIE_FARBEN = [
"#6929c4", // purple 70
"#1192e8", // cyan 50
"#005d5d", // teal 70
"#9f1853", // magenta 70
"#fa4d56", // red 50
"#520408", // red 100
"#198038", // green 60
"#002d9c", // blue 80
"#ee5396", // magenta 50
"#b28600", // yellow 50
"#009d9a", // teal 50
"#012749", // cyan 90
"#8a3800", // orange 70
"#a56eff", // purple 50
] as const;
/**
* Carbon empfiehlt maximal 5 gleichzeitig unterscheidbare Kategorien.
* Alles darüber wird in "Sonstige" zusammengefasst (siehe gruppiereRest()).
*/
export const MAX_KATEGORIEN = 5;
/** Ampelfarben für Status-Aussagen (nicht für Kategorien verwenden). */
export const STATUS_FARBEN = {
positiv: "#198038",
warnung: "#b28600",
negativ: "#da1e28",
neutral: "#8d8d8d",
info: "#1192e8",
} as const;
/**
* Feste Farbe je Gewerk.
*
* Eine Kategorie behält ihre Farbe über alle Charts und Seiten hinweg —
* sonst ist Heizung im Ring lila und im Balkendiagramm blau, und man muss
* jede Legende neu lesen. Die Werte stammen aus der Carbon-Palette.
*/
export const GEWERK_FARBEN: Record<string, string> = {
Heizung: "#6929c4",
Sanitär: "#1192e8",
"Klima & Lüftung": "#005d5d",
Photovoltaik: "#9f1853",
Elektro: "#198038",
};
/**
* Feste Farben für Statuswerte.
*
* Hier trägt die Farbe Bedeutung: „Verloren" und „Überfällig" sind rot,
* „Bezahlt" und „In Arbeit" grün, Neutrales grau. Eine zufällige
* Palettenfarbe für „Verloren" würde die Aussage der Grafik verwässern.
*/
export const STATUS_KATEGORIE_FARBEN: Record<string, string> = {
// Auftragsstatus
Anfrage: "#a56eff",
"Vor-Aufmaß": "#1192e8",
Angebot: "#6929c4",
Beauftragt: "#009d9a",
"In Arbeit": "#198038",
Abgeschlossen: "#005d5d",
Verloren: "#fa4d56",
// Rechnungsstatus
Bezahlt: "#198038",
Gestellt: "#1192e8",
Überfällig: "#da1e28",
Entwurf: "#8d8d8d",
// Förderung
Bewilligt: "#198038",
Beantragt: "#b28600",
"Nicht beantragt": "#8d8d8d",
// Abwesenheit
Urlaub: "#1192e8",
Krank: "#fa4d56",
Fortbildung: "#6929c4",
// Sammelposten
Sonstige: "#8d8d8d",
};
/**
* Textfarben für Statusaussagen in Tabellen und Listen.
*
* Nicht dieselben Werte wie STATUS_FARBEN: Flächen dürfen heller sein, Text
* braucht auf Weiß mehr Kontrast (WCAG AA, 4,5:1 bei 12–13 px). Deshalb je
* eine Stufe dunkler aus derselben Carbon-Familie.
*/
export const STATUS_TEXT = {
negativ: "#a2191f", // red 70
warnung: "#8a3800", // orange 70
positiv: "#0e6027", // green 70
} as const;
/**
* Vierstufige Risikorampe für geordnete Dimensionen (Forderungsalter).
*
* Durchgehend Statusfarben aus der Carbon-Skala, keine Palettenfarbe
* dazwischen: Altersklassen sind eine Reihenfolge mit wachsendem Risiko,
* keine unabhängigen Kategorien.
*/
export const RISIKO_RAMPE = [
"#198038", // green 60 — im Zahlungsziel
"#b28600", // yellow 50 — beobachten
"#ba4e00", // orange 60 — mahnen
"#da1e28", // red 60 — Abschreibungsrisiko
] as const;
/**
* Badge-Töne: helle Fläche mit dunklem Text derselben Carbon-Familie.
* Alle Paare erreichen auf ihrer Fläche mindestens 7:1 — Badges sind klein
* (11 px) und brauchen mehr Reserve als Fließtext.
*/
export const BADGE_TON = {
neutral: "bg-neutral-100 text-neutral-700",
information: "bg-[#e5f6ff] text-[#00539a]",
lila: "bg-[#e8daff] text-[#491d8b]",
teal: "bg-[#d9fbfb] text-[#005d5d]",
positiv: "bg-[#defbe6] text-[#0e6027]",
negativ: "bg-[#fff1f1] text-[#a2191f]",
} as const;
/** Einzelserie (Umsatzverlauf o. Ä.) — Carbon Cyan 50. */
export const PRIMAER_FARBE = "#1192e8";
/** Vergleichsserie (Vorjahr) — bewusst neutral, damit sie zurücktritt. */
export const VERGLEICH_FARBE = "#8d8d8d";
export const CHART = {
grid: "#e0e0e0",
achse: "#8d8d8d",
text: "#161616",
textSekundaer: "#525252",
// Seit Carbon v11 ist der Tooltip hell: --cds-layer-02 auf --cds-text-primary,
// getrennt vom Untergrund allein durch den Schatten. Bis v10 war er dunkel
// (#393939) — diese Angabe steht in vielen älteren Quellen noch so.
tooltipBg: "#ffffff",
tooltipFg: "#161616",
} as const;
/** Tooltip-Schatten laut @carbon/charts (.cds--cc--tooltip). */
export const TOOLTIP_SCHATTEN = "0 1px 6px 0 rgba(0,0,0,.2)";
/**
* Chart-Innenabstände.
* Carbon rechnet Achsenbeschriftungen in den Chart-Rahmen ein; die Karte selbst
* liefert bereits 16 px Padding, deshalb hier nur der Platz für Achsen.
*/
export const MARGIN = {
/** Vertikale Balken / Flächen mit Y-Achse links.
* Rechts genug Platz, damit die letzte Achsenbeschriftung nicht abschneidet. */
standard: { top: 8, right: 24, bottom: 4, left: 4 },
/** Horizontale Balken: links Platz für Kategorienamen. */
horizontal: { top: 4, right: 56, bottom: 4, left: 4 },
/** Sparkline / Mini-Chart ohne Achsen. */
kompakt: { top: 2, right: 2, bottom: 2, left: 2 },
} as const;
/** Typografie im Chart (Carbon: 12 px Labels, 14 px Titel). */
export const CHART_FONT = {
achse: 12,
label: 12,
legende: 12,
titel: 14,
} as const;
/** Gemeinsame Achsen-Props für Recharts (Carbon-Look: keine Ticks, dünne Achse). */
export const achseX = {
tickLine: false,
axisLine: { stroke: CHART.achse, strokeWidth: 1 },
tick: { fill: CHART.textSekundaer, fontSize: CHART_FONT.achse },
dy: 4,
} as const;
export const achseY = {
tickLine: false,
axisLine: false,
tick: { fill: CHART.textSekundaer, fontSize: CHART_FONT.achse },
// 58 px laut docs/DESIGN-SYSTEM.md §2 — reicht für "245 T€".
// Werte im Mio.-Bereich bekommen über achsenFormat() eine kurze Einheit.
width: 58,
} as const;
/** Balkenbreiten nach Carbon: Abstand zwischen Balken ≥ halbe Balkenbreite. */
export const BALKEN = {
maxBreite: 48,
/** Recharts-Kategorieabstand in Prozent der Kategoriebreite. */
kategorieAbstand: "28%",
radius: 2,
hoeheHorizontal: 20,
} as const;
/** Donut-Proportionen (Carbon: kräftiger Ring, Gesamtwert in der Mitte). */
export const DONUT = {
innenRadius: "62%",
aussenRadius: "88%",
segmentAbstand: 2,
} as const;
/**
* Reduziert eine Kategorienliste auf MAX_KATEGORIEN und fasst den Rest
* zu "Sonstige" zusammen — verhindert unlesbare Donuts mit 12 Segmenten.
*/
export function gruppiereRest<T extends { name: string; value: number }>(
daten: T[],
max: number = MAX_KATEGORIEN
): { name: string; value: number }[] {
const sortiert = [...daten].sort((a, b) => b.value - a.value);
if (sortiert.length <= max) return sortiert.map((d) => ({ name: d.name, value: d.value }));
const kopf = sortiert.slice(0, max - 1).map((d) => ({ name: d.name, value: d.value }));
const rest = sortiert.slice(max - 1).reduce((summe, d) => summe + d.value, 0);
// "Sonstige" wird einsortiert, nicht angehaengt: der Ring laeuft sonst nicht
// mehr absteigend, obwohl DESIGN-SYSTEM.md §4 genau das verlangt.
return [...kopf, { name: "Sonstige", value: rest }].sort((a, b) => b.value - a.value);
}
/**
* Rundes Achsenraster.
*
* Recharts leitet Ticks aus max/(n-1) ab und rundet den Rohschritt auf — bei
* 219 T€ ergibt das 0/55/110/165/220. Krumme Achsenwerte kosten den Leser
* bei jedem Blick eine Rechenoperation. Deshalb Schrittweiten aus der
* 1-2-2,5-5-Reihe erzwingen.
*/
export function nettesRaster(
max: number,
maxIntervalle = 5
): { domain: [number, number]; ticks: number[] } {
if (!Number.isFinite(max) || max <= 0) return { domain: [0, 1], ticks: [0, 1] };
const groessenordnung = 10 ** Math.floor(Math.log10(max));
const kandidaten = [1, 2, 2.5, 5, 10]
.flatMap((k) => [(k * groessenordnung) / 10, k * groessenordnung, k * groessenordnung * 10])
.sort((a, b) => a - b);
for (const schritt of kandidaten) {
const anzahl = Math.ceil(max / schritt);
if (anzahl <= maxIntervalle) {
return {
domain: [0, schritt * anzahl],
ticks: Array.from({ length: anzahl + 1 }, (_, i) => i * schritt),
};
}
}
return { domain: [0, max], ticks: [0, max] };
}
/**
* Ab wie vielen Datenpunkten nur noch jede n-te Beschriftung gezeigt wird.
* Eine Konstante, damit Flaeche und Balken nicht unterschiedlich ausduennen.
*/
export const MAX_ACHSENLABELS = 12;
/** Farbe nach Index, rotiert sicher innerhalb der Palette. */
export function farbe(index: number): string {
return KATEGORIE_FARBEN[index % KATEGORIE_FARBEN.length];
}
/**
* Farbe für einen Kategorienamen: feste Zuordnung wenn vorhanden,
* sonst Position in der Palette.
*/
export function farbeFuer(name: string, index: number): string {
return GEWERK_FARBEN[name] ?? STATUS_KATEGORIE_FARBEN[name] ?? farbe(index);
}
/** Abgestufte Einfärbung für Trichterstufen — eine Farbe, abnehmende Sättigung. */
export function trichterFarbe(index: number, gesamt: number): string {
const anteil = gesamt > 1 ? index / (gesamt - 1) : 0;
// Von Carbon Purple 70 nach Purple 40: gleiche Farbfamilie, klare Reihenfolge.
const start = { r: 0x69, g: 0x29, b: 0xc4 };
const ende = { r: 0xbe, g: 0x95, b: 0xff };
const misch = (a: number, b: number) => Math.round(a + (b - a) * anteil);
return `rgb(${misch(start.r, ende.r)} ${misch(start.g, ende.g)} ${misch(start.b, ende.b)})`;
}