Labelled Donut Chart Example

A donut chart with non overlapping labels using native Vega transforms.

This Vega example made by David Bacci @PBI-David.

Vega JSON Specification <>

{
  "$schema": "https://vega.github.io/schema/vega/v6.json",
  "description": "Donut with non-overlapping labels.",
  "width": 200,
  "height": 200,
  "autosize": "pad",
  "padding": 30,
  "signals": [
    {
      "name": "startAngle",
      "value": 0,
      "bind": {"input": "range", "min": 0, "max": 6.29, "step": 0.01}
    },
    {
      "name": "sortField",
      "update": "''",
      "description": "Sort field if desired"
    },
    {
      "name": "sortOrder",
      "update": "''",
      "description": "Sort order if desired (ascending or descending)"
    },
    {
      "name": "labelFontSize",
      "init": "12",
      "description": "The font size used for labels"
    },
    {
      "name": "labelHeight",
      "update": "labelFontSize",
      "description": "This is approximately the same as the label font size but can be adjusted if you want extra padding"
    },
    {"name": "innerRadius", "update": "60"},
    {"name": "outerRadius", "update": "100"},
    {
      "name": "counter",
      "description": "Counter used to loop through an array",
      "value": 0,
      "on": [
        {
          "events": {"type": "timer", "throttle": 0},
          "update": "counter<length(data('labelPositions'))?counter + 1:counter"
        },
        {"events": {"signal": "leftRightCount"}, "update": "0"}
      ]
    },
    {
      "name": "shiftArray",
      "description": "An array of shift positions",
      "update": "{right: leftRightCount.right!=0? pluck(data('labelPositions')[0]['shiftArray'],'shift'):[], left: leftRightCount.left!=0? pluck(data('labelPositions')[leftRightCount.right]['shiftArray'],'shift'):[]}"
    },
    {
      "name": "leftRightCountArray",
      "description": "Array of label sides",
      "update": "reverse(sort( pluck( data('labelPositions'), 'side')))"
    },
    {
      "name": "leftRightCount",
      "description": "Count of  label sides",
      "update": "{right:lastindexof(leftRightCountArray,'right')+1,left:length(leftRightCountArray)- (lastindexof(leftRightCountArray,'right')+1)}"
    },
    {
      "name": "p1",
      "description": "Used to loop through position array one element at a time with a running sum but reseting the counter to 0 if it ever goes negative",
      "value": {"right": 0, "left": 0},
      "on": [
        {
          "events": {"signal": "leftRightCount"},
          "update": "{'right': 0, 'left': 0}"
        },
        {
          "events": {"signal": "counter"},
          "update": "{right:shiftArray.right[counter-1]+p1.right<0?0:shiftArray.right[counter-1]+p1.right, left:shiftArray.left[counter-1]+p1.left<0?0:shiftArray.left[counter-1]+p1.left}",
          "force": true
        }
      ]
    },
    {
      "name": "p2",
      "description": "Used to reassemble the position array as a string with final shift positions",
      "value": {"right": [], "left": []},
      "on": [
        {
          "events": {"signal": "leftRightCount"},
          "update": "{'right': [], 'left': []}"
        },
        {
          "events": {"signal": "p1"},
          "update": "{right:length(p2.right)!=0? p1.right+','+p2.right:p1.right,left:length(p2.left)!=0? p1.left+','+p2.left:p1.left}"
        }
      ]
    },
    {
      "name": "shiftArrayRunning",
      "description": "Converts a string to an array and reverses",
      "update": "{right:reverse(split(p2.right,',')),left:reverse(split(p2.left,','))}"
    }
  ],
  "data": [
    {
      "name": "table",
      "values": [
        {"id": "United States", "value": 1},
        {"id": "France", "value": 1},
        {"id": "Germany", "value": 1},
        {"id": "Italy", "value": 1},
        {"id": "UK", "value": 1},
        {"id": "Canada", "value": 10},
        {"id": "China", "value": 3},
        {"id": "India", "value": 7},
        {"id": "Argentina", "value": 8}
      ],
      "transform": [
        {
          "type": "collect",
          "sort": {
            "field": {"signal": "sortField"},
            "order": {"signal": "sortOrder"}
          }
        },
        {
          "type": "pie",
          "field": "value",
          "startAngle": {"signal": "startAngle"},
          "endAngle": {"signal": "round((2*PI)*10000)/10000+startAngle"}
        },
        {
          "type": "formula",
          "as": "middleAngle",
          "expr": " (((datum.endAngle - datum.startAngle)/2) + datum.startAngle)-PI/2"
        },
        {
          "type": "formula",
          "as": "side",
          "expr": "datum.middleAngle + PI/2  <= PI || datum.middleAngle + PI/2 >= 2*PI && datum.middleAngle + PI/2 <= PI*3?'right':'left'"
        },
        {
          "type": "formula",
          "as": "x1",
          "expr": " (outerRadius *cos(datum.middleAngle))+width/2 "
        },
        {
          "type": "formula",
          "as": "y1",
          "expr": " (outerRadius*sin(datum.middleAngle))+width/2 "
        },
        {
          "type": "formula",
          "as": "x2",
          "expr": " ((outerRadius+10)*cos(datum.middleAngle))+width/2 "
        },
        {
          "type": "formula",
          "as": "y2",
          "expr": " ((outerRadius+10)*sin(datum.middleAngle))+width/2 "
        },
        {
          "type": "formula",
          "as": "x3",
          "expr": "datum.side== 'right'?outerRadius+(width/2)+20:(width/2)-outerRadius-20"
        },
        {"type": "formula", "as": "bin", "expr": "0"}
      ]
    },
    {
      "name": "leftExt",
      "source": ["table"],
      "transform": [
        {"type": "filter", "expr": "datum.side=='left'"},
        {"type": "extent", "field": "y2", "signal": "leftExt"}
      ]
    },
    {
      "name": "rightExt",
      "source": ["table"],
      "transform": [
        {"type": "filter", "expr": "datum.side=='right'"},
        {"type": "extent", "field": "y2", "signal": "rightExt"}
      ]
    },
    {
      "name": "labelSequenceRight",
      "transform": [
        {
          "type": "sequence",
          "start": {"signal": "rightExt[0]"},
          "stop": {"signal": "(rightExt[1]+labelHeight)"},
          "step": {"signal": "labelHeight"},
          "as": "bin"
        },
        {"type": "formula", "expr": "'right'", "as": "side"}
      ]
    },
    {
      "name": "labelSequenceLeft",
      "transform": [
        {
          "type": "sequence",
          "start": {"signal": "leftExt[0]"},
          "stop": {"signal": "(leftExt[1]+labelHeight)"},
          "step": {"signal": "labelHeight"},
          "as": "bin"
        },
        {"type": "formula", "expr": "'left'", "as": "side"}
      ]
    },
    {
      "name": "labelBinsRight",
      "source": ["rightExt", "labelSequenceRight"],
      "transform": [
        {
          "type": "bin",
          "field": "y2",
          "step": {"signal": "labelHeight"},
          "extent": {"signal": "rightExt"},
          "interval": false,
          "as": ["binTemp", "bin1"],
          "nice": false
        },
        {
          "type": "formula",
          "expr": "datum.bin==0?datum.binTemp:datum.bin",
          "as": "bin"
        }
      ]
    },
    {
      "name": "labelBinsLeft",
      "source": ["leftExt", "labelSequenceLeft"],
      "transform": [
        {
          "type": "bin",
          "field": "y2",
          "step": {"signal": "labelHeight"},
          "extent": {"signal": "leftExt"},
          "interval": false,
          "as": ["binTemp", "bin1"],
          "nice": false
        },
        {
          "type": "formula",
          "expr": "datum.bin==0?datum.binTemp:datum.bin",
          "as": "bin"
        }
      ]
    },
    {
      "name": "labelPositions",
      "source": ["labelBinsLeft", "labelBinsRight"],
      "transform": [
        {
          "type": "joinaggregate",
          "ops": ["count"],
          "as": ["count"],
          "groupby": ["side", "bin"]
        },
        {"type": "filter", "expr": "datum.value != null || datum.count == 1"},
        {"type": "formula", "expr": "datum.count-1", "as": "count"},
        {
          "type": "collect",
          "sort": {
            "field": ["side", "bin"],
            "order": ["descending", "ascending"]
          }
        },
        {
          "type": "window",
          "sort": {"field": ["bin", "y2"], "order": ["ascending", "ascending"]},
          "ops": ["row_number"],
          "as": ["index"],
          "groupby": ["side"]
        },
        {
          "type": "window",
          "sort": {"field": "index", "order": "ascending"},
          "ops": ["row_number"],
          "groupby": ["side", "bin"]
        },
        {
          "type": "formula",
          "expr": "datum.value==0?0:datum.row_number",
          "as": "row_number"
        },
        {
          "type": "formula",
          "expr": "datum.value==null?-labelHeight:datum.row_number==1?0:labelHeight",
          "as": "shift"
        },
        {
          "type": "collect",
          "sort": {
            "field": ["side", "index"],
            "order": ["descending", "ascending"]
          }
        },
        {
          "type": "joinaggregate",
          "ops": ["values"],
          "as": ["shiftArray"],
          "fields": ["shift"],
          "groupby": ["side"]
        }
      ]
    },
    {
      "name": "labelPositionsFinal",
      "source": ["labelPositions"],
      "transform": [
        {
          "type": "collect",
          "sort": {
            "field": ["side", "index"],
            "order": ["descending", "ascending"]
          }
        },
        {
          "type": "formula",
          "expr": "datum.side=='right'?toNumber(shiftArrayRunning.right[datum.index-1]):toNumber(shiftArrayRunning.left[datum.index-1])",
          "as": "shiftArrayRunning"
        },
        {
          "type": "formula",
          "expr": "datum.shiftArrayRunning+datum.bin",
          "as": "binShifted"
        },
        {
          "type": "formula",
          "as": "x4",
          "expr": "datum.side== 'right'?outerRadius+(width/2)+25:(width/2)-outerRadius-25"
        },
        {
          "type": "window",
          "sort": {"field": "index", "order": "ascending"},
          "ops": ["lead"],
          "fields": ["shift"],
          "groupby": ["side"]
        },
        {
          "type": "formula",
          "as": "y4",
          "expr": "datum.y2>datum.binShifted&&datum.lead_shift<0?datum.y2:datum.binShifted"
        },
        {
          "type": "formula",
          "as": "labelPath",
          "expr": "datum.value==null?'': 'M '+ datum.x1 + ' ' + datum.y1 + 'L'+ datum.x2 + ' ' + datum.y2 + ' H'+ datum.x3  +'L '+ datum.x4 + ' ' + datum.y4 "
        }
      ]
    }
  ],
  "scales": [
    {
      "name": "color",
      "type": "ordinal",
      "domain": {"data": "table", "field": "id"},
      "range": {"scheme": "category20"}
    }
  ],
  "marks": [
    {
      "type": "arc",
      "from": {"data": "table"},
      "encode": {
        "enter": {
          "fill": {"scale": "color", "field": "id"},
          "x": {"signal": "width / 2"},
          "y": {"signal": "height / 2"},
          "stroke": {"value": "white"}
        },
        "update": {
          "startAngle": {"field": "startAngle"},
          "endAngle": {"field": "endAngle"},
          "innerRadius": {"signal": "innerRadius"},
          "outerRadius": {"signal": "outerRadius"}
        }
      }
    },
    {
      "type": "path",
      "name": "labelPath",
      "from": {"data": "labelPositionsFinal"},
      "encode": {
        "update": {
          "strokeWidth": {"value": 1},
          "path": {"field": "labelPath"},
          "stroke": {"value": "grey"},
          "opacity": {"value": 0.7}
        }
      }
    },
    {
      "type": "rect",
      "from": {"data": "labelPositionsFinal"},
      "description": "Debug boxes. Opacity is set to zero but can be changed to better understand how the bins are being layed out",
      "encode": {
        "update": {
          "x": {"field": "x4"},
          "y": {"field": "y4"},
          "width": {"value": 20},
          "height": {"signal": "labelHeight"},
          "strokeWidth": {"value": 2},
          "stroke": {"value": "grey"},
          "fill": {"value": "#4682b4"},
          "opacity": {"signal": "datum.value==null?0:0"}
        }
      }
    },
    {
      "type": "text",
      "name": "labels",
      "from": {"data": "labelPositionsFinal"},
      "encode": {
        "update": {
          "text": {"field": "id"},
          "opacity": {"value": 0.6},
          "fill": {"value": "black"},
          "baseline": {"value": "middle"},
          "align": {"signal": "datum.side=='left'?'right':'left'"},
          "dx": {"signal": "datum.side=='left'?-3:3"},
          "x": {"field": "x4"},
          "y": {"field": "y4"},
          "fontSize": {"signal": "labelFontSize"}
        }
      }
    }
  ]
}