Dynamic update to y-axis of a zoomable area chart - d3.js

On zoom and pan the y-axis isn't updated to the maximum value in the visible zoomed dataset e.g. when the max value is 3500 the y axis still has ticks for 3500, 4000, 4500, 5000 & 5500 which restricts the display. Can the new max value for the filtered data be more accurately updated?
const height = 400,
width = 800;
const margin = {
top: 20,
right: 20,
bottom: 30,
left: 50
};
const parser = d3.timeParse("%Y-%m-%d");
const url = "https://static.observableusercontent.com/files/4e532df03705fa504e8f95c1ab1c114ca9e89546bf14d697c73a10f72028aafd9eb3d6ea2d87bb6b421d9707781b8ac70c2bf905ccd60664f9e452a775fe50ed?response-content-disposition=attachment%3Bfilename*%3DUTF-8%27%27Book1%25401.csv";
d3.csv(url, function(d) {
return {
date: parser(d.date),
value: +d.value
};
}).then(function(data) {
const x = d3.scaleUtc()
.domain(d3.extent(data, d => d.date))
.range([margin.left, width - margin.right]),
y = d3.scaleLinear()
.domain([0, d3.max(data, d => d.value)])
.range([height - margin.bottom, margin.top]),
xAxis = (g, x) => g
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x).ticks(width / 80).tickSizeOuter(0)),
yAxis = (g, y) => g
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(y).ticks(5))
.call(g => g.select(".domain").remove())
.call(g => g.select(".tick:last-of-type text").clone()
.attr("x", 3)
.attr("text-anchor", "start")
.attr("font-weight", "bold")
.text(data.y)),
area = (data, x) => d3.area()
.curve(d3.curveStepAfter)
.x(d => x(d.date))
.y0(y(0))
.y1(d => y(d.value))
(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);
const svg = d3.select("svg")
.attr("width", width)
.attr("height", height);
svg.append("clipPath")
.attr("id", "myclip")
.append("rect")
.attr("x", margin.left)
.attr("y", margin.top)
.attr("width", width - margin.left - margin.right)
.attr("height", height - margin.top - margin.bottom);
const path = svg.append("path")
.attr("clip-path", "url('#myclip')")
.attr("fill", "steelblue")
.attr("d", area(data, x));
const gx = svg.append("g")
.call(xAxis, x);
const gy = svg.append("g")
.call(yAxis, y);
svg.call(zoom)
.transition()
.duration(750)
.call(zoom.scaleTo, 1, [x(Date.UTC(2020, 8, 1)), 0]);
function zoomed(event) {
const xz = event.transform.rescaleX(x);
const yz = event.transform.rescaleY(y);
path.attr("d", area(data, xz));
gx.call(xAxis, xz);
gy.call(yAxis, yz);
}
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.2.0/d3.js"></script>
<svg></svg>

TLDR
Y scale's domain contains still max value from whole dataset. Update the domain of the y scale to the max value of the currently visible dataset, by filtering it first by the rescaled x scales' domain.
Long version
I guess, your chart should only zoom in the X direction. Under this assumption, you need to implement an auto scaling for the y axis yourself. The current problem is, that your y scale's domain contains the min and max values of your whole data set. Now that you have zoomed the max value might be smaller.
So, what you need to do is, get the domain of the rescaled x scale domain. Use that domain to filter your dataset for that time range and then pick the max value out of that time range filtered subset. Then you update the domain for your y scale with that new max value and rerender. By the way, rescaling the y scale is not necessary, if you only want to do zoom on the x axis.
// global cache for the data
const data;
function zoomed(event) {
const xz = event.transform.rescaleX(x);
const [minX, maxX] = xz.domain();
const filteredData = data.filter((item) => item.date >= minX && item.date <= maxX);
y.domain([0, d3.max(filteredData, d => d.value)]);
const yz = event.transform.rescaleY(y);
path.attr("d", area(data, xz));
gx.call(xAxis, xz);
gy.call(yAxis, yz);
}

