The snippet below creates a single x axis with starting ticks of 10. During zoom I'm updating ticks on the rescaled axis with:
.ticks(startTicks * Math.floor(event.transform.k))
With .scaleExtent([1, 50]) I can get down from years to 3-hourly blocks fairly smoothly (besides a little label overlap here and there).
But, when I request the number of ticks applied on the scale (xScale.ticks().length) I get a different number to the one I just assigned.
Also, when I get the labels (xScale.ticks().map(xScale.tickFormat())) they differ from the ones rendered as I get deeper into the zoom.
Reading here:
An optional count argument requests more or fewer ticks. The number of
ticks returned, however, is not necessarily equal to the requested
count. Ticks are restricted to nicely-rounded values (multiples of 1,
2, 5 and powers of 10), and the scale’s domain can not always be
subdivided in exactly count such intervals. See d3.ticks for more
details.
I understand I might not get the number of ticks I request, but it's counter-intuitive that:
I request more and more ticks (per k) - between 10 and 500
Then the returned ticks fluctuates between 5 and 19.
Why is this ? Is there a better or 'standard' way to update ticks whilst zooming for scaleTime or scaleUtc ?
var margin = {top: 0, right: 25, bottom: 20, left: 25}
var width = 600 - margin.left - margin.right;
var height = 40 - margin.top - margin.bottom;
// x domain
var x = d3.timeDays(new Date(2020, 00, 01), new Date(2025, 00, 01));
// start with 10 ticks
var startTicks = 10;
// zoom function
var zoom = d3.zoom()
.on("zoom", (event) => {
var t = event.transform;
xScale
.domain(t.rescaleX(xScale2).domain())
.range([0, width].map(d => t.applyX(d)));
var zoomedRangeWidth = xScale.range()[1] - xScale.range()[0];
var zrw = zoomedRangeWidth.toFixed(4);
var kAppliedToWidth = kw = t.k * width;
var kw = kAppliedToWidth.toFixed(4);
var zoomTicks = zt = startTicks * Math.floor(t.k);
svg.select(".x-axis")
.call(d3.axisBottom(xScale)
.ticks(zt)
);
var realTicks = rt = xScale.ticks().length;
console.log(`zrw: ${zrw}, kw: ${kw}, zt: ${zt}, rt: ${rt}`);
console.log(`labels: ${xScale.ticks().map(xScale.tickFormat())}`);
})
.scaleExtent([1, 50]);
// x scale
var xScale = d3.scaleTime()
.domain(d3.extent(x))
.range([0, width]);
// x scale copy
var xScale2 = xScale.copy();
// svg
var svg = d3.select("#scale")
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.call(zoom)
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
// clippath
svg.append("defs").append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("x", 0)
.attr("width", width)
.attr("height", height);
// x-axis
svg.append("g")
.attr("class", "x-axis")
.attr("clip-path", "url(#clip)")
.attr("transform", "translate(0," + height + ")")
.call(d3.axisBottom(xScale)
.ticks(startTicks));
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.3.1/d3.min.js"></script>
<div id="scale"></div>
The issue is in how the xScale is being updated on zoom.
The current approach in the example is:
xScale
.domain(t.rescaleX(xScale2).domain())
.range([0, width].map(d => t.applyX(d)));
This is doing two things:
Creating a rescaled copy of xScale2, but only to get its domain.
Extending the range of the xScale depending on the transform.
Because of step 2, the scale range is growing outside of the screen. When you request 500 ticks but only see 10, it is because there are 490 out of the viewport.
The solution is that continuous scales don't need to have the range updated on zoom, because the rescaleX method is enough for the transformation process.
The appropriate way to rescale a continuous scale on zoom is:
xScale = t.rescaleX(xScale2)
Which changes only the domain and keeps the range intact.
Consider this example to illustrate why only changing the domain is enough: If a scale maps from a domain [0,1] to a range [0, 100], and it is transformed with rescaleX, the new scale will now map from another domain (say, [0.4, 0.6]) to the same range [0, 100]. This is the zoom concept: it was showing data from 0 to 1 in a 100 width viewport, but now it is showing data from 0.4 to 0.6 in the same viewport; it "zoomed in" to 0.4 and 0.6.
The incorrect format returned from xScale.tickFormat() was a consequence of the range extension, but also of a mismatch between the displayed ticks and the computed ticks. The method only return the same ticks that are displayed if it also consideres the same amount of ticks, which is informed in the first parameter (in your example, it would be xScale.tickFormat(zt)). Since it had no arguments, it defaults to 10, and the 10 ticks computed in the time scale could be different or be in a different time granularity than the zt ticks that are displayed.
In summary, the snippet needs three changes:
Change 1: Update only the domain directly with rescaleX.
Change 2: Fix zoom ticks to a number, such as 10.
Change 3: Consider the number of ticks when using the tickFormat method.
The snippet below is updated with those changes:
var margin = {top: 0, right: 25, bottom: 20, left: 25}
var width = 600 - margin.left - margin.right;
var height = 40 - margin.top - margin.bottom;
// x domain
var x = d3.timeDays(new Date(2020, 00, 01), new Date(2025, 00, 01));
// start with 10 ticks
var startTicks = 10;
// zoom function
var zoom = d3.zoom()
.on("zoom", (event) => {
var t = event.transform;
// Change 1: Update only the domain directly with rescaleX
xScale = t.rescaleX(xScale2);
var zoomedRangeWidth = xScale.range()[1] - xScale.range()[0];
var zrw = zoomedRangeWidth.toFixed(4);
var kAppliedToWidth = kw = t.k * width;
var kw = kAppliedToWidth.toFixed(4);
// Change 2: Fix zoom ticks to a number, such as 10
var zoomTicks = zt = 10
svg.select(".x-axis")
.call(d3.axisBottom(xScale)
.ticks(zt)
);
var realTicks = rt = xScale.ticks().length;
console.log(`zrw: ${zrw}, kw: ${kw}, zt: ${zt}, rt: ${rt}`);
// Change 3: Consider zt when using the tickFormat method
console.log(`labels: ${xScale.ticks().map(xScale.tickFormat(zt))}`);
})
.scaleExtent([1, 50]);
// x scale
var xScale = d3.scaleTime()
.domain(d3.extent(x))
.range([0, width]);
// x scale copy
var xScale2 = xScale.copy();
// svg
var svg = d3.select("#scale")
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.call(zoom)
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
// clippath
svg.append("defs").append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("x", 0)
.attr("width", width)
.attr("height", height);
// x-axis
svg.append("g")
.attr("class", "x-axis")
.attr("clip-path", "url(#clip)")
.attr("transform", "translate(0," + height + ")")
.call(d3.axisBottom(xScale)
.ticks(startTicks));
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.3.1/d3.min.js"></script>
<div id="scale"></div>
Related
I have drawn a spiral using this code:
var width = 1000,
height = 1000,
start = 0,
end = 2.25,
numSpirals = 78,
margin = {top:50,bottom:50,left:50,right:50};
// Constructing the spiral:
// theta for the spiral
var theta = function(r) {
return numSpirals * Math.PI * r;
};
// the r works out the space within which the spiral can take shape - the width and height is set above
var r = d3.min([width, height]) / 2 - 40 ;
// The radius of the spiral
var radius = d3.scaleLinear()
.domain([start, end])
.range([40, r]);
// inserts svg into the DOM
var svg = d3.select("#chart").append("svg")
.attr("width", width + margin.right + margin.left)
.attr("height", height + margin.left + margin.right)
.append("g")
.attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");
// The path to draw the spiral needs data to inform it, points generates this, and is used in .datum(points) below
var points = d3.range(start, end + 0.02, (end - start) / 2000);
// this is the spiral, utilising the theta and radius generated above
var spiral = d3.radialLine()
.curve(d3.curveCardinal)
.angle(theta)
.radius(radius);
var path = svg.append("path")
.datum(points)
.attr("id", "spiral")
.attr("d", spiral)
.style("fill", "none")
.style("stroke", "grey")
.style("stroke", ("6, 5"))
.style("opacity",0.5);
But, now I want to draw some lines on top of this spiral, following the existing path of the spiral. These lines will need to connect two different dates across two different columns within the array. I have tried a few approaches, such as d3.line but I can't get the lines to follow the spiral. I imagine I somehow need to reference the initial spiral? I am unsure how to proceed with this though.
I have a dataset that the age field is represent as range, such as 0--8,9--17,18--23,etc.
year,gender,age,population
2002,Female,0--8,0
2002,Female,9--17,25
2002,Female,18--20,291
2002,Female,21--23,375
2002,Female,24--26,212
2002,Female,27--29,108
2002,Female,30--38,74
2002,Female,39--47,0
I want to use this age in the X axis of my barplot. How can I calculate the domain in this range form? Seems like I can't just use
d3.max(dataset, function(d) { return +d.age})
Below is my code until creating yScale:
function dataProcessor(d) {
return {
year: +d.year,
gender: d.gender,
age: +d.age,
population: +d.population
};
}
width = 600
height = 400
var svg = d3.select("svg");
var margin = {top: 50,
right: 50,
bottom: 50,
left: 100
};
chartWidth = +svg.attr("width") - margin.left - margin.right;
chartHeight = +svg.attr("height") - margin.top - margin.bottom;
g = svg.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
d3.csv('years.csv', dataProcessor).then(function(data) {
});
var xScale = d3.scaleBand().rangeRound([0, width])
.padding(0.1)
.domain([0, data.length-1])
var populationMax = d3.max(data, function(d) {
return +d.population
});
var populationMin = d3.min(data, function(d) {
return +d.population
});
var yScale = d3.scaleLinear()
.domain([populationMin, populationMax])
.range([height, 0]);
These age ranges aren’t continuous; they’re ordinal. Essentially, these are labels that happen to have a particular order, not numbers.
Rather than use d3.scaleLinear(), use d3.scaleBand() where the domain is ["0--8", "9--17", ... , "39--47"]. d3.scaleBand() maps from a discrete domain to continuous range. It’s designed to be used for things like bar charts, so it also includes a bandwidth() method to tell you how wide each band is based on the size of your domain, range, and any padding that you’ve configured the scale for.
let x = d3.scaleBand(["0--8", "9--17", "18--20", "21--23",
"24--26", "27--29", "30--38" , "39--47"]);
.range([[0, width]);
Then (assuming you already have a data-bound selection called bars) you can position each bar with something like
bars.
.attr("x", d => x(d.age))
.attr("width", x.bandwidth());
Normally chart data starts at the bottom of the Y-axis and left of the X-axis. However I have this bar chart that I'm trying to create in D3 that starts 30px above the bottom of the Y-axis and 30px to the right of the left of the X-axis (see mock-up design below). The 30px padding should be maintained on top and to the right as well.
I can't wrap my head around how this should be implemented because the axes lines should still be drawn all the way across but the ticks and the bar chart data should be padded 30px all the way around and the scale should be maintained.
Note: I've removed the rest of the ticks and tick values for clarity. X-axis ticks should be placed in the middle of each bar.
For achieving what you want you'll have to change the settings of the scales. Since you have a bar chart, I'm assuming you have:
A band scale for the x position;
A linear scale for the y position;
Also, because you didn't share any running code, I'll base my answer on this basic bar chart from d3noob.
The first step is setting your paddings:
const horPadding = 30;
const vertPadding = 30;
Now let's change the scales:
Band scale
For setting the padding in the band scale, we'll use scale.paddingOuter.
Because the value passed to that method is a multiple of scale.step() (that is, if you pass 1 it equals to passing scale.step()), we'll use that to calculate how much is 30px in padding. The math is simple:
scale.paddingOuter(horPadding / x.step());
Linear scale
Here the math is a bit more complicated. Basically, we'll calculate how much below zero we have to go to get exactly 30px (assuming that your lower domain is zero, which is a very basic rule in bar charts!).
That can be done with this as the first value of the domain, replacing 0:
-(d3.max(data, function(d) {
return d.sales;
}) * vertPadding / height)
Here, sales is the property used for the bars' height and height is obviously the height used for the scale and the axis. Change them according to your needs.
Then, don't forget to use scale(0) to set the base of the rectangles. In that d3noob code I'm sharing that would be:
return y(0) - y(d.sales);
And this is the result:
var csv = `salesperson,sales
Bob,33
Robin,12
Anne,41
Mark,16
Joe,59
Eve,38`;
const horPadding = 30;
const vertPadding = 30;
var margin = {
top: 20,
right: 20,
bottom: 30,
left: 40
},
width = 600 - margin.left - margin.right,
height = 400 - margin.top - margin.bottom;
var x = d3.scaleBand()
.range([0, width])
.padding(0.1);
var y = d3.scaleLinear()
.range([height, 0]);
var svg = d3.select("body").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 + ")");
const data = d3.csvParse(csv, d3.autoType);
x.domain(data.map(function(d) {
return d.salesperson;
}))
.paddingOuter(horPadding / x.step());
y.domain([-(d3.max(data, function(d) {
return d.sales;
}) * vertPadding / height), d3.max(data, function(d) {
return d.sales;
})])
svg.selectAll(".bar")
.data(data)
.enter().append("rect")
.attr("class", "bar")
.attr("x", function(d) {
return x(d.salesperson);
})
.attr("width", x.bandwidth())
.attr("y", function(d) {
return y(d.sales);
})
.attr("height", function(d) {
return y(0) - y(d.sales);
});
svg.append("g")
.attr("transform", "translate(0," + height + ")")
.call(d3.axisBottom(x));
svg.append("g")
.call(d3.axisLeft(y));
.bar {
fill: steelblue;
}
<script src="//d3js.org/d3.v4.min.js"></script>
I wish to create a line chart with transition. So for the first step, I wish to simply draw the axis and move the x-axis. The x-axis has 0-15 as values, and I want these values to keep on moving in a loop.. I took help from this code which I got through stackoverflow: http://jsfiddle.net/aggz2qbn/
Here is the code I have:
var t = 1, maxval;
var i, n = 40;
var duration = 750;
function refilter(){
var margin = {top: 10, right: 10, bottom: 20, left: 40},
width = 960 - margin.left - margin.right,
height = 500 - margin.top - margin.bottom;
var x = d3.scale.linear()
.domain([1, 15])
.range([0, width]);
var y = d3.scale.linear()
.domain([0, maxval])
.range([height, 0]);
var svg = d3.select("body").append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom);
var g = svg.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
// extra svg to clip the graph and x axis as they transition in and out
var graph = g.append("svg")
.attr("width", width)
.attr("height", height + margin.top + margin.bottom);
var xAxis = d3.svg.axis().scale(x).orient("bottom");
var axis = graph.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + height + ")")
.call(x.axis=xAxis);
g.append("g")
.attr("class", "y axis")
.call(d3.svg.axis().scale(y).orient("left"));
tick();
function tick() {
t++;
if(t>=15)
t=1;
else if(t<=15)
{
x.domain([t,15]);
axis.transition()
.duration(500)
.ease("linear")
.call(xAxis)
.each("end", tick);
}
// slide the x-axis left
}
}
The values on x-axis do move, but not in a repetitive way, instead they simply stretch frm 1 to 15. Can anybody help me out?
Your tick function is setting the domain as:
[1,15]
then
[2,15]
then
[3,15]
etc...
This is more a zoom towards the end. What you want is a rolling effect (say with an N of 5 ticks):
[1,5]
then
[2,6]
then
[3,7]
etc...
So:
function tick() {
var curD = x.domain(); // get current domain
if (curD[1] >= 15){ // at 15 reset to 1,5
curD[0] = 0;
curD[1] = n-1;
}
x.domain([curD[0]+1,curD[1]+1]); // increase both sides by one
// slide the x-axis left
axis.transition()
.duration(1500)
.ease("linear")
.call(xAxis)
.each("end", tick);
}
Example here.
EDITS FOR COMMENT
Datetimes can be handled the same way, but do the math different. For instance, scrolling my months:
function tick() {
var curD = x.domain();
var newD = [curD[0].setMonth(curD[0].getMonth() + 1),
curD[1].setMonth(curD[1].getMonth() + 1)]
x.domain(newD);
// slide the x-axis left
axis.transition()
.duration(1500)
.ease("linear")
.call(xAxis)
.each("end", tick);
}
Updated example.
I have several graphs set up to zoom on the container and it works great. However, on the initial load, the zoom level is way too close. Is there a method of setting the initial zoom level to avoid having to first zoom out? I am familiar with the .scale() method but have not had any luck implementing it. Is this the way to go or is there something I am missing?
Here is what I have thus far as pertaining to zoom:
var margin = {top: 20, right: 120, bottom: 20, left: 120},
width = 50000 - margin.right - margin.left,
height = 120000 - margin.top - margin.bottom;
var x = d3.scale.linear()
.domain([0, width])
.range([0, width]);
var y = d3.scale.linear()
.domain([0, height])
.range([height, 0]);
var tree = d3.layout.tree()
.size([height, width])
.separation(function(a, b) { return (a.parent == b.parent ? 1 : 2) / a.depth; });
var diagonal = d3.svg.diagonal()
.projection(function(d) { return [d.x, d.y]; });
function zoom(d) {
svg.attr("transform",
"translate(" + d3.event.translate + ")"+ " scale(" + d3.event.scale + ")");
}
var svg = d3.select("body").append("svg")
.attr("width", width + margin.right + margin.left)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")")
.attr("pointer-events", "all")
.call(d3.behavior.zoom()
.x(x)
.y(y)
.scaleExtent([0,8])
.on("zoom", zoom))
.append('g');
svg.append('rect')
.attr('width', width*5)
.attr('height', height)
.attr('border-radius', '20')
.attr('fill', 'sienna');
D3v4 answer
If you are here looking for the same but with D3 v4,
var zoom = d3.zoom().on("zoom", function(){
svg.attr("transform", d3.event.transform);
});
vis = svg.append("svg:svg")
.attr("width", width)
.attr("height", height)
.call(zoom) // here
.call(zoom.transform, d3.zoomIdentity.translate(100, 50).scale(0.5))
.append("svg:g")
.attr("transform","translate(100,50) scale(.5,.5)");
I finally got this to work by setting both the initial transform and the zoom behavior to the same value.
var zoom = d3.behavior.zoom().translate([100,50]).scale(.5);
vis = svg.append("svg:svg")
.attr("width", width)
.attr("height", height)
.call(zoom.on("zoom",zooming))
.append("svg:g")
.attr("transform","translate(100,50)scale(.5,.5)");
Applies to d3.js v4
This is similar to davcs86's answer, but it reuses an initial transform and implements the zoom function.
// Initial transform to apply
var transform = d3.zoomIdentity.translate(200, 0).scale(1);
var zoom = d3.zoom().on("zoom", handleZoom);
var svg = d3.select("body")
.append('svg')
.attr('width', 800)
.attr('height', 300)
.style("background", "red")
.call(zoom) // Adds zoom functionality
.call(zoom.transform, transform); // Calls/inits handleZoom
var zoomable = svg
.append("g")
.attr("class", "zoomable")
.attr("transform", transform); // Applies initial transform
var circles = zoomable.append('circle')
.attr("id", "circles")
.attr("cx", 100)
.attr("cy", 100)
.attr('r', 20);
function handleZoom(){
if (zoomable) {
zoomable.attr("transform", d3.event.transform);
}
};
See it in action: jsbin link
Adding this answer as an addendum to the accepted answer in case anyone is still having issues:
The thing that made this really easy to understand was looking here
That being said, I set three variables:
scale, zoomWidth and zoomHeight
scale is the initial scale you want the zoom to be, and then
zoomWidth and zoomHeight are defined as follows:
zoomWidth = (width-scale*width)/2
zoomHeight = (height-scale*height)/2
where width and height are the width and height of the "vis" svg element
the translate above is then amended to be:
.attr("transform", "translate("+zoomWidth+","+zoomHeight+") scale("+scale+")")
as well as the zoom function:
d3.behavior.zoom().translate([zoomWidth,zoomHeight]).scale(scale)
What this does is effectively ensures that your element is zoomed and centered when your visualization is loaded.
Let me know if this helps you! Cheers.
D3JS 6 answer
Let's say that you want your initial position and scale to be x, y, scale respectively.
const zoom = d3.zoom();
const svg = d3.select("#containerId")
.append("svg")
.attr("width", width)
.attr("height", height)
.call(zoom.transform, d3.zoomIdentity.translate(x, y).scale(scale)
.call(zoom.on('zoom', (event) => {
svg.attr('transform', event.transform);
}))
.append("g")
.attr('transform', `translate(${x}, ${y})scale(${k})`);
.call(zoom.transform, d3.zoomIdentity.translate(x, y).scale(scale) makes sure that when the zoom event is fired, the event.transform variable takes into account the translation and the scale. The line right after it handles the zoom while the last one is used to apply the translation and the scale only once on "startup".
I was using d3 with react and was very frustrated about the initial zoom not working.
I tried the solutions here and none of them worked, what worked instead was using an initial scale factor and positions and then updating the zoom function on the basis of those scale factor and positions
const initialScale = 3;
const initialTranslate = [
width * (1 - initialScale) / 2,
height * (1 - initialScale) / 2,
];
const container = svg
.append('g')
.attr(
'transform',
`translate(${initialTranslate[0]}, ${initialTranslate[1]})scale(${initialScale})`
);
The zoom function would look something like this
svg.call(
zoom().on('zoom', () => {
const transformation = getEvent().transform;
let {x, y, k} = transformation;
x += initialTranslate[0];
y += initialTranslate[1];
k *= initialScale;
container.attr('transform', `translate(${x}, ${y})scale(${k})`);
})
);
If you noticed the getEvent() as a function, it was because importing event from d3-selection was not working in my case. So I had to do
const getEvent = () => require('d3-selection').event;