/** * 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 = { 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 = { // 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( 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)})`; }