Thanks for guidance: https://observablehq.com/#steve-pegg/zoomable-area-chart
Changes below have worked.
function zoomed(event) {
var xz = event.transform.rescaleX(x);
var startDate = xz.domain()[0];
var endDate = xz.domain()[1];
var fData = data.filter(function (d) {
var date = new Date(d.date);
return (date >= startDate && date <= endDate);});
y.domain([0, d3.max(fData, function (d) { return d.value; })]);
path.attr("d", area(data, xz));
gx.call(xAxis, xz);
gy.call(yAxis, y);
}

Related

axis scale shift in d3.js

I am fairly new to d3.js
I am looking for a way to animate both x and y axises based on the new data. So it is more of a real time animation where the x axis is moving and the new data pops out from the right and y axis get updated dynamically as well and after a while the old data dissapear because I have so many data points.
I have this chart already made. https://jsfiddle.net/elvalencian/mfLjovx9/4/
// set the dimensions and margins of the graph
const margin = {
top: 40,
right: 80,
bottom: 60,
left: 50
},
width = 600 - margin.left - margin.right,
height = 300 - margin.top - margin.bottom;
// append the svg object to the body of the page
const svg = d3
.select("#root")
.append("svg")
.attr(
"viewBox",
`0 0 ${width + margin.left + margin.right} ${
height + margin.top + margin.bottom}`)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
//Read the data
d3.csv("https://raw.githubusercontent.com/sultanmalki/d3js/main/saudi_fdi.csv",
// When reading the csv, I must format variables:
function(d) {
return {
date: d3.timeParse("%Y")(d.date),
value: d.value
}
},
// Now I can use this dataset:
function(data) {
// Add X axis --> it is a date format
var x = d3.scaleTime()
.domain(d3.extent(data, function(d) {
return d.date;
}))
.range([0, width]);
svg.append("g")
.attr("transform", "translate(0," + height + ")")
.attr("class", "axis")
.transition().duration(5000)
.call(d3.axisBottom(x));
// Add Y axis
var y = d3.scaleLinear()
.domain([0, d3.max(data, function(d) {
return +d.value;
})])
.range([height, 0]);
svg.append("g")
.attr("class", "axis")
.transition()
.ease(d3.easeLinear)
.duration(5000)
.call(d3.axisLeft(y));
// Add the line
const linePath = svg
.append("path")
.datum(data)
.attr("fill", "none")
.attr("stroke", "#00B0F1")
.attr("stroke-width", 1.5)
.attr("d", d3.line().curve(d3.curveCardinal)
.x(function(d) {
return x(d.date)
})
.y(function(d) {
return y(d.value)
})
)
const pathLength = linePath.node().getTotalLength();
linePath
.attr("stroke-dasharray", pathLength)
.attr("stroke-dashoffset", pathLength)
.attr("stroke-width", 3)
.transition()
.attr("transform", "translate(" + ")")
.duration(5000)
.attr("stroke-width", 3)
.attr("stroke-dashoffset", 0);
})
I would really appreciate any help.
thank you in advance
Lines are rather difficult to animate, since they are only one path object instead of multiple objects as e. g. in a bar or scatter plot. You are already using the stroke-dasharray attribute for the animation of the static data. When there is new data, you need to
Rescale the axes:
To achieve this, compute the domain for x and y and set it via the domain method. Then re-render the axes with call(AxisObject) using a transition. Use the same transition t for both x and y.
Rescale the existing line
With the rescaled axes, also the existing line path must be rescaled. This works smoothly by transitioning the d attribute using the transition t before binding the new data.
Add new data
Wait till the end of transition t to bind the new data to the line path. Before doing that, calculate getTotalLength in order to set stroke-dasharray such that the new data is initially hidden. Then transition stroke-dasharray to the new path length. As the second value for stroke-dasharray I used 9999 which must be chosen longer than the maximal expected path length of the new data.
// Some stuff to generate random time series
// Standard Normal variate using Box-Muller transform.
function randn() {
let 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);
}
// Simulate geometric brownian motion
const mu = 0.8;
const sigma = 0.5;
function simulate() {
const prev = data[data.length - 1];
const x = prev.x + 0.01;
const bm = prev.bm + Math.sqrt(0.01) * randn();
data.push({
x: x,
bm: bm,
y: Math.exp((mu - sigma * sigma / 2) * x + sigma * bm)
});
}
// Initial data
let data = [{
x: 0,
bm: 0,
y: 1,
}];
// Add data to chart in chunks
const blockSize = 20;
let blockCounter = 0;
function addData() {
simulate();
blockCounter += 1;
if (blockCounter === blockSize) {
render(data.slice());
blockCounter = 0;
}
}
// Chart definitions
const width = 500,
height = 180,
marginLeft = 30,
marginRight = 10,
marginBottom = 30,
marginTop = 10;
const svg = d3.select("svg")
.attr("width", width)
.attr("height", height);
const xSlidingWindow = 2;
const x = d3.scaleLinear()
.range([marginLeft, width - marginRight]);
const y = d3.scaleLinear()
.range([height - marginBottom, marginTop]);
const xAxis = d3.axisBottom(x);
const yAxis = d3.axisLeft(y).ticks(3);
const line = d3.line()
.x(d => x(d.x))
.y(d => y(d.y));
const gx = svg.append("g")
.attr("transform", `translate(0,${height - marginBottom})`);
const gy = svg.append("g")
.attr("transform", `translate(${marginLeft},0)`);
// Clip path to only show lines inside the axes
const clipPath = svg.append("clipPath")
.attr("id", "clip-rect")
.append("rect")
.attr("x", marginLeft)
.attr("y", marginTop)
.attr("width", width - marginLeft - marginRight)
.attr("height", height - marginTop - marginBottom);
const path = svg.append("path")
.datum(data.slice())
.attr("clip-path", "url(#clip-rect)")
.attr("fill", "none")
.attr("stroke", "blue")
.attr("stroke-width", 2)
.attr("stroke-dasharray", "0, 9999");
function render(arr) {
// compute domain
const xMax = d3.max(arr, d => d.x);
x.domain([Math.max(xMax - xSlidingWindow, 0), Math.max(xSlidingWindow, xMax)]);
y.domain(d3.extent(arr, d => d.y));
// First, transition the axes
const t = d3.transition().duration(interval * blockSize / 2);
gx.transition(t).call(xAxis);
gy.transition(t).call(yAxis);
path.transition(t).attr("d", line);
t.on("end", () => {
// Then add new data
let pathLength = path.node().getTotalLength();
path.datum(arr)
.attr("stroke-dasharray", `${pathLength}, 9999`)
.attr("d", line);
pathLength = path.node().getTotalLength();
path.transition().duration(interval * blockSize / 2)
.attr("stroke-dasharray", `${pathLength}, 9999`)
.attr("d", line);
});
}
// Interval for data simulation
let intervalId;
const interval = 50;
function startStream() {
if (!intervalId) {
intervalId = setInterval(addData, interval);
}
}
function stopStream() {
clearInterval(intervalId);
intervalId = null;
}
function reset() {
clearInterval(intervalId);
data = [{
x: 0,
bm: 0,
y: 1,
}];
intervalId = setInterval(addData, interval);
}
d3.select("#start").on("click", startStream);
d3.select("#stop").on("click", stopStream);
d3.select("#reset").on("click", reset);
render(data.slice());
startStream();
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.3.0/d3.min.js"></script>
<div>
<button id="start">Start</button>
<button id="stop">Stop</button>
<button id="reset">Reset</button>
</div>
<svg></svg>

