Files
dejanandClaude Opus 5 6a0b36ddcc Geviertstriche aus Text, Kommentaren und Doku entfernen
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
2026-08-31 20:51:19 +02:00

324 lines
11 KiB
TypeScript
Raw Permalink 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;
/**
* 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)})`;
}