/blog/

􀀂􀀟􀀍􀀆 􀀂􀀜􀀍􀀅 Generating SVG pie charts in Hugo

I wanted to generate a static SVG pie chart in Hugo, without relying on any client-side rendering (e.g. JavaScript charting libraries.)

Look, it works!

60% Oranges 30% Leafy greens 10% Eggplant

Updates

  • 2024-08-25:
    • Added a legend background color, which lets us make sure it works in dark mode
    • Added demo of the JavaScript version

The Hugo partial

This partial generates an SVG with a legend. There isn’t that much space for the legend, so long labels will get cut off.

Call it like so:

{{ $data := slice (slice 60 "Oranges" "orange") (slice 30 "Leafy greens" "green") (slice 10 "Eggplant" "purple") }}
{{ partial "piechart.html" (dict "data" $data "size" 300) }}
partials/piechart.html code
{{/*  Generate a pie chart SVG

      Expects two arguments:
      - data: A slice of slices, where each inner slice contains:
        - a percentage (0-100) (int)
        - A label (string)
        - a color (hex or named color) (string)
      - size: the width and height of the SVG in pixels

      Call like so:
      {{ partial "piechart.html" (dict
          "data" (slice (slice 60 "Oranges" "orange") (slice 30 "Leafy greens" "green") (slice 10 "Eggplant" "purple"))
          "size" 200
        )
      -}}
*/}}

{{- $data := .data -}}
{{- $size := default 200 .size -}}
{{- $legendBg := default "transparent" .legendBg -}}

{{- $radius := math.Div $size 2 -}}
{{- $centerX := $radius -}}
{{- $centerY := $radius -}}
{{- $startAngle := math.Mul -0.5 math.Pi -}}

{{- $svgPaths := slice -}}
{{- $legend := slice -}}

{{/* Ensure that the percentages sum to 100% */}}
{{- $totalPercentage := 0 -}}
{{- range $data -}}
  {{- $totalPercentage = math.Add $totalPercentage (index . 0) -}}
{{- end -}}
{{- if gt (math.Abs (math.Sub $totalPercentage 100)) 0.01 -}}
  {{- errorf "Percentages must sum to 100%% but sums to %d" $totalPercentage -}}
{{- end -}}

{{- $maxLabelWidth := 0 -}}
{{- range $data -}}
  {{- $label := index . 1 -}}
  {{- $labelLength := len $label -}}
  {{- if gt $labelLength $maxLabelWidth -}}
    {{- $maxLabelWidth = $labelLength -}}
  {{- end -}}
{{- end -}}
{{- $maxLabelWidth = math.Mul $maxLabelWidth 7 -}}
{{- $legendWidth := math.Add $maxLabelWidth 70 -}}

{{- range $data -}}
  {{- $percentage := index . 0 -}}
  {{- $label := index . 1 -}}
  {{- $color := index . 2 -}}

  {{- $angleDifference := math.Mul (math.Div $percentage 100.0) 2 math.Pi -}}
  {{- $endAngle := math.Add $startAngle $angleDifference -}}

  {{/*  $x1,$y1 is the coordinate in the SVG where the slice starts on the circumference of the circle,
        and $x2,$y2 is the coordinate where the slice ends.
  */}}
  {{- $x1 := math.Add $centerX (math.Mul $radius (math.Cos $startAngle)) -}}
  {{- $y1 := math.Add $centerY (math.Mul $radius (math.Sin $startAngle)) -}}
  {{- $x2 := math.Add $centerX (math.Mul $radius (math.Cos $endAngle)) -}}
  {{- $y2 := math.Add $centerY (math.Mul $radius (math.Sin $endAngle)) -}}

  {{/*  {{- printf "<!-- angle start/end/diff: %f/%f/%f    coords: %f,%f -> %f,%f -->\n" $startAngle $endAngle $angleDifference $x1 $y1 $x2 $y2 | safeHTML }}  */}}

  {{/* Determine if the arc should be greater than 180 degrees */}}
  {{- $largeArcFlag := cond (gt $percentage 50) 1 0 -}}

  {{- $pathData := slice -}}
  {{/*  Move to the center of the circle */}}
  {{- $pathData = $pathData | append (printf "M %d %d" $centerX $centerY) -}}
  {{/* Draw a line from the center to the edge of the circle */}}
  {{- $pathData = $pathData | append (printf "L %.2f %.2f" $x1 $y1) -}}
  {{/* Draw an arc from the start to the end of the slice */}}
  {{- $pathData = $pathData | append (printf "A %d %d 0 %d 1 %.2f %.2f" $radius $radius $largeArcFlag $x2 $y2) -}}
  {{/* Close the path, drawing a line from the end of the arc back to the center of the circle */}}
  {{- $pathData = $pathData | append "Z" -}}
  {{/* Join the path data into a single string  */}}
  {{- $pathData = collections.Delimit $pathData " " -}}

  {{- $svgPaths = $svgPaths | append (printf "<path d=\"%s\" fill=\"%s\" />" $pathData $color) -}}

  {{- $verticalOffset := math.Mul (len $legend) 20 -}}
  {{- $roundedPercentage := int $percentage -}}
  {{- $legendItem := printf `
    <g transform="translate(0, %d)">
      <rect width="10" height="10" fill="%s" />
      <text x="15" y="10" font-size="12">%d%%</text>
      <text x="50" y="10" font-size="12">%s</text>
    </g>
  ` $verticalOffset $color $roundedPercentage $label -}}

  {{- $legend = $legend | append $legendItem -}}

  {{- $startAngle = $endAngle -}}
{{- end -}}

