D3.js zoomable axis- how to zoom in only one direction? - javascript

I am using D3.js v4.
I have a minimum example working with zooming in and out on a single axis, with the following code:
// Create dummy data
var data = [];
for (var i = 0; i < 100; i++) {
data.push([Math.random(), Math.random()]);
}
// Set window parameters
var width = 330
var height = 200
// Append div, svg
d3.select('body').append('div')
.attr('id', 'div1')
d3.select('#div1')
.append("svg").attr("width", width).attr("height",height)
.attr('id','chart')
// Create scaling factors
var x = d3.scaleLinear()
.domain([0,1])
.range([0, (width - 30)])
var y = d3.scaleLinear()
.domain([0,1])
.range([0,height])
// Create group, then append circles
d3.select('#chart').append('g')
.attr('id','circlesplot')
d3.select('#circlesplot')
.selectAll('circles')
.data(data)
.enter().append('circle')
.attr('cx', function(d,i){ return x(d[0]); })
.attr('cy', function(d,i){ return y(d[1]); })
.attr('r', 4)
// Create y axis, append to chart
var yaxis = d3.axisRight(y)
.ticks(10)
var yaxis_g = d3.select('#chart').append('g')
.attr('id', 'yaxis_g')
.attr('transform','translate(' + (width - 30) +',0)')
.call(yaxis)
// Create zoom svg to the right
var svg = d3.select('#div1')
.append('svg')
.attr('width', 30)
.attr('height', height)
.attr('transform', 'translate('+ width + ',0)')
.call(d3.zoom()
.on('zoom', zoom))
function zoom() {
// Rescale axis during zoom
yaxis_g.transition()
.duration(50)
.call(yaxis.scale(d3.event.transform.rescaleY(y)))
// re-draw circles using new y-axis scale
var new_y = d3.event.transform.rescaleY(y);
d3.selectAll('circle').attr('cy', function(d) { return new_y(d[1])})
}
fiddle here: https://jsfiddle.net/v0aw9Ler/#&togetherjs=2wg7s8xfhC
Putting the mouse just to the right of the yaxis and scrolling gives the zooming function on the y axis.
What I'd like to happen is for the y axis maximum (in this case 1.0) to stay fixed, while zooming only in the other direction. You can kind of see what I mean by placing the mouse at the very bottom and just to the right of the y axis, and see the points cluster at the bottom of the graph.
I think it has to do with using zoom.extent(), but I'm just really not sure where to go from here; advice is greatly appreciated.
Source for this min working example:
http://bl.ocks.org/feyderm/03602b83146d69b1b6993e5f98123175

Related

How do I draw gridlines in d3.js with zoom and pan

