Search code examples
jsonchartsvisualizationvega

Attempting to place text marks at the same level on a bar chart


Using this spec I am attempting to create an actual vs budget chart where the values are printed at the same level above the bars. I suspect a better way to do it would involve layers but at the moment I have tried adding a multiple to the maximum y value and placing the text marks there. which works fine when you only have 2 marks but the third mark can't be aligned to top/bottom and thus never renders correctly. The only partial success I have had is setting the y coordinate for the text mark explicitly. and it seems offset is ignored as well.

my end goal here is to produce something like the bullet chart in this video https://www.youtube.com/watch?v=Tc0D0THMI78 but with the text values above the bars... any guidance is greatly appreciated.

  "$schema": "https://vega.github.io/schema/vega/v5.json",
  "description": "Overlapping bar chart showing monthly actual vs budget values for 2024.",
  "width": 550,
  "height": {"signal": "chartHeight"},
  "padding": 5,
  "signals": [
    {
      "name": "maxY3",
      "update": "max(actualExtent[1], budgetExtent[1])*1.4"
    },
    {
      "name": "chartHeight",
      "update": "200"
    }
  ],
  "data": [
    {
      "name": "table",
      "values": [
        {"date": "2024-01-01", "actual": 100000000, "budget": 150000000},
        {"date": "2024-02-01", "actual": 120000000, "budget": 120000000},
        {"date": "2024-03-01", "actual": 140000000, "budget": 130000000},
        {"date": "2024-04-01", "actual": 160000000, "budget": 170000000},
        {"date": "2024-05-01", "actual": 180000000, "budget": 110000000},
        {"date": "2024-06-01", "actual": 220000000, "budget": 210000000},
        {"date": "2024-07-01", "actual": 240000000, "budget": 230000000},
        {"date": "2024-08-01", "actual": 260000000, "budget": 250000000},
        {"date": "2024-09-01", "actual": 280000000, "budget": 220000000},
        {"date": "2024-10-01", "actual": 300000000, "budget": 320000000},
        {"date": "2024-11-01", "actual": 350000000, "budget": 340000000},
        {"date": "2024-12-01", "actual": 370000000, "budget": 360000000}
      ],
      "transform": [
        {
          "type": "formula",
          "expr": "timeParse(datum.date, '%Y-%m-%d')",
          "as": "parsedDate"
        },{
          "type": "formula",
          "expr": "datum.actual-datum.budget",
          "as": "variance"
        },{
          "type": "formula",
          "expr": "datum.budget!=0?datum.variance/datum.budget:0",
          "as": "variancepct"
        },

        {
          "type": "extent",
          "field": "actual",
          "signal": "actualExtent"
        },
        {
          "type": "extent",
          "field": "budget",
          "signal": "budgetExtent"
        }
      ]
    }
  ],
  "scales": [
    {
      "name": "x",
      "type": "time",
      "domain": {"data": "table", "field": "parsedDate"},
      "range": "width"
    },
    {
      "name": "y",
      "type": "linear",
      "domain": [0, {"signal": "maxY3"}],
      "range": "height",
      "nice": true
    }
  ],
  "axes": [
    {
      "orient": "bottom",
      "scale": "x",
      "format": "%b",
      "title": "Month",
      "ticks": true,
      "labelAlign": "center",
      "tickOffset": 20
    },
    {
      "orient": "left",
      "scale": "y",
      "title": "",
      "domain": false,
      "labels": false,
      "grid": false,
      "ticks":false
    }
  ],
  "marks": [
    {
      "type": "rect",
      "from": {"data": "table"},
      "encode": {
        "enter": {
          "x": {"scale": "x", "field": "parsedDate", "offset": 0},
          "width": {"value": 30},
          "y": {"scale": "y", "field": "actual"},
          "y2": {"scale": "y", "value": 0},
          "fill": {"value": "grey"},
          "tooltip": {"signal": "{'Date': timeFormat(datum.parsedDate, '%B'), 'Actual': format(datum.actual, ',')}"}
        }
      }
    },
    {
      "type": "rect",
      "from": {"data": "table"},
      "encode": {
        "enter": {
          "x": {"scale": "x", "field": "parsedDate", "offset": 10},
          "width": {"value": 30},
          "y": {"scale": "y", "field": "budget"},
          "y2": {"scale": "y", "value": 0},
          "fill": {"value": "lightblue"},
          "tooltip": {"signal": "{'Date': timeFormat(datum.parsedDate, '%B'), 'Budget': format(datum.budget, ',')}"}
        }
      }
    },
    {
      "type": "text",
      "from": {"data": "table"},
      "encode": {
        "enter": {
          "x": {"scale": "x", "field": "parsedDate", "offset": 20},
          "y": {"scale": "y", "value": {"signal": "maxY3"}, "offset":0}, 
          "text": {"signal": "[format(datum.actual,'.4s')]"},
          "fontSize": {"value": 14},
          "fill": {"value": "black"},
          "align": {"value": "center"},
          "baseline": {"value": "bottom"}
        }
      }
    },
    {
      "type": "text",
      "from": {"data": "table"},
      "encode": {
        "enter": {
          "x": {"scale": "x", "field": "parsedDate", "offset":20},
          "y": {"scale": "y", "value": {"signal": "maxY3"}, "offset":0}, 
          "text": {"signal": "[format(datum.budget,'.4s')]"},
          "fontSize": {"value": 14},
          "fill": {"value": "black"},
          "align": {"value": "center"},
          "baseline": {"value": "top"} 
        }
      }
    },
    {
      "type": "text",
      "from": {"data": "table"},
      "encode": {
        "enter": {
          "x": {"scale": "x", "field": "parsedDate", "offset":20},
          "y": {"scale": "y", "value": {"signal": "maxY3"}, "offset":0}, 
          "text": {"signal": "[format(datum.variancepct,'.2p')]"},
          "fontSize": {"value": 14},
          "fill": {"value": "black"},
          "align": {"value": "center"},
          "baseline": {"value": "top"} 
        }
      }
    }
  ]
}

