Show only max values in d3 bar chart - javascript

I have the following bar chart.
https://jsfiddle.net/zyjp1abo/
As you can see the values are between 1000 and 1005. Showing all the data from 0 to 1005 does not sense since the differences aren't visible.
I'd like to show the bars from 1000 and 1005 and change the y axis accordingly. Simply using extent and changing the domain does not work since the bars are drawn through the bottom margin. I want them to stop at the lowest value, i.e 1000.
https://jsfiddle.net/zyjp1abo/1/
Any ideas? Thank you!

If you want the domain to go from 1000 to 1005, you should use d3.extent. That's not the problem.
The problem is that you are using d3.extent but you keep using y(0) both for translating your x axis and for calculating the bars heights, which is wrong. You have to use your height and your margins.
Here is your code with those changes:
var defaults = {
target: '#chart',
width: 500,
height: 170,
margin: {
top: 20,
right: 20,
bottom: 20,
left: 50
},
yTicks: 5
}
class Barchart {
constructor(config) {
Object.assign(this, defaults, config)
const {
target,
width,
height,
margin
} = this
const w = width - margin.left - margin.right
const h = height - margin.top - margin.bottom
const {
yTicks
} = this
this.chart = d3.select(target)
.attr('width', width)
.attr('height', height)
.append('g')
.attr('transform', `translate(${margin.left}, ${margin.top})`)
this.x = d3.scaleBand()
.rangeRound([0, w])
.padding(0.1)
this.y = d3.scaleLinear()
.rangeRound([h, 0])
this.xAxis = d3.axisBottom(this.x)
this.chart.append('g')
.attr('class', 'x axis')
this.yAxis = d3.axisLeft(this.y)
.ticks(yTicks)
this.chart.append('g')
.attr('class', 'y axis')
}
render(data) {
const {
x,
y,
xAxis,
yAxis,
chart
} = this
// y.domain(d3.extent(data, v => v.value))
y.domain(d3.extent(data, v => v.value))
const domain = data.map(d => d.timestamp)
x.domain(domain)
chart.select('.x.axis')
.attr('transform', `translate(0, ${(defaults.height - defaults.margin.bottom - defaults.margin.top)})`)
.call(xAxis)
chart.select('.y.axis')
.call(yAxis)
const bars = chart.selectAll('.bar')
.data(data)
bars
.enter()
.append('rect')
.attr('class', 'bar')
.merge(bars)
.attr('x', d => x(d.timestamp))
.attr('y', d => y(d.value))
.attr('width', x.bandwidth())
.attr('height', d => defaults.height - defaults.margin.bottom - defaults.margin.top - y(d.value))
}
}
const random = (min = -10, max = 10) => (
window.Math.floor(window.Math.random() * (max - min + 1)) + min
)
let bar = []
for (let i = 0; i < 20; i++) {
bar.push({
timestamp: Date.now() - (19 - i) * 500,
value: random(1000, 1005)
})
}
const barchart = new Barchart()
barchart.render(bar)
<script src="https://d3js.org/d3.v4.min.js"></script>
<svg id="chart">
</svg>
PS: You could simplify the math for the SVG size and margins.
PPS: There is a lot of debate regarding if a dataviz can have a non-zero baseline. I believe that some kinds of charts, in some situations, can and should have a non-zero baseline, specially line charts, when the change rate is more important than the absolute value, for instance. However, bar charts should always have a zero baseline.

After you change your domain, this line:
.attr('height', d => Math.abs(y(d.value) - y(0)))
Is still calculating the height of the bar based of a 0 value. The conventional way to calculate the height as in this example, is to base it off the inner height of your chart (your variable h).
Here's an updated fiddle.

y.domain([0, d3.max(data, v => v.value)])
change 0 here to 1000, and other places respectively

Related

How to create Stacked Line Chart in d3js Multiple Y Axis and common X Axis

