Waterfall Chart

Creating a waterfall chart using Recharts and trying out some data visualization.

June 13, 2023

As part of a take-home technical challenge, I received some data and was tasked with creating a waterfall chart showing the cumulative values of certain categories and subcategories. I’m writing about it here to document what I’ve learned from this experience.

I’ve changed a few bits of data and variable names you’ll see here for privacy purposes.

Features

Technologies used

Pros to this approach

✅ Recharts component library to quickly get started with a simple bar graph.

✅ react-papaparse for parsing CSV data.

✅ Material UI for form components and radio buttons.

✅ Next.js project to keep up with modern tech stack.

Cons to this approach

❌ Customizing the existing Material UI components is not quick - it involves creating themes, which would have been overkill for this project.

Challenges to note

1. Fetching the data

2. Parsing the data

3. Processing the data

4. Calculating previous and cumulative values of each category

5. Calculating the total value for all categories

6. Build the UI

7. Assumptions

1. Fetching the data

Inside the pages/index.tsx file where the Home component lives, below it I use getStaticProps to fetch the csv data from an endpoint. This allows the application to have the data before and components are rendered, and avoid needing to re-rerender after fetching data.

export async function getStaticProps() {
  const dataset1 = await fetch("link-to-file1.csv").then((resp) => resp.text());

  const dataset2 = await fetch("link-to-file2.csv").then((resp) => resp.text());

  return {
    props: { dataset1, dataset2 },
  };
}

This data is immediately available to the Home component:

const Home = ({ dataset1, dataset2 }: HomeProps) => {...}

2. Parsing the data

The dataset returned are each one long string of the categories, subcategories, and their values. Within the Home component I use readString from react-papaparse to convert the string, and return an array of values.

const { readString } = usePapaParse();

const [d1, setD1] = useState<DataObj[]>([]);
const [d2, setD2] = useState<DataObj[]>([]);
const [method, setMethod] = useState<Methods>("Method1");
const [view, setView] = useState<View>("SubCat1");

useEffect(() => {
  const getData = (data: string) =>
    readString(data, {
      complete: (results) => {
        console.log("---------------------------");
        console.log("Results:", results);
        console.log("---------------------------");
      },
      worker: false,
    }) as unknown as ParseResults;

  const _dataset1 = getData(dataset1);
  const _dataset2 = getData(dataset2);

  if (_dataset1 && _dataset2) {
    // remove the first array of arrays which contain the CSV header values
    _dataset1.data.shift();
    _dataset2.data.shift();

    const _D1data = buildDataObj(_dataset1?.data);
    const _D2data = buildDataObj(_dataset2?.data);

    setD1(_D1data);
    setD2(_D2data);
  }
}, []);

3. Processing the data

I thought it would be most useful and meaningful to group the related data into, and return objects containing the name, category, sub category, costs and other values. It would look like the following:

export interface DataObj {
  name: string;
  subCategory: string;
  cost: number;
  prev_cost: number;
  cumulative_cost: number;
  carbonIntensity: number;
  prev_carbonIntensity: number;
  cumulative_carbonIntensity: number;
}

4. Calculating previous and cumulative values of each category

As seen above, the buildDataObj calculates the cumulative and previous values for the cost and carbon intensity. These values are used to create a waterfall effect later in the BarChart.

export const buildDataObj = (data: string[][]): DataObj[] => {
  const _obj: DataObj[] = data.map((item, index) => {
    let prev = data[index - 1];

    return {
      name: item[0],
      subCategory: item[1],

      // if there is a previous item, and it is the same category as the current item,
      // assign prev_cost to the cost of the previous item
      prev_cost: prev && prev[0] === item[0] ? Number(prev[2]) : 0,
      cost: Number(item[2]),

      // if there is a previous item, and it is the same category as the current item,
      // add the cost of the previous item and the current item
      // otherwise the cumulative cost is equal to the current cost
      cumulative_cost:
        prev && prev[0] === item[0]
          ? Number(prev[2]) + Number(item[2])
          : Number(item[2]),

      // if there is a previous item, and it is the same category as the current item,
      // assign prev_carbonIntensity to the carbon intensity of the previous item
      prev_carbonIntensity: prev && prev[0] === item[0] ? Number(prev[3]) : 0,
      carbonIntensity: Number(item[3]),

      // if there is a previous item, and it is the same category as the current item,
      // add the carbon intensity of the previous item and the current item
      // otherwise the cumulative carbon intensity is equal to the current carbon intensity
      cumulative_carbonIntensity:
        prev && prev[0] === item[0]
          ? Number(prev[3]) + Number(item[3])
          : Number(item[3]),
    };
  });
  ...
}

5. Calculating the total value for all categories

getTotal calculates the final total for the two subcategories. The object returned from getTotal is pushed into the _obj placeholder array. The _obj array is returned from buildDataObj.