After posting the question and making the comment below about best practice, I made the following edits which pretty much get me where I wanted to go at this point but I still would like a critique of the method

{
  "$schema": "https://vega.github.io/schema/vega/v5.json",
  "description": "Overlapping bar chart showing monthly actual vs budget values for 2024.",
  "width": 550,
  "height": {"signal": "chartHeight"},
  "title": "my generic chart title",
  "padding": 5,
  "signals": [{
      "name": "maxY4",
      "update": "max(actualExtent[1], budgetExtent[1])*1.4"
    },{
      "name": "maxY1",
      "update": "max(actualExtent[1], budgetExtent[1])*1.3"
    },
    {
      "name": "maxY2",
      "update": "max(actualExtent[1], budgetExtent[1])*1.2"
    }, {
      "name": "maxY3",
      "update": "max(actualExtent[1], budgetExtent[1])*1.1"
    },
    {
      "name": "chartHeight",
      "update": "200"
    }
  ],
  "data": [
    {
      "name": "table",
      "values": [
        {"date": "2024-01-01", "actual": 100000000, "budget": 150000000},
        {"date": "2024-02-01", "actual": 120000000, "budget": 120000000},
        {"date": "2024-03-01", "actual": 140000000, "budget": 130000000},
        {"date": "2024-04-01", "actual": 160000000, "budget": 170000000},
        {"date": "2024-05-01", "actual": 180000000, "budget": 110000000},
        {"date": "2024-06-01", "actual": 220000000, "budget": 210000000},
        {"date": "2024-07-01", "actual": 240000000, "budget": 230000000},
        {"date": "2024-08-01", "actual": 260000000, "budget": 250000000},
        {"date": "2024-09-01", "actual": 280000000, "budget": 220000000},
        {"date": "2024-10-01", "actual": 300000000, "budget": 320000000},
        {"date": "2024-11-01", "actual": 350000000, "budget": 340000000},
        {"date": "2024-12-01", "actual": 370000000, "budget": 360000000}
      ],
      "transform": [
        {
          "type": "formula",
          "expr": "timeParse(datum.date, '%Y-%m-%d')",
          "as": "parsedDate"
        },{
          "type": "formula",
          "expr": "datum.actual-datum.budget",
          "as": "variance"
        },{
          "type": "formula",
          "expr": "datum.budget!=0?datum.variance/datum.budget:0",
          "as": "variancepct"
        },{
          "type": "formula",
          "expr": "datum.variance>=0 ?1:0",
          "as": "isgreen"
        },

        {
          "type": "extent",
          "field": "actual",
          "signal": "actualExtent"
        },
        {
          "type": "extent",
          "field": "budget",
          "signal": "budgetExtent"
        }
      ]
    }
  ],
  "scales": [
    {
      "name": "x",
      "type": "time",
      "domain": {"data": "table", "field": "parsedDate"},
      "range": "width"
    },
    {
      "name": "y",
      "type": "linear",
      "domain": [0, {"signal": "maxY3"}],
      "range": "height",
      "nice": true
    }
  ],
  "axes": [
    {
      "orient": "bottom",
      "scale": "x",
      "format": "%b",
      "title": "Month",
      "ticks": true,
      "labelAlign": "center",
      "tickOffset": 20
    },
    {
      "orient": "left",
      "scale": "y",
      "title": "",
      "domain": false,
      "labels": false,
      "grid": false,
      "ticks":false
    }
  ],
  "marks": [
    {
      "type": "rect",
      "from": {"data": "table"},
      "encode": {
        "enter": {
          "x": {"scale": "x", "field": "parsedDate", "offset": 0},
          "width": {"value": 30},
          "y": {"scale": "y", "field": "budget"},
          "y2": {"scale": "y", "value": 0},
          "fill": {"value": "grey"},
          "tooltip": {"signal": "{'Date': timeFormat(datum.parsedDate, '%B'), 'Actual': format(datum.actual, ',')}"}
        }
      }
    },
    {
      "type": "rect",
      "from": {"data": "table"},
      "encode": {
        "enter": {
          "x": {"scale": "x", "field": "parsedDate", "offset": 10},
          "width": {"value": 30},
          "y": {"scale": "y", "field": "actual"},
          "y2": {"scale": "y", "value": 0},
          "fill": {"value": "lightblue"},
          "tooltip": {"signal": "{'Date': timeFormat(datum.parsedDate, '%B'), 'Budget': format(datum.budget, ',')}"}
        }
      }
    },
    {
      "type": "text",
      "from": {"data": "table"},
      "encode": {
        "enter": {
          "x": {"scale": "x", "field": "parsedDate", "offset": 20},
          "y": {"scale": "y", "signal": "maxY1", "offset": 0}, 
          "text": {"signal": "[format(datum.actual,'.4s')]"},
          "fontSize": {"value": 12},
          "fill": {"value": "black"},
          "align": {"value": "center"}
        }
      }
    },
    {
      "type": "text",
      "from": {"data": "table"},
      "encode": {
        "enter": {
          "x": {"scale": "x", "field": "parsedDate", "offset":20},
           "y": {"scale": "y", "signal": "maxY2", "offset": 0},
          "text": {"signal": "[format(datum.budget,'.4s')]"},
          "fontSize": {"value": 12},
          "fill": {"value": "black"},
          "align": {"value": "center"}
        }
      }
    },
    {
      "type": "text",
      "from": {"data": "table"},
      "encode": {
        "enter": {
          "x": {"scale": "x", "field": "parsedDate", "offset":20},
          "y": {"scale": "y", "signal": "maxY3", "offset": 0},
          "text": {"signal": "[format(datum.variancepct,'.2p')]"},
          "fontSize": {"value": 12},
          "fill": {"value": "black"},
          "align": {"value": "center"} ,
          "fill": {
            "signal":  "datum.isgreen==1?'green':'red'"
          }
        }
      }
    },
    {
      "type": "text",
      "from": {"data": "table"},
      "encode": {
        "enter": {
          "x": {"scale": "x", "field": "parsedDate", "offset":20},
          "y": {"scale": "y", "signal": "maxY4", "offset": 0},
          "text": {"signal": "[timeFormat(datum.parsedDate, '%b')]"},
          "fontSize": {"value": 12},
          "fontWeight":{"value":"bold"},
          "fill": {"value": "black"},
          "align": {"value": "center"} 
        }
      }
    },
    {
      "type": "text",
      "encode": {
        "enter": {
          "x": {"value": -10},
          "y": {"scale": "y", "signal": "maxY1", "offset": 0}, 
          "text": {"value": "Actual"},
          "fontSize": {"value": 12},
          "fontWeight":{"value":"bold"},
          "fill": {"value": "black"},
          "align": {"value": "right"}
        }
      }
    },
    {
      "type": "text",
      "encode": {
        "enter": {
          "x": {"value": -10},
          "y": {"scale": "y", "signal": "maxY2", "offset": 0}, 
          "text": {"value": "Budget"},
          "fontSize": {"value": 12},
          "fontWeight":{"value":"bold"},
          "fill": {"value": "black"},
          "align": {"value": "right"}
        }
      }
    },
    {
      "type": "text",
      "encode": {
        "enter": {
          "x": {"value": -10},
          "y": {"scale": "y", "signal": "maxY3", "offset": 0}, 
          "text": {"value": "Variance %"},
          "fontWeight":{"value":"bold"},
          "fontSize": {"value": 12},
          "fill": {"value": "black"},
          "align": {"value": "right"}
        }
      }
    },
    {
      "type": "text",
      "encode": {
        "enter": {
          "x": {"value": -10},
          "y": {"scale": "y", "signal": "maxY4", "offset": 0}, 
          "text": {"value": "Month"},
          "fontSize": {"value": 12},
          "fontWeight":{"value":"bold"},
          "fill": {"value": "black"},
          "align": {"value": "right"}
        }
      }
    }
  ]
}

