Home

Data Vis For React Devs

Resources

  1. Observable HQ @ d3
  2. FE Masters Course
  3. Data Wrapper Academy - chloropleth maps
  4. Course Slides
  5. MDN Path documentation
  6. SVG Explorations - Observable
  1. Data Vis for React Devs
  2. Intro to D3
  3. Building Custom Data Visualations

[3] builds on top of [2] and these notes are based on [1].

[3] will talk more about the Gestalt laws and how to design the visualisations.

Intro To Different Data Types

There are a number of data types:

  1. Categorical (genres)
  2. Ordinal (t-shirt sizes)
  3. Quantitative (temperatures)
  4. Temporal (dates)
  5. Spatial (cities)

The basic charts and when to use them:

  1. Bar chart: for categorial comparisons. The domain is categorial and the range is quantitative.
  2. Histogram: for categorial distributions. Domain is quantitative bins and range is the frequency of quantitative bin. Example: x-axis is movie scores and height is number of movies that fall into that score.
  3. Scatter plots: for correlation. 2 attributes and the relationship between their quantitative values.
  4. Line chart: for temporal trends. Domain is temporal and range is quantitative.
  5. Tree: For hierarchy, parent-child relationships, multiple tiers of category. Unidirectional.
  6. Node-link diagram: For connection. Shows the relationship between entities. Cyclical.
  7. Chlorodepth: For spatial trends. Domain: spatial regions. Range: Quantitative. Not good for subtle differences in data.
  8. Pie charts: used sparingly. For hierarchical part-to-whole. Best for: When values are around 25%, 30% or 75% (3 or 4 values). Not good for comparing fine differences and multiple totals.

Intro to SVG

SVG is really good at drawing shapes onto the sreen. The most used SVG elements are rect, circle, text and path.

The SVG co-ordinate starts with (0,0) at the top-left and the numbers in crease for X to the right or down when talking about Y.

In the three charts the course makes (bar chart, line chart, radial chart), Susie explains that the bar chart uses <rect/> elements, the line chart uses <path/> elements and the radial chart uses <path/> elements.

Data to SVG Shapes

The work here is to look at how some elements are made through the Observable notebook.

D3 is a tool that helps us take data to SVG without the difficulty.

People can be intimidated by the size of the API. There is an API slide you can see here that breaks down the API modules.

Scales

Mapping from data attributes (domain) to display (range).

d3.linearScale() .domain([min, max]) // input .range([min, max]); // output

An example of taking the data and scaling by fetching min/max:

var width = 800; var height = 600; var data = [ { date: new Date('01-01-2015'), temp: 0 }, { date: new Date('01-01-2017'), temp: 3 }, ]; var min = d3.min(data, d => d.date); var max = d3.max(data, d => d.date); // or use extent, which gives back [min, max] const [min, max] = d3.extent(data, d => d.date); var xScale = d3 .scaleTime() .domain([min, max]) .range([0, width]); var yScale = d3 .scaleLinear() .domain([min, max]) .range([height, 0]); // to account for 0,0 viewbox

Which scale to use and when:

TypeDomainRangeScale
QuantitativeContinuousContinuousscaleLinear
QuantitativeContinuousContinuousscaleLog
QuantitativeContinuousContinuousscaleTime
QuantitativeContinuousDiscretescaleQuantize
CategorialDiscreteDiscretescaleOrdinal
CategorialDiscreteContinuousscaleBand

Creating Bar Charts

You can do the example on 2 of this Observable notebook.

const barChartData = () => { const extent = d3.extent(data, d => d.date); const xScale = d3 .scaleTime() .domain(extent) .range([0, width]); const tempMax = d3.max(data, d => d.high); const tempMin = d3.min(data, d => d.low); const yScale = d3 .scaleLinear() .domain([tempMin, tempMax]) .range([height, 0]); return data.map(d => ({ x: xScale(d.date), y: yScale(d.high), height: yScale(d.low) - yScale(d.high), })); };

Here we wanted to calculate the x-axis of time and y-axis of height and use these scales to calculate values for x, y and height.

We also used min and max functions for the temp as they were different keys in the data.

Adding in a color scale

We add this in addition to the work in the section above.

