D3 code breakdown - RooyyDoe/functional-programming GitHub Wiki
I am using the library that Nadieh Bremer made for a radar chart. This library is also redesigned through the same person that made it. she wanted to redesign one of her first made d3 libraries and that is the exact one I am going to use for my visualization. I will breakdown her code in this file.
This are the main props that are used in the radarChart function. You can change the different elements that are declared here. It is mostly styling and editing for example the witdh and height of the circle. All these props are coming back later in the d3 function when they get used.
export default function RadarChart(id, data) {
let cfg = {
w: 1200,
h: 520,
margin: { top: 40, right: 40, bottom: 40, left: 40 },
levels: 4,
maxValue: .75,
labelFactor: 1.1,
wrapWidth: 60,
opacityArea: 0.3,
dotRadius: 3,
opacityCircles: .7,
strokeWidth: 2,
roundStrokes: false,
color: d3.scale.category10()
};
CFG Object
> w: Gives the width of the circle
> h: Gives the Height of the circle
> margin: The margins of the SVG
> levels: How many levels or inner circles should there be drawn
> maxValue: value of the biggest circle in the radarChart
> labelFactor: How much further than the radius of the outer circle the labels should be placed
> wrapWidth: The number of pixels after which a label needs to be given a new line
> opacityArea: The opacity of the area of the blob
> dotRadius: The size of the colored circles of each blob
> opacityCircles: The opacity of the circles of each blob
> strokeWidth: The width of the stroke around each blob
> roundStrokes: determines whether the blobs have sharp corners or smooth/round corners (False/True)
> color: Color function that decides what color each blob will have
This part of the code will calculate the maxValue
of the data that has been given through. In my data there is one record that has a way bigger percentage than all the other ones and this is giving the radarChart a big spike on one side this is why the radarChart don't looks that good.
let maxValue = Math.max(cfg.maxValue, d3.max(data, (i) => {
return d3.max(i.map((o) => {
return o.value;
}));
}));
maxValue
in the cfg
object there is a variable maxValue
that has the value 0
let maxValue = Math.max(cfg.maxValue, d3.max(data, (i) => {}
A variable maxValue will be made, and the value will be decided by the function. Math.max()
is used to get the highest value and returns this. it looks what the value is of cfg.maxValue
and this is 0
so it has to calculate what the highest value is for this they use the d3.max
method.
return d3.max(i.map((o) => {
return o.value;
}));
In this part of the code the max value of the data will be calculated and put into new array
by .map()
this value will get returned
back to the function and Math.max()
will look what value is the highest of the two values. But this will be the o.value
, because the standard value of maxValue is 0
so everything that is higher will get picked before the standard value will. Unless there is no value at all but then there is no visualization as well.
The biggest value in the data is: 0.4842694561989119
So the biggest circle of the radarChart will start at 48% +/-
this piece of code will get all the axis names out of the data and returns them in the variable allAxis
. It creates a new array with all the names of the categories in it.
let allAxis = (data[0].map( (i) => {
return i.axis;
})),
total = allAxis.length,
radius = Math.min(cfg.w / 2, cfg.h / 2),
Format = d3.format('%'),
angleSlice = Math.PI * 2 / total;
allAxis
Looks into the first record of the data to find all the Axis and then returns them in the variable allAxis
that can be re-used in other parts of the code. the .map()
makes a new array
with all the axis in it.
let allAxis = (data[0].map( (i) => {
return i.axis;
}))
Props
> total: Looks how many Axis there are in the data and puts it back into `total`
> radius: Calculates the radius of the outermost circle of the visualization
> Format: gives the percentage format.
> angleSlice: Calculates the width in radians of each "slice"
the d3.scale.linear()
method is used to evenly divide value over a specified length. it needs the .range
and the .domain
they both need a min and max value to calculate the scale of the visualization. .range
takes the radius value that is calculated above here as max. and .domain
takes the maxValue as the max. in both cases the min value is 0
let rScale = d3.scale.linear()
.range([0, radius])
.domain([0, maxValue]);
Removes the chart if there is one on the screen and you reload. So that there will not be two visualization on top of each other.
d3.select(id).select('svg').remove();
it selects the element were the svg is placed in. it will look if there is an svg
element in this main element and invokes .remove();
on this will be done every time the page gets refreshed.
In this part of the code it will select the id
element in HTML and append a svg
into this and it adds different .attr
these attributes will add the width, height to the svg
and give it a new class radar
let svg = d3.select(id).append('svg')
.attr('width', cfg.w + cfg.margin.left + cfg.margin.right)
.attr('height', cfg.h + cfg.margin.top + cfg.margin.bottom)
.attr('class', 'radar' + id);
In this part of the code we will be adding a g
element to the svg group. we also will be adding a transform
attribute to this element. This attribute will ensure that the g element and the children will be moved to the center of the screen. If you don't do this the visualization will be at the left corner of your screen.
let g = svg.append('g')
.attr('transform', 'translate(' + (cfg.w / 2 + cfg.margin.left) + ',' + (cfg.h / 2 + cfg.margin.top) + ')');
width / 2 + margin-left ( 1200 / 2 + 40 = 640)
height / 2 + margin-top ( 520 / 2 + 40 = 300)
In this part of the code there is a variable made filter
. The 'defs' element gets added to the g
element and after that the filter
element will be appended to defs
and will also be getting an id glow
. there are different filters added to the filter
element. These four filter elements will style the circles lines and give them a nice glowing effect.
let filter = g.append('defs').append('filter').attr('id', 'glow'),
feGaussianBlur = filter.append('feGaussianBlur').attr('stdDeviation', '2.5').attr('result', 'coloredBlur'),
feMerge = filter.append('feMerge'),
feMergeNode_1 = feMerge.append('feMergeNode').attr('in', 'coloredBlur'),
feMergeNode_2 = feMerge.append('feMergeNode').attr('in', 'SourceGraphic');
There is a variable axisGrid
made and the g
element will be selected and there will be added a new g
element nested inside with a class axisWrapper
.
let axisGrid = g.append('g').attr('class', 'axisWrapper');
Here we select the variable axisGrid
and select all the .levels
these are not yet existing. this part of code will draw all the circles based on the levels.
axisGrid.selectAll('.levels')
.data(d3.range(1, (cfg.levels + 1)).reverse())
.enter()
.append('circle')
.attr('class', 'gridCircle')
.attr('r', (d) => {
return radius / cfg.levels * d;
})
.style('fill', '#DFE4F5')
.style('stroke', '#4A4B4E')
.style('fill-opacity', cfg.opacityCircles)
.style('filter', 'url(#glow)');
Draw Circles
It selects all the circles (.levels
) these are not existing yet. the data is getting a range and always starts at 1 and goes to 6 At the end it reverses the range. then the .enter()
function will run and after that it will append all the circles. it will go through all .levels
one by one.
axisGrid.selectAll('.levels')
.data(d3.range(1, (cfg.levels + 1)).reverse())
.enter()
.append('circle')
Every time a level will be made it gets different attributes added to it.
- Every circle gets the class
gridCircle
added to it - The size of the rings, depends on the value of the current level per loop (this runs from 6 to 1, so the circles gets smaller every time)
- Every circle gets filled with a color
- Every circle gets a stroke with a color
- Every circle gets a opacity that is declared in the props of
cfg
- Every circle gets a divined filter added to get the glow.
.attr('class', 'gridCircle')
.attr('r', (d) => {
return radius / cfg.levels * d;
})
.style('fill', '#DFE4F5')
.style('stroke', '#4A4B4E')
.style('fill-opacity', cfg.opacityCircles)
.style('filter', 'url(#glow)');
This part of the code will be repeated till all the .levels
are done so it will create 6 circles at the end and all of them have a different radius.
In this part of the code the % text will be made that is aligned from the first circle to the top circle. this will be depending on how big the maxValue is. So the highest % will be: 0.4842694561989119 (48%)
axisGrid.selectAll('.axisLabel')
.data(d3.range(1, (cfg.levels + 1)).reverse())
.enter()
.append('text')
.attr('class', 'axisLabel')
.attr('x', 4)
.attr('y', (d) => {
return -d * radius / cfg.levels;
})
.attr('dy', '1.2em')
.style('font-size', '10px')
.attr('fill', '#A7AAB2')
.text((d) => {
return Format(maxValue * d / cfg.levels);
});
Percentage Text
In this piece of code will happen the exact same as it did above. It selects all the .axisLabel
(They are not yet divined) Then it looks how many circles are created and appends text to these circles. it also reverses it because it needs to be the same as how the circles are created.
axisGrid.selectAll('.axisLabel')
.data(d3.range(1, (cfg.levels + 1)).reverse())
.enter()
.append('text')
- Every text element gets the class
axisLabel
added to it - Every text element gets the x attribute with the value of 4
- Every text element gets the y attribute this value depends on the radius that have been declared in the top code. example: (-1 [-1, -2, -3, -4] * 260 / 4) = -65) So the list will be: -65, -130, -195, -260
- Every text element gets a
dy
attribute this is the place the text will be on the y-as. - Every text element gets a
font-size
of 10px - Every text element gets filled with a color
- Every text element gets the calculated value that will be calculated like this: (maxValue * d / cfg.levels)
( 0.48 * 1 / 4 = 0.12, 0.48 * 2 / 4 = 0.24, 0.48 * 3 / 4 = 0.36, 0.48 * 4 / 4 = 0.48)
This will be turned into % because we have given the format in the props.
.attr('class', 'axisLabel')
.attr('x', 4)
.attr('y', (d) => {
return -d * radius / cfg.levels;
})
.attr('dy', '1.2em')
.style('font-size', '10px')
.attr('fill', '#A7AAB2')
.text((d) => {
return Format(maxValue * d / cfg.levels);
});
In this part of the code the elements with an 'axis' class are selected (these do not yet exist at the moment)
let axis = axisGrid.selectAll('.axis')
.data(allAxis)
.enter()
.append('g')
.attr('class', 'axis');
create axis
.data(allAxis)
selects all the axis that are given data that is delivered to d3.
.enter()
gets all the 19 data array items en loops through them
.append('g')
It appends a g element to every item that is in the array.
.attr('class', 'axis');
It gives everyg
element aaxis
class.
A line
element is added to each axis in the HTML.
axis.append('line')
.attr('x1', 0)
.attr('y1', 0)
.attr('x2', (d, i) => {
return rScale(maxValue * 1.1) * Math.cos(angleSlice * i - Math.PI / 2);
})
.attr('y2', (d, i) => {
return rScale(maxValue * 1.1) * Math.sin(angleSlice * i - Math.PI / 2);
})
.attr('class', 'line')
.style('stroke', '#B0B4C1')
.style('stroke-width', '1px');
add lines
.attr('x1', 0)
The first coordinate will be calculated (left horizontal)
.attr('y1', 0)
the second coordinate will be calculated (left vertical)
.attr('x2', (d, i) => {}
the third coordinate will be calculated
.attr('y2', (d, i) => {}
the fourth coordinate will be calculated
These 4 coordinates will make a square together. now you can draw a line between x1 & y1 and x2 & y2 this is the way all the lines will get drawn in the HTML.
.attr('class', 'line')
Every line get a classline
.style('stroke', '#B0B4C1')
Every line will be colored.
.style('stroke-width', '1px');
Every line will be 1px.
Every as gets a text
element that will be shown in HTML. these will be all the main category names from the data.
axis.append('text')
.attr('class', 'legend')
.style('font-size', '11px')
.attr('text-anchor', 'middle')
.attr('x', (d, i) => {
return rScale(maxValue * cfg.labelFactor) * Math.cos(angleSlice * i - Math.PI / 2);
})
.attr('y', (d, i) => {
return rScale(maxValue * cfg.labelFactor) * Math.sin(angleSlice * i - Math.PI / 2);
})
.text((d) => {
return d;
});
axis labels
.attr('class', 'legend')
Every text element gets the class legend
.style('font-size', '11px')
Every text element has afont-size
of 11px
.attr('text-anchor', 'middle')
Every text element will be aligned in the middle
.attr('x', (d, i) => {}
x attribute, this determines how much space the labels are removed from the center point on the x-axis (horizontal offset)
.attr('y', (d, i) => {}
y attribute, this determines how much space the labels are removed from the center point on the y-axis (vertical offset)
.text((d) => {}
This gives the values to the labels (So all the main categories)
In this part of the code is we have selected a svg
element and used the method radial()
on it to create a line.
let radarLine = d3.svg.line.radial()
.interpolate('linear-closed')
.radius((d) => {
return rScale(d.value);
})
.angle((d, i) => {
return i * angleSlice;
});
-
.interpolate('linear-closed')
calculates all the values between the data-points -
.radius((d) => {}
calculates all the coordinates through calculating the radius -
.angle((d, i) => {}
calculates all the points through calculating the angle.
The radius
& angle
will decide where the data-point will be in the visualization. these coordinates will be used in interpolate()
and then all the points will be calculated. and if all the points are calculated there will be lines drawn. these will function like a border for the blob.
There is an option to turn on rounded lines for the blobs. When this is turned on it will work exactly als the code above here and it will run interpolate()
again but this time with cardinal-closed
as argument, this will give the lines of the blobs small curves instead of angular points.
if (cfg.roundStrokes) {
radarLine.interpolate('cardinal-closed');
}
In this part of the code the blobs are created that will be shown inside the radarChart. every group with the class radarWrapper
is selected (They are not made yet)
let blobWrapper = g.selectAll('.radarWrapper')
.data(data)
.enter().append('g')
.attr('class', 'radarWrapper');
create the blobs
.data(data)
binding the data.
.enter().append('g')
loops over all the data-points and append a group to it.
.attr('class', 'radarWrapper');
gives everyg
element a classradarWrapper
In this part of the code the blobs will get a background and you will be able to hover over the blobs and it will highlight the blob that you are hovering over.
blobWrapper
.append('path')
.attr('class', 'radarArea')
.attr('d', (d, i) => {
return radarLine(d);
})
.style('fill', (d, i) => {
return cfg.color(i);
})
.style('fill-opacity', cfg.opacityArea)
.on('mouseover', function (d, i) {
//Dim all blobs
d3.selectAll(".radarArea")
.transition().duration(200)
.style("fill-opacity", 0.5);
//Bring back the hovered over blobx
d3.select(this)
.transition().duration(200)
.style("fill-opacity", 0.8);
})
.on('mouseout', function () {
//Bring back all blobs
d3.selectAll(".radarArea")
.transition().duration(200)
.style("fill-opacity", cfg.opacityArea);
});
background blobs
.append('path')
This is adding a path in the HTML
.attr('class', 'radarArea')
Adds a classradarArea
to every blobWrapper
.attr('d', (d, i) => {}
Will give all the SVG coordinates so that the blob background can be created.
.style('fill', (d, i) => {}
Gives every blob an unique color out ofd3.scale.category10()
in the props. This is a ColorScheme, there is also a possibility to create ur own when you give through the options as a parameter.
blobWrapper
.append('path')
.attr('class', 'radarArea')
.attr('d', (d, i) => {
return radarLine(d);
})
.style('fill', (d, i) => {
return cfg.color(i);
})
.style('fill-opacity', cfg.opacityArea)
This eventListener is added to the mouse interaction. It will highlight the blob when you hover over it with the mouse. This wil happen in a transition(animation) that will change the opacity
of the blob. this activity will happen when you do mouseover
, mouseout
.on('mouseover', function (d, i) {
//Dim all blobs
d3.selectAll(".radarArea")
.transition().duration(200)
.style("fill-opacity", 0.5);
//Bring back the hovered over blobx
d3.select(this)
.transition().duration(200)
.style("fill-opacity", 0.8);
})
.on('mouseout', function () {
//Bring back all blobs
d3.selectAll(".radarArea")
.transition().duration(200)
.style("fill-opacity", cfg.opacityArea);
});
This part of the code will create the outer lines of the blobs.
blobWrapper.append('path')
.attr('class', 'radarStroke')
.attr('d', (d, i) => {
return radarLine(d);
})
.style('stroke-width', cfg.strokeWidth + 'px')
.style('stroke', (d, i) => {
return cfg.color(i);
})
.style('fill', 'none')
.style('filter', 'url(#glow)');
outer lines
.attr('class', 'radarStroke')
Adds a classradarStroke
to everypath
element
.attr('d', (d, i) => {}
adds a data attribute to thepath
elements this calls the valueradarLine()
.style('stroke-width', cfg.strokeWidth + 'px')
Adds how many pixels the lines are of the blob
.style('stroke', (d, i) => {}
makes the stroke the same color as the inside of the blob.
.style('fill', 'none')
Ensures that the outlines of the blobs have no fill
.style('filter', 'url(#glow)');
Gives all the outlines a glow filter.
This part of the code creates the circles and puts them in the HTML. when this runs the visualization will be visible to look at in the browser.
blobWrapper.selectAll('.radarCircle')
.data((d, i) => {
return d;
})
.enter().append('circle')
.attr('class', 'radarCircle')
.attr('r', cfg.dotRadius)
.attr('cx', (d, i) => {
return rScale(d.value) * Math.cos(angleSlice * i - Math.PI / 2);
})
.attr('cy', (d, i) => {
return rScale(d.value) * Math.sin(angleSlice * i - Math.PI / 2);
})
.style('fill', (d, i, j) => {
return cfg.color(j);
})
.style('fill-opacity', 0.8);
At first all the classes .radarCircle
will be selected (at the time this will not be created yet)
.data((d, i) => {}
Links the data
.enter().append('circle')
Makes for every data element a circle
.attr('class', 'radarCircle')
Adds to every circle a classradarCircle
.attr('r', cfg.dotRadius)
Gives every blob a radius that has been divined in the props.
.attr('cx', (d, i) => {}
gives the blob-dot an x coordinate (this is the x coordinate for the center point / radius) value is calculated by rScale()
.attr('cy', (d, i) => {}
gives the blob-dot an y coordinate (this is the y coordinate for the center point / radius) value is calculated by rScale()
.style('fill', (d, i, j) => {}
Gives the blob-dots the same color as the line and inside of the blob so everything looks the same and fits together.
.style('fill-opacity', 0.8);
Adds aopacity
of0.8
to the blob-dots.