turnley.dev

PHP and MySQL code for faucet operators

Tools & Calculators

STA-004

Donut chart in SVG using arcs

Proportions of a whole, drawn with stroked arc paths. Includes the one case that breaks every naive implementation: a single slice at 100%.

A donut is a circle drawn as a series of arcs, each stroked in its own colour. The arithmetic is a running angle: each slice starts where the last ended and sweeps a fraction of a full turn.

The one thing that catches everyone is a single slice covering the whole circle. An SVG arc whose start and end points are identical draws nothing at all, so a dataset with one category renders as an empty ring. Detect that case and draw a plain circle instead.

Keep it to four or five slices. Beyond that the labels stop fitting and a bar chart is better anyway.

PHP
function chart_donut(array $parts, int $size = 200, int $stroke = 18): string
{
    $colors = ['#2E6FD6', '#D9822B', '#2AA4A4', '#B2589B', '#F2B441'];

    $total = array_sum(array_map('intval', $parts));
    if ($total <= 0) {
        return '';
    }

    $r  = $size / 2 - $stroke / 2 - 1;
    $cx = $size / 2;
    $cy = $size / 2;

    $svg = '<svg viewBox="0 0 ' . $size . ' ' . $size . '" role="img" xmlns="http://www.w3.org/2000/svg">';
    $angle = -M_PI / 2;                                  // start at twelve o'clock
    $i = 0;

    foreach ($parts as $label => $value) {
        $frac = ((int) $value) / $total;
        if ($frac <= 0) {
            $i++;
            continue;
        }
        $color = $colors[$i % count($colors)];
        $title = htmlspecialchars($label . ': ' . (int) $value . ' (' . round($frac * 100) . '%)');

        if ($frac >= 0.999) {
            // A full circle has identical start and end points, so an arc draws
            // nothing. This is the case that renders as an empty ring.
            $svg .= '<circle cx="' . $cx . '" cy="' . $cy . '" r="' . $r . '" fill="none" stroke="' . $color
                  . '" stroke-width="' . $stroke . '"><title>' . $title . '</title></circle>';
            break;
        }

        $end = $angle + $frac * 2 * M_PI;
        $x1 = $cx + $r * cos($angle); $y1 = $cy + $r * sin($angle);
        $x2 = $cx + $r * cos($end);   $y2 = $cy + $r * sin($end);
        $large = $frac > 0.5 ? 1 : 0;

        $svg .= '<path d="M ' . round($x1, 2) . ' ' . round($y1, 2)
              . ' A ' . $r . ' ' . $r . ' 0 ' . $large . ' 1 ' . round($x2, 2) . ' ' . round($y2, 2) . '"'
              . ' fill="none" stroke="' . $color . '" stroke-width="' . $stroke . '">'
              . '<title>' . $title . '</title></path>';

        $angle = $end;
        $i++;
    }

    return $svg . '</svg>';
}

Using it

Render a legend beside it rather than labelling the slices. Text inside a donut needs rotation and collision handling for very little gain.

Sort the slices largest first so the colour order is stable between page loads and the eye can compare across days.

What bites people

The large-arc flag has to be 1 for any slice over half the circle. Get it wrong and the slice is drawn the short way round, which looks like the data is wrong rather than the geometry.

Zero-value entries must be skipped, not drawn. A zero-width arc still consumes a colour and shifts every following slice's colour by one.

Also in Stats and Reporting