d3.js: distance point to svg:path - d3.js

Is there an (efficient) method to (a) calculate the shortest distance between a fixed point and a svg:path element in d3.js and (b) determine the point on the path which belongs to this distance?

In the general case, I don´t think so. An SVG path is a complex element. For instance, if the path is a Bezier curve, the control points may be off the represented line, and the represented shape may be off the bounding box of the control points.
I think that if you have a set of points that you use to generate the path, you may use this points to compute the distance from this points to a given point and get the minimum distance. In the MDN SVG Path Tutorial you can find some examples of complex shapes and how they are made.

Although my calculus answer is still valid, you could just do everything in this bl.ocks example:
var points = [[474,276],[586,393],[378,388],[338,323],[341,138],[547,252],[589,148],[346,227],[365,108],[562,62]];
var width = 960,
height = 500;
var line = d3.svg.line()
.interpolate("cardinal");
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height);
var path = svg.append("path")
.datum(points)
.attr("d", line);
var line = svg.append("line");
var circle = svg.append("circle")
.attr("cx", -10)
.attr("cy", -10)
.attr("r", 3.5);
svg.append("rect")
.attr("width", width)
.attr("height", height)
.on("mousemove", mousemoved);
function mousemoved() {
var m = d3.mouse(this),
p = closestPoint(path.node(), m);
line.attr("x1", p[0]).attr("y1", p[1]).attr("x2", m[0]).attr("y2", m[1]);
circle.attr("cx", p[0]).attr("cy", p[1]);
}
function closestPoint(pathNode, point) {
var pathLength = pathNode.getTotalLength(),
precision = pathLength / pathNode.pathSegList.numberOfItems * .125,
best,
bestLength,
bestDistance = Infinity;
// linear scan for coarse approximation
for (var scan, scanLength = 0, scanDistance; scanLength <= pathLength; scanLength += precision) {
if ((scanDistance = distance2(scan = pathNode.getPointAtLength(scanLength))) < bestDistance) {
best = scan, bestLength = scanLength, bestDistance = scanDistance;
}
}
// binary search for precise estimate
precision *= .5;
while (precision > .5) {
var before,
after,
beforeLength,
afterLength,
beforeDistance,
afterDistance;
if ((beforeLength = bestLength - precision) >= 0 && (beforeDistance = distance2(before = pathNode.getPointAtLength(beforeLength))) < bestDistance) {
best = before, bestLength = beforeLength, bestDistance = beforeDistance;
} else if ((afterLength = bestLength + precision) <= pathLength && (afterDistance = distance2(after = pathNode.getPointAtLength(afterLength))) < bestDistance) {
best = after, bestLength = afterLength, bestDistance = afterDistance;
} else {
precision *= .5;
}
}
best = [best.x, best.y];
best.distance = Math.sqrt(bestDistance);
return best;
function distance2(p) {
var dx = p.x - point[0],
dy = p.y - point[1];
return dx * dx + dy * dy;
}
}
path {
fill: none;
stroke: #000;
stroke-width: 1.5px;
}
line {
fill: none;
stroke: red;
stroke-width: 1.5px;
}
circle {
fill: red;
}
rect {
fill: none;
cursor: crosshair;
pointer-events: all;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>
And I spent all that time in the previous answer writing up pretty LaTeX!

I'm not aware of a d3-specific solution to this. But if your path can be represented as a segment of a function, there is hope with a little calculus.
Start with the line length equation .
Plug in your point to x1 and y1.
Replace the remaining y with the function representing your path.
Simplify, then calculate the derivative .
Set to 0 and solve. Hopefully one x value will be within the bounds of your path endpoints. Apply this x to your path function and you have your point on the path.
A more visual example. There are plenty of considerations to make this happen in JavaScript: what is my function? What is the fastest way to take a derivative of the above? These are specific to your situation.

Related

Why doesn't my geo LineString follow latitude/graticule curves?

I'm trying to draw LineStrings that follow various latitude segments, however the built-in geodesic arc interpolation doesn't seem to be drawing arcs that follow latitude. My question is: why not and how do I achieve this?
Here is my result:
And my code:
const width = 500;
const height = 500;
const scale = 200;
const svg = d3.select('svg').attr("viewBox", [0, 0, width, height]);
const projection = d3.geoStereographic().rotate([0, -90]).precision(0.1).clipAngle(90.01).scale(scale).translate([width / 2, height / 2]);
const path = d3.geoPath(projection);
const graticule = d3.geoGraticule().stepMajor([15, 15]).stepMinor([0, 0])();
svg
.append("path")
.datum(graticule)
.attr("d", path)
.attr("fill", "none")
.attr("stroke", '#000000')
.attr("stroke-width", 0.3)
.attr("stroke-opacity", 1);
let curve = {
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [
[-180, 15],
[-90, 15]
]
}
}
svg
.append("path")
.datum(curve)
.attr("d", path)
.attr('fill-opacity', 0)
.attr('stroke', 'red')
.attr("stroke-width", 1)
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<svg></svg>
My fiddle: https://jsfiddle.net/jovrtn/komfxycz/
D3 is fairly unique when it comes to geographic data: it uses spherical math (which despite many benefits, does lead to some challenges). d3.geoPath samples a line segment between two points so that the path follows a great circle (the shortest path between two points on a globe). Parallels do not follow great circle distances, so your path does not follow the parallel.
The behavior you are looking for requires us to draw a line between two points of latitude longitude as though they were Carteisan, even though they are not, and then preserve the points along that line when applying the stereographic projection.
When using an cylindrical projection the solution is easy enough, don't sample between points on a line. This answer contains such a solution.
This doesn't help with a stereographic projection - the linked approach would just result in a straight line between the first point and end point instead of a curved line along the parallel.
A solution is to manually sample points between start and end as though the data were Cartesian, then treat them as 3D in order to project them with a stereographic projection. This results in a path that follows parallels where start and end have the same north/south value. How frequently you sample reduces/eliminates the effect of great circle distances when using d3.geoPath.
In my solution I'm going to use two d3 helper functions:
d3.geoDistance which measures the distance between two lat long pairs in radians.
d3.interpolate which creates an interpolation function between two values.
let sample = function(line) {
let a = line.geometry.coordinates[0]; // first point
let b = line.geometry.coordinates[1]; // end point
let distance = d3.geoDistance(a, b); // in radians
let precision = 1*Math.PI/180; // sample every degree.
let n = Math.ceil(distance/precision); // number of sample points
let interpolate = d3.interpolate(a,b) // create an interpolator
let points = []; // sampled points.
for(var i = 0; i <= n; i++) { // sample n+1 times
points.push([...interpolate(i/n)]); // interpolate a point
}
line.geometry.coordinates = points; // replace the points in the feature
}
The above assumes a line with two points/one segment, naturally if your lines are more complex than that you'll need to adjust this. It's intended just as a starting point.
And in action:
const width = 500;
const height = 500;
const scale = 200;
const svg = d3.select('svg').attr("viewBox", [0, 0, width, height]);
const projection = d3.geoStereographic().rotate([0, -90]).precision(0.1).clipAngle(90.01).scale(scale).translate([width / 2, height / 2]);
const path = d3.geoPath(projection);
const graticule = d3.geoGraticule().stepMajor([15, 15]).stepMinor([0, 0])();
svg
.append("path")
.datum(graticule)
.attr("d", path)
.attr("fill", "none")
.attr("stroke", '#000000')
.attr("stroke-width", 0.3)
.attr("stroke-opacity", 1);
let curve = {
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [
[-180, 15],
[-90, 15]
]
}
}
svg
.append("path")
.datum(curve)
.attr("d", path)
.attr('fill-opacity', 0)
.attr('stroke', 'red')
.attr("stroke-width", 1)
let sample = function(line) {
let a = line.geometry.coordinates[0];
let b = line.geometry.coordinates[1];
let distance = d3.geoDistance(a, b); // in radians
let precision = 5*Math.PI/180;
let n = Math.ceil(distance/precision);
let interpolate = d3.interpolate(a,b)
let points = [];
for(var i = 0; i <= n; i++) {
points.push([...interpolate(i/n)]);
}
line.geometry.coordinates = points;
}
sample(curve);
svg
.append("path")
.datum(curve)
.attr("d", path)
.attr('fill-opacity', 0)
.attr('stroke', 'blue')
.attr("stroke-width", 1)
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<svg></svg>