I have been able to make a scatter plot with zoom and pan functionality where the axes scale properly and everything works well. Now I am trying to figure out how to add gridlines, but running into some issues. I have started with only adding x-axis gridlines to figure things out. I have attached a fiddle with a working example to build from.
I commented out the initial gridlines when the graph is generated, because they would remain after zooming causing clutter, and I will add them back later when I get things working. When zooming the gridlines appear to be drawn correctly, but they do not match up with the x-axis labels, and the x-axis labels disappear after zooming or panning.
If you comment out line 163 and uncomment line 164 you can see the basic graph without any gridlines. Clicking the plot button will always generate a new graph. I have left behind some commented out code of different things that I have tried from searching through stackoverflow.
Example is using d3.js - 5.9.2
JSFiddle: https://jsfiddle.net/eysLvqkh/11/
HTML:
<div id="reg_plot"></div>
<button id="b" class="myButton">plot</button>
Javascript:
var theButton = document.getElementById("b");
theButton.onclick = createSvg;
function createSvg() {
// clear old chart when 'plot' is clicked
document.getElementById('reg_plot').innerHTML = ""
// dimensions
var margin = {top: 20, right: 20, bottom: 30, left: 55},
svg_dx = 1200,
svg_dy =600,
chart_dx = svg_dx - margin.right - margin.left,
chart_dy = svg_dy - margin.top - margin.bottom;
// data
var y = d3.randomNormal(400, 100);
var x_jitter = d3.randomUniform(-100, 1400);
var d = d3.range(1000)
.map(function() {
return [x_jitter(), y()];
});
// fill
var colorScale = d3.scaleLinear()
.domain(d3.extent(d, function(d) { return d[1]; }))
.range([0, 1]);
// y position
var yScale = d3.scaleLinear()
.domain(d3.extent(d, function(d) { return d[1]; }))
.range([chart_dy, margin.top]);
// x position
var xScale = d3.scaleLinear()
.domain(d3.extent(d, function(d) { return d[0]; }))
.range([margin.right, chart_dx]);
// y-axis
var yAxis = d3.axisLeft(yScale);
// x-axis
var xAxis = d3.axisBottom(xScale);
// append svg to div element 'reg_plot' and set zoom to our function named 'zoom'
var svg = d3.select("#reg_plot")
.append("svg")
.attr("width", svg_dx)
.attr("height", svg_dy);
svg.call(d3.zoom().on("zoom", zoom));
// clip path - sets boundaries so points will not show outside of the axes when zooming/panning
var clip = svg.append("defs").append("svg:clipPath")
.attr("id", "clip")
.append("svg:rect")
.attr("id", "clip-rect")
.attr("x", "0")
.attr("y", "0")
.attr('width', chart_dx)
.attr('height', chart_dy);
// plot data
var circles = svg.append("g")
.attr("id", "circles")
.attr("transform", "translate(75, 0)")
.attr("clip-path", "url(#clip)")
.selectAll("circle")
.data(d)
.enter()
.append("circle")
.attr("r", 4)
.attr("cx", function(d) { return xScale(d[0]); })
.attr("cy", function(d) { return yScale(d[1]); })
.style("fill", function(d) {
var norm_color = colorScale(d[1]);
return d3.interpolateInferno(norm_color)
});
// add y-axis
var y_axis = svg.append("g")
.attr("id", "y_axis")
.attr("transform", "translate(75,0)")
.call(yAxis).style("font-size", "10px")
// add x-axis
var x_axis = svg.append("g")
.attr("id", "x_axis")
.attr("transform", `translate(${margin.left}, ${svg_dy - margin.bottom - margin.top})`)
.call(xAxis).style("font-size", "10px")
// add x and y grid lines
x_axis.call(xAxis.scale(xScale).ticks(20).tickSize(-chart_dy));
y_axis.call(yAxis.scale(yScale).ticks(20).tickSize(-chart_dx));
function zoom(e) {
// re-scale y axis during zoom
y_axis.transition()
.duration(50)
.call(yAxis.scale(d3.event.transform.rescaleY(yScale)));
// re-scale x axis during zoom
x_axis.transition()
.duration(50)
.call(xAxis.scale(d3.event.transform.rescaleX(xScale)));
// re-draw circles using new scales
var new_xScale = d3.event.transform.rescaleX(xScale);
var new_yScale = d3.event.transform.rescaleY(yScale);
// re-scale axes and gridlines
x_axis.call(xAxis.scale(new_xScale).ticks(20).tickSize(-chart_dy));
y_axis.call(yAxis.scale(new_yScale).ticks(20).tickSize(-chart_dx));
circles.data(d)
.attr('cx', function(d) {return new_xScale(d[0])})
.attr('cy', function(d) {return new_yScale(d[1])});
}
}
For anyone looking, I have solved this problem. I have updated the javascript in the original post, and updated the jsfiddle. If you are copying this code to your local machine where you are using d3.js 7.4.4 or higher then you need to change the lines that say d3.event.transform.... to just e.transform.

hide circles on Orthographic drag

