I am trying to work with D3 transitions and an image and I seem to be having trouble.
I am trying to cause the image to disappear by having the top and bottom of the image close together until it gets to the middle, like a shutter effect, or erasing lines from the top and bottom until it is all gone. I don't want the image to scale at all, I just want it to close.
So far I have gotten the image to scale down to 0, but that is not what I want.
Also on the first transition it drops the image to the middle of the box before it starts the transition, whats up with that?
http://jsfiddle.net/Qda6B/
var svgContainer = d3.select("#box").append("svg")
.style("width", '100%')
.style("height", '100%')
.style("background-color","blue");
var imgs = svgContainer.append("svg:image")
.attr("xlink:href", "http://guiaavare.com/img/upload/images/Aishwarya-Rai-face.jpg")
.attr("width", "400")
.attr("height", "400");
d3.select("#inbutton").on("click", function () {
imgs
.attr("height",400)
.transition()
.attr({
height: 0,
y: 200
})
.duration(500);
});
d3.select("#outbutton").on("click", function () {
imgs
.transition()
.attr({
height: 400,
y: 0
})
.duration(500);
});
Thanks in advance
You can achieve the effect you are after using clipPath: http://jsfiddle.net/Qda6B/5/
var clipRect = svgContainer.append('svg:defs')
.append('svg:clipPath')
.attr('id', 'shutter-clip')
.append('rect')
.attr({
x: 0,
y: 0,
height: 400,
width: 400
})
var imgs = svgContainer.append("svg:image")
// ...
.attr("clip-path", "url(#shutter-clip)");
And then doing the transitions on the height of the rect inside the clipPath.
As an aside, though this is possible to do with d3, this very likely belongs in the domain of CSS3 transitions or jQuery or GASP animations.
Related
So I have a d3 chart with a rect overlay to hold crosshair elements on mouseover events. Under the overlay I have other rects displaying data that have mouseover event handlers also, but The overlay is blocking mouseover events form triggeron the children rects below.
let chartWindow = svg
.append("g");
/* this holds axis groups, and cadlestick group*/
let candleStickWindow = chartWindow.append("g")
//this event never fires
.on('mousemove', ()=>console.log('mouse move'));
let candlesCrosshairWindow = chartWindow
.append("rect")
.attr("class", "overlay")
.attr("height", innerHeight)
.attr("width", innerWidth)
.on("mouseover", function() {
crosshair.style("display", null);
})
.on("mouseout", function() {
crosshair.style("display", "none");
removeAllAxisAnnotations();
})
.on("mousemove", mousemove);
The CrosshairWindow has CSS property pointer-events: all. If I remove that, I get my events to fire on the candleStickWindow but not the CrosshairWindow. How can I get mouse events onto both of the elements??
Thanks for any help!
Update
I changed the crosshair rect element to be on the bottom and it kinda works, the candlestick bars mouseover event works but it blocks the crosshair from working.
One solution that comes to mind might use event bubbling which, however, only works if the events can bubble up along the same DOM sub-tree. If, in your DOM structure, the crosshairs rectangle and the other elements do not share a common ancestor to which you could reasonably attach such listener, you need to either rethink your DOM or resort to some other solution. For this answer I will lay out an alternative approach which is more generally applicable.
You can position your full-size rect at the very bottom of your SVG and have its pointer-events set to all. That way you can easily attach a mousemove handler to it to control your crosshairs' movements spanning the entire viewport. As you have noticed yourself, however, this does not work if there are elements above which have listeners for that particular event type attached to them. Because in that case, once the event has reached its target, there is no way propagating it further to the underlying rectangle for handling the crosshairs component. The work-around is easy, though, since you can clone the event and dispatch that new one directly to your rectangle.
Cloning the event is done by using the MouseEvent() constructor passing in the event's details from the d3.event reference:
new MouseEvent(d3.event.type, d3.event)
You can then dispatch the newly created event object to your crosshairs rect element by using the .dispatchEvent() method of the EventTarget interface which is implemented by SVGRectElement:
.dispatchEvent(new MouseEvent(d3.event.type, d3.event));
For lack of a full example in your question I set up a working demo myself illustrating the approach. You can drag around the blue circle which is a boiled down version of your crosshairs component. Notice, how the circle can be seamlessly moved around even when under the orange rectangles. To demonstrate the event handlers attached to those small rectangles they will transition to green and back to orange when entering or leaving them with the mouse pointer.
const width = 500;
const height = 500;
const radius = 10;
const orange = d3.hsl("orange");
const steelblue = d3.hsl("steelblue");
const limegreen = d3.hsl("limegreen");
const svg = d3.select("body")
.append("svg")
.attr("width", width)
.attr("height", height);
const target = svg.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", width)
.attr("height", height)
.attr("fill", "none")
.attr("pointer-events", "all")
.on("mousemove", () => {
circle.attr("cx", d3.event.clientX - radius);
circle.attr("cy", d3.event.clientY - radius);
});
const circle = svg.append("circle")
.attr("r", radius)
.attr("fill", steelblue)
.attr("pointer-events", "none");
const rect = svg.selectAll(null)
.data(d3.range(3).map(d => [Math.random() * width, Math.random() * height]))
.enter().append("rect")
.attr("x", d => d[0])
.attr("y", d => d[1])
.attr("width", 50)
.attr("height", 50)
.attr("fill", orange)
.attr("opacity", 0.5)
.on("mouseover", function() {
d3.select(this).transition().attr("fill", limegreen);
})
.on("mousemove", function() {
target.node().dispatchEvent(new MouseEvent(d3.event.type, d3.event));
})
.on("mouseout", function() {
d3.select(this).transition().attr("fill", orange);
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
If you click the red button in this example:
https://bl.ocks.org/interwebjill/fe782e6f195b17f6fe6798a24c390d90
you can see that the chart translates so that the circle is in the center and then zooms in to a specified level (reclicking on the button zooms back out). Translating and then zooming in this way leaves a gap on the left that I would rather not have. How might I change the code so that the chart zooms first and then translates to center so that I don't have this gap in the chart?
I have tried reversing the order of the scale and translate in both the zoom definition and the zoomToExtent function but there is no different in effect.
The ultimate source of the problem is d3.interpolateZoom. This interpolator has scale interpolate faster than translate - even though they mostly both are transitioning at the same time. The pattern implemented with d3.interpolateZoom is based on this paper.
Because scale and translate both interpolate differently in d3.interpolateZoom, you get a gap in the side of your chart as the scale decreases/increases more rapidly than the translate values.
d3.interpolateZoom is used when you call the zoom on a transition.
However, if you apply a transform directly on a transition using .attr(), the d3 transition will use d3.interpolateString, which will search the start and end strings for corresponding numbers and use d3.interpolateNumber on those. This will apply the same interpolation to both scale and translate.
Using both methods we can compare the discrepancy between d3.interpolateZoom and d3.interpolateString. Below the black rectangle uses d3.interpolateString while the orange rectangle uses d3.interpolateZoom. Click on a rectangle to start the transition:
var svg = d3.select("body").append("svg")
.attr("width", 500)
.attr("height", 300);
var g1 = svg.append("g"), g2 = svg.append("g");
var zoom1 = d3.zoom().on("zoom", function() {
g1.attr("transform", d3.event.transform);
});
var zoom2 = d3.zoom().on("zoom", function() {
g2.attr("transform", d3.event.transform);
});
g1.call(zoom1.transform, d3.zoomIdentity
.translate(150, 100)
.scale(2));
g2.call(zoom2.transform, d3.zoomIdentity
.translate(150,100)
.scale(2));
g1.append("rect")
.attr("x", 20)
.attr("y", 20)
.attr("width", 50)
.attr("height", 50);
g2.append("rect")
.attr("x", 22)
.attr("y", 22)
.attr("width", 46)
.attr("height",46)
.attr("fill","orange");
d3.selectAll("rect").on("click", function() {
g1.transition()
.duration(6000)
.attr("transform", d3.zoomIdentity)
.on("end", function() {
d3.select(this).call(zoom1.transform, d3.zoomIdentity);
})
g2.transition()
.duration(6000)
.call(zoom2.transform, d3.zoomIdentity)
});
<script type="text/javascript" src="https://d3js.org/d3.v5.js"></script>
Where the first rectangle transitions the transform with .attr(), we need to call the zoom afterwards to ensure the zoom has the current transform, we don't need to in this example, but if you wanted to use the zoom after the transform you need to do this
Comparing these two we get:
(Y axis indicates percentage remaining in transition from start attribute to end attribute)
You want scale and translate to move simultaneously at the same rate when transitioning. We can do this if we use a tweening function. Unlike above we can't just use transition().attr("transform",newTransfrom) because you are also drawing canvas and updating the axis. So we'll need to create our own tweening function that can use the current transform and scale, apply it to the axis, canvas, and markers.
For example, rather than calling the zoom (which will use d3.interpolateZoom):
function zoomToExtent(d0, d1) {
zoomRect.call(zoom).transition()
.duration(1500)
.call(zoom.transform, d3.zoomIdentity
.translate(-xSVG(d0), 0)
.scale(width / (xSVG(d1) - xSVG(d0))));
}
Instead, we can use a tweening function which controls the element's transform and applies the same interpolator to scale and translate:
function zoomToExtent(d0, d1) {
//get transition start and end values:
var startScale = d3.zoomTransform(zoomRect.node()).k;
var startTranslate = d3.zoomTransform(zoomRect.node()).x;
var endTranslate = -xSVG(d0);
var endScale = width / (xSVG(d1) - xSVG(d0));
zoomRect.call(zoom).transition()
.duration(1500)
.tween("transform", function() {
var interpolateScale = d3.interpolateNumber(startScale,endScale);
var interpolateTranslate = d3.interpolateNumber(startTranslate,endTranslate);
return function(t) {
var t = d3.zoomIdentity.translate(interpolateTranslate(t),0).scale(interpolateScale(t));
zoomed(t);
}
})
.on("end", function() { // update the zoom identity on end:
d3.select(this).call(zoom.transform, d3.zoomIdentity
.translate(endTranslate, 0)
.scale(endScale));
})
}
You may notice I'm passing a transform value to the zoomed function, since there is no d3.event.transform for this, we need to modify the zoomed function to use the passed parameter if available, otherwise to fall back on the event transform:
function zoomed(transform) {
var t = transform || d3.event.transform;
...
Altogether, that might look something like this.
For another comparison between the two transitioning methods, I've created a gridded comparison that can be toggled between the two zoom identities:
var svg = d3.select("body").append("svg")
.attr("width", 510)
.attr("height", 310);
var g1 = svg.append("g");
var g2 = svg.append("g");
var rectangles1 = g1.selectAll()
.data(d3.range(750))
.enter()
.append("rect")
.attr("x", function(d) { return d%25*20; })
.attr("y", function(d) { return Math.floor(d/25)*20; })
.attr("width", 20)
.attr("height", 20)
.attr("fill","#ccc")
.attr("stroke","white")
.attr("stroke-width", 2);
var rectangles2 = g2.selectAll()
.data(d3.range(750))
.enter()
.append("rect")
.attr("x", function(d) { return d%25*20; })
.attr("y", function(d) { return Math.floor(d/25)*20; })
.attr("width", 20)
.attr("height", 20)
.attr("fill","none")
.attr("stroke","#444")
.attr("stroke-width", 1);
var startZoom = d3.zoomIdentity
.translate(-250,-200)
.scale(4);
var endZoom = d3.zoomIdentity
.translate(-100,-100)
.scale(5);
var zoom1 = d3.zoom().on("zoom", function() { g1.attr("transform", d3.event.transform); });
var zoom2 = d3.zoom().on("zoom", function() { g2.attr("transform", d3.event.transform); });
g1.call(zoom1.transform, startZoom);
g2.call(zoom2.transform, startZoom);
var toggle = true;
svg.on("click", function() {
toggle = !toggle;
g1.transition()
.duration(5000)
.call(zoom1.transform, toggle ? startZoom: endZoom)
g2.transition()
.duration(5000)
.attr("transform", toggle ? startZoom: endZoom)
.on("end", function() {
d3.select(this).call(zoom2.transform, toggle ? startZoom: endZoom);
})
})
rect {
opacity: 0.5;
}
<script type="text/javascript" src="https://d3js.org/d3.v5.js"></script>
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.
I created a CodePen to illustrate my problem:
See the CodePen Simple Minimap dashed Viewport.
I'm part of a small team, my job is (among others) to do the minimap.
The code in the pen is a boiled-down version of our code regarding the minimap.
I'm new to d3js, so I'm happy to hear anything which could be improved about it.
I'm stuck with the bug regarding the dashed rectangle, which is supposed to show what part of the big map we can see.
Panning works, zooming might be finished - the (in my eyes) only technical problem remaining is that the dashed rectangle also pans when you zoom.
The zoom-panning amount is dependant on the mouse position - if the mouse is in the top-left corner, panning is minimal. The further the mouse moves to bottom or right (or both), the more this bug becomes apparent.
I'm using d3.event to get information about pan & zoom amount, and noticed that even if you do not pan at all, if you only zoom, d3.event.translate[0/1] contains values != zero.
Is my approach wrong? Being a new member of the team, I cannot change much of the code, but I can change everything regarding the minimap.
I need to capture pan and zoom events from
var rect = svg.append("rect")
...
.style("pointer-events", "all");
Can anyone tell me what I'm doing wrong? I'm stuck on that problem for days now, any help would be appreciated a lot. Thanks in advance!
HTML:
<div class="canvas" id="canvas"></div>
CSS:
body
{
background-color: #fcfcfc;
}
.canvas
{
position: absolute;
border-radius: 7px;
background-color: #ddd;
left: 7%;
top: 10%;
}
.minimap {
position: absolute;
border-radius: 8px;
border: 2px solid #c2d1f0;
bottom: 2%;
right: 2%;
}
JS:
var width = 800;
var height = 500;
var canvas = d3.select("#canvas")
.attr("width", width)
.attr("height", height);
var zoom = d3.behavior.zoom()
.scaleExtent([0.1, 10])
.on("zoom", zoomed);
var svg = canvas.append("svg")
.attr("width", width)
.attr("height", height)
.append("g")
.call(zoom);
// invisible rectangle to handle mouse interaction
var rect = svg.append("rect")
.attr("width", width)
.attr("height", height)
.style("fill", "none")
.style("pointer-events", "all");
var group = svg.append("g");
function zoomed() {
group.attr("transform", "translate(" + d3.event.translate + ")scale(" + d3.event.scale + ")");
// update the minimap whenever pan or zoom have happened
updateMinimap();
}
// an array of circles just to have something to look at and as orientation
var circles = [
{x: 150, y: 100},
{x: 50, y: 50},
{x: 80, y: 350},
{x: 200, y: 150},
{x: 350, y: 200},
{x: 140, y: 300},
{x: 230, y: 280}
];
// draw those circles
group.selectAll("circle")
.data(circles)
.enter()
.append("circle")
.attr("r", 5)
.attr("fill", "red")
.attr("cx", function(d) {return d.x;})
.attr("cy", function(d) {return d.y;})
var minimapScale = 1 / 3; // size of big map times this = size of minimap
var minimapWidth = width * minimapScale;
var minimapHeight = height * minimapScale;
var minimap = canvas.append("svg")
.attr("class", "minimap")
.attr("width", minimapWidth)
.attr("height", minimapHeight);
// run it once so we can see it even if no action was done
updateMinimap();
function updateMinimap() {
// clear outdated objects from minimap
minimap.selectAll("*").remove();
// set default values
var scale = 1;
var dx = 0;
var dy = 0;
if (d3.event) { // overwrite those values when necessary
scale = d3.event.scale;
dx = -d3.event.translate[0] * minimapScale;
dy = -d3.event.translate[1] * minimapScale;
}
// debug output
//console.log("scale: " + scale + ", dx: " + dx + ", dy: " + dy);
// repaint objects on minimap
group.selectAll("*").each(function (circle) {
var cx = circle.x * minimapScale;
var cy = circle.y * minimapScale;
minimap.append("circle")
.attr("r", 2)
.attr("fill", "blue")
.attr("cx", cx)
.attr("cy", cy);
});
// draw the dashed rectangle, indicating where we are on the big map
drawDashedRectangle(scale, dx, dy);
}
function drawDashedRectangle(scale, dx, dy) {
minimap.append("rect")
.attr("x", dx)
.attr("y", dy)
.attr("width", minimapWidth / scale)
.attr("height", minimapHeight / scale)
.style("stroke-dasharray", (10, 5))
.style("stroke-width", 2)
.style("stroke", "gray")
.style("fill", "none");
}
We'd like to have something like that, though we don't need pan/zoom interaction on the minimap. We only need it on the big map, and the minimap should only reflect those changes.
I'm experiencing 'stutter' with the D3 drag behavior.
Seems to be a similar problem to "Stuttering" drag when using d3.behavior.drag() and transform
However the solution does not seem to work for the zoom behavior.
Here is an example of the issue: (try dragging the rectangle)
http://jsfiddle.net/EMNGq/109/
blocks = [
{ x: 0, y: 0 }
];
var translate_var = [0,0];
zoom_var = d3.behavior.zoom()
.on("zoom", function(d) {
d.x = d3.event.x;
d.y = d3.event.y;
draw();
});
svg = d3.select("body")
.append("svg:svg")
.attr("width", 600)
.attr("height", 600);
function draw() {
g = svg.selectAll("g")
.data(blocks);
gEnter = g.enter().append("g")
.call(zoom_var);
g.attr("transform", function(d) { return "translate("+translate_var[0]+","+translate_var[1]+")"; });
gEnter.append("rect")
.attr("height", 100)
.attr("width", 100);
}
draw()
You zoom in or drag the element, but then translate the same element. Because the translation is relative, it results in this stuttering.
As stated in the documentation for Zoom Behavior:
This behavior automatically creates event listeners to handle zooming and panning gestures on a container element. Both mouse and touch events are supported.
Contrast it to the documentation for Drag Behavior:
This behavior automatically creates event listeners to handle drag gestures on an element. Both mouse events and touch events are supported.
Your solution is inverse to the similar question. Call your zoom function on the container.
svg = d3.select("body")
.append("svg:svg")
.attr("width", 600)
.attr("height", 600)
.call(zoom_var);
Here's the demo.
You might also be interested in the actual zoom. To do that simply add the scale to your transform rule. Here's the demo with zoom enabled.