Can you "bend" a SVG around bezier path while animating?

Is there any way to "bend" an SVG object as its being animated along a bezier path? Ive been using mostly GSAP for animating things. The effect would look something like this: https://www.behance.net/gallery/49401667/Twisted-letters-2 (the one with the blue pencil). I have managed to get the red arrow to animate along the path but the shape stays the same the whole time. Id like for it to follow along the green path and bend as it goes around the curve so that at the end of the animation it has the shape of the purple arrow. Here is the codepen.
GSAP code:
var motionPath = MorphSVGPlugin.pathDataToBezier("#motionPath", {align:"#arrow1"});
var tl1 = new TimelineMax({paused:true, reversed:true});
tl1.set("#arrow1", {xPercent:-50, yPercent:-50});
tl1.to("#arrow1", 4, {bezier:{values:motionPath, type:"cubic"}});
$("#createAnimation").click(function(){
tl1.reversed() ? tl1.play() : tl1.reverse();
});
Is there a way to do this with just GSAP? Or will I need something like Pixi?
This is how I would do it:
First I need an array of points to draw the arrow and a track. I want to move the arrow on the track, and the arrow should bend following the track. In order to achieve this effect with every frame of the animation I'm calculating the new position of the points for the arrow.
Also: the track is twice as long as it seams to be.
Please read the comments in the code
let track = document.getElementById("track");
let trackLength = track.getTotalLength();
let t = 0.1;// the position on the path. Can take values from 0 to .5
// an array of points used to draw the arrow
let points = [
[0, 0],[6.207, -2.447],[10.84, -4.997],[16.076, -7.878],[20.023, -10.05],[21.096, -4.809],[25.681, -4.468],[31.033, -4.069],[36.068, -3.695],[40.81, -3.343],[45.971, -2.96],[51.04, -2.584],[56.075, -2.21],[60.838, -1.856],[65.715, -1.49],[71.077, -1.095],[75.956, -0.733],[80, 0],[75.956, 0.733],[71.077, 1.095],[65.715, 1.49],[60.838, 1.856],[56.075, 2.21],[51.04, 2.584],[45.971, 2.96],[40.81, 3.343],[36.068, 3.695],[31.033, 4.069],[25.681, 4.468],[21.096, 4.809],[20.023, 10.05],[16.076, 7.878],[10.84, 4.997],[6.207, 2.447],[0, 0]
];
function move() {
requestAnimationFrame(move);
if (t > 0) {
t -= 0.001;
} else {
t = 0.5;
}
let ry = newPoints(track, t);
drawArrow(ry);
}
move();
function newPoints(track, t) {
// a function to change the value of every point on the points array
let ry = [];
points.map(p => {
ry.push(getPos(track, t, p[0], p[1]));
});
return ry;
}
function getPos(track, t, d, r) {
// a function to get the position of every point of the arrow on the track
let distance = d + trackLength * t;
// a point on the track
let p = track.getPointAtLength(distance);
// a point near p used to calculate the angle of rotation
let _p = track.getPointAtLength((distance + 1) % trackLength);
// the angle of rotation on the path
let a = Math.atan2(p.y - _p.y, p.x - _p.x) + Math.PI / 2;
// returns an array of coordinates: the first is the x, the second is the y
return [p.x + r * Math.cos(a), p.y + r * Math.sin(a)];
}
function drawArrow(points) {
// a function to draw the arrow in base of the points array
let d = `M${points[0][0]},${points[0][1]}L`;
points.shift();
points.map(p => {
d += `${p[0]}, ${p[1]} `;
});
d += "Z";
arrow.setAttributeNS(null, "d", d);
}
svg {
display: block;
margin: 2em auto;
border: 1px solid;
overflow: visible;
width:140vh;
}
#track {
stroke: #d9d9d9;
vector-effect: non-scaling-stroke;
}
<svg viewBox="-20 -10 440 180">
<path id="track" fill="none"
d="M200,80
C-50,280 -50,-120 200,80
C450,280 450,-120 200,80
C-50,280 -50,-120 200,80
C450,280 450,-120 200,80Z" />
<path id="arrow" d="" />
</svg>

