Changing direction of D3 animation / transition? - javascript

I've recently built a small heatmap whose individual cells appear through a fade-in one after another after the page is loaded:
https://codepen.io/ChrisBean/pen/KKwpmjb
The fade-in animation is triggered by setting the initial opacity value of the cells to 0
squares.transition()
.duration(1000)
.delay((_d, i) => i * 200)
.style('opacity', 1);
As of now, the cells fade in from the bottom to the top column by column. I want them to fade in from left to right, row by row. This is the succession that I'm aiming at, quickly visualized with a pen drawing on a thinkpad:
Can anyone push me in the right direction of what to change in the transition trigger to change the direction?

There is no such a thing as the "direction of a transition" in D3. The whole issue here is that you're using the indices of the elements to set the delay. That being said, just change the order of the objects inside the data array, so the indices match the direction you want.
For instance:
data.sort(function(a,b){
return myVars.indexOf(b.variable) - myVars.indexOf(a.variable) ||
myGroups.indexOf(a.group) - myGroups.indexOf(b.group)
});
Here is the code with that change:
// set the dimensions and margins of the graph
const margin = {
top: 0,
right: 0,
bottom: 0,
left: 0,
};
const width = 400 - margin.left - margin.right;
const height = 400 - margin.top - margin.bottom;
// append the svg object to the body of the page
// eslint-disable-next-line no-undef
const svg = d3.select('#my_dataviz')
.append('svg')
.attr('viewBox', '0 0 900 320')
.append('g')
.attr('transform',
`translate(${margin.left},${margin.top})`);
// Labels of row and columns
const myGroups = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'];
const myVars = ['v1', 'v2', 'v3', 'v4', 'v5', 'v6', 'v7', 'v8', 'v9', 'v10'];
// Build X scales and axis:
const x = d3.scaleBand()
.range([0, width])
.domain(myGroups)
.padding(0.00);
svg.append('g')
.attr('transform', `translate(0,${height})`)
.call(d3.axisBottom(x));
// Build X scales and axis:
const y = d3.scaleBand()
.range([height, 0])
.domain(myVars)
.padding(0.00);
svg.append('g')
.call(d3.axisLeft(y));
// Build color scale
const myColor = d3.scaleLinear()
.range(['white', '#363636'])
.domain([1, 100]);
// Read the data
d3.csv('https://raw.githubusercontent.com/holtzy/D3-graph-gallery/master/DATA/heatmap_data.csv', (data) => {
data.sort(function(a, b) {
return myVars.indexOf(b.variable) - myVars.indexOf(a.variable) || myGroups.indexOf(a.group) - myGroups.indexOf(b.group)
});
// create a tooltip
const tooltip = d3.select('#my_dataviz')
.append('div')
.style('opacity', 0)
.attr('class', 'tooltip')
.style('background-color', 'white')
.style('border', 'solid')
.style('border-width', '2px')
.style('border-radius', '5px')
.style('padding', '5px');
// Three function that change the tooltip when user hover / move / leave a cell
const mouseover = function() {
tooltip.style('opacity', 1);
};
const mousemove = function(d) {
tooltip
.html(`Client Branch:${d.value} <br>
Project: <br>`)
.style('left', `${d3.mouse(this)[0] + 70}px`)
.style('top', `${d3.mouse(this)[1]}px`);
};
const mouseleave = function() {
tooltip.style('opacity', 0);
};
// add the squares
const squares = svg.selectAll()
.data(data, (d) => `${d.group}:${d.variable}`)
.enter()
.append('rect')
.attr('x', (d) => x(d.group))
.attr('y', (d) => y(d.variable))
.attr('width', x.bandwidth())
.attr('height', y.bandwidth())
.style('fill', (d) => myColor(d.value))
.style('opacity', 0)
.on('mouseover', mouseover)
.on('mousemove', mousemove)
.on('mouseleave', mouseleave);
squares.transition()
.duration(1000)
.delay((_d, i) => i * 200)
.style('opacity', 1);
});
<!DOCTYPE html>
<meta charset="utf-8">
<!-- Load d3.js -->
<script src="https://d3js.org/d3.v4.js"></script>
<!-- Create a div where the graph will take place -->
<div id="my_dataviz"></div>

Related

D3 - Chart with positive and negative values