I've created a globe which has circles and a drag. The problem is that the circles appear on the far side of the globe. I would like those circles to be hidden.
My bl.ock can be found here:
http://bl.ocks.org/anonymous/dc2d4fc810550586d40d4b1ce9088422/40c6e199a5be4e152c0bd94a13ea94eba41f004b
For example, I would like my globe to function like this one: https://bl.ocks.org/larsvers/f8efeabf480244d59001310f70815b4e
I've seen solutions such as this one: How to move points in an orthogonal map? but it doesn't quite work for me. The points simply disappear, as d[0] and d[1] seem to be undefined.
I've also tried using methods such as this: http://blockbuilder.org/tlfrd/df1f1f705c7940a6a7c0dca47041fec8 but that also doesn't seem to work. The problem here seems to be that he is using the json as his data, while my circles data are independent of the json.
Only similar example I've found is the one: https://bl.ocks.org/curran/115407b42ef85b0758595d05c825b346 from Curran but I don't really understand his code. His method is quite different than mine.
Here is my JavaScript code:
(function(){
var h = 600;
var w = 900;
var i = 0;
var map = void 0;
var world = void 0;
var US = void 0;
var margin = {
top: 10,
bottom: 40,
left: 0,
right: 30
};
var circleScale = d3.scaleSqrt()
.domain([0, 4445])
.range([0.5, 10])
var width = w - margin.left - margin.right;
var height = h - margin.top - margin.bottom;
var dragging = function(d){
var c = projection.rotate();
projection.rotate([c[0] + d3.event.dx/6, c[1] - d3.event.dy/6])
map.selectAll('path').attr('d', path);
map.selectAll(".circles").attr("cx", function(d){
var coords = projection([d.Longitude_imp, d.Latitude_imp])
return coords[0];
})
.attr("cy", function(d){
var coords = projection([d.Longitude_imp, d.Latitude_imp])
return coords[1];
})
}
var drag = d3.drag()
.on("drag", dragging)
var projection = d3.geoOrthographic().clipAngle(90);
var path = d3.geoPath().projection(projection);
var svg = d3.select("body")
.append("svg")
.attr("id", "chart")
.attr("width", w)
.attr("height", h)
d3.json("world.json", function(json){
d3.csv("arms_transfer_2012_2016_top - arms_transfer_2012_2016_top.csv", function(error, data){
var countries = topojson.feature(json, json.objects.countries).features
var US = countries[168]
map = svg.append('g').attr('class', 'boundary');
world = map.selectAll('path').data(countries);
US = map.selectAll('.US').data([US]);
Circles = map.selectAll(".circles").data(data)
console.log(countries[168])
world.enter()
.append("path")
.attr("class", "boundary")
.attr("d", path)
US.enter()
.append("path")
.attr("class", "US")
.attr("d", path)
.style("fill", "lightyellow")
.style("stroke", "orange")
Circles.enter()
.append("circle")
.attr("class", "circles")
.attr("r", function(d){
return circleScale(d.Millions)
})
.attr("cx", function(d){
var coords = projection([d.Longitude_imp, d.Latitude_imp])
return coords[0];
})
.attr("cy", function(d){
var coords = projection([d.Longitude_imp, d.Latitude_imp])
return coords[1];
})
.style("fill", "#cd0d0e")
svg.append("rect")
.attr("class", "overlay")
.attr("width", w)
.attr("height", h)
.call(drag)
})
})
})();
There are a few different methods to achieve this, but one of the easier methods would be to calculate the angular distance between the projection centroid (as determined by the rotation) and the circle center on the drag event:
map.selectAll("circle")
.style("display", function(d) {
var circle = [d.Longitude_imp, d.Latitude_imp];
var rotate = projection.rotate(); // antipode of actual rotational center.
var center = [-rotate[0], -rotate[1]]
var distance = d3.geoDistance(circle,center);
return (distance > Math.PI/2 ) ? 'none' : 'inline';
})
Take the center of each point and get the rotational center with projection.rotate() - note that the rotation values are inverse of the centering point. A rotation of [10,-20] centers the map at [-10,20], you move the map under you. With these two points we can use d3.geoDistance() which calculates the distance between two points in radians, hence the use of Math.PI/2 - which gives us points outside of 90 degrees, for these we hide, for the rest we show.
This can be incorporated a little nicer into your code, but I keep it separate here to show what is happening clearer.
Here's an example block - drag to trigger, I haven't applied the logic to the initial load.
An alternative approach, as noted by Gerardo Furtado, would be to use a path to display the circles - using path.pointRadius to set the size of the circle for each point. Instead of appending a circle, you could append path with the following format:
Circles.enter()
.append("path")
.attr("class", "circles")
.attr("d",createGeojsonPoint)
The, on update/drag:
map.selectAll('.circles').attr('d',createGeojsonPoint);
This method uses the clip angle of the orthographic to hide features when they are more than 90 degrees from the center of the projection (as determined by rotation). Your createGeojsonPoint function needs to set the radius and return a valid geojson object:
var createGeojsonPoint = function(d) {
console.log(d);
path.pointRadius(circleScale(d.Millions)); // set point radius
return path({"type":"Point","coordinates":[d.Longitude_imp,d.Latitude_imp]}) // create geojson point, return path data
}
All together, with the necessary modifications, your code might look like this.

