I'm implementing a chart using d3 that has a sliding x axis. Demo
I noticed that the amount of ticks (i.e. the amount of axis labels) keeps growing, meaning that the labels that slide out of the chart are not removed from the DOM.
Why are the old labels stay in the DOM, and how could I fix that?
const timeWindow = 10000;
const transitionDuration = 3000;
const xScaleDomain = (now = new Date()) =>
[now - timeWindow, now];
const totalWidth = 500;
const totalHeight = 200;
const margin = {
top: 30,
right: 50,
bottom: 30,
left: 50
};
const width = totalWidth - margin.left - margin.right;
const height = totalHeight - margin.top - margin.bottom;
const svg = d3.select('.chart')
.append('svg')
.attr('width', totalWidth)
.attr('height', totalHeight)
.append('g')
.attr('transform', `translate(${margin.left}, ${margin.top})`)
svg
.append('rect')
.attr('width', width)
.attr('height', height);
// Add x axis
const xScale = d3.scaleTime()
.domain(xScaleDomain(new Date() - transitionDuration))
.range([0, width]);
const xAxis = d3.axisBottom(xScale);
const xAxisSelection = svg
.append('g')
.attr('transform', `translate(0, ${height})`)
.call(xAxis);
// Animate
const animate = () => {
console.log(d3.selectAll('.tick').size()); // DOM keeps growing!!!
xScale.domain(xScaleDomain());
xAxisSelection
.transition()
.duration(transitionDuration)
.ease(d3.easeLinear)
.call(xAxis)
.on('end', animate);
};
animate();
svg {
margin: 30px;
background-color: #ccc;
}
rect {
fill: #fff;
outline: 1px dashed #ddd;
}
<script src="https://unpkg.com/d3#4.4.1/build/d3.js"></script>
<div class="chart"></div>
Analysis
The axis component will actually try to remove the ticks, which are no longer visible. Examining the source code brings up the line:
tickExit.remove();
Debugging to this line shows, that the exit selection is correctly calculated, i.e. all exiting nodes are contained in tickExit. But the nodes will not be removed as expected, because you have an active transition running on them. The documentation has it:
# transition.remove() <>
For each selected element, removes the element when the transition ends, as long as the element has no other active or pending transitions. If the element has other active or pending transitions, does nothing.
Workaround
One—admittely hacky—workaround could make use of the way D3 fades the ticks, which are no longer visible. This is not very nice, though, because it relies on the inner workings of D3 and might break in the future, should this behavior be altered.
Because selection.remove() is not that faint hearted, it can be used to take care of the removal instead of using transition.remove(). Personally, I would use something along the following lines in your animate() function:
d3.selectAll(".tick")
.filter(function() {
return +d3.select(this).attr("opacity") === 1e-6;
})
.remove();
Because the axis component will eventually fade all non-visible ticks to an opacity of 1e-6 this can be used to discard those elements. Note, however, that the tick count will at first come up to some value other than the starting value, because the transition to the final opacity will take some time to complete. But, the excess tick count is small and can safely be ignored.
Have a look at the following working demo. In this example, the tick count will increase from the initial 10 to 19 and subsequently stay at this value.
const timeWindow = 10000;
const transitionDuration = 3000;
const xScaleDomain = (now = new Date()) =>
[now - timeWindow, now];
const totalWidth = 500;
const totalHeight = 200;
const margin = {
top: 30,
right: 50,
bottom: 30,
left: 50
};
const width = totalWidth - margin.left - margin.right;
const height = totalHeight - margin.top - margin.bottom;
const svg = d3.select('.chart')
.append('svg')
.attr('width', totalWidth)
.attr('height', totalHeight)
.append('g')
.attr('transform', `translate(${margin.left}, ${margin.top})`)
svg
.append('rect')
.attr('width', width)
.attr('height', height);
// Add x axis
const xScale = d3.scaleTime()
.domain(xScaleDomain(new Date() - transitionDuration))
.range([0, width]);
const xAxis = d3.axisBottom(xScale);
const xAxisSelection = svg
.append('g')
.attr('transform', `translate(0, ${height})`)
.call(xAxis);
// Animate
const animate = () => {
console.log(d3.selectAll('.tick').size()); // DOM keeps growing!!!
d3.selectAll(".tick")
.filter(function() {
return +d3.select(this).attr("opacity") === 1e-6;
})
.remove();
xScale.domain(xScaleDomain());
xAxisSelection
.transition()
.duration(transitionDuration)
.ease(d3.easeLinear)
.call(xAxis)
.on('end', animate);
};
animate();
svg {
margin: 30px;
background-color: #ccc;
}
rect {
fill: #fff;
outline: 1px dashed #ddd;
}
<script src="https://d3js.org/d3.v4.js"></script>
<div class="chart"></div>
Anything below is my take on the comments to issue #23 "Axis labels are not removed from the DOM" opened by OP for the d3-axis module, which contains some really good points.
The comment by Mike Bostock provides a more in-depth look at the concurring transitions on the same element, which will eventually prevent the removal of the ticks:
The problem is that when the end event for the parent G element is dispatched, the axis has not yet removed the old ticks. The ticks are removed by transition.remove, which listens for the end event on the tick elements. The end event for the G element is dispatched prior to the end event for the tick elements, so you are starting a new transition that interrupts the old one before the axis has a chance to remove the old ticks.
The real gem whatsoever is to be found in the comment by #curran, who suggested to use setTimeout(animate). This is brilliant and, as far as I know, the only non-intrusive, non-hacky solution to this problem! By pushing the animate function to the end of the event loop, this will defer the creation of the next transition until after the actual transition has had the chance to clean up after itself.
And, to wrap up this theoretical discussion, the probably best conclusion to your actual problem seems to be Mike Bostock's:
If you want a real-time axis, you probably don’t want transitions. Instead, use d3.timer and redraw the axis with every tick.
Related
I'm simply trying to create a y-axis labelled from January to December, but I can't understand why my code creates: January, March, March, April.... December. Can anyone explain this to me please?
Thanks!
const w = 1078;
const h = 666;
const padding = 70;
const svg = d3
.select("#svg-container")
.append("svg")
.attr("width", w)
.attr("height", h);
const months = Array(12)
.fill(0)
.map((v, i) => new Date().setMonth(i));
const yScale = d3
.scaleTime()
.domain([months[11], months[0]])
.range([h - padding, padding]);
const yAxis = d3
.axisLeft(yScale)
.tickValues(months)
.tickFormat(d3.timeFormat("%B"));
svg
.append("g")
.attr("transform", "translate(" + padding + ", -20)")
.attr("id", "y-axis")
.call(yAxis);
body {
background: gray;
}
svg {
background: white;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<div id="svg-container"></div>
That happens because setMonth() also sets the day you are currently in. Today is day 30 and February 2021 has only 28 days, therefore the non-existing February 30 2021 is in fact March 2 2021. You can see this if you print the dates in a different format:
To fix this, you can use the full Date function new Date(2021, i, 1)
const w = 1078;
const h = 666;
const padding = 70;
const svg = d3
.select("#svg-container")
.append("svg")
.attr("width", w)
.attr("height", h);
const months = Array(12)
.fill(0)
.map((v, i) => new Date(2021, i, 1));
const yScale = d3
.scaleTime()
.domain([months[11], months[0]])
.range([h - padding, padding]);
const yAxis = d3
.axisLeft(yScale)
.tickValues(months)
.tickFormat(d3.timeFormat("%B %d"));
svg
.append("g")
.attr("transform", "translate(" + padding + ", -20)")
.attr("id", "y-axis")
.call(yAxis);
body {
background: gray;
}
svg {
background: white;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<div id="svg-container"></div>
Good for you for posting this on the 30th, otherwise there would be a hidden bug waiting to appear!
Additional resources
MBostock's month axis example: https://bl.ocks.org/mbostock/1849162
D3 Scaletime documentation: https://observablehq.com/#d3/d3-scaletime
I would like to create a vertical grid using D3.
I know I could use d3 axis methods (like axisBottom, axisLeft, ...) and set tickSize to manage the grid lines size but the axis generators create not only the lines but also the axis and the labels and I don't need them.
For example, this is what I could draw using axisBottom:
const container = d3.select('svg')
container.append('g').attr('class', 'vertical-grid')
const height = 100
const numberOfTicks = 5
const xScale = d3.scaleLinear()
.domain([0, 100])
.range([0, 200])
const xGridGenerator = d3.axisBottom(xScale)
.tickSize(height)
.ticks(numberOfTicks)
container
.select('.vertical-grid')
.attr('transform', `translate(${0}, ${0})`)
.call(xGridGenerator)
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<div class="app">
<svg></svg>
</div>
This is what I would like to draw:
I'm interested only in the red and blue lines, no labels, no axes.
I have the g container, the two scales and the number of ticks.
How can I do that?
Honestly I don't know how to start
You have a good start. A simple way to do the customization is to modify the axes' elements (labels, line colors, etc.) after you've added them. For example:
xAxis.selectAll('text').remove()
xAxis.selectAll('line').attr('stroke', 'blue')
Here's a complete example that renders a version of what you want (though the width/height are a little off because you'd want to have some margin):
const height = 200
const width = 200
const margin = { top: 10, bottom: 10, left: 10, right: 10 }
const container = d3.select('svg')
.attr('height', height)
.attr('width', width)
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`)
container.append('g').attr('class', 'vertical-grid')
container.append('g').attr('class', 'horizontal-grid')
const numberOfTicks = { x: 8, y: 4 }
const xScale = d3.scaleLinear()
.domain([0, 100])
.range([0, width - margin.left - margin.right ])
const xGridGenerator = d3.axisBottom(xScale)
.tickSize(height - margin.top - margin.bottom)
.ticks(numberOfTicks.x)
const xAxis = container
.select('.vertical-grid')
.attr('transform', `translate(${0}, ${0})`)
.call(xGridGenerator)
const yScale = d3.scaleLinear()
.domain([0, 100])
.range([0, height - margin.top - margin.bottom ])
const yGridGenerator = d3.axisRight(yScale)
.tickSize(width - margin.left - margin.right)
.ticks(numberOfTicks.y)
const yAxis = container
.select('.horizontal-grid')
.attr('transform', `translate(${0}, ${0})`)
.call(yGridGenerator)
// Customize
xAxis.selectAll('text').remove()
xAxis.selectAll('line').attr('stroke', 'blue')
yAxis.selectAll('text').remove()
yAxis.selectAll('line').attr('stroke', 'red')
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<div class="app">
<svg></svg>
</div>
I have a Choropleth map where the tooltip is working for most of it, but the central states are now showing the tooltip...in face, they are not even running the mouseout callback function at all (tested with a console.log command).
At first I was using d3-tip, and that wasn't working, and it was the first time attempting it, so I thought I might be doing something wrong, so I opted to implement a standard div that toggles between display: none and display: block and when it still wasn't working, I threw in a console.log command to see if the callback function was running at all, and it's not. It's mostly an issue with Kansas, but some of the counties in the surrounding states are having problems too. and I know it's not an issue with the data set, because the example given, which pulls from the same data set is working fine.
Here is the css for the tooltip:
#tooltip{
display: none;
background-color: rgba(32,32,32,1);
position: absolute;
border-radius: 10px;
padding: 10px;
width: 200px;
height: 40px;
color: white
}
and the JS code:
$(function(){
//svg setup
const svgPadding = 60;
const svgWidth = 1000;
const svgHeight = 600;
var svg = d3.select('body')
.append('svg')
.attr('width', svgWidth)
.attr('height', svgHeight)
.attr('id', 'map');
function createChart(topData, eduData){
//scales
var colorScale = d3.scaleSequential(d3.interpolateBlues);
var unitScale = d3.scaleLinear()
.domain(d3.extent(eduData.map(e => e.bachelorsOrHigher)))
.range([0,1])
//map
var path = d3.geoPath();
svg.selectAll('.county')
.data(topojson.feature(topData, topData.objects.counties).features)
.enter()
.append('path')
.attr('class', 'county')
.attr('d', path)
.attr('data-fips', d=>d.id)
.attr('eduIndex', d => eduData.map(e => e.fips).indexOf(d.id))
.attr('data-education', function(){
var index = d3.select(this).attr('eduIndex');
if (index == -1)return 0;
return eduData[
d3.select(this).
attr('eduIndex')
]
.bachelorsOrHigher
})
.attr('fill', function(){
var value = d3.select(this).attr('data-education');
return colorScale(unitScale(value));
})
.attr('stroke', function(){
return d3.select(this).attr('fill');
})
.on('mouseover', function(d){
var index = d3.select(this).attr('eduIndex');
var education = d3.select(this).attr('data-education');
var county = index == -1 ? 'unknown' : eduData[index].area_name;
console.log(county)
var tooltip = d3.select('#tooltip')
.style('left', d3.event.pageX + 10 + 'px')
.style('top', d3.event.pageY + 10 + 'px')
.style('display', 'block')
.attr('data-education', education)
.html(`${county}: ${education}`)
})
.on('mouseout', ()=>d3.select('#tooltip').style('display', 'none'));
svg.append('path')
.datum(topojson.mesh(topData, topData.objects.states, (a,b)=>a.id!=b.id))
.attr('d', path)
.attr('fill', 'rgba(0,0,0,0)')
.attr('stroke', 'black')
.attr('stroke-width', 0.4)
//legend scale
const legendWidth = 0.5 * svgWidth;
const legendHeight = 30;
const numCells = 1000;
const cellWidth = legendWidth/numCells;
const legendUnitScale = d3.scaleLinear()
.domain([0, legendWidth])
.range([0,1]);
//legend
var legend = svg.append('svg')
.attr('id', 'legend')
.attr('width', legendWidth)
.attr('height', legendHeight)
.attr('x', 0.5 * svgWidth)
.attr('y', 0)
for (let i = 0; i < numCells; i++){
legend.append('rect')
.attr('x', i * cellWidth)
.attr('width', cellWidth)
.attr('height', legendHeight - 10)
.attr('fill', colorScale(legendUnitScale(i*cellWidth)))
}
}
//json requests
d3.json('https://raw.githubusercontent.com/no-stack-dub-sack/testable-projects-fcc/master/src/data/choropleth_map/counties.json')
.then(function(topData){
d3.json('https://raw.githubusercontent.com/no-stack-dub-sack/testable-projects-fcc/master/src/data/choropleth_map/for_user_education.json')
.then(function(eduData){
createChart(topData, eduData);
});
});
});
The issue is that you are applying a fill to the state mesh. Let's change the fill from rgba(0,0,0,0) to rgba(10,10,10,0.1):
It should be clear now why the mouse interaction doesn't work in certain areas: the mesh is filled over top of it. Regardless of the fact you can't see the mesh due to it having 0 opacity, it still intercepts the mouse events.
The mesh is meant only to represent the borders: it is a collection of geojson lineStrings (see here too). The mesh is not intended to be filled, it only should have a stroke.
If you change the mesh fill to none, or the pointer events of the mesh to none, then the map will work as expected.
I'm implementing a chart using d3 that has a sliding x axis. Demo
The problem is, when I change to another tab, and then go back (say after 10 seconds), d3 seems to try to replay the missing transitions, which results in a very awkward behavior of the axis. See here.
Mike Bostock mentions that:
D3 4.0 fixes this problem by changing the definition of time. Transitions don’t typically need to be synchronized with absolute time; transitions are primarily perceptual aids for tracking objects across views. D3 4.0 therefore runs on perceived time, which only advances when the page is in the foreground. When a tab is backgrounded and returned to the foreground, it simply picks up as if nothing had happened.
Is this really fixed? Am I doing anything wrong?
const timeWindow = 10000;
const transitionDuration = 3000;
const xScaleDomain = (now = new Date()) =>
[now - timeWindow, now];
const totalWidth = 500;
const totalHeight = 200;
const margin = {
top: 30,
right: 50,
bottom: 30,
left: 50
};
const width = totalWidth - margin.left - margin.right;
const height = totalHeight - margin.top - margin.bottom;
const svg = d3.select('.chart')
.append('svg')
.attr('width', totalWidth)
.attr('height', totalHeight)
.append('g')
.attr('transform', `translate(${margin.left}, ${margin.top})`)
svg
.append('rect')
.attr('width', width)
.attr('height', height);
// Add x axis
const xScale = d3.scaleTime()
.domain(xScaleDomain(new Date() - transitionDuration))
.range([0, width]);
const xAxis = d3.axisBottom(xScale);
const xAxisSelection = svg
.append('g')
.attr('transform', `translate(0, ${height})`)
.call(xAxis);
// Animate
const animate = () => {
xScale.domain(xScaleDomain());
xAxisSelection
.transition()
.duration(transitionDuration)
.ease(d3.easeLinear)
.call(xAxis)
.on('end', animate);
};
animate();
svg {
margin: 30px;
background-color: #ccc;
}
rect {
fill: #fff;
outline: 1px dashed #ddd;
}
<script src="https://unpkg.com/d3#4.4.1/build/d3.js"></script>
<div class="chart"></div>
The problem is not D3 transitions. The problem here is new Date().
Every time you go to another tab, the transition pauses. So far, so good. But when you come back to the chart, let's say, after 20 seconds, you get a new date that's the current date... however your timeWindow is the same, as well as your transitionDuration:
const timeWindow = 10000;
const transitionDuration = 3000;
const xScaleDomain = (now = new Date()) => [now - timeWindow, now];
That makes the axis jump ahead faster, because the difference between the old and new values at any point in the domain is not 3 seconds anymore.
Here is a very simple solution, too crude and requiring improvements, just to show you that the problem is new Date(). In this solution (again, far from perfect), I manually set the date in each animation to jump 10 seconds, no matter how long you stay in another tab:
var t = xScale.domain()[1];
t.setSeconds(t.getSeconds() + 10);
xScale.domain([xScale.domain()[1], t]);
Here is the CodePen: http://codepen.io/anon/pen/GrjMxy?editors=0010
A better solution, using your code, would be changing timeWindow and transitionDuration to take into consideration the difference between the new new Date() and the old new Date() (that is, how long the user has been in another tab).
How can I create a D3 axis that does not have any labels at its tick markers?
Here's an example that shows what I'm after, from Mike Bostock no less. There are several Axis objects rotated around the centre, and only the first one has tick labels.
In this case, he's achieved the result using CSS to hide all but the first axis's labels:
.axis + .axis g text {
display: none;
}
However this still results in the creation of SVG text elements in the DOM. Is there a way to avoid their generation altogether?
I'm just going to leave this here since people are likely to end up on this question. Here are the different ways you can easily manipulate a D3 axis.
Without any ticks or tick labels:
d3.svg.axis().tickValues([]);
No line or text elements are created this way.
Without ticks and with tick labels:
d3.svg.axis().tickSize(0);
The line elements are still created this way.
You can increase the distance between the tick labels and the axis with .tickPadding(10), for example.
With ticks and without tick labels:
d3.svg.axis().tickFormat("");
The text elements are still created this way.
You can't avoid the generation of the text elements without modifying the source. You can however remove those elements after they have been generated:
var axisElements = svg.append("g").call(axis);
axisElements.selectAll("text").remove();
Overall, this is probably the most flexible way to approach this as you can also remove labels selectively. You can get the data used to generated them from the scale you're using (by calling scale.ticks()), which would allow you to easily do things like remove all the odd labels.
Create a D3 axis without tick labels
A tick mark without a label can be created by using a function that returns an empty string. This works in both the Javascript and Typescript versions of D3.
d3.svg.axis().tickFormat(() => "");
Further explained on github #types/d3-axis
https://github.com/DefinitelyTyped/DefinitelyTyped/pull/24716#issuecomment-381427458
I know this is an old question but if you are using D3 version 7 probably this approach will help you.
There are 4 axes: top, left, bottom and right. We will define the left and bottom as main axes, and top and right as tick-less axes.
There are two way to render tick-less axes either declaring empty array for the tickValues function like this:
xAxisTop = d3.axisTop().scale(xScale).tickSize(0).tickValues([])
Or declaring an empty label for the tick using tickFormat function like this:
const yAxisRight = d3.axisRight().scale(yScale).tickSize(0).tickFormat('')
However, both require that we define the tickSize function to 0 to avoid the default ticks at the start and the end of the axis.
This is working snippet:
const element = document.getElementById('plot')
const margin = {
top: 20,
right: 20,
bottom: 30,
left: 50,
}
const width = 600, height = 180
const data = [10, 15, 20, 25, 30]
const svg = d3.create('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', [0, 0, width, height])
.attr('style', 'max-width: 100%; height: auto; height: intrinsic;')
const xScale = d3.scaleLinear()
.domain([d3.min(data), d3.max(data)])
.range([margin.left, width - margin.right])
const yScale = d3.scaleLinear()
.domain([d3.min(data), d3.max(data)])
.range([height - margin.bottom, margin.top])
const xAxis = d3.axisBottom().scale(xScale)
const xAxisTop = d3.axisTop().scale(xScale).tickSize(0).tickValues([])
const yAxis = d3.axisLeft().scale(yScale)
const yAxisRight = d3.axisRight().scale(yScale).tickSize(0).tickFormat('')
svg.append('g')
.attr('transform', `translate(${margin.left},0)`)
.call(yAxis)
svg.append('g')
.attr('transform', `translate(${width - margin.right}, 0)`)
.call(yAxisRight)
svg.append('g')
.attr('transform', `translate(0,${height - margin.bottom})`)
.call(xAxis)
svg.append('g')
.attr('transform', `translate(0, ${margin.top})`)
.call(xAxisTop)
element.appendChild(svg.node())
.center {
display: flex;
justify-content: center;
align-items: center;
width: 98vw;
height: 190px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<div id='plot' class='center'></div>