const barChartData = () => { const extent = d3.extent(data, d => d.date); const xScale = d3 .scaleTime() .domain(extent) .range([0, width]); const tempMax = d3.max(data, d => d.high); const tempMin = d3.min(data, d => d.low); const yScale = d3 .scaleLinear() .domain([tempMin, tempMax]) .range([height, 0]); // the important part const colorExtent = d3.extent(data, d => d.avg).reverse(); // scaleSequential allows you to use an interpolator to map // to the range. const colorScale = d3 .scaleSequential() .domain(colorExtent) .interpolator(d3.interpolateRdYlBu); return data.map(d => ({ x: xScale(d.date), y: yScale(d.high), height: yScale(d.low) - yScale(d.high), fill: colorScale(d.avg), })); };

Creating Line Charts

The important part here is again understanding the line SVG and parts that go into it.

const lineChartData = () => { const extent = d3.extent(data, d => d.date); const xScale = d3 .scaleTime() .domain(extent) .range([0, width]); const tempMax = d3.max(data, d => d.high); const tempMin = d3.min(data, d => d.low); const yScale = d3 .scaleLinear() .domain([tempMin, tempMax]) .range([height, 0]); // you could also create two different lines and pass the .y func const line = d3.line().x(d => xScale(d.date)); return [ { path: line.y(d => yScale(d.high))(data), fill: 'red' }, { path: line.y(d => yScale(d.low))(data), fill: 'blue' }, ]; };

Building a Radial Chart

You use d3.arc which is similar to d3.line, but we give an object of one data point as opposed to an array.

var pie = { data: 1, value: 1, startAngle: 6.050474740247008, endAngle: 6.166830023713296, }; var arc = d3 .arc() .innerRadius(0) .outerRadius(100) .startAngle(d => d.startAngle) .endAngle(d => d.endAngle); arc(pie); // M-23.061587074244123,-97.30448705798236A100,100,0,0,1,-11.609291412523175,-99.32383577419428L0,0Z

Commonly used for a pie chart.

Creating the Radial Chart

const radialChartData = () => { const radiusScale = d3 .scaleLinear() .domain([d3.min(data, d => d.low), d3.max(data, d => d.high)]) .range([0, width / 2]); // startAngle = i * perSliceAngle // endAngle = (i+1) * perSliceAngle const arcGenerator = d3.arc(); // get the angle for each slide // 2PI / 365 const perSliceAngle = (2 * Math.PI) / data.length; const colorExtent = d3.extent(data, d => d.avg).reverse(); const colorScale = d3 .scaleSequential() .domain(colorExtent) .interpolator(d3.interpolateRdYlBu); return data.map((d, i) => { const path = arcGenerator({ startAngle: i * perSliceAngle, endAngle: (i + 1) * perSliceAngle, innerRadius: radiusScale(d.low), outerRadius: radiusScale(d.high), }); return { path, fill: colorScale(d.avg), }; }); };

Breaking Down the D3 API

Out of the sections for D3, there are a two sections that Susan breaks is down into.

  1. Data preparation: Some could be replaced by JS/Lodash etc. Chords, hierarchies and pies here are very useful.
  2. Layout calculation: Calulations that help for drawing out the SVG objects themselves. This includes things like geo, chord etc.
  3. DOM manipulation

...and...

  1. Finishing touches: anyimations, color shemes, axes etc.
  2. Interactions: making the charts interactive

Something interesting was replacing blocks.org with blockbuilder.org ie https://blockbuilder.org/mbostock/2e73ec84221cb9773f4c it will take you to an interactive editor.

For React, the important sections to probably note are selections from DOM manipulations (basically the enter, update, exit lifecycle) and Dispatches.

Bar Chart Exercise

With React, we don't need to both with the enter, exit, update lifecycle as React can handle this for us just with state.

// helper func const barChartData = data => { const extent = d3.extent(data, d => d.date); const xScale = d3 .scaleTime() .domain(extent) .range([0, width]); const tempMax = d3.max(data, d => d.high); const tempMin = d3.min(data, d => d.low); const yScale = d3 .scaleLinear() .domain([tempMin, tempMax]) .range([height, 0]); // the important part const colorExtent = d3.extent(data, d => d.avg).reverse(); // scaleSequential allows you to use an interpolator to map // to the range. const colorScale = d3 .scaleSequential() .domain(colorExtent) .interpolator(d3.interpolateRdYlBu); return data.map(d => ({ x: xScale(d.date), y: yScale(d.high), height: yScale(d.low) - yScale(d.high), fill: colorScale(d.avg), })); }; const Component = ({ data, width, height }) => { const res = useCallback(() => barChartData(data)); return ( <svg width={width} height={height}> {res.map(d => ( // she manually put <rect x={d.x} y={d.y} width={2} height={d.height} fill={d.fill}> <rect {...d} /> ))} </svg> ); };

Arc Exercise

In this particular exercise, we need to actually shift the center from 0,0 using a transformation:

const radialChartData = () => { const radiusScale = d3 .scaleLinear() .domain([d3.min(data, d => d.low), d3.max(data, d => d.high)]) .range([0, width / 2]); // startAngle = i * perSliceAngle // endAngle = (i+1) * perSliceAngle const arcGenerator = d3.arc(); // get the angle for each slide // 2PI / 365 const perSliceAngle = (2 * Math.PI) / data.length; const colorExtent = d3.extent(data, d => d.avg).reverse(); const colorScale = d3 .scaleSequential() .domain(colorExtent) .interpolator(d3.interpolateRdYlBu); return data.map((d, i) => { const path = arcGenerator({ startAngle: i * perSliceAngle, endAngle: (i + 1) * perSliceAngle, innerRadius: radiusScale(d.low), outerRadius: radiusScale(d.high), }); return { path, fill: colorScale(d.avg), }; }); }; const Component = ({ data, width, height }) => { const res = useCallback(() => radialChartData(data)); // <g /> used to transform the arc to where the center should be return ( <svg width={width} height={height}> <g transform={`translate(${width / 2}, ${height / 2})`}> {res.map(d => ( // she manually put <path d={d.x=path} fill={d.fill}> <path {...d} /> ))} </g> </svg> ); };

The Three Exceptions to React

Axis, brush, translations and zoom don't always play well together between React and D3.

Axes, Legends + Annotations

// 1. Create axisLeft or axisBottom at beginning of lifecycle with corresponding scale const yAxis = d3.axisLeft().scale(yScale); // 2. Create an SVG group element in `render` // parents omitted for brevity return <g ref="group" />; // 3. Call axis on the group element in componentDidUpdate d3.select(this.refs.group).call(yAxis);

In context:

// helper func const barChartData = data => { const xAxis = d3.axisBottom(); const yAxis = d3.axisLeft(); const extent = d3.extent(data, d => d.date); const xScale = d3 .scaleTime() .domain(extent) .range([0, width]); const tempMax = d3.max(data, d => d.high); const tempMin = d3.min(data, d => d.low); const yScale = d3 .scaleLinear() .domain([tempMin, tempMax]) .range([height, 0]); // the important part const colorExtent = d3.extent(data, d => d.avg).reverse(); // scaleSequential allows you to use an interpolator to map // to the range. const colorScale = d3 .scaleSequential() .domain(colorExtent) .interpolator(d3.interpolateRdYlBu); return { data: data.map(d => ({ x: xScale(d.date), y: yScale(d.high), height: yScale(d.low) - yScale(d.high), fill: colorScale(d.avg), })), xAxis: xAxis.scale(xScale), yAxis: yAxis.scale(yScale), }; }; const BarChart = ({ chartData, width, height }) => { const { data, xAxis, yAxis } = barChartData(chartData, width, height); const xAxisRef = useRef('xAxis'); const yAxisRef = useRef('yAxis'); useEffect(() => { d3.select(xAxisRef).call(xAxis); d3.select(yAxisRef).call(yAxis); }, [data]); return ( <svg width={width} height={height}> {data.map((d, i) => ( // she manually put <rect x={d.x} y={d.y} width={2} height={d.height} fill={d.fill}> <rect key={i} {...d} /> ))} <g ref={xAxisRef} transform={`translate(0, ${height})`} /> <g ref={yAxisRef} transform={`translate(${leftPadding}, 0)`} /> </svg> ); };

Note: You will want to update the functions to use margins to then add in the axis.

Transitions

In general, React recommeneds you setState for animations. For D3, the approach changes. Susan uses D3 or Greenstock.

// in componentDidUpdate (or similar) d3.select(this.refs.bars) .selectAll('rect') .data(this.state.bars) .transition() .attr('y', d => d.y) .attr('height', d => d.height) .attr('fill', d => d.fill); return ( <g ref="bars"> {this.state.bars.map((d, i) => ( <rect key={i} x={d.x} width="2" /> ))} </g> );

Important: Make sure that the attributes that React does not manage is not placed in the SVG element.

Brush

In componentDidMount:

  1. Create brush instance
  2. Define brushable area (extent)
  3. Pass in a function to execute on every brush, or brush end.

this.brush = d3.brush().extent([0,0], [width, height]).on('end', () => { // end function }) d3.select(this.refs.brush).call(this.brush) // in render <g ref="brush" />

Once the d3 brush is in, you get the interactivity.

Use useRef and useEffect for function components.

An example handler for the brush:

this.brush = d3 .brushX() .extent([0, 0], [width, height]) .on('end', () => { // end function console.log(d3.event.selection); // [leftValue, rightValue] const [minX, maxX] = d3.event.selection; const range = [ this.state.XScale.invert(minX) // denormalise values this.state.XScale.invert(maxX) ] functionToUpdateRange(range) }); // handling coloring const isColored = !range.length || range[0] < d.date && d.date < range[1] return { //... other properties fill: isColored ? colorScale(d.avg) : '#ccc' // grey }

There is also a brushX and brushY available.

Additional Resources

d3-annotation and react-annotation was made by Susie and she has a library for that.

vx is an example and semiotic are resources from others.

Canvas

If you need to have a few thousand SVG nodes on the screen, consider using Canvas.

While interactivity is easier for SVG, canvas is more like a painting with no as much ability for interactivity.

// in render <canvas ref="canvas" style={{ width: `${width}px`, height: `${height}px` }} width={2 * width} height={2 * height} />; ctx = this.refs.canvas.getContext('2d'); // some available commands ctx.fillRect(x, y, width, height); // circle ctx.beginPath(); ctx.arc(x, y, radius, startAngle, endAngle, anticlockwise); ctx.fill(); // line ctx.beginPath(); // moveTo, lineTo, bezierCurveTo ctx.fill();