How better organize grid lines?

There are grid lines from points.
Is there another solution with better performance, because if I add many svg elements(etc. rects, circles, paths) and increase the dimension of the grid I will see the freeze effect when I use zoom, move element...
The size of the grid is changed.
Also, how can I create endless grid lines, instead limited (gridCountX, gridCountY)?
Thanks
var svg = d3.select("body").append("svg");
var svgG = svg.append("g");
var gridLines = svgG.append("g").classed("grid-lines-container", true).data(["gridLines"]);
var gridCountX = _.range(100);
var gridCountY = _.range(100);
var size = 10;
gridLines.selectAll("g").data(gridCountY)
.enter()
.append("g")
.each(function(d) {
d3.select(this).selectAll("circle").data(gridCountX).enter()
.append("circle")
.attr("cx", function(_d) {return _d*size;})
.attr("cy", function(_d) {return d*size;})
.attr("r", 0.5)
.attr("style", function() {
return "stroke: black;";
});
});
var zoomSvg = d3.zoom()
.scaleExtent([1, 10])
.on("zoom", function(){
svgG.attr("transform", d3.event.transform);
});
svg.call(zoomSvg);
svg {
width: 100%;
height: 100%;
border: 1px solid #a1a1a1;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js"></script>
<script src="https://d3js.org/d3.v4.min.js"></script>
As you note, this approach is not really scalable and has a larger impact on performance. I have found the approach of utilizing d3 axes for grids to have minimal performance impact while also being relatively straightforward to incorporate with zoom such that you can have infinite zoom with the grid lines updating in a sensible manner due to the "magic" of automatic generation of sensible tick locations in d3.
To implement something similar in d3 v4, you can do something along these lines:
var svg = d3.select("svg"),
margin = {top: 20, right: 140, bottom: 50, left: 70},
width = svg.attr("width") - margin.left - margin.right,
height = svg.attr("height") - margin.top - margin.bottom,
g = svg.append("g").attr("transform", "translate(" + margin.left + "," + margin.top + ")"),
innerSvg = g.append("svg").attr("width", width).attr("height", height);
// Calculate domain for x and y from data and store in x0, y0 (not shown here)
x.domain(x0);
y.domain(y0);
xGridAxis = d3.axisBottom(x).ticks(10);
yGridAxis = d3.axisLeft(y).ticks(10 * height / width);
// Create grouping and additional set of axes for displaying grid
innerSvg.append("g")
.attr("class", "grid x-grid")
.attr("transform", "translate (0," + height + ")")
.call(xGridAxis
.tickSize(-height, 0, 0)
.tickFormat("")
)
.selectAll(".tick");
innerSvg.append("g")
.attr("class", "grid y-grid")
.attr("transform", "translate (" + width + ", 0)")
.call(yGridAxis
.tickSize(width)
.tickFormat("")
);
// Add element to capture mouse events for drag and pan of plots
var zoom = d3.zoom()
.on("zoom", zoomed);
var scrollZoom = innerSvg.append("rect")
.attr("class", "zoom")
.attr("width", width)
.attr("height", height)
.attr("pointer-events", "all") // Defaults to panning with mouse
.call(zoom);
// Mouse panning and scroll-zoom implementation using d3.zoom
// Modification of : http://bl.ocks.org/lorenzopub/013c0c41f9ffab4d27f860127f79c5f5
function zoomed() {
lastEventTransform = d3.event.transform;
// Rescale the grid using the new transform associated with zoom/pan action
svg.select(".x-grid").call(xGridAxis.scale(lastEventTransform.rescaleX(x)));
svg.select(".y-grid").call(yGridAxis.scale(lastEventTransform.rescaleY(y)));
// Calculate transformed x and y locations which are used to redraw all plot elements
var xt = lastEventTransform.rescaleX(x),
yt = lastEventTransform.rescaleY(y);
// Code below just shows how you might do it. Will need to tweak based on your plot
var line = d3.line()
.x(function(d) { return xt(d.x); })
.y(function(d) { return yt(d.y); });
innerSvg.selectAll(".line")
.attr("d", function(d) { return line(d.values); });
innerSvg.selectAll(".dot")
.attr("cx", function(d) {return xt(d.x); })
.attr("cy", function(d) {return yt(d.y); });
}
Here is a worked out example in d3 v4 that inspired my version above:
http://bl.ocks.org/lorenzopub/013c0c41f9ffab4d27f860127f79c5f5

d3 zoom behavior when window is resized

I am using D3 to set up my chart area that makes use of the zoomable behavior. The chart includes both x and y axis.
The chart area should be responsive when window is resized. In this case, I need to reset the x and y axis domain and range on window resizing.
The issue happened with window resizing. I've noticed the zoom focus isn't lined up with the mouse anymore after the following steps:
First pan and zoom in the chart area
Then resize the window
Then pan and zoom the chart area again
After that, the above problem happened. See the following jsfiddle which has this issue.
So what's the right way of handing this? I've noticed a couple discussions about this issue such as:
d3 Preserve scale/translate after resetting range
I tried this approach but I couldn't make it working using D3 V4 API.
The main idea is the use the window.addEventListener to update the plot:
window.addEventListener('resize', () => {
// obtain the current transformation by calling the `d3.zoomTransform` function
const t0 = d3.zoomTransform(
chart.node());
// obtain the client width by evaluating the `div` (class: `zoom-chart`)
width = document.getElementById('zoom-chart').clientWidth;
height = 480;
// update the base scale
baseX.range([0, width])
.interpolate(d3.interpolateNumber);
baseY.range([height, 0])
.interpolate(d3.interpolateNumber);
// update the view port
vb = [0,0, width,height];
chart
.attr('viewBox', vb);
// replot with the original transform
chart.call(zoom.transform, t0);
});
I also added the whole solution here:
const data = [
{x:1 , y:1},
{x:9 , y:1},
{x:9 , y:9},
{x:1 , y:9},
];
var width = document.getElementById('zoom-chart').clientWidth;
var height = 480;
var baseX = d3.scaleLinear().domain([0,10])
.range([0, width])
.interpolate(d3.interpolateNumber);
var baseY = d3.scaleLinear().domain([0,10])
.range([height, 0])
.interpolate(d3.interpolateNumber);
var scaleX = baseX;
var scaleY = baseY;
const zoom = d3.zoom()
.on('zoom', () => {
const transform = d3.event.transform;
scaleX = transform.rescaleX(baseX)
.interpolate(d3.interpolateNumber)
scaleY = transform.rescaleY(baseY)
.interpolate(d3.interpolateNumber)
render();
});
const chart = d3.select('#zoom-chart')
.append('svg')
.attr('class', 'chart')
.call(zoom);
const rect = chart
.append('rect')
.attr('rx', 10).attr('ry', 10)
.attr('x', 10).attr('y', 10)
.attr('fill', '#00222b')
.attr('width', width-20)
.attr('height', height-20);
const plot = chart
.append('g')
.attr('class', 'plot-area');
function render() {
plot.selectAll('.data')
.data(data)
.join(
enter => enter.append('circle')
.attr('class', 'data')
.attr('r', 10)
.attr('stroke', '#004455')
.attr('stroke-width', 5),
update => update,
exit => exit.remove()
)
.attr('cx', d => scaleX(d.x))
.attr('cy', d => scaleY(d.y));
}
window.addEventListener('resize', () => {
const t0 = d3.zoomTransform(
chart.node());
width = document.getElementById('zoom-chart').clientWidth;
height = 480;
baseX.range([0, width])
.interpolate(d3.interpolateNumber);
baseY.range([height, 0])
.interpolate(d3.interpolateNumber);
vb = [0,0, width,height];
chart
.attr('viewBox', vb);
rect
.attr('width', width-20)
.attr('height', height-20);
chart.call(zoom.transform, t0);
});
let vb = [0,0, width,height];
chart
.attr('viewBox', vb);
chart.call(zoom.transform, d3.zoomIdentity);
<script src="https://unpkg.com/d3#5.15.0/dist/d3.min.js"></script>
<div id="zoom-chart" class="chart large">
</div>

D3 Pan Zoom Overflow

I was wondering if you could help me with the follwoing D3js Zoom and pan functionality in the following fiddle: http://jsfiddle.net/moosejaw/nUF6X/5/
I hope the code (although not great) is straight forward.
I have a chart that has total chromosome length by total chromosome length. The tick values are the individual lengths (totals) of each chromosome. The ticks are formatted to be the name of the chromosomes (to look nice to the end user).
The problems that I am having are:
The x-axis and y-axis labels are extending outside the graph area. When I do not supply the tick values explicitly, the labels "disappear" as they should. See:
var yAxis = d3.svg.axis()
.scale(y)
.orient("left")
.tickValues(tickValues)
.tickFormat(function(d) {
var ret = bpToChrMBP(d);
return ret.chr;
});
How do I prevent the x axis to not pan to the left before the minimum value? Also not pan to the right past the maximum value? This happens whether or not I am zoomed in. (The same for y-axis, except top and bottom).
Is there a way to "center" the axis labels between the tick marks. The tick marks are not evenly spaced. I tried using subdivide for minor tick marks, but that doesn't subdivide between tick marks correctly.
Any help would be greatly appreciated!
Matt
This Fiddle solves most of your problems: http://jsfiddle.net/CtTkP/
The explanations are below:
I am not sure what you meant by extending beyond the graphs area. Should the labels be insde the chart-area? If you mean that on panning, the labels extend beyond the axis, the problem can be solved by using two more clip-paths judiciously, though this does not allow for graceful fading of values which svg.axis translations provide:
var clipX = svg.append("clipPath")
.attr('id', 'clip-x-axis')
.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', width)
.attr('height', margin.bottom);
svg.append("g")
.attr("class", "x axis")
.attr('clip-path', 'url(#clip-x-axis)')
.attr("transform", "translate(0, " + height + ")")
.call(xAxis);
// ...
var clipY = svg.append("clipPath")
.attr('id', 'clip-y-axis')
.append('rect')
.attr('x', - margin.left)
.attr('y', 0)
.attr('height', height)
.attr('width', margin.left);
svg.append("g")
.attr("class", "y axis")
.attr('clip-path', 'url(#clip-y-axis)')
.call(yAxis);
To prevent the panning from extending beyond values, you will have to manually restrict the translate for the zoom:
function zoomed() {
var trans = zoom.translate(),
scale = zoom.scale();
tx = Math.min(0, Math.max(width * (1 - scale), trans[0]));
ty = Math.min(0, Math.max(height * (1 - scale), trans[1]));
zoom.translate([tx, ty]);
svg.select(".x.axis").call(xAxis);
svg.select(".y.axis").call(yAxis);
// ...
This will not allow the graph from panning beyond the limits.
As you are explicitly overriding the tickValues, you can tweak the values to center them:
var tickValues2 = [];
tickValues.forEach(function (t, idx) {
if (idx < tickValues.length - 1) {
tickValues2.push((t + tickValues[idx + 1]) / 2);
}
});
Then instead of using tickValues for xAxis and yAxis, use tickValues2.
The problem is that you are setting tickValues manually, instead of letting the x and y scale do it for you. Try commenting it out: // .tickValues(tickValues)
var x = d3.scale.linear().rangeRound([0, width]).domain(d3.extent(tickValues));
var xAxis = d3.svg.axis()
.scale(x)
.orient("bottom")
// .tickValues(tickValues)
.tickFormat(function(d) {
var ret = bpToChrMBP(d);
return ret.chr;
});
A quick and dirty fix to allow setting tickValues explicitly could be to define a clippingPath for each axis.
You also don't need the make_x_axis function (same for y axis). Check out this zoomable scatterplot example: http://bl.ocks.org/ameliagreenhall/raw/d30a9ceb68f5b0fc903c/
To prevent panning left/right past the cutoffs you would have to re-implement d3.behavior.zoom(). Right now there is a function called mousemove that calls translateTo and this function doesn't have a limit:
function translateTo(p, l) {
l = point(l);
translate[0] += p[0] - l[0];
translate[1] += p[1] - l[1];
}
You can try playing with the dx and dy attributes when you define the axes.

Categories

Resources