Zwoelf Monatsnamen brauchen rund 340 px. In einer Drittelkarte bei 1400 px sind knapp 300 da — "Aug", "Sep" und "Okt" standen ohne Luecke aneinander und lasen sich als ein Wort. Die Ursache war eine Schwelle, die nur die Datenmenge kannte: Erst ab mehr als zwoelf Punkten wurde ueberhaupt ausgeduennt. Genau zwoelf Monate lagen darunter und wurden alle gezeichnet, egal wie schmal die Karte war. Jetzt zwei Stufen, und die zweite haengt an der Breite statt an der Menge: Ueber zwoelf Werten weiterhin fest jede n-te Beschriftung — das ist eine Entscheidung ueber die Reihe und darf nicht vom Bildschirm abhaengen. Darunter entscheidet Recharts zur Laufzeit (preserveStartEnd mit minTickGap) und laesst weg, was nicht passt, haelt dabei aber die erste und letzte Beschriftung; ohne die waere der Zeitraum nicht mehr ablesbar. Ergebnis: bei 1920 px stehen alle zwoelf Monate mit Luft, bei 1400 px zeigt dieselbe Karte Jan, Maer, Mai, Jul, Sep, Dez. Diagramme mit wenigen Punkten (fuenf Kalenderwochen im Auftragseingang) bleiben unveraendert vollstaendig. Die Regel liegt jetzt als achsenIntervall() in chartTheme.ts, weil sie zwei Diagramme betrifft. Der Zeitverlauf hatte den Fall schon behoben, das Saeulendiagramm nicht — genau so laeuft so etwas auseinander. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
/**
|
||
* 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;
|
||
|
||
/**
|
||
* Mindestabstand zwischen zwei Beschriftungen der Zeitachse, in Pixeln.
|
||
*
|
||
* 12 px entspricht Carbons `$spacing-04`. Weniger ist keine Frage des
|
||
* Geschmacks: Bei rund 8 px beruehren sich "Nov" und "Dez" bereits, und die
|
||
* Achse liest sich als ein einziges Wort.
|
||
*/
|
||
export const MIN_TICK_ABSTAND = 12;
|
||
|
||
/**
|
||
* Wie stark die Zeitachse ihre Beschriftungen ausduennt.
|
||
*
|
||
* Zwei Stufen, weil zwei verschiedene Dinge sie zum Kollidieren bringen:
|
||
*
|
||
* **Viele Punkte.** Ueber MAX_ACHSENLABELS Werten wird fest jede n-te
|
||
* Beschriftung gezeigt. Das ist eine Entscheidung ueber die Lesbarkeit der
|
||
* Reihe und darf nicht von der Kartenbreite abhaengen — sonst zeigt dieselbe
|
||
* Reihe auf zwei Bildschirmen verschiedene Stuetzstellen.
|
||
*
|
||
* **Wenige Punkte in einer schmalen Karte.** Zwoelf Monatsnamen brauchen rund
|
||
* 340 px. Eine Drittelkarte hat bei 1400 px knapp 300 — dort standen "Aug",
|
||
* "Sep" und "Okt" ohne Luecke aneinander. Diese Faelle haengen an der Breite
|
||
* und nicht an der Datenmenge, deshalb entscheidet sie Recharts zur Laufzeit:
|
||
* `preserveStartEnd` laesst weg, was nicht passt, und haelt dabei die erste
|
||
* und letzte Beschriftung — ohne die waere der Zeitraum nicht mehr ablesbar.
|
||
*/
|
||
export function achsenIntervall(anzahlPunkte: number): number | "preserveStartEnd" {
|
||
return anzahlPunkte > MAX_ACHSENLABELS
|
||
? Math.ceil(anzahlPunkte / MAX_ACHSENLABELS) - 1
|
||
: "preserveStartEnd";
|
||
}
|
||
|
||
/** 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)})`;
|
||
}
|