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!
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 ofZgotmplZ
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 simple1
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
The function:
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 colorSwatchWidth = 10;
const legendSpacing = 15;
const legendTextWidth = 45; // Approximate width of XX% text
const legendWidth = maxLabelWidth + colorSwatchWidth + legendSpacing + legendTextWidth;
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="${colorSwatchWidth}" height="${colorSwatchWidth}" fill="${color}" />
<text x="${legendSpacing}" 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();
}
Call the function in this page:
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.