467 Stellen. Nicht durch einen anderen Strich ersetzt, sondern durch das
Satzzeichen, das an der jeweiligen Stelle richtig ist: Doppelpunkt, wo eine
Erlaeuterung folgt; Komma, wo der Satz weiterlaeuft; Punkt, wo zwei
Hauptsaetze aneinanderstiessen. Ein Halbgeviertstrich waere nur derselbe
Griff mit kuerzerem Strich gewesen.
Regelbasiert ersetzt und danach nachgeprueft, weil eine Regel allein nicht
reicht. Gefunden und einzeln korrigiert:
- Eingeschobene Saetze mit Strichpaar. Nur der oeffnende Strich passte ins
Muster, der schliessende blieb als "—," stehen. Drei Stellen, jetzt mit
Klammern oder als eigener Satz.
- Doppelte Doppelpunkte, wo beide Striche eines Paars zu Doppelpunkten wurden
("Globale Regeln: Schrift, Groessen, Abstaende: kommen aus HiveGrid").
Sieben Stellen in Doku und Oberflaeche, jetzt in Klammern.
- Pfade, denen ein Komma statt eines Doppelpunkts folgte.
- Ausgerichtete Kommentartabellen, in denen der Strich eine Spalte war.
Nicht angefasst: drei Geviertstriche, die keine Prosa sind, sondern
Tabellenplatzhalter fuer "kein Wert" (Trichter, Auftraege, Kunden). Dort ist
der Strich die Angabe selbst; ihn zu entfernen liesse leere Zellen zurueck.
tsc, lint, build, verify:carbon und verify:layout laufen sauber.
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)})`;
|
||
}
|