How to set a specific duration to interpolate along a path one point at time?

I'm trying to figure out the best way to interpolate a circle along a path as Mike Bostock does in this example: http://bl.ocks.org/mbostock/1705868. However, instead of setting one transition value as he does, I'd like to be able to set a unique duration for each point-to-point interpolation; e.g., transition the circle from node[0] to node [1] over x milliseconds, transition from node [1] to node [2] over y milliseconds, etc. Is there a way to do this without splitting the path up into a bunch of smaller separate paths and transitioning along them consecutively? The limiting factor seems to be path.getTotalLength() - is there a way to get the length of only the subset of a path?
transition();
function transition() {
circle.transition()
.duration(10000)
.attrTween("transform", translateAlong(path.node()))
.each("end", transition);
}
// Returns an attrTween for translating along the specified path element.
function translateAlong(path) {
var l = path.getTotalLength();
return function(d, i, a) {
return function(t) {
var p = path.getPointAtLength(t * l);
return "translate(" + p.x + "," + p.y + ")";
};
};
}
There's in a fact a way but it's way too ugly (because it needs an initial brute force computation), the solution involves the following:
First of all you need an array with the transition times between nodes, in my example is times, for example the first element 3000 corresponds to the time in ms to get from [480,200] to [580,400]
compute the sum of the transition times (needed for the duration of the overall transition)
compute the linear time in ms to reach each one of the points that made this path, this is actually tricky when the path between two points is not a line e.g. a curve, in my example I compute those times by brute force which makes it ugly, it'd be awesome if there was a method that computed the path length needed to get to some point lying on the path itself, unfortunately such a method doesn't exist as far as I know
Finally once you know the linear times you have to compute the correct time as if it followed the list of the numbers in the times array e.g.
Let's say that the linear time to get to the first point is 50ms and we're currently on the time t < 50ms, we have to map this value which is between [0ms, 50ms] to somewhere in the range [0ms, 3000ms] which is given by the formula 3000 * (t ms - 0ms) / (50ms - 0ms)
var points = [
[480, 200],
[580, 400],
[680, 100],
[780, 300],
[180, 300],
[280, 100],
[380, 400]
];
var times = [3000, 100, 5000, 100, 3000, 100, 1000]
var totalTime = times.reduce(function (a, b) {return a + b}, 0)
var svg = d3.select("body").append("svg")
.attr("width", 960)
.attr("height", 500);
var path = svg.append("path")
.data([points])
.attr("d", d3.svg.line()
.tension(0) // Catmull–Rom
.interpolate("cardinal-closed"));
svg.selectAll(".point")
.data(points)
.enter().append("circle")
.attr("r", 4)
.attr("transform", function(d) { return "translate(" + d + ")"; });
var circle = svg.append("circle")
.attr("r", 13)
.attr("transform", "translate(" + points[0] + ")");
function transition() {
circle.transition()
.duration(totalTime)
.ease('linear')
.attrTween("transform", translateAlong(path.node()))
.each("end", transition);
}
// initial computation, linear time needed to reach a point
var timeToReachPoint = []
var pathLength = path.node().getTotalLength();
var pointIndex = 0
for (var t = 0; pointIndex < points.length && t <= 1; t += 0.0001) {
var data = points[pointIndex]
var point = path.node().getPointAtLength(t * pathLength)
// if the distance to the point[i] is approximately less than 1 unit
// make `t` the linear time needed to get to that point
if (Math.sqrt(Math.pow(data[0] - point.x, 2) + Math.pow(data[1] - point.y, 2)) < 1) {
timeToReachPoint.push(t);
pointIndex += 1
}
}
timeToReachPoint.push(1)
function translateAlong(path) {
return function(d, i, a) {
return function(t) {
// TODO: optimize
var timeElapsed = t * totalTime
var acc = 0
for (var it = 0; acc + times[it] < timeElapsed; it += 1) {
acc += times[it]
}
var previousTime = timeToReachPoint[it]
var diffWithNext = timeToReachPoint[it + 1] - timeToReachPoint[it]
// range mapping
var placeInDiff = diffWithNext * ((timeElapsed - acc) / times[it])
var p = path.getPointAtLength((previousTime + placeInDiff) * pathLength)
return "translate(" + p.x + "," + p.y + ")"
}
}
}
transition();
path {
fill: none;
stroke: #000;
stroke-width: 3px;
}
circle {
fill: steelblue;
stroke: #fff;
stroke-width: 3px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>

Trying to get only one tooltip to show on mouseover using d3

I am trying get some tooltips working on my d3 code. I've simplified this code as shown below.
Currently the code produces 6 moving circles with names associated to them, and a console.log shows that when I mouseover over each circle, it's associated name is logged.
Also, when I mouseover a circle, the name labels appear near to the circles. However, the labels appear over all of the circles, and I only want a label to appear over the circle that the cursor is hovering over.
I'm not interested in adding a mouseout or anything just yet, just want to get it so that only the hovered over circle gets a label displayed. Any advice on how to do this?
Here is what my code currently looks like:
<!DOCTYPE html>
<meta charset="utf-8">
<body>
<script src="//d3js.org/d3.v3.min.js"></script>
<script type="text/javascript">
var names = ["Jack","Anne","Jacob","Mary","Michael","Lisa"];
var width = 960,
height = 500;
var n = names.length,
m = 12,
degrees = 180 / Math.PI;
var bubbles = d3.range(n).map(function() {
var x = Math.random() * width,
y = Math.random() * height;
return {
vx: Math.random() * 2 - 1,
vy: Math.random() * 2 - 1,
path: d3.range(m).map(function() { return [x, y]; }),
count: 0
};
});
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height);
var g = svg.selectAll("g")
.data(bubbles)
.enter().append("g")
.on("mouseover", function(d,i){console.log(names[i])});
var labels = g.selectAll("text")
.data(bubbles)
.enter().append("text")
.attr("dy",".35em")
.attr("class", "tooltip")
.style("visibility", "hidden")
.text(function(d,i){return names[i]})
var head = g.selectAll("circle")
.data(bubbles)
.enter().append("circle")
.attr("r", 6)
.on("mouseover", function(d,i) {
labels.style("visibility","visible")
})
d3.timer(function() {
for (var i = -1; ++i < n;) {
var bubble = bubbles[i],
path = bubble.path,
dx = bubble.vx,
dy = bubble.vy,
x = path[0][0] += dx,
y = path[0][1] += dy,
speed = Math.sqrt(dx * dx + dy * dy),
count = speed * 10,
k1 = -5 - speed / 3;
if (x < 0 || x > width) bubble.vx *= -1;
if (y < 0 || y > height) bubble.vy *= -1;
for (var j = 0; ++j < m;) {
var vx = x - path[j][0],
vy = y - path[j][1],
k2 = Math.sin(((bubble.count += count) + j * 3) / 300) / speed;
path[j][0] = (x += dx / speed * k1) - dy * k2;
path[j][1] = (y += dy / speed * k1) + dx * k2;
speed = Math.sqrt((dx = vx) * dx + (dy = vy) * dy);
}
}
labels.attr("transform", labelsTransform);
head.attr("transform", headTransform);
});
function headTransform(d) {
return "translate(" + d.path[0] + ")rotate(" + Math.atan2(d.vy, d.vx) * degrees + ")";
}
function labelsTransform(d) {
return "translate(" + d.path[0] + ")translate(10)";
}
</script>
<body>
</html>
This is my first question on Stack Overflow, so sorry if my question is badly formatted or presented! Any help would be much appreciated, even if it's about how to better present my question!
Welcome to Stack Overflow! :)
The issue you're having can be traced back to this section of code:
var head = g.selectAll("circle")
.data(bubbles)
.enter().append("circle")
.attr("r", 6)
.on("mouseover", function(d,i) {
labels.style("visibility","visible")
})
What this says is that when the user mouseovers any circle, make all labels visible. You probably want something like this
.on("mouseover", function(d,i) {
// only make visible the current mouseover-ed point
labels.filter(function(p){
if(p === d) d3.select(this).style("visibility","visible");
else d3.select(this).style("visibility","hidden");
});
})

