Files
AfiS-Aktensystem/web/components/PieChart.tsx
T
Harlekin e0b03ea336
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00

128 lines
4.2 KiB
TypeScript

/**
* Ein Kreisdiagramm.
*
* **Serverseitig gerendertes SVG, kein Skript.** Eine Diagrammbibliothek wäre
* hier das Teuerste auf der Seite; die Rechnung ist ein Dutzend Zeilen
* Trigonometrie, und das Ergebnis lädt ohne ein Byte JavaScript.
*
* Die Farben kommen **nicht** aus dem Stufensystem. Die sechs
* Klassifizierungsfarben sind das einzige Buntsystem der Anwendung und
* bedeuten dort etwas Bestimmtes; ein Tortenstück in Oxidrot würde als
* „streng geheim" gelesen. Die Segmente sind deshalb Abstufungen von
* `--steel`, von kräftig nach blass.
*
* Die Beschriftung steht daneben als Liste mit Zahlen, nicht nur als Farbe im
* Kreis: Farbe allein darf keine Angabe tragen, siehe docs/10-design-system.md.
* Damit ist das Diagramm der Schmuck und die Liste die Auskunft.
*/
export type Slice = { label: string; value: number }
const GROESSE = 160
const RADIUS = 70
export function PieChart({
slices,
emptyLabel,
totalLabel,
}: {
slices: Slice[]
emptyLabel: string
/** „12 Fälle" - steht in der Mitte und sagt, worauf sich die Anteile beziehen. */
totalLabel: (total: number) => string
}) {
const gesamt = slices.reduce((summe, stueck) => summe + stueck.value, 0)
if (gesamt === 0) return <p className="text-text-dim">{emptyLabel}</p>
let begonnen = 0
const segmente = slices.map((stueck, index) => {
const anteil = stueck.value / gesamt
const start = begonnen
begonnen += anteil
return {
...stueck,
anteil,
farbe: farbeFuer(index, slices.length),
pfad: kreisAusschnitt(start, begonnen),
}
})
return (
<div className="flex flex-wrap items-center gap-8">
<svg
width={GROESSE}
height={GROESSE}
viewBox={`0 0 ${GROESSE} ${GROESSE}`}
role="img"
aria-hidden
className="flex-none"
>
{segmente.length === 1 ? (
// Ein einziger Anteil ist ein voller Kreis. Als Pfad gezeichnet
// würde er zu einer Linie zusammenfallen.
<circle
cx={GROESSE / 2}
cy={GROESSE / 2}
r={RADIUS}
fill={segmente[0]!.farbe}
stroke="var(--color-surface)"
/>
) : (
segmente.map((segment) => (
<path
key={segment.label}
d={segment.pfad}
fill={segment.farbe}
stroke="var(--color-surface)"
strokeWidth={1}
/>
))
)}
</svg>
<dl className="min-w-0 grow space-y-1">
<div className="mb-2 font-condensed text-2xs text-text-dim">{totalLabel(gesamt)}</div>
{segmente.map((segment) => (
<div key={segment.label} className="flex items-baseline gap-3">
<span
aria-hidden
className="inline-block h-2.5 w-2.5 flex-none rounded-full"
style={{ backgroundColor: segment.farbe }}
/>
<dt className="min-w-0 grow truncate text-sm">{segment.label}</dt>
<dd className="font-mono text-2xs text-text-dim">
{segment.value} · {Math.round(segment.anteil * 100)}%
</dd>
</div>
))}
</dl>
</div>
)
}
/**
* Abstufungen von `--steel`, gleichmässig verteilt. Bei einem einzigen
* Segment die volle Farbe, sonst von kräftig nach blass - so bleibt die
* Reihenfolge der Liste auch im Kreis ablesbar.
*/
function farbeFuer(index: number, anzahl: number): string {
if (anzahl === 1) return 'var(--color-accent)'
const anteil = 100 - Math.round((index / (anzahl - 1)) * 65)
return `color-mix(in srgb, var(--color-accent) ${anteil}%, var(--color-panel))`
}
/** Ein Kreisausschnitt von `start` bis `ende`, beide als Anteil von 0 bis 1. */
function kreisAusschnitt(start: number, ende: number): string {
const mitte = GROESSE / 2
const [x1, y1] = punkt(start)
const [x2, y2] = punkt(ende)
const grosserBogen = ende - start > 0.5 ? 1 : 0
return `M ${mitte} ${mitte} L ${x1} ${y1} A ${RADIUS} ${RADIUS} 0 ${grosserBogen} 1 ${x2} ${y2} Z`
}
function punkt(anteil: number): [number, number] {
// Bei zwölf Uhr anfangen, im Uhrzeigersinn.
const winkel = anteil * 2 * Math.PI - Math.PI / 2
const mitte = GROESSE / 2
return [mitte + RADIUS * Math.cos(winkel), mitte + RADIUS * Math.sin(winkel)]
}