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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
128 lines
4.2 KiB
TypeScript
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)]
|
|
}
|