export const buildDataObj = (data: string[][]): DataObj[] => {
  ...
  ...
  const getTotal = (subCategory: string, value: View) => {
    return _obj
      .map((item) => (item.subCategory === subCategory ? item[value] : 0))
      .reduce((acc, curr) => acc + curr);
  };

  const getSubCat1TotalCost = getTotal("SubCat1", "cost");
  const getSubCat2TotalCost = getTotal("SubCat2", "cost");
  const getSubCat1TotalCarbonIntensity = getTotal("SubCat1", "carbonIntensity");
  const getSubCat2TotalCarbonIntensity = getTotal("SubCat2", "carbonIntensity");

  const subCategoryTotals = [
    {
      name: "SubCat1",
      totalCost: getSubCat1TotalCost,
      totalCarbonIntensity: getSubCat1TotalCarbonIntensity,
    },
    {
      name: "SubCat2",
      totalCost: getSubCat2TotalCost,
      totalCarbonIntensity: getSubCat2TotalCarbonIntensity,
    },
  ];

  const calculatedTotals = [..._obj];

  subCategoryTotals.forEach(({ name, totalCarbonIntensity, totalCost }) =>
    calculatedTotals.push({
      name: "Total",
      subCategory: name,
      cost: totalCost,
      prev_cost: 0,
      cumulative_cost: 0,
      carbonIntensity: totalCarbonIntensity,
      prev_carbonIntensity: 0,
      cumulative_carbonIntensity: 0,
    })
  );

  return calculatedTotals;
}

This returns something like the following array:

[
  {
    name: "Grid power",
    subCategory: "SubCat1",
    prev_cost: 0,
    cost: 0.2887455077,
    cumulative_cost: 0.2887455077,
    prev_carbonIntensity: 0,
    carbonIntensity: 0.4624669544,
    cumulative_carbonIntensity: 0.4624669544,
  },
  {
    name: "Grid power",
    subCategory: "SubCat2",
    prev_cost: 0.2887455077,
    cost: 0.113164276,
    cumulative_cost: 0.4019097837,
    prev_carbonIntensity: 0.4624669544,
    carbonIntensity: 0.7811921769,
    cumulative_carbonIntensity: 1.2436591313,
  },
  ...
  ...{
    name: "Total",
    subCategory: "SubCat1",
    cost: 4.398918355899999,
    prev_cost: 0,
    cumulative_cost: 0,
    carbonIntensity: 2.1240786142,
    prev_carbonIntensity: 0,
    cumulative_carbonIntensity: 0,
  },
  {
    name: "Total",
    subCategory: "SubCat2",
    cost: 2.1838843297999997,
    prev_cost: 0,
    cumulative_cost: 0,
    carbonIntensity: 1.7022823318,
    prev_carbonIntensity: 0,
    cumulative_carbonIntensity: 0,
  },
];

6. Build the UI

Using Material UI and the bar graph using Recharts,

interface OptionsProps {
  title: string;
  value: any;
  list: Option[];
  onChangeHandler: React.Dispatch<SetStateAction<any>>;
}

const Options = ({ title, value, list, onChangeHandler }: OptionsProps) => {
  return (
    <FormControl>
      <h2>{title}</h2>
      <RadioGroup
        aria-labelledby="controlled-radio-buttons-group"
        name="controlled-radio-buttons-group"
        value={value}
        onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
          onChangeHandler(event.target.value)
        }
      >
        {list.map((option) => (
          <FormControlLabel
            value={option.name}
            control={<Radio />}
            label={option.label}
            key={option.name}
          />
        ))}
      </RadioGroup>
    </FormControl>
  );
};

interface WaterfallChartProps {
  view: View;
  method: Methods;
  data: {
    dSet1: DataObj[];
    dSet2: DataObj[];
  };
}

export const GetDataCells = (dataSet: DataObj[]) =>
  dataSet.map((entry, index) => {
    return <Cell fill={COLORS[entry.subCategory] ?? COLORS.none} key={index} />;
  });

const WaterfallChart = ({ view, method, data }: WaterfallChartProps) => {
  return (
    <BarChart
      width={1100}
      height={500}
      data={data[method]}
      margin={{
        top: 20,
      }}
    >
      <CartesianGrid strokeDasharray="3 3" />
      <XAxis dataKey="name" />
      <YAxis />
      <Tooltip contentStyle={{ color: "black" }} />
      <Legend />
      <>
        <Bar dataKey={`prev_${view}`} stackId="a" fill="transparent" />
        <Bar dataKey={view} name={view} stackId="a" fill={COLORS[view]}>
          {GetDataCells(data[method])}
        </Bar>
      </>
      {SubCategories.map((category) => (
        <Bar
          dataKey={category.name}
          stackId="a"
          fill={COLORS[category.name]}
          fillOpacity={0}
          name={category.label}
          key={category.name}
        />
      ))}
    </BarChart>
  );
};

Finally in the Home component we return the following:

return (
  <Stack
    direction="row"
    justifyContent="center"
    alignItems="center"
    gap={2}
    paddingTop={10}
    className={inter.className}
  >
    <Stack
      direction="column"
      justifyContent="flex-start"
      alignItems="center"
      gap={2}
    >
      <Stack spacing={2} width={200}>
        <Options
          title="Methods"
          value={method}
          list={MethodsList}
          onChangeHandler={setMethod}
        />
      </Stack>
      <Stack spacing={2} width={200}>
        <Options
          title="Subcategories"
          value={view}
          list={ViewOptions}
          onChangeHandler={setView}
        />
      </Stack>
    </Stack>
    <Stack direction="column">
      <h1>Hydrogen Production</h1>
      <WaterfallChart
        view={view}
        method={method}
        data={{ dSet1: d1, dSet2: d2 }}
      />
    </Stack>
  </Stack>
);

7. Assumptions

In the interest of time, the code assumes only Cost and Carbon Intensity are the only options available. Further refactoring would be required if other options are added.