{{- $legendHeight := math.Add (math.Mul (len $legend) 20) 10 -}}
{{- $svgWidth := math.Add $size (math.Add $legendWidth 20) }}
{{- $svgHeight := math.Max $legendHeight $size }}

<svg width="{{ $svgWidth }}" height="{{ $svgHeight }}" xmlns="http://www.w3.org/2000/svg">
  <g transform="translate(0, 0)">
    {{- range $svgPaths -}}
      {{- . | safeHTML -}}
    {{- end -}}
  </g>
  <rect x="{{ math.Add $size 10 }}" y="0" width="{{ math.Add $legendWidth 10 }}" height="{{ $legendHeight }}" fill="{{ $legendBg }}" />
  {{- $legendTransform := printf "translate(%d, 10)" (math.Add $size 15) -}}
  <g transform="{{ $legendTransform }}">
    {{- range $legend -}}
      {{- . | safeHTML -}}
    {{- end -}}
  </g>
</svg>

A few pain points:

  • I had to do a lot of printf to emit raw SVG like this from Hugo, to avoid a bunch of ZgotmplZ in the output. From the Hugo docs, “ZgotmplZ is a special value that indicates that unsafe content reached a CSS or URL context at runtime.”
  • I had to keep track of which values were decimal and which were floating point, so that I could printf them properly. When I failed to do this, printf would give me values like %!f(int=1) where a simple 1 was supposed to be.
  • I first was running a Hugo version too old for math.Pi, so I got a bunch of values like %!d(<nil>) or %!f(<nil>) where numbers were supposed to be.

Calling the partial from content

In Hugo, content files cannot call partial templates directly. Instead, you must define shortcodes to call the partial for you. However, shortcodes may only accept string arguments. Therefore, we have to write a shortcode that accepts chart data as a string and breaks it into the slice-of-slices that the partial requires.

We can call it like this from content:

{{< piechart data="60,Oranges,orange;30,Leafy greens,green;10,Eggplant,purple" >}}
shortcodes/piechart.html code
{{/*  Shortcode to call the piechart partial

      Call like this:
      {{< piechart data="60,Oranges,orange;30,Leafy greens,green;10,Eggplant,purple" legendBg="#f0f0f0" size="200" >}}

      The piechart partial expects its data in the form of a slice of slices.
      Shortcodes may only pass string data,
      so we have to accept formatted strings, turn them into slices,
      and then pass them to the partial.
*/}}

{{- $size := .Get "size" }}
{{- $legendBg := .Get "legendBg" }}

{{- $strData := .Get "data" -}}
{{- $sliceData := slice -}}
{{- range $strElement := split $strData ";" -}}
  {{- $splitElement := split $strElement "," -}}
  {{- $sliceElement := slice -}}
  {{- $sliceElement = $sliceElement | append (index $splitElement 0 | int) -}}
  {{- $sliceElement = $sliceElement | append (index $splitElement 1) -}}
  {{- $sliceElement = $sliceElement | append (index $splitElement 2) -}}

  {{/*  This is a workaround for building a slice-of-slices in Hugo
        There's an example of how this is supposed to work in
        <https://github.com/gohugoio/hugo/issues/11004>.

        If we don't have any data in the slice, appending to it will flatten all our sub-slices.
        Once we do have a slice with a single element (a sub-slice),
        we can then append slices to it and they will be nested correctly.
  */}}
  {{- if eq (len $sliceData) 0 -}}
    {{- $sliceData = slice ($sliceElement) -}}
  {{- else -}}
    {{- $sliceData = $sliceData | append $sliceElement -}}
  {{- end -}}

