[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.
There are a number of data types:
The basic charts and when to use them:
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.
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
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:
Type | Domain | Range | Scale |
---|---|---|---|
Quantitative | Continuous | Continuous | scaleLinear |
Quantitative | Continuous | Continuous | scaleLog |
Quantitative | Continuous | Continuous | scaleTime |
Quantitative | Continuous | Discrete | scaleQuantize |
Categorial | Discrete | Discrete | scaleOrdinal |
Categorial | Discrete | Continuous | scaleBand |
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.
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), })); };
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' }, ]; };
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.
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), }; }); };
Out of the sections for D3, there are a two sections that Susan breaks is down into.
...and...
Something interesting was replacing
blocks.org
withblockbuilder.org
iehttps://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.
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> ); };
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> ); };
Axis, brush, translations and zoom don't always play well together between React and D3.
// 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.
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.
In componentDidMount
:
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
anduseEffect
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.
d3-annotation
and react-annotation
was made by Susie and she has a library for that.
vx
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();