Example Linechart
How can i draw a linechart like this in d3js?
You can draw individual line charts which are translated vertically. Translating them vertically can be achieved by placing each chart into a SVG group (g) and translating the group by setting the transform attribute.
They all share the same x axis with the same domain and range.
const x = d3.scaleLinear()
.domain([d3.min(t), d3.max(t)])
.range([0, width]);
Here is the entire code:
// dummy data with common domain for all charts
const t = d3.range(200).map(o => o * 0.1 + 1);
// three series of data => three charts
const data = [
t.map(t => (1 + Math.cos(t))/2),
t.map(t => 1 - 1/Math.pow(0.2*t, 2)),
t.map(t => (1 + Math.sin(t))/2),
];
// three different colors for three graphs
const graphColor = ['#F2CD5C', '#a71df2', '#A61f69'];
// we need to add some margin to see the full axis
const margin = { top: 10, bottom: 75, left: 30, right: 10 };
// static width and height - change this to whatever suits you
const width = 300;
const height = 400;
// the height of each stacked
const singleChartHeight = height / data.length;
const svg = d3.select('#stacked-charts')
.append('svg')
// set the size of the chart
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.attr("viewBox", [0, 0, width, height]);
// create a common x-axis for all charts
const x = d3.scaleLinear()
.domain([d3.min(t), d3.max(t)])
.range([0, width]);
// a global group with padding
const coreGroup = svg.append('g')
.attr('class', 'stacked-charts')
// translate the padding
.attr('transform', 'translate(' + margin.left + ' 0)');
// create three different y-axes - one for each series
const y = data.map(dataSeries => {
return d3.scaleLinear()
.domain([d3.min(dataSeries), d3.max(dataSeries)])
.range([singleChartHeight, 0]);
});
// the line generator
const lineGenerator = d3.line();
// create a chart for each series of data
data.forEach((dataSeries, chartIndex) => {
// create an individual group
const chartGroup = coreGroup.append('g')
.attr('class', 'chart')
// stack by translating vertically
.attr('transform', 'translate(0 ' + (chartIndex * singleChartHeight) + ')');
// use x/y axes to create the points
const points = t.map((timestamp, timeStampIndex) => {
return [
// the x value
x(timestamp),
// the y value from one of the three axes
y[chartIndex](dataSeries[timeStampIndex])
]
});
// create the SVG path for the line
const path = lineGenerator(points);
// draw the graph
chartGroup.append('path')
.attr('class', 'graph')
.attr('d', path)
.attr('fill', 'none')
.attr('stroke', graphColor[chartIndex]);
// add x axis
chartGroup.append('g')
.attr('class', 'x-axis')
.call(d3.axisBottom(x))
.attr('transform', 'translate(0 '+ singleChartHeight + ')');
// add y axis
chartGroup.append('g')
.attr('class', 'y-axis')
.call(d3.axisLeft(y[chartIndex]));
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<div style="text-align: center;">
<h1>Stacked Linear Charts</h1>
<div id="stacked-charts"></div>
</div>
If you want all charts to have the same y-scale, they could also share the same y-axis.

How can I remove non-integer ticks while zooming in with D3.js?

Problem: I am trying to create a pannable/zoomable Linear X axis with ticks on each integer from 0 to a predefined large number (let's say 1000 but could be much higher). Obviously this will not all fit on the screen and be readable, so I need it to go offscreen and be pannable. Starting with 0 through 10 is acceptable. There are a number of problems with my solution so far, but the first being that if I zoom in, D3 automatically adds ticks in between the integers at decimal places to continue to have 10 ticks on the screen. The second being when I zoom out, the range will display beyond 1000.
A quick note: The data that will be displayed is irrelevant. The axis has to go from 0 to 1000 regardless if there is a datapoint on 1000 or not so I cannot determine the domain based on my dataset.
Solutions I've tried:
.ticks(). This lets me specify the number of ticks I want. However this appears to put all the ticks into the initial range specified. If I increase the width of the range past the svg size, it spreads out the ticks, but not in any controllable way (to my knowledge). Additionally I run into performance issues where the panning and zooming. I find this behavior strange since if I don't specify ticks, it makes an infinite number I can pan through without any lag. I assume the lag occurs because it's trying to render all 1000 ticks immediately and the default d3.js functionality renders them dynamically as you scroll. This seems like a potential solution, but I'm not sure how to execute it.
.tickValues(). I can provide an array of 0 through 1000 and this works but exhibits the exact same lag behavior as .ticks(). This also doesn't dynamically combine ticks into 10s or 100s as I zoom out.
.tickFormat(). I can run a function through so that any non-integer number is converted to an empty string. However, this still leaves the tick line.
Here are the relevant parts of my code using the latest version of D3.js(7.3):
const height = 500
const width = 1200
const margin = {
top: 20,
right: 20,
bottom: 20,
left: 35
}
const innerWidth = width - margin.left - margin.right
const innerHeight = height - margin.top - margin.bottom
const xScale = d3.scaleLinear()
.domain([0, 10])
.range([0, innerWidth])
const svg = d3.select('svg')
.attr("width", width)
.attr("height", height)
svg.append('defs').append('clipPath')
.attr('id', 'clip')
.append('rect')
.attr('width', innerWidth)
.attr('height', innerHeight)
const zoom = d3.zoom()
.scaleExtent([1, 8])
.translateExtent([
[0, 0],
[xScale(upperBound), 0]
])
.on('zoom', zoomed)
const g = svg.append('g')
.attr('transform', `translate(${margin.left}, ${margin.top})`)
const xAxis = d3.axisBottom()
.scale(xScale)
const xAxisG = g.append('g')
.style('clip-path', 'url(#clip)')
.attr("class", "x-axis")
.call(xAxis)
.attr('transform', `translate(0, ${innerHeight})`)
svg.call(zoom)
function zoomed(event) {
const updateX = event.transform.rescaleX(xScale)
const zx = xAxis.scale(updateX)
xAxisG.call(zx)
}
Instead of dealing with the axis' methods, you can simply select the ticks in the container group itself, removing the non-integers:
xAxisG.call(zx)
.selectAll(".tick")
.filter(e => e % 1)
.remove();
Here is your code with that change:
const upperBound = 1000
const height = 100
const width = 600
const margin = {
top: 20,
right: 20,
bottom: 20,
left: 35
}
const innerWidth = width - margin.left - margin.right
const innerHeight = height - margin.top - margin.bottom
const xScale = d3.scaleLinear()
.domain([0, 10])
.range([0, innerWidth])
const svg = d3.select('svg')
.attr("width", width)
.attr("height", height)
svg.append('defs').append('clipPath')
.attr('id', 'clip')
.append('rect')
.attr('width', innerWidth)
.attr('height', innerHeight)
const zoom = d3.zoom()
.scaleExtent([1, 8])
.translateExtent([
[0, 0],
[xScale(upperBound), 0]
])
.on('zoom', zoomed)
const g = svg.append('g')
.attr('transform', `translate(${margin.left}, ${margin.top})`)
const xAxis = d3.axisBottom()
.scale(xScale)
.tickFormat(d => ~~d)
const xAxisG = g.append('g')
.style('clip-path', 'url(#clip)')
.attr("class", "x-axis")
.call(xAxis)
.attr('transform', `translate(0, ${innerHeight})`)
svg.call(zoom)
function zoomed(event) {
const updateX = event.transform.rescaleX(xScale)
const zx = xAxis.scale(updateX)
xAxisG.call(zx)
.selectAll(".tick")
.filter(e => e % 1)
.remove();
}
<script src="https://d3js.org/d3.v7.min.js"></script>
<svg></svg>

D3 scatterplot newbe

I'm new to D3 and need a simple scatterplot.
The problem is that the data is not showing up where I want it to show up. I made some test data giving values for x and y between 100 an 200 but the dots
always seem to be in the same place on the screen. What I change to domain or range they show up on the same place. I think It must be something fundamental but I cant find it. Please give me a clue.
This is the code from the test:
<!DOCTYPE html>
<meta charset="utf-8">
<!-- Load d3.js -->
<script src="d3.v6.js"></script>
<!-- Create a div where the graph will take place -->
<div id="my_dataviz"></div>
<script>
// set the dimensions and margins of the graph
const margin = {top: 10, right: 30, bottom: 30, left: 60},
width = 920 - margin.left - margin.right,
height = 800 - margin.top - margin.bottom;
// append the svg object to the body of the page
const svg = d3.select("#my_dataviz")
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", `translate(${margin.left}, ${margin.top})`);
let dataExample = [];
for (let i = 0; i < 10000; i++) {
const x = Math.floor(Math.random() * 100) + 100;
const y = Math.floor(Math.random() * 100) + 100;
dataExample.push([x, y]);
}
//Read the data (DataFile.csv is NOT used. Using data from dataExample
d3.csv("DataFile.csv").then( function(data) {
// Add X axis
const x = d3.scaleLinear()
.domain([0, 10000])
.range([ 0, width ]);
svg.append("g")
.attr("transform", `translate(0, ${height})`)
.call(d3.axisBottom(x));
// Add Y axis
const y = d3.scaleLinear()
.domain([0, 10000])
.range([ height, 0]);
svg.append("g")
.call(d3.axisLeft(y));
// Add dots
svg.append('g')
.selectAll("dot")
.data(dataExample)
.join("circle")
.attr("cx", function (d) { return d[0]; } )
.attr("cy", function (d) { return d[1]; } )
//console.log(dataExample)
.attr("r", 1.5)
.style("fill", "#69b3a2")
})
</script>
The axes are 0 to 10000 but the plotted data shows op between y=7200 to 8800 and x=800 and 2500.
You need to use your x and y scales when setting the "cx" and "cy" attributes of the circles. Right now you're setting these attributes to the values in your data, without using the scales. This code should look like this:
svg.append('g')
.selectAll("circle")
.data(dataExample)
.join("circle")
.attr("cx", function (d) { return x(d[0]); } )
.attr("cy", function (d) { return y(d[1]); } )
.attr("r", 1.5)
.style("fill", "#69b3a2");
The purpose of the x and y scales in the scatterplot are to map values in your data to positions in the scatterplot.
With this fix, you'll likely want to update the domains of the scales to better match the values in the data, such as by using d3.extent to get the min and max values.

Translate zoom after domain change

I have some time-series data whose domain changes: I can take the last 6-months, last year, the last 2 years, and so on. I've created a D3 chart that just displays data.
However, you can also zoom this chart, but when you zoom then change the domain, the zoom "resets" but works again when you click.
When the domain changes, I'd like to keep the current zoom: since it's timeseries data, I'd like it to be in the same place. How can I accomplish this?
<head>
<script src="https://d3js.org/d3.v6.min.js"></script>
<div class="buttons">
<button id="sixmo">Last 6 months</button>
<button id="oneyear">Last year</button>
<button id="twoyears">Last 2 years</button>
</div>
</head>
<body>
<script>
// Random data
function randomData() {
function randn_bm() {
var u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
}
let days = []
let endDate = new Date(2020, 1, 0)
for (var d = new Date(2018, 0, 0); d <= endDate; d.setDate(d.getDate() + 1)) {
days.push(new Date(d));
}
return days.map(d => ({
date: d,
value: randn_bm()
}))
}
// Chart
const height = 600
const width = 800
const margin = { top: 20, right: 0, bottom: 30, left: 40 }
let x;
let y;
const zoomed = (event) => {
let xz = event.transform.rescaleX(x);
gX.call(xAxis, xz);
gLine.selectAll("path")
.data([data])
.join("path")
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5)
.attr("d", d3.line()
.x(d => xz(d.date))
.y(d => y(d.value)))
}
const zoom = d3.zoom()
.scaleExtent([1, 32])
.extent([[margin.left, 0], [width - margin.right, height]])
.translateExtent([[margin.left, -Infinity], [width - margin.right, Infinity]])
.on("zoom", zoomed);
const svg = d3.select("body").append("svg")
.attr("viewBox", [0, 0, width, height]);
svg.call(zoom)
const gLine = svg.append("g").attr("class", "series").attr("clip-path", "url(#clip)")
const gX = svg.append("g").attr("class", "x-axis")
const gY = svg.append("g").attr("class", "y-axis")
const xAxis = (g, x) => g
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x).tickSizeOuter(0))
const yAxis = (g, y) => g
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(y))
.call(g => g.select(".domain").remove())
svg.append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("x", margin.left)
.attr("y", margin.top)
.attr("width", width - margin.left - margin.right)
.attr("height", height - margin.top - margin.bottom);
function renderChart(data) {
x = d3.scaleTime()
.domain(d3.extent(data, d => d.date))
.range([margin.left, width - margin.right])
y = d3.scaleLinear()
.domain(d3.extent(data, d => d.value)).nice()
.range([height - margin.bottom, margin.top])
gX.call(xAxis, x);
gY.call(yAxis, y);
gLine.selectAll("path")
.data([data])
.join("path")
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5)
.attr("d", d3.line()
.x(d => x(d.date))
.y(d => y(d.value)))
}
// Buttons
const data = randomData()
const lastDataDate = new Date(2020, 1, 0)
const buttons = d3.select(".buttons")
.selectAll("button")
.data([6, 12, 24])
.join("button")
.on("click", (_, months) => {
const startDate = new Date(lastDataDate)
startDate.setMonth(startDate.getMonth() - months)
const filteredData = data.filter(d => d.date > startDate)
renderChart(filteredData)
})
renderChart(data)
</script>
</body>
Problem
If you use d3.zoom to zoom/pan, you need to let d3.zoom know when you've manually gone ahead and altered the pan/zoom. It doesn't "know" what sort of tampering you do outside of it. Further, if you are going to update the zoom status of an element so that d3.zoom "knows" of the change, why not use d3.zoom to actually do the zooming and panning too?
In your example, you use the zoom to set the scale of the data, but when you click on the buttons, you set the zoom by merely filtering the data. d3.zoom is none the wiser. That's why the jump occurs when you use a button and then the zoom - the zoom behavior picks up where it was last left.
Lastly, you have written two methods to zoom and pan, when you could just run it all through d3.zoom.
This is not an uncommon problem - here's an example of a the same principle at play.
Solution
Only use one method to zoom/pan. This way there is no need to sync the behavior and state of two separate mechanisms for zoom/pan. You can use d3.zoom for both programmatic zooms and standard zooming quite easily.
You'll find it easiest with a reference scale when dealing with axes and scales - this way zooming is relative to the original zoom state and not the last zoom state (which can cause problems). We use the reference scale each zoom event to rescale our working scale. The working scale is passed to the axis generator and used to position the data.
So in your case, our zoom function simply looks like:
const zoomed = (event) => {
xScale.domain(event.transform.rescaleX(xReference).domain());
draw(data);
}
We rescale the xScale each time to reflect the new domain shown by the zoom transform provided by the zoom event.
This works for mouse interaction with no further modification. We can invoke the programmatic zoom with svg.call(zoom.transform, someZoomTransform), all we have to do is calculate the proper transform, using your code as an example this looked something like:
const endDate = lastDataDate;
const startDate = d3.timeMonth.offset(endDate,-months);
// k = width of range needed for data set / width of range needed for area of interest
const k = (xReference.range()[1] - xReference.range()[0]) / (xReference(endDate) - xReference(startDate))\
// translate to account for starting point of area of interest.
const tx = xReference(startDate);
// let the zoom handle it.
svg.call(zoom.transform, d3.zoomIdentity
.scale(k)
.translate(-tx+margin.left/k, 0) // margin.left/k : account for scale range not starting at 0.
);
Putting that together we get:
const height = 500;
const width = 500;
const margin = { top: 20, right: 0, bottom: 30, left: 40 }
const svg = d3.select("body").append("svg")
.attr("width",width)
.attr("height",height);
var data = randomData();
// Set up Scales:
let xScale = d3.scaleTime()
.domain(d3.extent(data, d => d.date))
.range([margin.left, width - margin.right])
// Reference to hold starting version of scale:
const xReference = xScale.copy();
let yScale = d3.scaleLinear()
.domain(d3.extent(data, d => d.value)).nice()
.range([height - margin.bottom, margin.top])
// Set up Zoom:
const zoomed = (event) => {
xScale.domain(event.transform.rescaleX(xReference).domain());
draw(data);
}
const zoom = d3.zoom()
.scaleExtent([1, 32])
.extent([[margin.left, 0], [width - margin.right, height]])
.translateExtent([[margin.left, -Infinity], [width - margin.right, Infinity]])
.on("zoom", zoomed);
svg.call(zoom);
// Set up axes and miscellania
const gLine = svg.append("g").attr("class", "series").attr("clip-path", "url(#clip)")
const gX = svg.append("g").attr("class", "x-axis")
const gY = svg.append("g").attr("class", "y-axis")
const xAxis = (g, x) => g
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(xScale).tickSizeOuter(0))
const yAxis = (g, y) => g
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(yScale))
.call(g => g.select(".domain").remove())
svg.append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("x", margin.left)
.attr("y", margin.top)
.attr("width", width - margin.left - margin.right)
.attr("height", height - margin.top - margin.bottom);
// Draw:
function draw(data) {
gX.call(xAxis, xScale);
gY.call(yAxis, yScale);
gLine.selectAll("path")
.data([data])
.join("path")
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5)
.attr("d", d3.line()
.x(d => xScale(d.date))
.y(d => yScale(d.value)))
}
// Button Behavior
const lastDataDate = new Date(2020, 1, 0)
const buttons = d3.select(".buttons")
.selectAll("button")
.data([6, 12, 24])
.join("button")
.on("click", (_, months) => {
const endDate = lastDataDate;
const startDate = d3.timeMonth.offset(endDate,-months);
// k = width of range needed for data set / width of range needed for area of interest
const k = (xReference.range()[1] - xReference.range()[0]) / (xReference(endDate) - xReference(startDate))
// translate to account for starting point of area of interest.
const tx = xReference(startDate);
// let the zoom handle it.
svg.call(zoom.transform, d3.zoomIdentity
.scale(k)
.translate(-tx+margin.left/k, 0) // account for scale range not starting at 0.
);
})
draw(data);
// Random data
function randomData() {
function randn_bm() {
var u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
}
let days = []
let endDate = new Date(2020, 1, 0)
for (var d = new Date(2018, 0, 0); d <= endDate; d.setDate(d.getDate() + 1)) {
days.push(new Date(d));
}
return days.map(d => ({
date: d,
value: randn_bm()
}))
}
<script src="https://d3js.org/d3.v6.min.js"></script>
<div class="buttons">
<button id="sixmo">Last 6 months</button>
<button id="oneyear">Last year</button>
<button id="twoyears">Last 2 years</button>
</div>
One way to do this would be by storing the last zoom event in a variable, and, if one exists, then instead of redrawing the axis from scratch, calling zoomed() with that last event.
EDIT: I now understand your question better. What I did below was as follows:
Whenever a button is clicked, get first the zoomed domain xz;
Then see if we need to clamp it so the domain is a subset of the new data. xz.domain() must always fall within x.domain();
If that was the case, calculate the scaling factor and the point at the centre of the viewport;
Redraw the chart completely;
Ask d3 to scale to the correct ratio, using the previously calculated scale, then ask it to pan to the correct position, using the centre point previously calculated.
Furthermore, I've changed the y-domain to always be calculated with the entire data set. This makes sure the line doesn't jump vertically when any of the buttons are pushed.
There is no jumping around the x-axis, unless your viewport covers data that is no longer available after you click a button.
Testcases
The view should remain the same with all of these:
Click 'last year', then '2 years';
Click '2 years', then zoom into range Nov-Jan 2020. Click '6 months';
Click 'last year', zoom and pan until it covers Feb-Apr 2019. Click '2 years';
Click '6 months', then 'last year', then '2 years'.
The view should change with all of these:
Click '2 years', zoom out fully, then '6 months';
Click '2 years', then zoom into range Feb-Jan 2020. Click '6 months';
Click 'last year', zoom and pan until it covers Feb-Apr 2019. Click '6 months'.
// Random data
function randomData() {
function randn_bm() {
var u = 0,
v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
}
let days = []
let endDate = new Date(2020, 1, 0)
for (var d = new Date(2018, 0, 0); d <= endDate; d.setDate(d.getDate() + 1)) {
days.push(new Date(d));
}
return days.map(d => ({
date: d,
value: randn_bm()
}))
}
// Chart
const height = 400
const width = 800
const margin = {
top: 20,
right: 0,
bottom: 30,
left: 40
}
let x;
let y;
let xz;
const zoomed = (event) => {
xz = event.transform.rescaleX(x);
gX.call(xAxis, xz);
gLine.selectAll("path")
.data([data])
.join("path")
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5)
.attr("d", d3.line()
.x(d => xz(d.date))
.y(d => y(d.value)))
}
const zoom = d3.zoom()
.scaleExtent([1, 32])
.extent([
[margin.left, 0],
[width - margin.right, height]
])
.translateExtent([
[margin.left, -Infinity],
[width - margin.right, Infinity]
])
.on("zoom", zoomed);
const svg = d3.select("body").append("svg")
.attr('width', width)
.attr('height', height);
svg.call(zoom)
const gLine = svg.append("g").attr("class", "series").attr("clip-path", "url(#clip)")
const gX = svg.append("g").attr("class", "x-axis")
const gY = svg.append("g").attr("class", "y-axis")
const xAxis = (g, x) => g
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x).tickSizeOuter(0))
const yAxis = (g, y) => g
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(y))
.call(g => g.select(".domain").remove())
svg.append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("x", margin.left)
.attr("y", margin.top)
.attr("width", width - margin.left - margin.right)
.attr("height", height - margin.top - margin.bottom);
function renderChart(data) {
x = d3.scaleTime()
.domain(d3.extent(data, d => d.date))
.range([margin.left, width - margin.right])
let reScale = false,
domain,
centerPoint;
if(xz !== undefined) {
domain = xz.domain();
centerPoint = xz.invert((width - margin.left - margin.right) / 2);
// If the previous center completely falls out of the current bounds, draw the chart anew.
if(domain[1] < data[0].date || domain[0] > data[data.length - 1].date) {
// Nothing
} else {
// Else, clip the domain to fit the data.
if(domain[0] < data[0].date) {
domain[0] = data[0].date;
}
if(domain[1] > data[data.length - 1].date) {
domain[1] = data[data.length - 1].date;
}
reScale = true;
}
}
gY.call(yAxis, y);
gX.call(xAxis, x);
gLine.selectAll("path")
.data([data])
.join("path")
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5)
.attr("d", d3.line()
.x(d => x(d.date))
.y(d => y(d.value)))
if(reScale) {
const scale = (x.domain()[1] - x.domain()[0])/(domain[1] - domain[0]);
svg.call(zoom.scaleTo, scale)
.call(zoom.translateTo, centerPoint, 0);
}
}
// Buttons
const data = randomData()
// To avoid jumpy behaviour, make sure the y-domain is steady
y = d3.scaleLinear()
.domain(d3.extent(data, d => d.value)).nice()
.range([height - margin.bottom, margin.top])
const lastDataDate = new Date(2020, 1, 0)
const buttons = d3.select(".buttons")
.selectAll("button")
.data([6, 12, 24])
.join("button")
.on("click", (_, months) => {
const startDate = new Date(lastDataDate)
startDate.setMonth(startDate.getMonth() - months)
const filteredData = data.filter(d => d.date > startDate)
renderChart(filteredData)
})
renderChart(data)
<script src="https://d3js.org/d3.v6.js"></script>
<div class="buttons">
<button id="sixmo">Last 6 months</button>
<button id="oneyear">Last year</button>
<button id="twoyears">Last 2 years</button>
</div>

