loading...

I built a COVID-19 Visualisation with D3 and React Hooks

szenius profile image Sze Ying 🌻 Updated on ・4 min read

One Saturday morning after Singapore's Circuit Breaker began, I woke up thinking about this COVID19 visualisation. And I couldn't stop thinking about it. So I decided to build it.

Demo of COVID-19 Graph

I started with the crux of the project — the visualisation. The following is based on Singapore's COVID-19 cases dataset.

// App.jsx
import data from './data/graph.json';

const App = () => {
  const {nodes, links} = data;
  return (
    <div className="app">
      <Visualisation nodes={nodes} links={links} />
    </div>
  );
};

export default App;
// components/Visualisation.jsx
export const Visualisation = ({nodes, links}) => {
  const vizContainer = useRef(null);
  const width = 2048;
  const height = 1024;
  useEffect(() => {
    if (vizContainer.current) {
      const simulation = createSimulation({nodes, links});
      d3.select(vizContainer.current)
        .attr('viewBox', [-width / 2, -height / 2, width, height])
        .call((svg) => draw(svg, simulation, {nodes, links}));
    }
  }, [nodes, links]);
  return <svg ref={vizContainer} />;
};

To be honest, I am still struggling to understand how the following D3 code works. I didn't find many full JavaScript resources online (most were Observable notebooks) so I had to trial and error to piece everything together :")

// helpers/visualisation.js
export const createSimulation = ({nodes, links}) => {
  return d3
    .forceSimulation(nodes)
    .force(
      'link',
      d3.forceLink(links).id((d) => d.id),
    )
    .force('charge', d3.forceManyBody())
    .force('x', d3.forceX())
    .force('y', d3.forceY());
};

export const draw = (svg, simulation, {nodes, links}) => {
  // Defining links
  const link = svg
    .append('g')
    .selectAll('line')
    .data(links)
    .join('line')
    .attr('stroke', 'grey')
    .attr('stroke-opacity', 0.6)
    .attr('stroke-width', 3);

  // Defining nodes
  const node = svg
    .selectAll('.node')
    .data(nodes)
    .enter()
    .append('g')
    .attr('class', 'node')
    .call(drag(simulation)); // drag is some helper fn

  // Display nodes as images
  node
    .append('image')
    .attr('xlink:href', (d) => getImage(d)) // getImage is some helper fn
    .attr('x', -20)
    .attr('y', -20)
    .attr('width', (d) => (d.id.startsWith('Case') ? 50 : 100))
    .attr('height', (d) => (d.id.startsWith('Case') ? 50 : 100));

  // Add labels to the title attribute
  node.append('title').text((d) => d.label);
  link.append('title').text((d) => d.label);

  // This part updates the visualisation based on the current state
  // of where the nodes and links are. 
  simulation.on('tick', () => {
    link
      .attr('x1', (d) => d.source.x)
      .attr('y1', (d) => d.source.y)
      .attr('x2', (d) => d.target.x)
      .attr('y2', (d) => d.target.y);

    node.attr('transform', (d) => {
      return 'translate(' + d.x + ',' + d.y + ')';
    });
  });

  return svg.node();
};

After I was done with this part, I slacked off for two weeks. During these two weeks, Singapore's number of COVID19 cases shot up by thousands. With the large amount of data, my visualisation became incredibly slow. In fact, my laptop hanged 90% of the times I tried to load it.

For it to be usable again, I decided to add a filter component to filter the dataset by case number. For example, a filter value of 1000 would mean that only cases 1 to 1000 and their associated cluster nodes will be displayed.

I chose to use the react-rangeslider library. This might not be the best UX decision since the visualisation struggles with its load time, and a typical slider would mean multiple reloading while it is being dragged. Truth to be told, I thought a slider would be cool, and was too lazy to change it after realising it might be bad UX.

Anyway, to avoid the whole app freezing up due to the multiple reloading, I added logic to reload the visualisation only when the slider was no longer actively being dragged.

To pass the filter state around, I used React Hooks and Context API. Here's the comprehensive guide that I followed for this.

// components/CaseFilterSlider.jsx
export const SLIDER_MIN = 1;
export const SLIDER_MAX = 3000;

export const CaseFilterSlider = () => {
  const {state, dispatch} = useContext(store);
  const caseNum = state.caseNum;
  return (
    <div className="slider">
      <Slider
        min={SLIDER_MIN}
        max={SLIDER_MAX}
        value={caseNum}
        onChangeStart={() => {
          // store in our state that the slider is now active
          dispatch({type: SET_SLIDER_START});
        }}
        onChange={(value) => {
          // update the case number filter value in our store
          dispatch({type: SET_CASE_NUM, payload: {caseNum: value}});
        }}
        onChangeComplete={() => {
          // store in our state that the slider is now inactive
          dispatch({type: SET_SLIDER_COMPLETE});
        }}
      />
      Displaying {caseNum} cases
    </div>
  );
};
// updated components/App.jsx
import data from './data/graph.json';

const App = () => {
  const {caseNum, slider} = useContext(store).state;
  const [nodes, setNodes] = useState({});
  const [links, setLinks] = useState({});
  useEffect(() => {
    // slider is a boolean value to check if the slider was currently
    // active. This prevents a reloading of the viz before the slider
    // has reached its final value.
    if (!slider) {
      // filterData is a helper fn to filter out nodes and links 
      // relevant to the current filter value
      const filteredData = filterData(data, caseNum);
      setNodes(filteredData.nodes);
      setLinks(filteredData.links);
    }
  }, [caseNum, slider]);
  return (
    <div className="app">
      <h2>COVID19 - Singapore</h2>
      <Visualisation nodes={nodes} links={links} />
      <CaseFilterSlider />
    </div>
  );
};

export default App;

And that's all for the main logic of this mini project! It is still unusable for the full dataset — Singapore has 12,693 cases at the time of writing — so I defined SLIDER_MAX to only 3000. Perhaps an optimisation to cater for the large dataset could be to retain the old positions of nodes and links when reloading the visualisation. This could reduce computation time of the node and link positions when reloading the visualisation.

The full source code and dataset can be found here. Here's the live demo of this project.

Posted on Apr 26 by:

szenius profile

Sze Ying 🌻

@szenius

Hi I am a Software Engineer and I don't wear the same grey shirt to work every day

Discussion

markdown guide