Bars not showing up in grouped bar chart

I'm trying to create a grouped bar chart showing students attenadance(present or absent) but for some reasons my chart is showing only one set of bars(the highest for present and the highest for absent. On inspecting the console I see that the remaining bars are there but they are not showing up on the chart. I don't know if the problem is with my data or my code(I have only included the js part of the code)
const SVG = {
height: 900,
width: 900
}
const margin = {top: 40, bottom: 40, right: 40, left: 40};
const innerWidth = SVG.width - margin.left - margin.right;
const innerHeight = SVG.height - margin.top - margin.bottom;
const bar = d3.select('body').append('svg').attr('width', SVG.width).attr('height', SVG.height);
const grp = bar.append('g').attr('transform', `translate(${margin.left}, ${margin.top})`);
const data = d3.csv('WEEK.csv')
.then(
function(data) {
console.log(data);
const keys = data.columns.slice(1);
console.log(keys)
const group = d3.map(data, function(d){return +d.Week}).keys()
const x0 = d3.scaleBand().domain(group)
.range([0, innerWidth]).padding(0.1)
const x1 = d3.scaleBand().domain(keys)
.range([0, x0.bandwidth()]).padding(0.05)
const y0 = d3.scaleLinear().domain([0, d3.max(data, function(d) {return d3.max(keys, function (key){return +d[key];}
)})])
.range([innerHeight, 0]);
const color = d3.scaleOrdinal().range('#fcba03', '#eb4034')
const yAxis = d3.axisLeft(y0);
const xAxis = d3.axisBottom(x0);
const yAxisG = grp.append('g').call(yAxis)
const xAxisG = grp.append('g').call(xAxis)
.attr('transform', `translate(0, ${innerHeight})`);
grp.append('g')
.selectAll('g')
.data(data)
.enter().append('g')
.selectAll('rect')
.attr("transform", function(d) {return "translate(" + x0(+d.Week) + ",0)"})
.data(function(d){return keys.map(function(key){return{key: key, value: +d[key]} }) })
.enter()
.append('rect')
.attr('width', x1.bandwidth())
.attr('height', d => (innerHeight - y0(d.value)))
.attr('y', d => y0(d.value))
.attr('x', d => x1(d.key))
.attr('fill', d => color(d.key));
})
The dataset:
Week ,Present,Absent
2,10481,4010
3,11277,4551
4,10499,5036
5,9970,5126
6,8901,4909
7,7929,8405
8,7995,5062
9,7785,5447
10,7670,5822
11,7177,6162
12,6258,6499
13,4689,6631