I am trying to build the d3 chart with the positive and negative number values as below
and I found some examples of this and this. I am facing difficulties in customizing it because I have no prior experience in d3 and I think it would need some time for learning. I tried that as well. Created some simple chart examples but could not achieve the above. So I thought of reaching for help. Maybe someone can help with this if they have already done a similar chart or some guidance would be greatly appreciated. Thanks in advance.
The first step would be to identify how this chart can be simplified. Removing features until the most basic thing remains. Then, build that and gradually add features until it resembles what you want.
In your case, that'd be a horizontal bar chart. Then, add some negative values and a centred zero-line. Finally, make the height of the bars less so they become nodes, and add the text.
I'll try to add something like this, in these steps, without the layout and everything, but hopefully you'll be able to see my logic.
The basic vertical bar chart
// Some fake data
const data = ['SaaS', 'Sales', 'Fruits & Veggies', 'IT'].map((v, i) => ({
name: v,
value: 3 * i + 2
}));
const width = 600,
height = 300
margin = {
top: 20,
left: 100,
right: 40,
bottom: 40
};
// Process it to find the x and y axis domains
// scaleLinear because it considers numbers
const x = d3.scaleLinear()
.domain([0, d3.max(data.map(d => d.value))]) // the possible values
.range([0, width]); // the available screen space
// scaleBand because it's just categorical data
const y = d3.scaleBand()
.domain(data.map(d => d.name)) // all possible values
.range([height, 0]) // little weird, y-axis is always backwards, because (0,0) is the top left
.padding(0.1);
const svg = d3.select('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom);
const g = svg
// Append a container element. This will hold the chart
.append('g')
// Move it a little to account for the axes and labels
.attr('transform', `translate(${margin.left} ${margin.right})`);
// Draw the bars
// First, assign the data to the bar objects, this will decide which to remove, update, and add
const bars = g.append('g')
.selectAll('rect')
.data(data);
// Good practice: always call remove before adding stuff
bars.exit().remove();
// Add the new bars and assign any attributes that do not depend on the data
// for example, font for texts
bars.enter()
.append('rect')
.attr('fill', 'steelblue')
// Now merge it with the existing bars
.merge(bars)
// From now on we operate on both the old and the new bars
// Bars are weird, first we position the top left corner of each bar
.attr('x', 0)
.attr('y', d => y(d.name))
// Then we determine the width and height
.attr('width', d => x(d.value))
.attr('height', y.bandwidth())
// Draw the x and y axes
g.append('g')
.classed('x-axis', true)
.attr('transform', `translate(0, ${height})`)
.call(d3.axisBottom(x))
g.append('g')
.classed('y-axis', true)
.call(d3.axisLeft(y))
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<svg></svg>
Now I'll remove all old comments and explain what I'm doing differently.
The negative horizontal bar chart
// Now, the data can also be negative
const data = ['SaaS', 'Sales', 'Fruits & Veggies', 'IT'].map((v, i) => ({
name: v,
value: 3 * i - 5
}));
const width = 600,
height = 300,
margin = {
top: 20,
left: 100,
right: 40,
bottom: 40
};
// Now, we don't use 0 as a minimum, but get it from the data using d3.extent
const x = d3.scaleLinear()
.domain(d3.extent(data.map(d => d.value)))
.range([0, width]);
const y = d3.scaleBand()
.domain(data.map(d => d.name))
.range([height, 0])
.padding(0.1);
const svg = d3.select('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom);
const g = svg
.append('g')
.attr('transform', `translate(${margin.left} ${margin.right})`);
const bars = g.append('g')
.selectAll('rect')
.data(data);
bars.exit().remove();
bars.enter()
.append('rect')
.merge(bars)
// All the same until here
// Now, if a bar is positive it starts at x = 0, and has positive width
// If a bar is negative it starts at x < 0 and ends at x = 0
.attr('x', d => d.value > 0 ? x(0) : x(d.value))
.attr('y', d => y(d.name))
// If the bar is positive it ends at x = v, but that means it's x(v) - x(0) wide
// If the bar is negative it ends at x = 0, but that means it's x(0) - x(v) wide
.attr('width', d => d.value > 0 ? x(d.value) - x(0) : x(0) - x(d.value))
.attr('height', y.bandwidth())
// Let's color the bar based on whether the value is positive or negative
.attr('fill', d => d.value > 0 ? 'darkgreen' : 'darkred')
g.append('g')
.classed('x-axis', true)
.attr('transform', `translate(0, ${height})`)
.call(d3.axisBottom(x))
g.append('g')
.classed('y-axis', true)
.call(d3.axisLeft(y))
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<svg></svg>
And now, I'll change the bars to the nodes you have in your example code.
The horizontal chart with nodes
const data = ['SaaS', 'Sales', 'Fruits & Veggies', 'IT'].map((v, i) => ({
name: v,
value: 3 * i - 5
}));
// We want to center each rect around the value it's supposed to have.
// That means that we need to have a node width
const nodeWidth = 60;
const width = 600,
height = 300,
margin = {
top: 20,
left: 100,
right: 40,
bottom: 40
};
// We also need to make sure there is space for all nodes, even at the edges.
// One way to get this is by just extending the domain a little.
const domain = d3.extent(data.map(d => d.value));
const x = d3.scaleLinear()
.domain([domain[0] - 1.5, domain[1] + 1.5])
.range([0, width]);
const y = d3.scaleBand()
.domain(data.map(d => d.name))
.range([height, 0])
.padding(0.1);
const svg = d3.select('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom);
const g = svg
.append('g')
.attr('transform', `translate(${margin.left} ${margin.right})`);
const bars = g.append('g')
.selectAll('rect')
.data(data);
bars.exit().remove();
// All the same until here
bars.enter()
.append('rect')
// width has become a constant
.attr('width', nodeWidth)
// Now, transform each node so it centers around the value it's supposed to have
.attr('transform', `translate(${-nodeWidth / 2} 0)`)
// Round the corners for aesthetics
.attr('rx', 15)
.merge(bars)
// `x` denotes the placement directly again
.attr('x', d => x(d.value))
.attr('y', d => y(d.name))
.attr('height', y.bandwidth())
.attr('fill', d => d.value > 0 ? 'darkgreen' : 'darkred');
// Now one more thing, we want to add labels to each node.
// `<rect>` can't have children, we we add them to the plot seperately
// using the same `data` as for the bars
const labels = g.append('g')
.selectAll('text')
.data(data);
labels.exit().remove();
labels.enter()
.append('text')
.attr('fill', 'white')
.attr('text-anchor', 'middle') // center-align the text
.attr('dy', 5) // place it down a little so it middles nicely in the node.
.merge(bars)
// `x` denotes the placement directly
.attr('x', d => x(d.value))
// Add half a bar's height to target the center of each node
.attr('y', d => y(d.name) + y.bandwidth() / 2)
// Actually fill in the text
.text(d => d.value);
g.append('g')
.classed('x-axis', true)
.attr('transform', `translate(0, ${height})`)
.call(d3.axisBottom(x))
g.append('g')
.classed('y-axis', true)
.call(d3.axisLeft(y))
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<svg></svg>
I hope you can follow this. Let me know if anything about this tutorial is unclear.

Offset x-axis ticks with scaleTime

I'm trying to figure out how I can offset the x-axis ticks, as shown in this example, to be in the center of the bar when the x-axis uses scaleTime.
Right now, I'm applying a transform to the axis when I append it to the svg as such:
// x-axis code
const x = d3.scaleTime()
.domain([d3.min(dates), d3.max(dates)])
.range([margin.left, width - margin.right]);
const x_axis = d3.axisBottom()
.scale(x);
...
// offsetting the axis horizontally when I append it with bandwidth / 2
svg.append('g')
.attr('transform', `translate(${bandwidth / 2},${height - margin.bottom})`)
.call(x_axis);
But this feels hacky and leaves space between the x-axis and the y-axis.
It seems like the example I mentioned has this right because it's not using scaleTime but once scaleTime comes into the picture then things get bad. How can offset my scaleTime ticks so they line up with the middle of my bars?
Full code below:
import * as d3 from 'd3';
import rawData from './data/readings.json';
import {
barSpacing,
margin,
getBandwidth,
} from './helpers';
const width = 1000;
const height = 500;
const animationDurationRatio = 5;
const barStyle = {
color: 'steelblue',
opacity: {
default: .9,
hover: 1
}
};
const getStepData = (data, stepNum) => {
return data.map((item, i) => {
const value = i < stepNum ? item.value : 0
return {
...item,
value
};
});
};
const data = rawData.map(item => {
return {
date: new Date(item.date),
value: item.breakfast
}
});
const dates = data.map(d => d.date);
const x = d3.scaleTime()
.domain([d3.min(dates), d3.max(dates)])
.range([margin.left, width - margin.right]);
const y = d3.scaleLinear()
.domain([0, d3.max(data.map(d => d.value))]).nice()
.range([height - margin.bottom, margin.top]);
const color = d3.scaleSequential(d3.interpolateRdYlGn)
.domain([140, 115]);
// Got these values using trial and error
// Still not 100% sure how this domain works
const x_axis = d3.axisBottom()
.scale(x);
const y_axis = d3.axisLeft()
.scale(y);
const chartWidth = x.range()[1];
const bandwidth = getBandwidth(chartWidth, data, barSpacing);
const svg = d3.create('svg')
.attr('width', chartWidth)
.attr('height', height)
.attr('font-family', 'sans-serif')
.attr('font-size', 10)
.attr('text-anchor', 'end');
const bar = svg.selectAll('g')
.data(getStepData(data, 0))
.join('g');
bar.append('rect')
.attr('fill', d => {
return color(d.value);
})
.attr('opacity', barStyle.opacity.default)
.attr('x', d => {
return x(d.date)
})
.attr('y', d => y(d.value))
.attr('width', bandwidth)
.attr('height', d => y(0) - y(d.value))
.on('mouseover', function() {
d3.select(this)
.transition(30)
.attr('opacity', barStyle.opacity.hover);
})
.on('mouseout', function() {
d3.select(this)
.transition()
.attr('opacity', barStyle.opacity.default);
});
bar.append('text')
.attr('fill', 'white')
.attr('x', (d, i) => x(d.date) + bandwidth / 2)
.attr('y', d => y(0) - 10)
.attr('dx', d => `0.${d.value.toString().length * 50}em`)
.text((d, i) => data[i].value);
svg.append('g')
.attr('transform', `translate(${bandwidth / 2},${height - margin.bottom})`)
.call(x_axis);
svg.append('g')
.attr('transform', `translate(${margin.left},0)`)
.call(y_axis);
document.querySelector('body').appendChild(svg.node());
function animateBars (data) {
const bars = svg.selectAll('rect')
.data(data);
bars
.transition()
.ease(d3.easeLinear)
.duration(d => animationDurationRatio * d.value)
.attr('y', d => y(d.value))
.attr('fill', d => {
return color(d.value);
})
.attr('height', d => y(0) - y(d.value));
}
animateBars(data)
Because scaleTime is a continuous scale, not a banded one, this isn't supported without some sort of hack or workaround (see workarounds below).
Note: Perhaps one reason for this is that some think bar charts aren't a good fit for time scales. Instead time is better visualized with a line chart or an area chart.
Solution
The best solution for this is to switch to scaleBand or something else that supports discrete bands (here's an example).
Workarounds
One workaround is to make the first bar half the size and then offset all bars by half their width:
.attr('x', d => {
return x(d.date) - bandwidth / 2
})
.attr('width', (d, i) => i === 0 ? bandwidth / 2 : bandwidth)
Other hacks/workarounds might include adding an extra day, hiding the first tick, or messing with the axis offset.
Source: https://observablehq.com/#d3/stacked-bar-chart#comment-af5453e2ab24d987
You need to translate both the axes with the same x units.
svg.append('g')
.attr('transform', `translate(${margin.left},${height-margin.bottom})`)
.call(x_axis);
svg.append('g')
.attr('transform', `translate(${margin.left},0)`)
.call(y_axis);
const numberOfTicks = 5;
const x_axis = d3.axisBottom()
.scale(x)
.ticks(numberOfTicks - 1);
You can set the number of ticks in x-axis. The number of ticks generated on graph will be numberOfTicks + 1

Beautify scaleBand

I just tried out d3js for some days and I want to beautify the x and y scales of my graph to be something like this
But this is what I got so far.
I have tried changing from scaleBand() to scaleLinear() and fix the normally bandwidth() method to a constant value, the graph just would not show.
This is the code
mounted () {
this.generateChart()
},
methods: {
generateChart () {
// set the dimensions and margins of the graph
const margin = { top: 20, right: 20, bottom: 30, left: 30 }
const width = 1850 - margin.left - margin.right
const height = 200 - margin.top - margin.bottom
// make the area for the graph to stay
const svg = d3.select('#heatmap')
.append('svg') // svg area can include headers and color scales
.attr('width', width + margin.left + margin.right) // set width
.attr('height', height + margin.top + margin.bottom) // set height
.append('g') // new g tag area for graph only
.attr('transform', `translate(${margin.left}, ${margin.bottom})`)
// stick g tag to the bottom
// range function generate graph scales
// TODO: make a range using date and time
const xLabel = d3.range(259)
const yLabel = d3.range(23, -1, -1)
// create x, y scales and axes
const x = d3.scaleBand()
.domain(xLabel)
.range([0, width])
.padding(0.05)
svg.append('g')
.attr('transform', `translate(0, ${height})`)
.call(d3.axisBottom(x))
const y = d3.scaleBand()
.domain(yLabel)
.range([height, 0])
.padding(0.05)
svg.append('g').call(d3.axisLeft(y))
d3.json('../predictions.json').then(function (data) {
svg.selectAll()
.data(data.heatmaps.kw.Sand_Heads)
.enter()
.append('rect')
.attr('x', function (d) {
return x(d[1]) // return cell's position
})
.attr('y', function (d) {
return y(d[0])
})
.attr('cx', 1)
.attr('cy', 1)
.attr('width', x.bandwidth()) // return cell's width
.attr('height', y.bandwidth()) // return cell's height
.style('fill', function (d) {
return rgbaToHex(0, 128, 255, 100 * d[2])
})
.on('mouseover', function () { // box stroke when hover
d3.select(this)
.style('stroke', 'black')
.style('opacity', 1)
})
.on('mouseout', function () { // fade block stroke when mouse leave the cell
d3.select(this)
.style('stroke', 'none')
.style('opacity', 0.8)
})
})
}
Note: I have to make it work with date selection in the future too.
This is the structure of the data I'm working on.
{
"days": ["2019-04-11", "2019-04-12", ..., "2019-12-25"],
"heatmaps": {
"kw": {
"Tilly_Point": [[5, 112, 0.0012], [6, 112, 0.0016], ...],
"Mouat_Point": [...]
},
"hw": {
...
}
}
}
Explanation:
the first element of subarray in Tilly_Point is the time of the whale found. ranging from 0 to 23 (midnight to next midnight) and 5 means 05:00 A.M. to 06:00 A.M.
the second element is the nth day of the operation. It's 112 meaning it's the 112th day of the operation. which is 1 August 2019
the last element is the real data being plotted on the graph. the higher -> darker colour towards the real color with 1 opacity
By looking at the desired design we can understand what you mean by "beautify" is reducing the number of ticks. And you are absolutely correct: in very few and specific situations we need to show all of them; most of the times, the design is cleaner and the user benefits from a more tidy dataviz if we choose what ticks to display.
That's clear if we look at this basic example I wrote, simulating your axes:
const svg = d3.select("svg");
const yScale = d3.scaleBand()
.domain(d3.range(25))
.range([10, 80])
.paddingInner(1);
const xScale = d3.scaleBand()
.domain(d3.range(261))
.range([25, 490])
.paddingInner(1);
d3.axisLeft(yScale)(svg.append("g").attr("transform", "translate(25,0)"));
d3.axisBottom(xScale)(svg.append("g").attr("transform", "translate(0,80)"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<svg width="500" height="100"></svg>
There are different approaches for reducing the number of ticks here: you can explicitly chose the ticks to show by value or, as I'll do in this answer, you can simply choose how many of them to show. Here, I'll do this using the remainder operator (%) filtering the scale's domain and passing it to tickValues (since you have a band scale we cannot use ticks), for instance showing every 6th value for the y axis:
.tickValues(yScale.domain().filter((_, i) => !(i % 6)))
Here is the result:
const svg = d3.select("svg");
const yScale = d3.scaleBand()
.domain(d3.range(25))
.range([10, 80])
.paddingInner(1);
const xScale = d3.scaleBand()
.domain(d3.range(261))
.range([25, 490])
.paddingInner(1);
d3.axisLeft(yScale).tickValues(yScale.domain().filter((_, i) => !(i % 6)))(svg.append("g").attr("transform", "translate(25,0)"));
d3.axisBottom(xScale).tickValues(xScale.domain().filter((_, i) => !(i % 20)))(svg.append("g").attr("transform", "translate(0,80)"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<svg width="500" height="300"></svg>

How to cluster icons in a time scale upon zooming out in D3?

I have a D3 (using D3 version 3.5.2) time scale chart utilizing only the x-axis at this point. The graph plots a series of svg "rect" elements on the x-axis based on an array of dates while it randomizes the data points on the y-axis to prevent clustering issues. The chart also has the ability to zoom and pan.
What I would like to do is upon zooming out of the scale is the following:
Cluster all the icons together so that it shows a new icon when zoomed out based on the "collective overlapping amount of events or data points" for a particular vertical slice of the timeline chart as the user zooms out.
For example, if there are seven data points clustered for the month of May, 2018, then it would show an icon with the number of events for that particular vertical time slice (or the month of May, 2018) showing inside the icon, so in this case the number seven will appear in the clustered icon box. Zooming into May, 2018 would cause the "clustered icon" to disappear and show the actual seven individual "rect" elements displayed across the time scale (so Tue, 23th, Thursday, 25th, etc...).
The tricky part here would be to grab the actual number of elements for the zoomed out vertical slice of a particular date and render the clustered icon as the user zooms out, and do the opposite when the user zooms in (hide the clustered icon and render individual icons across the timeline).
Here's my current code:
//D3 Timescale demo
const width = 1200,
height = 500,
parsedDate = d3.time.format('%Y-%m-%d').parse;
const changedDates = [
'1988-01-01', '1988-01-02', '1988-01-03',
'1988-01-04', '1988-01-05', '1988-01-06',
'1988-01-07', '1989-01-08', '1989-01-09',
'1995-01-10', '1995-01-11', '1998-01-12',
'1998-01-13', '1998-01-14', '2002-01-15',
'2002-01-16', '2002-01-17', '2004-01-18',
'2004-01-19', '2004-01-20', '2004-01-21',
'2004-01-22', '2007-01-23', '2007-01-24',
'2007-01-25', '2007-01-26', '2007-01-27',
'2008-01-28', '2008-01-29', '2008-01-30',
'2008-01-31', '2008-02-01', '2010-02-02',
'2010-02-03', '2010-02-04', '2012-02-05',
'2012-02-06', '2012-02-07', '2012-02-08',
'2014-02-09', '2014-02-10', '2014-02-11',
'2017-02-12', '2017-02-13', '2017-02-14',
'2018-02-15', '2018-02-16', '2018-02-17',
'2018-02-18', '2018-02-19', '2018-02-20'
].map(d => parsedDate(d));
const svg = d3.select('#timescale')
.append('svg')
.attr('preserveAspectRatio', 'xMinYMin meet')
.attr('viewBox', `0 0 ${width} ${height}`)
.classed('svg-content', true);
// .attr('width', width)
// .attr('height', height);
const clipPath = svg.append('defs')
.append('clipPath')
.attr('id', 'clip')
.append('rect')
.attr('width', width - 110)
.attr('height', height);
const xScale = d3.time.scale()
.domain([new Date(Date.parse(d3.min(changedDates, d => d))), new Date(Date.parse(d3.max(changedDates, d => d)))])
.range([10, width - 110]);
const yScale = d3.scale.linear()
.domain([200, 0])
.range([0, height - 29]);
const xAxis = d3.svg.axis()
.scale(xScale)
.tickSize(1)
.orient('bottom');
const yAxis = d3.svg.axis()
.scale(yScale)
.tickSize(1)
.tickValues([0, 100, 200])
.orient('right');
const zoom = d3.behavior.zoom()
.on('zoom', function () {
svg.select('g.xaxis').call(xAxis).selectAll('text').style('font-size', '10px');
updateEvents();
}).x(xScale);
// Draw base area to interact on
const rect = svg.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', width - 100)
.attr('height', height)
.attr('opacity', 0)
.call(zoom);
svg.append('g')
.attr('class', 'xaxis')
.attr('transform', 'translate(' + 10 + ',' + 480 + ')')
.call(xAxis)
.selectAll('text')
.style('font-size', '10px');
svg.append('g')
.attr('class', 'yaxis')
.attr('transform', 'translate(' + 1100 + ',' + 10 + ')')
.call(yAxis)
.selectAll('text')
.style('font-size', '10px');
const renderEvents = dates => {
const events = svg.selectAll('rect').data(dates);
events.enter()
.append('rect')
.attr('class', 'item')
.attr('x', d => xScale(d))
.attr('y', () => Math.random() * 100)
.attr('width', 10)
.attr('height', 10)
.attr('transform', (d, i) => (i === changedDates.length - 1) ? 'translate(' + 0 + ',' + 362 + ')' : 'translate(' + 10 + ',' + 362 + ')')
.attr('clip-path', 'url(#clip)')
.style('fill', 'blue');
events.exit()
.remove();
}
const updateEvents = () => {
// The console logs here are to try and figure the distinct amount of inverted x-scale values to try and decipher a pattern for the number of elements
// needed to display in the clustered icon box.
svg.selectAll('rect.item').attr('x', d => xScale(d)).classed('deleteIcon', d => { console.log('text d: ', Math.floor(xScale(d))); });
console.log(`Elements on chart: ${svg.selectAll('rect.item').size()}`);
}
renderEvents(changedDates);
.svg-container {
display: inline-block;
position: relative;
width: 100%;
padding-bottom: 100%;
vertical-align: top;
overflow: hidden;
top: 20px;
}
.svg-content {
display: inline-block;
position: absolute;
top: 0;
left: 0;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>D3 Timescale Intro</title>
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<div id="timescale" class="svg-container"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.2/d3.min.js"></script>
<script src="./timescale.js"></script>
</body>
</html>
I have implemented a D3v5 based on Pan & Zoom Axes
It gives the dates a fixed y-coord during parsing.
It calculates the month groups of the dates. After grouping the key of the group is the Time-in-milliseconds so we have to convert it first to a date when used: new Date().setTime(parseInt(d.key))
Based on the separation of the months on the X-axis it decides to draw the individual points (blue) or the group points (red).
You have to tweak the styling of the text and its alignment. And the group points are not clipped for some reason.
Every zoom/pan action all is redrawn based on a new xScale.
<!DOCTYPE html>
<meta charset="utf-8">
<style>
.axis path {
display: none;
}
.axis line {
stroke-opacity: 0.3;
shape-rendering: crispEdges;
}
.view {
fill: none;
stroke:none;
}
button {
position: absolute;
top: 20px;
left: 20px;
}
</style>
<button>Reset</button>
<div id="timescale" class="svg-container"></div>
<script src="https://d3js.org/d3.v5.min.js"></script>
<script>
//D3 Timescale demo
const width = 1200,
height = 500,
parsedDate = d3.utcParse("%Y-%m-%d");
const changedDates = [
'1988-01-01', '1988-01-02', '1988-01-03',
'1988-01-04', '1988-01-05', '1988-01-06',
'1988-01-07', '1989-01-08', '1989-01-09',
'1995-01-10', '1995-01-11', '1998-01-12',
'1998-01-13', '1998-01-14', '2002-01-15',
'2002-01-16', '2002-01-17', '2004-01-18',
'2004-01-19', '2004-01-20', '2004-01-21',
'2004-01-22', '2007-01-23', '2007-01-24',
'2007-01-25', '2007-01-26', '2007-01-27',
'2008-01-28', '2008-01-29', '2008-01-30',
'2008-01-31', '2008-02-01', '2010-02-02',
'2010-02-03', '2010-02-04', '2012-02-05',
'2012-02-06', '2012-02-07', '2012-02-08',
'2014-02-09', '2014-02-10', '2014-02-11',
'2017-02-12', '2017-02-13', '2017-02-14',
'2018-02-15', '2018-02-16', '2018-02-17',
'2018-02-18', '2018-02-19', '2018-02-20'
].map(d => { return { date: parsedDate(d), y: Math.random() * 100 + 50 }; });
const svg = d3.select('#timescale')
.append('svg')
.attr('preserveAspectRatio', 'xMinYMin meet')
.attr('viewBox', `0 0 ${width} ${height}`)
.classed('svg-content', true);
// .attr('width', width)
// .attr('height', height);
const clipPath = svg.append('defs')
.append('clipPath')
.attr('id', 'clip')
.append('rect')
.attr('width', width - 110)
.attr('height', height);
var minDate = d3.min(changedDates, d => d.date);
var maxDate = d3.max(changedDates, d => d.date);
minDate.setUTCFullYear(minDate.getUTCFullYear()-1);
maxDate.setUTCFullYear(maxDate.getUTCFullYear()+1);
const xScale = d3.scaleTime()
.domain([minDate, maxDate])
.range([10, width - 110]);
const yScale = d3.scaleLinear()
.domain([200, 0])
.range([0, height - 29]);
const xAxis = d3.axisBottom()
.scale(xScale)
.tickSize(1);
const yAxis = d3.axisRight()
.scale(yScale)
.tickSize(1)
.tickValues([0, 100, 200]);
var view = svg.append("rect")
.attr("class", "view")
.attr("x", 0.5)
.attr("y", 0.5)
.attr("width", width - 109)
.attr("height", height - 28);
var gX = svg.append('g')
.attr('class', 'xaxis')
.attr('transform', `translate(10,${height-20})`)
.call(xAxis);
// .selectAll('text')
// .style('font-size', '10px');
svg.append('g')
.attr('class', 'yaxis')
.attr('transform', 'translate(' + 1100 + ',' + 10 + ')')
.call(yAxis);
// .selectAll('text')
// .style('font-size', '10px');
var zoom = d3.zoom()
.scaleExtent([1, 100])
// .translateExtent([[-100, -100], [width + 90, height + 100]])
.on("zoom", zoomed);
d3.select("button")
.on("click", resetted);
svg.call(zoom);
var points = svg.append("g")
.attr('class', 'points');
var monthCount = d3.nest()
.key(function(d) { return Date.UTC(d.date.getUTCFullYear(), d.date.getUTCMonth(), 1); })
.rollup(function(v) { return { count: v.length, y: Math.random() * 100 + 50 }; })
.entries(changedDates);
function drawDates(dates, xScale) {
var points = svg.select(".points");
points.selectAll(".item").remove();
// use domain to group the dates
var minDate = xScale.domain()[0];
var minDatep1m = new Date(minDate.getTime()+30*24*60*60*1000); // + 1 month
var deltaX = xScale(minDatep1m) - xScale(minDate);
if (deltaX > 20) {
points.selectAll('.item')
.data(dates)
.enter()
.append('rect')
.attr('class', 'item')
.attr('x', d => xScale(d.date))
.attr('y', d => d.y)
.attr('width', 10)
.attr('height', 10)
.attr('clip-path', 'url(#clip)')
.style('fill', 'blue');
} else {
var groups = points.selectAll('.item')
.data(monthCount)
.enter()
.append('g')
.attr('class', 'item')
.attr('transform', d => `translate(${xScale(new Date().setTime(parseInt(d.key)))},${d.value.y})`);
groups.append('rect')
.attr('x', -5)
.attr('y', -5)
.attr('width', 10)
.attr('height', 10)
.attr('clip-path', 'url(#clip)')
.style('fill', 'red');
groups.append('text')
.text(d => d.value.count);
}
}
drawDates(changedDates, xScale);
function zoomed() {
var transformedX = d3.event.transform.rescaleX(xScale);
gX.call(xAxis.scale(transformedX));
drawDates(changedDates, transformedX)
}
function resetted() {
svg.transition()
.duration(750)
.call(zoom.transform, d3.zoomIdentity);
}
</script>

D3 combination of bar and area chart

I am wondering is it possible to achieve the combination of area and bar chart in the way shown in the screenshot below?
Along with making the area in between clickable for some other action.
It would be really helpful if you can guide me to some of the examples to get an idea how to achieve the same.
I posted a codepen here. That creates a bar chart, and then separate area charts between each bar chart.
const BarChart = () => {
// set data
const data = [
{
value: 48,
label: 'One Rect'
},
{
value: 32,
label: 'Two Rect'
},
{
value: 40,
label: 'Three Rect'
}
];
// set selector of container div
const selector = '#bar-chart';
// set margin
const margin = {top: 60, right: 0, bottom: 90, left: 30};
// width and height of chart
let width;
let height;
// skeleton of the chart
let svg;
// scales
let xScale;
let yScale;
// axes
let xAxis;
let yAxis;
// bars
let rect;
// area
let areas = [];
function init() {
// get size of container
width = parseInt(d3.select(selector).style('width')) - margin.left - margin.right;
height = parseInt(d3.select(selector).style('height')) - margin.top - margin.bottom;
// create the skeleton of the chart
svg = d3.select(selector)
.append('svg')
.attr('width', '100%')
.attr('height', height + margin.top + margin.bottom)
.append('g')
.attr('transform', 'translate(' + margin.left + ', ' + margin.top + ')');
xScale = d3.scaleBand().padding(0.15);
xAxis = d3.axisBottom(xScale);
yScale = d3.scaleLinear();
yAxis = d3.axisLeft(yScale);
svg.append('g')
.attr('class', 'x axis')
.attr('transform', `translate(0, ${height})`);
svg.append('g')
.attr('class', 'y axis');
svg.append('g')
.attr('class', 'x label')
.attr('transform', `translate(10, 20)`)
.append('text')
.text('Value');
xScale
.domain(data.map(d => d.label))
.range([0, width])
.padding(0.3);
yScale
.domain([0, 75])
.range([height, 0]);
xAxis
.scale(xScale);
yAxis
.scale(yScale);
rect = svg.selectAll('rect')
.data(data);
rect
.enter()
.append('rect')
.style('fill', d => '#00BCD4')
.attr('y', d => yScale(d.value))
.attr('height', d => height - yScale(d.value))
.attr('x', d => xScale(d.label))
.attr('width', xScale.bandwidth());
// call the axes
svg.select('.x.axis')
.call(xAxis);
svg.select('.y.axis')
.call(yAxis);
// rotate axis text
svg.select('.x.axis')
.selectAll('text')
.attr('transform', 'rotate(45)')
.style('text-anchor', 'start');
if (parseInt(width) >= 600) {
// level axis text
svg.select('.x.axis')
.selectAll('text')
.attr('transform', 'rotate(0)')
.style('text-anchor', 'middle');
}
data.forEach(
(d, i) => {
if (data[i + 1]) {
areas.push([
{
x: d.label,
y: d.value
},
{
x: data[i + 1].label,
y: data[i + 1].value
}
]);
}
}
);
areas = areas.filter(
d => Object.keys(d).length !== 0
);
areas.forEach(
a => {
const area = d3.area()
.x((d, i) => {
return i === 0 ?
xScale(d.x) + xScale.bandwidth() :
xScale(d.x);
})
.y0(height)
.y1(d => yScale(d.y));
svg.append('path')
.datum(a)
.attr('class', 'area')
.style('fill', d => '#B2EBF2')
.attr('d', area)
.on('click', d => {
console.log('hello click!');
});
}
)
}
return { init };
};
const myChart = BarChart();
myChart.init();
#bar-chart {
height: 500px;
width: 100%;
}
<script src="https://unpkg.com/d3#5.2.0/dist/d3.min.js"></script>
<div id="bar-chart"></div>
After creating the bar chart, I repackage the data to make it conducive to creating an area chart. I created an areas array where each item is going to be a separate area chart. I'm basically taking the values for the first bar and the next bar, and packaging them together.
data.forEach(
(d, i) => {
if (data[i + 1]) {
areas.push([
{
x: d.label,
y: d.value
},
{
x: data[i + 1].label,
y: data[i + 1].value
}
]);
}
}
);
areas = areas.filter(
d => Object.keys(d).length !== 0
);
I then iterate through each element on areas and create the area charts.
The only tricky thing here, I think, is getting the area chart to span from the end of the first bar to the start of the second bar, as opposed to from the end of the first bar to the end of the second bar. To accomplish this, I added a rectangle width from my x-scale to the expected x value of the area chart when the first data point is being dealt with, but not the second.
I thought of this as making two points on a line: one for the first bar and one for the next bar. D3's area function can shade all the area under a line. So, the first point on my line should be the top-right corner of the first bar. The second point should be the top-left corner of the next bar.
Attaching a click event at the end is pretty straightforward.
areas.forEach(
a => {
const area = d3.area()
.x((d, i) => {
return i === 0 ?
xScale(d.x) + xScale.bandwidth() :
xScale(d.x);
})
.y0(height)
.y1(d => yScale(d.y));
svg.append('path')
.datum(a)
.attr('class', 'area')
.style('fill', d => '#B2EBF2')
.attr('d', area)
.on('click', d => {
console.log('hello click!');
});
}
)
In the example below, I have combined a simple bar chart (like in this famous bl.lock) with some polygons in between. I guess it could also be achieved with a path.
const data = [
{ letter: "a", value: 9 },
{ letter: "b", value: 6 },
{ letter: "c", value: 3 },
{ letter: "d", value: 8 }
];
const svg = d3.select("#chart");
const margin = { top: 20, right: 20, bottom: 30, left: 40 };
const width = +svg.attr("width") - margin.left - margin.right;
const height = +svg.attr("height") - margin.top - margin.bottom;
const xScale = d3.scaleBand()
.rangeRound([0, width]).padding(0.5)
.domain(data.map(d => d.letter));
const yScale = d3.scaleLinear()
.rangeRound([height, 0])
.domain([0, 10]);
const g = svg.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
g.append("g")
.attr("class", "axis axis--x")
.attr("transform", `translate(0,${height})`)
.call(d3.axisBottom(xScale));
g.append("g")
.attr("class", "axis axis--y")
.call(d3.axisLeft(yScale));
g.selectAll(".bar")
.data(data)
.enter().append("rect")
.attr("class", "bar")
.attr("x", d => xScale(d.letter))
.attr("y", d => yScale(d.value))
.attr("width", xScale.bandwidth())
.attr("height", d => height - yScale(d.value));
// Add polygons
g.selectAll(".area")
.data(data)
.enter().append("polygon")
.attr("class", "area")
.attr("points", (d,i,nodes) => {
if (i < nodes.length - 1) {
const dNext = d3.select(nodes[i + 1]).datum();
const x1 = xScale(d.letter) + xScale.bandwidth();
const y1 = height;
const x2 = x1;
const y2 = yScale(d.value);
const x3 = xScale(dNext.letter);
const y3 = yScale(dNext.value);
const x4 = x3;
const y4 = height;
return `${x1},${y1} ${x2},${y2} ${x3},${y3} ${x4},${y4} ${x1},${y1}`;
}
})
.on("click", (d,i,nodes) => {
const dNext = d3.select(nodes[i + 1]).datum();
const pc = Math.round((dNext.value - d.value) / d.value * 100.0);
alert(`${d.letter} to ${dNext.letter}: ${pc > 0 ? '+' : ''}${pc} %`);
});
.bar {
fill: steelblue;
}
.area {
fill: lightblue;
}
.area:hover {
fill: sandybrown;
cursor: pointer;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.13.0/d3.min.js"></script>
<svg width="400" height="300" id="chart"></svg>

Categories