Skip to content

Bar Chart Race

Adapted from Bar Chart Race, Explained by Mike Bostock.

js
// SPDX-License-Identifier: Apache-2.0
// Copyright : J.P. Morgan Chase & Co.

import * as anu from '@jpmorganchase/anu';
import * as d3 from 'd3';
import { Scene, HemisphericLight, ArcRotateCamera, StandardMaterial, Vector3, Color3 } from '@babylonjs/core';
import * as gui from '@babylonjs/gui';
import data from './data/category-brands.json' assert {type: 'json'}; //Data from https://interbrand.com/

//Create and export a function that takes a Babylon engine and returns a Babylon Scene
export function animationBarChartRace(engine) {

  //Babylon boilerplate
  const scene = new Scene(engine);
  const light = new HemisphericLight('light1', new Vector3(0, 10, 0), scene);
  light.intensity = 2;
  const camera = new ArcRotateCamera("Camera", -Math.PI / 2, Math.PI / 2, 3, new Vector3(1.5, 1, 0), scene);
  camera.wheelPrecision = 20;
  camera.minZ = 0;
  camera.attachControl(true);

  //Create the chart's Center of Transformation
  let CoT = anu.create("cot", "cot");
  let chart = anu.selectName("cot", scene);

  //Transform our data in the correct format
  //Based on and adapted from https://observablehq.com/@d3/bar-chart-race-explained by Mike Bostock
  let names = new Set(data.map(d => d.name));

  let datevalues = Array.from(d3.rollup(data, ([d]) => d.value, d => d.date, d => d.name))
    .map(([date, data]) => [new Date(date), data])
    .sort(([a], [b]) => d3.ascending(a, b))

  let topN = 12;  //How many companies to show at a time

  function rank(value) {
    const data = Array.from(names, name => ({name, value: value(name)}));
    data.sort((a, b) => d3.descending(a.value, b.value));
    for (let i = 0; i < data.length; ++i) data[i].rank = Math.min(topN, i);
    return data;
  }

  let keyframes = [];
  let k = 10;     //How many timesteps per year, higher number results in smoother animation
  let ka, a, kb, b;
  for ([[ka, a], [kb, b]] of d3.pairs(datevalues)) {
    for (let i = 0; i < k; ++i) {
      const t = i / k;
      keyframes.push([
        new Date(ka * (1 - t) + kb * t),
        rank(name => (a.get(name) || 0) * (1 - t) + (b.get(name) || 0) * t)
      ]);
    }
  }
  keyframes.push([new Date(kb), rank(name => b.get(name) || 0)]);
  //In our case, sort the companies alphabetically so that the n-th company in the list is always the same one
  keyframes.forEach(kf => kf[1] = kf[1].sort((a, b) => d3.ascending(a.name, b.name)));

  
  //Create D3 scales
  let scaleX = d3.scaleLinear().domain([0, Math.max(...keyframes[0][1].map(d => d.value))]).range([0, 3]);
  let scaleY = d3.scaleBand().domain(d3.range(topN + 1)).paddingInner(0.3).range([2, 0]);
  let scaleC = (d) => {
    const scale = d3.scaleOrdinal(d3.schemeTableau10);
    if (data.some(d => d.category !== undefined)) {
      const categoryByName = new Map(data.map(d => [d.name, d.category]))
      scale.domain(Array.from(categoryByName.values()));
      return scale(categoryByName.get(d.name));
    }
    return scale(d.name);
  };

  //Bind our boxes and labels for the very first keyframe
  let bars = chart.bind('box', { width: 1, height: 0.15, depth: 0.01 }, keyframes[0][1])
                  .positionX((d,n,i) => scaleX(d.value) / 2)
                  .scalingX((d,n,i) => scaleX(d.value))
                  .positionY((d,n,i) => scaleY(d.rank))
                  .material((d,n,i) => new StandardMaterial(d.name + 'Mat'))
                  .prop('material.alpha', (d,n,i) => (d.rank) < topN ? 1 : 0)   //Companies not in the top N are transparent
                  .diffuseColor((d,n,i) => Color3.FromHexString(scaleC(d)));

  let labels = chart.bind('planeText', { text: "0", size: 0.1, align: "right"}, keyframes[0][1])
                    .positionX((d,n,i) => scaleX(d.value) - 0.04)   //Offsets to neatly place bar label
                    .positionY((d,n,i) => scaleY(d.rank) - 0.0285)
                    .positionZ(-0.011); //Move slightly in-front of the box

  //Customize and create our the axes
  let axesOptions = new anu.AxesConfig({ x: scaleX, y: scaleY });
  axesOptions.parent = chart;
  axesOptions.grid.y = false;
  axesOptions.domainMaterialOptions = { width: 0.01 };
  axesOptions.background.x = false;
  axesOptions.background.y = false;
  axesOptions.labelFormat.x = (v) => Number(v.toFixed(0)).toLocaleString();
  axesOptions.labelMargin.x = -0.125;
  axesOptions.label.y = false;
  let axes = anu.createAxes('axes', scene, axesOptions);

  //Label for the current year at the bottom right
  let yearLabel = anu.createPlaneText('yearLabel', { text: "0", size: 0.4, parent: chart });
  yearLabel.position = new Vector3(2.7, 0.1, 0);

  let timestep = 0;     //Incremental counter to iterate through the keyframe array
  let interval = 250;   //Time betwen keyframes in milliseconds

  nextTimestep();

  function nextTimestep() {
    //Stop at the end of the dataset
    if (timestep >= keyframes.length) {
      return;
    }

    //Recreate our scale to account for this new timestep's ranges
    scaleX = d3.scaleLinear().domain([0, Math.max(...keyframes[timestep][1].map(d => d.value))]).range([0, 3]);

    //Animate our bars
    bars.prop("metadata.data", (d,n,i) => keyframes[timestep][1][i])  //Bind new data to the Meshes
      .transition((d,n,i) => ({
        duration: interval,
        onAnimationEnd: () => {         //When the animation ends, call this function again to begin the animation for the next year
          if (i == 0)                   //This callback is run for every Mesh in the selection, so the easiest way to have this be run
              nextTimestep(timestep++); //only once is to put the conditional you see here (i.e., the first Mesh in the selection)
        }}))
      .tween((d,n,i) => {               //We use tween() here as it gives us finer grain control of the animation in each frame during the transition
        //Create D3 interpolators to help tween between start and end values
        let posXTween = d3.interpolateNumber(n.position.x, scaleX(d.value) / 2);
        let scaleXTween = d3.interpolateNumber(n.scaling.x, scaleX(d.value));
        let posYTween = d3.interpolateNumber(n.position.y, scaleY(d.rank));
        let alphaTween = d3.interpolateNumber(n.material.alpha, (d.rank) < topN ? 1 : 0);  //Companies not in the top N are transparent

        //We have to return a function with t as an argument that will actually modify the Mesh, in this case using our D3 interpolators
        //This function will be called every frame until the animation finishes, where t starts at 0 and ends at 1
        return (t) => {
          n.position.x = posXTween(t);
          n.position.y = posYTween(t);
          n.scaling.x = scaleXTween(t);
          n.material.alpha = alphaTween(t);
        }
      });
    
    //Animate the labels
    labels.prop("metadata.data", (d,n,i) => keyframes[timestep][1][i])
      .transition((d,n,i) => ({ duration: interval }))
      .tween((d,n,i) => {
        let textTween = d3.interpolateNumber(Number(n.text.split('\n').pop().replace(',', '')), d.value);
        let posXTween = d3.interpolateNumber(n.position.x, scaleX(d.value) - 0.04);   //Offsets to neatly place bar label
        let posYTween = d3.interpolateNumber(n.position.y, scaleY(d.rank) - 0.0285);
        let alphaTween = d3.interpolateNumber(n.opacity, (d.rank) < topN ? 1 : 0);

        return (t) => {
          n.position.x = posXTween(t);
          n.position.y = posYTween(t);
          //Updating text is rather expensive since text vertices need to be calculated and redrawn each update, especially if we are doing
          //this every frame, therefore we only do it for those in the top N
          if (d.rank < topN) {
            n.isVisible = true;
            n.updatePlaneText({ text: d.name + "\n" + Number(textTween(t).toFixed(0)).toLocaleString(), opacity: alphaTween(t) });
          }
          else {
            n.isVisible = false;
          }
        }
      });

    //Update the year
    yearLabel.text = keyframes[timestep][0].getFullYear();
    
    //Update our axes with our new scaleX object which has the up-to-date domain
    axesOptions.scale.x = scaleX;
    axes.updateAxes(axesOptions, { duration: interval });   //Second param is transition options, setting a duration will interpolate axis changes
  }

  //Create a button to restart the race
  let advancedTexture = gui.AdvancedDynamicTexture.CreateFullscreenUI("UI");
  let button = gui.Button.CreateSimpleButton("restartButton", "Restart");
  button.width = 0.1;
  button.height = "40px";
  button.cornerRadius = 2;
  button.color = "white";
  button.thickness = 4;
  button.background = "blue";
  button.top = "45%";
  button.left = "42.5%";
  button.onPointerClickObservable.add(() => {
    //If the keyframes have been exhausted, we need to call nextTimestep() again
    if (timestep >= keyframes.length) {
      timestep = 0;
      nextTimestep();
    }
    //Otherwise, we can set this and then wait for nextTime() to be called organically
    else {
      timestep = -1;
    }
  });
  advancedTexture.addControl(button);
  
  
  return scene;
}