d3 : can't get values to display when more data is added to a bar chart

I'm using d3 to build a horizontal bar chart.
When it is first rendered the values are displayed within the bar:
However, when further data is added something is preventing it and the new bars from showing:
This problem is related to my code to display the values within the bars.
When I remove this chunk of code, the new bars show (just without the values in them):
Where am I going wrong?
function renderBarChart(data, metric, countryID) {
data = sortByHighestValues(data, metric)
const width = 0.9 * screen.width
const height = 0.8 * screen.height
const margin = { top: 0, right: 0, bottom: 20, left: 30 }
const innerHeight = height - margin.top - margin.bottom
const xScale = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d[metric])])
.range([margin.left, width]);
const yScale = d3
.scaleBand()
.domain(data.map((d) => d[countryID]))
.range([0, innerHeight])
.padding(0.2);
const yAxis = d3.axisLeft(yScale);
const xAxis = d3.axisBottom(xScale).ticks(10);
if (!barChartAxisRendered) {
renderYAxis(width, height, margin, yAxis)
renderXAxis(width, height, margin, xAxis, innerHeight)
} else {
updateXAxis(width, height, xAxis)
updateYAxis(width, height, yAxis)
}
barChartAxisRendered = true
renderBars(data, yScale, xScale, margin, metric, countryID)
};
function renderBars(data, yScale, xScale, margin, metric, countryID) {
let selectDataForBarCharts = d3.select("svg")
.selectAll("rect")
.data(data)
selectDataForBarCharts
.enter()
.append("rect")
.merge(selectDataForBarCharts)
.attr("fill", d => setBarColor(d))
.attr("y", (d) => yScale(d[countryID]))
.attr("width", (d) => xScale(d[metric]))
.attr("height", yScale.bandwidth())
.attr("transform", `translate(${margin.left}, ${margin.top})`)
selectDataForBarCharts
.enter()
.append("text")
.merge(selectDataForBarCharts)
.attr("class", "casesPerCapitaValues")
.attr('text-anchor', 'middle')
.attr("x", d => xScale(d[metric])-10)
.attr("y", d => yScale(d[countryID]) + 13)
.attr("fill", "white")
.style("font-size", "10px")
.text(d => d.casesPerCapita)
}
Your code breaks because you overwrite the rectangles with text. Make a different selections for texts and rects, or make a group containing corresponding rect+text of one row.