D3 Grouped Bar Chart From Arrays

I'm really having trouble with D3 and need some help changing my existing barchart to be a grouped barchart The barchart is being used within a tooltip and currently looks like:
Each colour represents a sector of industry (pink = retail, teal = groceries...etc).
I need to change the bar chart so that it compares the percentage change in each industry with the world average percentage change in this industry.
At the moment the bar chart is being created from an array of data. I also have an array with the world percentage values.
So imagine:
countryData = [10,-20,-30,-63,-23,20],
worldData = [23,-40,-23,-42,-23,40]
Where index 0 = retail sector, index 1 = grocery sector, etc.
I need to plot a grouped barchart comparing each sector to the world average (show the world average in red). This is a bit tricky to explain so I drew it for you (...excuse the shoddy drawing).
Please can someone help me change my existing tooltip?
Here's the current code. If you want to simulate the data values changing.
If you want to scrap my existing code that's fine.
.on('mouseover', ({ properties }) => {
// get county data
const mobilityData = covid.data[properties[key]] || {};
const {
retailAverage,
groceryAverage,
parksAverage,
transitAverage,
workplaceAverage,
residentialAverage,
} = getAverage(covid1);
let avgArray = [retailAverage, groceryAverage, parksAverage, transitAverage, workplaceAverage, retailAverage];
let categoriesNames = ["Retail", "Grocery", "Parks", "Transit", "Workplaces", "Residential"];
// create tooltip
div = d3.select('body')
.append('div')
.attr('class', 'tooltip')
.style('opacity', 0);
div.html(properties[key]);
div.transition()
.duration(200)
.style('opacity', 0.9);
// calculate bar graph data for tooltip
const barData = [];
Object.keys(mobilityData).forEach((industry) => {
const stringMinusPercentage = mobilityData[industry].slice(0, -1);
barData.push(+stringMinusPercentage); // changing it to an integer value, from string
});
//combine the two lists for the combined bar graph
var combinedList = [];
for(var i = 0; i < barData.length; i++) {
const stringMinusPercentage2 = +(avgArray[i].slice(0, -1));
const object = {category: categoriesNames[i], country: barData[i], world: stringMinusPercentage2}
combinedList.push(object); //Push object into list
}
console.log(combinedList);
// barData = barData.sort(function (a, b) { return a - b; });
// sort into ascending ^ keeping this in case we need it later
const height2 = 220;
const width2 = 250;
const margin = {
left: 50, right: 10, top: 20, bottom: 15,
};
// create bar chart svg
const svgA = div.append('svg')
.attr('height', height2)
.attr('width', width2)
.style('border', '1px solid')
.append('g')
// apply the margins:
.attr('transform', `translate(${[`${margin.left},${margin.top}`]})`);
const barWidth = 30; // Width of the bars
// plot area is height - vertical margins.
const chartHeight = height2 - margin.top - margin.left;
// set the scale:
const yScale = d3.scaleLinear()
.domain([-100, 100])
.range([chartHeight, 0]);
// draw some rectangles:
svgA
.selectAll('rect')
.data(barData)
.enter()
.append('rect')
.attr('x', (d, i) => i * barWidth)
.attr('y', (d) => {
if (d < 0) {
return yScale(0); // if the value is under zero, the top of the bar is at yScale(0);
}
return yScale(d); // otherwise the rectangle top is above yScale(0) at yScale(d);
})
.attr('height', (d) => Math.abs(yScale(0) - yScale(d))) // the height of the rectangle is the difference between the scale value and yScale(0);
.attr('width', barWidth)
.style('fill', (d, i) => colours[i % 6]) // colour the bars depending on index
.style('stroke', 'black')
.style('stroke-width', '1px');
// Labelling the Y axis
const yAxis = d3.axisLeft(yScale);
svgA.append('text')
.attr('class', 'y label')
.attr('text-anchor', 'end')
.attr('x', -15)
.attr('y', -25)
.attr('dy', '-.75em')
.attr('transform', 'rotate(-90)')
.text('Percentage Change (%)');
svgA.append('g')
.call(yAxis);
})
.on('mouseout', () => {
div.style('opacity', 0);
div.remove();
})
.on('mousemove', () => div
.style('top', `${d3.event.pageY - 140}px`)
.style('left', `${d3.event.pageX + 15}px`));
svg.append('g')
.attr('transform', 'translate(25,25)')
.call(colorLegend, {
colorScale,
circleRadius: 10,
spacing: 30,
textOffset: 20,
});
};
drawMap(svg1, geoJson1, geoPath1, covid1, key1, 'impact1');
drawMap(svg2, geoJson2, geoPath2, covid2, key2, 'impact2');
};
In short I would suggest you to use two Band Scales for x axis. I've attached a code snippet showing the solution.
Enjoy ;)
//Assuming the following data final format
var finalData = [
{
"groupKey": "Retail",
"sectorValue": 70,
"worldValue": 60
},
{
"groupKey": "Grocery",
"sectorValue": 90,
"worldValue": 90
},
{
"groupKey": "other",
"sectorValue": -20,
"worldValue": 30
}
];
var colorRange = d3.scaleOrdinal().range(["#00BCD4", "#FFC400", "#ECEFF1"]);
var subGroupKeys = ["sectorValue", "worldValue"];
var svg = d3.select("svg");
var margin = {top: 20, right: 20, bottom: 30, left: 40};
var width = +svg.attr("width") - margin.left - margin.right;
var height = +svg.attr("height") - margin.top - margin.bottom;
var container = svg.append("g").attr("transform", "translate(" + margin.left + "," + margin.top + ")");
// The scale spacing the groups, your "sectors":
var x0 = d3.scaleBand()
.domain(finalData.map(d => d.groupKey))
.rangeRound([0, width])
.paddingInner(0.1);
// The scale for spacing each group's bar, your "sector bar":
var x1 = d3.scaleBand()
.domain(subGroupKeys)
.rangeRound([0, x0.bandwidth()])
.padding(0.05);
var yScale = d3.scaleLinear()
.domain([-100, 100])
.rangeRound([height, 0]);
//and then you will need to append both, groups and bars
var groups = container.append('g')
.selectAll('g')
.data(finalData, d => d.groupKey)
.join("g")
.attr("transform", (d) => "translate(" + x0(d.groupKey) + ",0)");
//define groups bars, one per sub group
var bars = groups
.selectAll("rect")
.data(d => subGroupKeys.map(key => ({ key, value: d[key], groupKey: d.groupKey })), (d) => "" + d.groupKey + "_" + d.key)
.join("rect")
.attr("fill", d => colorRange(d.key))
.attr("x", d => x1(d.key))
.attr("width", (d) => x1.bandwidth())
.attr('y', (d) => Math.min(yScale(0), yScale(d.value)))
.attr('height', (d) => Math.abs(yScale(0) - yScale(d.value)));
//append x axis
container.append("g")
.attr("class", "axis")
.attr("transform", "translate(0," + height + ")")
.call(d3.axisBottom(x0));
//append y axis
container.append("g")
.attr("class", "y axis")
.call(d3.axisLeft(yScale))
.append("text")
.attr("x", 2)
.attr("y", yScale(yScale.ticks().pop()) + 0.5)
.attr("dy", "0.32em")
.attr("fill", "#000")
.attr("font-weight", "bold")
.attr("text-anchor", "start")
.text("Values");
<script src="https://d3js.org/d3.v7.min.js"></script>
<svg width="600" height="400"></svg>

Categories

Resources