In general is there a better way to do this? How it looks now


Solution

  • You could have fewer text marks by making a dataset but otherwise looks fine.

    {
      "$schema": "https://vega.github.io/schema/vega/v5.json",
      "description": "Overlapping bar chart showing monthly actual vs budget values for 2024.",
      "width": 550,
      "height": {"signal": "chartHeight"},
      "title": "my generic chart title",
      "padding": 5,
      "signals": [
        {"name": "maxY4", "update": "max(actualExtent[1], budgetExtent[1])*1.4"},
        {"name": "maxY1", "update": "max(actualExtent[1], budgetExtent[1])*1.3"},
        {"name": "maxY2", "update": "max(actualExtent[1], budgetExtent[1])*1.2"},
        {"name": "maxY3", "update": "max(actualExtent[1], budgetExtent[1])*1.1"},
        {"name": "chartHeight", "update": "200"}
      ],
      "data": [
        {
          "name": "table",
          "values": [
            {"date": "2024-01-01", "actual": 100000000, "budget": 150000000},
            {"date": "2024-02-01", "actual": 120000000, "budget": 120000000},
            {"date": "2024-03-01", "actual": 140000000, "budget": 130000000},
            {"date": "2024-04-01", "actual": 160000000, "budget": 170000000},
            {"date": "2024-05-01", "actual": 180000000, "budget": 110000000},
            {"date": "2024-06-01", "actual": 220000000, "budget": 210000000},
            {"date": "2024-07-01", "actual": 240000000, "budget": 230000000},
            {"date": "2024-08-01", "actual": 260000000, "budget": 250000000},
            {"date": "2024-09-01", "actual": 280000000, "budget": 220000000},
            {"date": "2024-10-01", "actual": 300000000, "budget": 320000000},
            {"date": "2024-11-01", "actual": 350000000, "budget": 340000000},
            {"date": "2024-12-01", "actual": 370000000, "budget": 360000000}
          ],
          "transform": [
            {
              "type": "formula",
              "expr": "timeParse(datum.date, '%Y-%m-%d')",
              "as": "parsedDate"
            },
            {
              "type": "formula",
              "expr": "datum.actual-datum.budget",
              "as": "variance"
            },
            {
              "type": "formula",
              "expr": "datum.budget!=0?datum.variance/datum.budget:0",
              "as": "variancepct"
            },
            {"type": "formula", "expr": "datum.variance>=0 ?1:0", "as": "isgreen"},
            {"type": "extent", "field": "actual", "signal": "actualExtent"},
            {"type": "extent", "field": "budget", "signal": "budgetExtent"}
          ]
        },
        {
          "name": "headers",
          "values": [
            {"id": 1, "val": "Month"},
            {"id": 2, "val": "Actual"},
            {"id": 3, "val": "Budget"},
            {"id": 4, "val": "Variance %"}
          ]
        }
      ],
      "scales": [
        {
          "name": "x",
          "type": "time",
          "domain": {"data": "table", "field": "parsedDate"},
          "range": "width"
        },
        {
          "name": "y",
          "type": "linear",
          "domain": [0, {"signal": "maxY3"}],
          "range": "height",
          "nice": true
        }
      ],
      "axes": [
        {
          "orient": "bottom",
          "scale": "x",
          "format": "%b",
          "title": "Month",
          "ticks": true,
          "labelAlign": "center",
          "tickOffset": 20
        },
        {
          "orient": "left",
          "scale": "y",
          "title": "",
          "domain": false,
          "labels": false,
          "grid": false,
          "ticks": false
        }
      ],
      "marks": [
        {
          "type": "rect",
          "from": {"data": "table"},
          "encode": {
            "enter": {
              "x": {"scale": "x", "field": "parsedDate", "offset": 0},
              "width": {"value": 30},
              "y": {"scale": "y", "field": "budget"},
              "y2": {"scale": "y", "value": 0},
              "fill": {"value": "grey"},
              "tooltip": {
                "signal": "{'Date': timeFormat(datum.parsedDate, '%B'), 'Actual': format(datum.actual, ',')}"
              }
            }
          }
        },
        {
          "type": "rect",
          "from": {"data": "table"},
          "encode": {
            "enter": {
              "x": {"scale": "x", "field": "parsedDate", "offset": 10},
              "width": {"value": 30},
              "y": {"scale": "y", "field": "actual"},
              "y2": {"scale": "y", "value": 0},
              "fill": {"value": "lightblue"},
              "tooltip": {
                "signal": "{'Date': timeFormat(datum.parsedDate, '%B'), 'Budget': format(datum.budget, ',')}"
              }
            }
          }
        },
        {
          "type": "text",
          "from": {"data": "table"},
          "encode": {
            "enter": {
              "x": {"scale": "x", "field": "parsedDate", "offset": 20},
              "y": {"scale": "y", "signal": "maxY1", "offset": 0},
              "text": {"signal": "[format(datum.actual,'.4s')]"},
              "fontSize": {"value": 12},
              "fill": {"value": "black"},
              "align": {"value": "center"}
            }
          }
        },
        {
          "type": "text",
          "from": {"data": "table"},
          "encode": {
            "enter": {
              "x": {"scale": "x", "field": "parsedDate", "offset": 20},
              "y": {"scale": "y", "signal": "maxY2", "offset": 0},
              "text": {"signal": "[format(datum.budget,'.4s')]"},
              "fontSize": {"value": 12},
              "fill": {"value": "black"},
              "align": {"value": "center"}
            }
          }
        },
        {
          "type": "text",
          "from": {"data": "table"},
          "encode": {
            "enter": {
              "x": {"scale": "x", "field": "parsedDate", "offset": 20},
              "y": {"scale": "y", "signal": "maxY3", "offset": 0},
              "text": {"signal": "[format(datum.variancepct,'.2p')]"},
              "fontSize": {"value": 12},
              "fill": {"signal": "datum.isgreen==1?'green':'red'"},
              "align": {"value": "center"}
            }
          }
        },
        {
          "type": "text",
          "from": {"data": "table"},
          "encode": {
            "enter": {
              "x": {"scale": "x", "field": "parsedDate", "offset": 20},
              "y": {"scale": "y", "signal": "maxY4", "offset": 0},
              "text": {"signal": "[timeFormat(datum.parsedDate, '%b')]"},
              "fontSize": {"value": 12},
              "fontWeight": {"value": "bold"},
              "fill": {"value": "black"},
              "align": {"value": "center"}
            }
          }
        },
        {
          "type": "text",
          "name": "aa",
          "from": {"data": "headers"},
          "encode": {
            "update": {
              "x": {"value": -10},
              "y": {
                "scale": "y",
                "signal": "datum.id == 1?maxY4:datum.id == 2?maxY1:datum.id == 3?maxY2:maxY3"
              },
              "text": {"field": "val"},
              "fontSize": {"value": 12},
              "fontWeight": {"value": "bold"},
              "fill": {"value": "black"},
              "align": {"value": "right"}
            }
          }
        }
      ]
    }