D3 line graph show positive and negative numbers

I'm working with D3 to create a line-graph. This graph is available here jsfiddle.
I'm trying to draw lines manually to represent certain data-point-values. I've tried to add comments to most of the lines in the code, so hopefully you can follow along.
My problem is that I cannot seem to draw negative numbers in a good way, if i do, then the graph-data-lines are misaligned. So my question is: How can i scale my graph so that I can show both negative and positive numbers? In this case, the graph should go from 2 to -2 based on the max/min values i've set.
currently. I'm scaling my graph like this
//
// Setup y scale
//
var y = d3.scale.linear()
.domain([0, max])
.range([height, 0]);
//
// Setup x scale
//
var x = d3.time.scale()
.domain(d3.extent(data, dateFn))
.range([0, width]);
In my mind, doing .domain([-2,max]) would be sufficient, but that seems to make things worse.
Also, my lines do not seem to match what the data-lines are saying. In the jsfiddle, the green line is set at 1. But the data-lines whose value are 1, are not on that green line.
So, this is pretty much a scale question i guess.
Visual (picasso-esc) representation of what the graph should look like if it worked.
As you want your y domain to be [-2, 2] as opposed to be driven by the data, you can remove a lot of setup and helper functions from your drawGraph function.
After drawing your graph, you can simply loop through the yLines array, and draw a line for each with the specified color, at the specified val according to your yScale.
Update: EDITED: As you will be supplied the values of nominal, upperTolerance, lowerTolerance, innerUpperTolerance, innerLowerTolerance from your endpoint (and they don't need to be calculated from the data on the client side), just feed those values into your data-driven yScale to draw the coloured lines.
Below I have just used the values 1, 1.8, -1.8, but you will receive values that will be more meaningfully tied to your data.
// Setup
const yLines = [{
val: 1,
color: 'green'
},
{
val: 1.8,
color: 'yellow'
},
{
val: -1.8,
color: 'red'
}
]
const margin = {
top: 10,
right: 80,
bottom: 60,
left: 20
};
const strokeWidth = 3;
const pointRadius = 4;
const svgWidth = 600;
const svgHeight = 600;
const width = svgWidth - margin.left - margin.right;
const height = svgHeight - margin.top - margin.bottom;
const stroke = '#2990ea'; // blue
const areaFill = 'rgba(41,144,234,0.1)'; // lighter blue
const format = d3.time.format("%b %e %Y");
const valueFn = function(d) {
return d.value
};
const dateFn = function(d) {
return format.parse(d.name)
};
// select the div and append svg to it
const graph = d3.select('#chart').append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.style('overflow', 'visible');
const transformGroup = graph.append('g')
.attr('tranform', `translate(${margin.left}, ${margin.right})`)
// Make a group for yLines
const extraLines = transformGroup.append('g')
.attr('class', 'extra-lines')
// Generate some dummy data
const getData = function() {
let JSONData = [];
for (var i = 0; i < 30; i++) {
JSONData.push({
"name": moment().add(i, 'days').format('MMM D YYYY'),
"value": Math.floor(Math.random() * (Math.floor(Math.random() * 20))) - 10
})
}
return JSONData.slice()
}
const drawGraph = function(data) {
console.log(data)
// Setup y scale
const y = d3.scale.linear()
.domain(d3.extent(data.map((d) => d.value)))
.range([height, 0]);
// Setup y axis
const yAxis = d3.svg.axis()
.scale(y)
.orient("left")
.ticks(10)
.tickSize(0, 0, 0)
// append group & call yAxis
transformGroup.append("g")
.attr("class", "y axis")
.attr("transform", "translate(" + margin.left + ",0)")
.call(yAxis);
// Draw extra coloured lines from yLines array
extraLines.selectAll('.extra-line')
.data(yLines)
.enter()
.append('line')
.attr('class', 'extra-line')
.attr('x1', margin.left)
.attr('x2', svgWidth - margin.right)
.attr('stroke', d => d.color)
.attr('y1', d => y(+d.val))
.attr('y2', d => y(+d.val))
.attr('stroke-width', strokeWidth)
.attr('opacity', 0.5)
// Setup x scale
const x = d3.time.scale()
.domain(d3.extent(data, dateFn))
.range([0, width])
// function for filling area under chart
const area = d3.svg.area()
.x(d => x(format.parse(d.name)))
.y0(height)
.y1(d => y(d.value))
// function for drawing line
const line = d3.svg.line()
.x(d => x(format.parse(d.name)))
.y(d => y(d.value))
const lineStart = d3.svg.line()
.x(d => x(format.parse(d.name)))
.y(d => y(0))
// make the line
transformGroup.append('path')
.attr('stroke', stroke)
.attr('stroke-width', strokeWidth)
.attr('fill', 'none')
.attr('transform', `translate(${margin.left}, ${margin.top})`)
.attr('d', lineStart(data))
.attr('d', line(data))
// fill area under the graph
transformGroup.append("path")
.datum(data)
.attr("class", "area")
.attr('fill', areaFill)
.attr('transform', `translate(${margin.left}, ${margin.top})`)
.attr('d', lineStart(data))
.attr("d", area)
}
drawGraph(getData())
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.10.6/moment.min.js"></script>
<div id="chart" style="margin: 0 auto;"></div>

Live Horizontal Bar Chart keeps adding nodes

I am trying to make a horizontal bar chart for test purposes which changes data in real time. I notice that nodes keep adding.
var dataset = [ 5, 10, 15, 20, 25 ]
var w = 1200;
var h = 500;
var barPadding = 1;
var container = d3.select("body")
.append("svg")
.attr("width", w)
.attr("height", h)
.append("g");
var rects = container.selectAll("rect")
var yScale = d3.scaleLinear()
.range([h, 0])
function draw(dataset, translate){
yScale.domain([0, d3.max(dataset)])
rects.data(dataset)
.enter()
.append("rect")
.attr("x", function(d, i){
return i * 12 + translate
})
.attr("y", function(d){
return yScale(d)
})
.attr("width", 11)
.attr("height", function(d) { return (h - yScale(d)) })
rects.exit().remove()
}
var translate = 0
setInterval(function(){
container.attr("transform", "translate("+-translate+",0)")
dataset.push(Math.floor(Math.random() * 30))
draw(dataset, translate)
translate = translate + 12
dataset.shift()
}, 1000)
rects.exit.remove() doesn't seem to work, how can I fix this? I could not find any examples of live horizontal bar charts on d3 v5 which is what I am using here
Right now you don't have a proper update selection, which is:
var rects = container.selectAll("rect")
.data(dataset);
Because of that, all rectangles belong to the enter selection.
Here is the updated code, with the size of the update selection in the console:
var dataset = [5, 10, 15, 20, 25]
var w = 500;
var h = 300;
var barPadding = 1;
var container = d3.select("body")
.append("svg")
.attr("width", w)
.attr("height", h)
.append("g");
var yScale = d3.scaleLinear()
.range([h, 0]);
var translate = 0
draw(dataset, translate)
function draw(dataset, translate) {
yScale.domain([0, d3.max(dataset)])
var rects = container.selectAll("rect")
.data(dataset);
rects.enter()
.append("rect")
.merge(rects)
.attr("x", function(d, i) {
return i * 12 + translate
})
.attr("y", function(d) {
return yScale(d)
})
.attr("width", 11)
.attr("height", function(d) {
return (h - yScale(d))
})
rects.exit().remove();
console.log("the update size is: " + rects.size())
}
setInterval(function() {
container.attr("transform", "translate(" + -translate + ",0)")
dataset.push(Math.floor(Math.random() * 30))
draw(dataset, translate)
translate = translate + 12
dataset.shift()
}, 1000)
<script src="https://d3js.org/d3.v5.min.js"></script>

Resources