How to repeat rotation using d3

I'm trying to figure out how to repeat a transition. I' m using world tour with my own tsv file. The tsv file s much smaller which ends the world tour quickly.
How can I repeat the rotation so its starts at beginning?
//Globe rotating
(function transition() {
d3.transition()
.duration(1500)
.each("start", function() {
title.text(countries[i = (i + 1) % n].name);
})
.style("color", "lightgreen")
.style("text-anchor", "middle")
.tween("rotate", function() {
var p = d3.geo.centroid(countries[i]),
r = d3.interpolate(projection.rotate(), [-p[0], -p[1]]);
return function(t) {
projection.rotate(r(t));
c.clearRect(0, 0, width, height); //clear the canvas for redrawing
c.fillStyle = "black", c.beginPath(), path(land), c.fill();
c.fillStyle = "lightgreen", c.beginPath(), path(countries[i]), c.fill();
c.strokeStyle = "green", c.lineWidth = .5, c.beginPath(), path(borders), c.stroke();
c.strokeStyle = "#000", c.lineWidth = 2, c.beginPath(), path(globe), c.stroke();
};
})
.transition()
.each("end", transition);
})();
}
One option would be to reset i to zero when it exceeds the number of countries in your list. Something like this:
.each("start", function() {
i = (i + 1) % n;
if(i >= names.length)
i = 0;
title.text(countries[i].name);
})
Edit: After looking at the World Tour example code, a simpler solution would be redefine n to be the length of your data (instead of the number of countries on the map):
n = names.length; // instead of countries.length
Then you can leave the rest of the code as is. The modulo in this expression - i = (i + 1) % n - will reset to zero once you reach the end of your list.

Resources