{{- end -}}

{{- partial "piechart.html" (dict "data" $sliceData "size" $size "legendBg" $legendBg) -}}

The only hard part here was figuring out how to programmatically build a slice made of other slices.

A JavaScript implementation

It was helpful as I was writing this to have a working JavaScript implementation. Claude 3.5 Sonnet and ChatGPT both failed to give me a working Hugo partial, but they were both able to give me a working JavaScript function on the first try, and I could compare what my partial was doing to the working JavaScript implementation as I went.

The JavaScript implementation
function generatePieChart(data, size = 200, legendBgColor = 'white') {
  // Validate that percentages sum to 100
  const totalPercentage = Object.values(data).reduce((sum, [percentage]) => sum + percentage, 0);
  if (Math.abs(totalPercentage - 100) > 0.01) {
    throw new Error("Percentages must sum to 100%");
  }

  const radius = size / 2;
  const centerX = radius;
  const centerY = radius;
  let startAngle = -Math.PI / 2; // Start at 12 o'clock
  let svgPaths = [];
  let legend = [];

  // Calculate the maximum width of the label text
  const maxLabelWidth = Math.max(...Object.keys(data).map(label => label.length)) * 7; // Approximate character width
  const legendWidth = maxLabelWidth + 70; // 10 for color swatch, 15 for spacing, 45 for percentage

  for (const [label, [percentage, color]] of Object.entries(data)) {
    const endAngle = startAngle + (percentage / 100) * 2 * Math.PI;

    // Calculate path
    const x1 = centerX + radius * Math.cos(startAngle);
    const y1 = centerY + radius * Math.sin(startAngle);
    const x2 = centerX + radius * Math.cos(endAngle);
    const y2 = centerY + radius * Math.sin(endAngle);

    const largeArcFlag = percentage > 50 ? 1 : 0;

    const pathData = [
      `M ${centerX} ${centerY}`,
      `L ${x1} ${y1}`,
      `A ${radius} ${radius} 0 ${largeArcFlag} 1 ${x2} ${y2}`,
      'Z'
    ].join(' ');

    svgPaths.push(`<path d="${pathData}" fill="${color}" />`);

    // Add to legend
    legend.push(`
      <g transform="translate(0, ${legend.length * 20})">
        <rect width="10" height="10" fill="${color}" />
        <text x="15" y="10" font-size="12">${percentage}%</text>
        <text x="${legendWidth - 5}" y="10" font-size="12" text-anchor="end">${label}</text>
      </g>
    `);

    startAngle = endAngle;
  }
  const legendHeight = Object.keys(data).length * 20 + 10;
  const svgHeight = Math.max(size, legendHeight);
  const svgContent = `
    <svg width="${size + legendWidth + 20}" height="${svgHeight}" xmlns="http://www.w3.org/2000/svg">
      <g transform="translate(0, 0)">
        ${svgPaths.join('\n')}
      </g>
      <rect x="${size + 10}" y="0" width="${legendWidth + 10}" height="${legendHeight}" fill="${legendBgColor}" />
      <g transform="translate(${size + 15}, 10)">
        ${legend.join('\n')}
      </g>
    </svg>
  `;

  // console.log(svgContent.trim());
  return svgContent.trim();
}

const chartData = {
  "Apples": [60, "red"],
  "Bananas": [30, "yellow"],
  "Oranges": [10, "blue"]
};
const svgString = generatePieChart(chartData, 300, "#f0f0f0");
const chartParent = document.querySelector("#javascriptchart");
chartParent.innerHTML = svgString;

What can you do with this?

I want to use this in a future version of my projects list, showing how many projects are complete/abandoned/maintained/etc. (Update: this is now live on the projects page.)

You could also use it to generate SVG output files from site data.

Responses

Webmentions

Hosted on remote sites, and collected here via Webmention.io (thanks!).

Comments

Comments are hosted on this site and powered by Remark42 (thanks!).