Dash around single point in interpolated curve with d3.js - javascript

I want to draw a curve with interpolation for some given points. Here, the points represent how much solar energy can be generated by solar panels, so there is one point per hour of the day with sun. The number of points may vary depending on the month of the year (for example, 10 points in December and 16 points in June, because there are respectively 10 and 16 hours of sun a day in those months).
Until here everything fine, but now we want to add a sun image at the hour of the day you're seeing the graphics. For this, I have created 2 lines : one before the current hour and one after, and put the sun image in the current hour position. It looks like this in June with 16 points at 1PM :
This looks fine. The problem is when there are less points, the space between the point before and after the current hour is bigger, and becomes graphically too big. This is for January at 9AM with 10 points (wrong graphical rendering) :
(in both images, the ending / beginning time at the bottom are static)
I want the blank space that is left for the sun to be always the same.
I have tried various things :
adding some points "closer to the sun" in the data : doesn't work because it messes up the scale, and even with a scale updated after adding the points, the top part of the curve is not centered anymore
putting a background on the sun image : the graph must be integrated in a transparent container
using stroke-dasharray : i couldn't manage to understand the percentage / pixels values enough to calculate it. For example, with a distance to dash of 100%, it would dash before the end of the line. For the pixels unit, I haven't found any way to calculate the number of pixels generated by the curve drawing so it isn't possible to calculate the exact position of the dash
using a linearGradiant : I can't get to scale a proper percentage positioning. Anyway, the render is ugly because it cuts the line color vertically, which is not nice graphically
If anyone has an idea of how to properly accomplish this, it would be great. Also I may probably have missed something obvious or think a wrong way for this problem, but after 3 days of thinking about it I'm a bit overloading haha. Thank you for reading

Sounds like you have your answer but I'll propose a different approach. This sounds very solvable using stroke-dasharray. Here a quick demo:
<!DOCTYPE html>
<html>
<head>
<script data-require="d3#4.0.0" data-semver="4.0.0" src="https://d3js.org/d3.v4.min.js"></script>
</head>
<body>
<script>
var svg = d3.select("body")
.append("svg")
.attr("width", 500)
.attr("height", 500);
var line = d3.line()
.curve(d3.curveCardinal)
.x(function(d) {
return d[0];
})
.y(function(d) {
return d[1];
});
var data = [[10,450], [250, 50], [490, 450]];
var p = svg.append("path")
.datum(data)
.attr("d", line)
.style("stroke", "orange")
.style("stroke-width", "5px")
.style("fill", "none");
var l = p.node().getTotalLength(),
sunSpace = l / 12;
function createSpace(){
var sunPos = Math.random() * l;
p.attr("stroke-dasharray", (sunPos - sunSpace/2) + "," + sunSpace + "," + l);
}
createSpace();
setInterval(createSpace, 1000);
</script>
</body>
</html>
EDITS FOR COMMENTS
<!DOCTYPE html>
<html>
<head>
<script data-require="d3#4.0.0" data-semver="4.0.0" src="https://d3js.org/d3.v4.min.js"></script>
</head>
<body>
<svg width="500" height="500"></svg>
<script>
var svg = d3.select("svg"),
margin = {
top: 20,
right: 20,
bottom: 30,
left: 50
},
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 + ")");
var y = d3.scaleLinear()
.rangeRound([height, 0])
.domain([0, 10]);
var x = d3.scaleLinear()
.rangeRound([0, width])
.domain([0, 10]);
var line = d3.line()
.curve(d3.curveCardinal)
.x(function(d) {
return x(d[0]);
})
.y(function(d) {
return y(d[1]);
});
var data = [
[1, 1],
[5, 9],
[9, 1]
];
g.append("g")
.attr("class", "axis axis--x")
.attr("transform", "translate(0," + height + ")")
.call(d3.axisBottom(x));
g.append("g")
.attr("class", "axis axis--y")
.call(d3.axisLeft(y))
var p = g.append("path")
.datum(data)
.attr("d", line)
.style("stroke", "orange")
.style("stroke-width", "5px")
.style("fill", "none");
var pathLength = p.node().getTotalLength(),
sunSpace = pathLength / 12;
function createSpace() {
var sunPos = x(3);
var beginning = 0,
end = pathLength,
target;
while (true) {
target = Math.floor((beginning + end) / 2);
pos = p.node().getPointAtLength(target);
if ((target === end || target === beginning) && pos.x !== sunPos) {
break;
}
if (pos.x > sunPos) end = target;
else if (pos.x < sunPos) beginning = target;
else break; //position found
}
p.attr("stroke-dasharray", (target - sunSpace/2) + "," + sunSpace + "," + pathLength);
}
createSpace();
</script>
</body>
</html>

One solution to this problem was to use a svg mask.
Like explained here : "SVG clipPath to clip the *outer content out", you can create masks that will create a zone of "non-display" of the element that you apply it to. In other terms, I created a mask with a circle that is at the sun position all the time, which hides the part of the curve that is inside the circle.

You may be overthinking it. Have you considered drawing the full line and including a white circle around your sun icon? As long as you draw the icon after the line, it would leave just the right amount of space.

You should be able to separate your data from your rendering model. You didn't include code, so your specific solution will vary. But the general idea is that you convert the actual data into something that better suits your rendering needs. For example:
// If this is your actual data ...
var numberOfHours = 10;
var showSunAt = 3;
// ... and this is your desired "resolution" for the path ...
var resolution = 16;
// ... map your data to work with this resolution.
var mapped = showSunAt / numberOfHours * resolution;
// Define the "space" to leave between segments.
var spaceBetweenSegments = 1;
// Then, dynamically set the start/end points for each segment.
var firstSegmentEndsAt = mapped - (spaceBetweenSegmenets / 2);
var secondSegmenetStartsAt = mapped + (spaceBetweenSegmenets / 2);
You now have the exact points along the path where the first segment should end, the icon should be rendered, and the second segment should begin.

Related

how do you interpret the d3 event.x and event.y coordinates?

I'm trying to implement drag events in d3 (v6.2), and I'm having some trouble interpreting the d3 x and y coordinates. Consider the following code. When I inspect the console output, it seems to me like, in the drag handler:
event.x and event.y are the SUM of the user (graph) coordinate object locations and the total movement/distance in SVG coordinates?
event.dx and event.dy are indicator that tell you whether since the last update, you've moved left/up (-1), not moved (0), or right/down (1)?
event.subject.x and event.subject.y give the location of the object being dragged?
if I want the current coordinates of the drag (either in user/graph coordinates or SVG coordinates), I need to calculate them myself (see example in code, which seems to work)?
I can't find where the specifics of this are documented. My questions are:
Are the above impressions correct?
If not correct, what's the best way to get the current drag coordinates?
If correct, why would one SUM the values from two different coordinate systems? That doesn't make sense to me.
<html>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.2.0/d3.min.js"></script>
<svg width="500" height="300"></svg>
<style>
circle.circ {
fill: red;
stroke: black;
stroke-width: 1px;
}
</style>
<script>
function dragHandler(e, d){
const objx = scaleX.invert(e.x - d.x + scaleX(d.x))
const objy = scaleY.invert(e.y - d.y + scaleY(d.y))
console.log(`x: ${e.x}, dx: ${e.dx} sx: ${e.subject.x} objx: ${objx}\ny: ${e.y} dy: ${e.dy} sy: ${e.subject.y} objy: ${objy}`)
}
var drag = d3
.drag()
.on('drag', dragHandler)
var x = [{x: 150, y: 150}]
var svg = d3.select("svg")
var margin = {
top: 20,
right: 80,
bottom: 30,
left: 50
}
var width = svg.attr("width") - margin.left - margin.right
var height = svg.attr("height") - margin.top - margin.bottom
g = svg.append("g").attr("transform", "translate(" + margin.left + "," + margin.top + ")")
var scaleX = d3.scaleLinear().range([0, width]).domain([140, 160])
var scaleY = d3.scaleLinear().range([height, 0]).domain([140, 160])
g.append("g")
.attr("class", "axis axis--x")
.attr("transform", "translate(0," + height + ")")
.call(d3.axisBottom(scaleX));
g.append("g")
.attr("class", "axis axis--y")
.call(d3.axisLeft(scaleY))
var circ = g.selectAll(".circ")
.data(x)
.enter()
.append("circle")
.attr("r", 5)
.attr("cx", function(d) { return scaleX(d.x) })
.attr("cy", function(d) { return scaleY(d.y) })
.attr("class", "circ")
.call(drag);
</script>
</body>
<html>
Your impression is right. You can either
compute the distance in local coordinates as the difference between event.x/y and event.subject.x/y
retrieve the screen distance with event.clickDistance() as a getter
These two work in different coordinate systems.
The client coordinate system is that of the browser viewport. They are just read from the native DOM mousemove event.
The user coordinate system is the one of the thing being dragged, using the same coordinates as those used when writing SVG markup. They are computed from the client coordinates with a transform matrix the SVG API provides. The source code is here.
What you did was the in-between way that normally is not needed: by applying the scaleX/Y transform, you computed the coordinates in the system of the SVG viewBox, i. e. the coordinate system that is found when the viewBox is applied to the outermost <svg> element. That can be the client coordinate system, but only if the attribute is not set, or the width and height values of the element match the contents of the viewBox.
In almost every practical use case, the two will differ. The "Scalable" in SVG is just about that distinction. Leaving out the viewBox like you did deprives you of the possibility to easily scale your grafic as a whole.
Adding to #ccprog's answer above ( that worked for me ). It is also useful to add .container(selection.node) to your drag object. This makes the coordinates work well. Pay attention not to pass the selection but the selection.node() to .container(...).

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.

d3 US Map zoom on load to marked state

US map with d3.v3 using Mike Bostock's example:
I want the map to zoom into the marked locations initially when the page loads but the entire map should be rendered so that a user can zoom out if he wants to.
var w = 300;
var h = 280;
//Define map projection
var projection = d3.geo.albersUsa()
.translate([w/2, h/2])
.scale([300]);
//Define path generator
var path = d3.geo.path()
.projection(projection);
//Create SVG element
var svg = d3.select("#map1").append("svg")
.attr("width", w)
.attr("height", h)
var g = svg.append("g");
var tooltip = d3.select("body")
.append("div")
.style("position", "absolute")
.style("z-index", "1000")
.style('opacity', 0)
.style("font-family", "sans-serif")
.style("background-color", "white")
.style("border-radius", "5px")
.style("padding", "10px")
.style('color', '#000')
.style("font-size", "12px");
//Load in GeoJSON data
d3.json("us-states.json", function(json) {
d3.csv("cities.csv", function(error, data) {
g.selectAll("circle")
.data(data)
.enter()
.append("circle")
.attr("cx", function(d) {
return projection([d.longi, d.lati])[0];
})
.attr("cy", function(d) {
return projection([d.longi, d.lati])[1];
})
.attr("r", 4)
.style("fill", "#4F6D88")
.on("mouseover", function(d){
tooltip.transition().style("opacity", 0.9)
.style('left', (d3.event.pageX) + 'px')
.style('top', (d3.event.pageY) + 'px')
.text(d.city)
})
.on("mousemove", function(event){
tooltip.style("top", (event.pageY-10)+"px").style("left",(event.pageX+10)+"px");
})
.on("mouseout", function(){
tooltip.transition().delay(500).style("opacity", 0);
});
});
//Bind data and create one path per GeoJSON feature
g.selectAll("path")
.data(json.features)
.enter()
.append("path")
.attr("d", path);
});
var zoom = d3.behavior.zoom()
.scaleExtent([1, 50])
.on("zoom", function() {
var e = d3.event,
tx = Math.min(0, Math.max(e.translate[0], w - w * e.scale)),
ty = Math.min(0, Math.max(e.translate[1], h - h * e.scale));
zoom.translate([tx, ty]);
g.attr("transform", [
"translate(" + [tx, ty] + ")",
"scale(" + e.scale + ")"
].join(" "));
});
svg.call(zoom)
I have the code to zoom in with scroll which i have pasted above but i want it to zoom on load to those specific locations. How i want it to be:
There are two primary ways to zoom a map in d3:
modify the projection which will re-draw the paths, or
modify the drawn paths with scale and transform.
Modifying the projection is easiest in d3v4 with fitSize or fitExtent - though you would need to turn your points into geojson. You can also manually calculate the translate and scale values to update a projection (see this answer by Mike Bostock which explains this common d3v3 approach).
Alternatively, you can modify the drawn paths by calling the zoom function - this question asked yesterday has an excellent example of doing so (in d3v4). Or you can calculate and apply the zoom manually and then update the zoom to indicate the current scale and translate. I'll use the common method of modifying a d3v3 projection mentioned above (with Mike's answer) and apply it to the transform on the paths - rather than modifying the projection. Though it should not be difficult to see how my answer could be changed to modify the projection instead.
First you need to determine the maximum difference between the x and y coordinates of your points. If dealing with two points, this will be fairly easy:
var data = [[-100,45],[-110,45]];
var p1 = projection(data[0]);
var p2 = projection(data[1]);
var dx = Math.abs(p1[0] - p2[0]);
var dy = Math.abs(p1[1] - p2[1]);
I'm assuming a simple data format for the sake of a shorter answer. Also, if dealing with many points, this would be a bit more complex. One potential option would be to place your points in geojson and get the bounding box of the points.
Now we need to find out the centroid of the points - in the case of two points this is just the average of the x and y values:
var x = (p1[0] + p2[0])/2;
var y = (p1[1] + p2[1])/2;
Next we need to calculate a new scale, while also determining if the scale is restricted by the difference in x values of the coordinates or the difference in y values of the coordinates:
var scale = 0.9 / Math.max( dx/w , dy/h );
The 0.9 reduces the scale slightly, it is the same as 0.9 * scale and allows a variable amount of margin. The value returned by dx/w is one over the scale value we need to stretch the difference across the width of the svg container.
(it would probably make more sense written like: var scale = 0.9 * Math.min(w/dx,h/dy); - we want to limit the zoom by the lowest scale value and multiply it by some percentage to give margins. But the other representation is ubiquitous in online examples)
Now we have a scale, we only need to determine a translate. To do so we find out how far we need to re-position the values held in the x and y variables so that those values would be centered:
var translate = [w/2 - scale * x, h/2-scale*y];
Now you can set the initial scale and translate of the map:
g.attr("transform", "translate("+translate+")scale("+scale+")");
But, you probably want to update the zoom parameters on page load to reflect the initial zoom and translate:
zoom.translate(translate);
zoom.scale(scale);
This way when you zoom in or out from the initial view, the change is relative to your initial zoom.
Now all you have to do is include the above code when you add the points. Note that this technique might require some modification if you want to return to the initial position.

d3 chord: center text on circle

I use the d3 chord diagram example of Andrew and want to center all text labels within the curved slice. I tried many things but was never able to center the texts. Do you know what wizzard trick there is needed?
var width = 720,
height = 720,
outerRadius = Math.min(width, height) / 2 - 10,
innerRadius = outerRadius - 24;
var formatPercent = d3.format(".1%");
var arc = d3.svg.arc()
.innerRadius(innerRadius)
.outerRadius(outerRadius);
var layout = d3.layout.chord()
.padding(.04)
.sortSubgroups(d3.descending)
.sortChords(d3.ascending);
var path = d3.svg.chord()
.radius(innerRadius);
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height)
.append("g")
.attr("id", "circle")
.attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");
svg.append("circle")
.attr("r", outerRadius);
d3.csv("ex_csv.csv", function(cities) {
d3.json("ex_json.json", function(matrix) {
// Compute the chord layout.
layout.matrix(matrix);
// Add a group per neighborhood.
var group = svg.selectAll(".group")
.data(layout.groups)
.enter().append("g")
.attr("class", "group")
.on("mouseover", mouseover);
// Add the group arc.
var groupPath = group.append("path")
.attr("id", function(d, i) { return "group" + i; })
.attr("d", arc)
.style("fill", function(d, i) { return cities[i].color; });
// Add a text label.
var groupText = group.append("text")
.attr("x", 6)
.attr("dy", 15);
groupText.append("textPath")
.attr("xlink:href", function(d, i) { return "#group" + i; })
.text(function(d, i) { return cities[i].name; });
// Remove the labels that don't fit. :(
groupText.filter(function(d, i) { return groupPath[0][i].getTotalLength() / 2 - 16 < this.getComputedTextLength(); })
.remove();
// Add the chords.
var chord = svg.selectAll(".chord")
.data(layout.chords)
.enter().append("path")
.attr("class", "chord")
.style("fill", function(d) { return cities[d.source.index].color; })
.attr("d", path);
}
});
});
</script>
As an aside, I would suggest looking to upgrade to v4, documentation for v2 is nearly non-existent and is very hard to help with.
You can set both the text-anchor and the startOffset property to achieve what you are looking for.
First, you'll want to set text-anchor to middle as it is easier to specify the middle point than to find the middle point and work back to find where the text should start.
Second you'll need to set a startOffset. Note that if you use 50%, the text will not appear where you want, as the total length of the text path is all sides of the closed loop (chord anchor) you are appending to. Setting it to 25 % would work if you did not have a different outer and inner radius. But, as you have an outer radius that is 24 pixels greater than the inner radius you can try something like this to calculate the number of pixels you need to offset the center of the text:
groupText.append("textPath")
.attr("xlink:href", function(d, i) { return "#group" + i; })
.text(function(d, i) { return cities[i].name; })
.attr("startOffset",function(d,i) { return (groupPath[0][i].getTotalLength() - 48) / 4 })
.style("text-anchor","middle");
I subtract 48 because the sides of the anchor are 24 pixels each (the difference in the radii). I divide by four because the path doubles back on itself. If it was a general line I would just divide by two.
This approach is a little simplistic as the outer circumference is not the same as the inner circumference of each chord anchor, so I am off by a little bit, but it should be workable.
For labels that are on the cusp of being displayed, this will be awkward: the inner radius is shorter, so the formula for deteriming if a string is short enough to be displayed may be wrong - which may lead to some characters climbing up the side of the anchor (your example also 16 pixels as the difference in radii to calculate if text is too long, rather than 24).
This is the end result:
Here is a demonstration.

Manipulating D3 axis labels

I'm working with D3 for the first time and I'm trying to make a parallel coordinate graph. I basically am using this demo. The only real changes I've had is changing the data and changing the far right axis so it has strings instead of numbers as the labels. I do this by using the following:
if(d === "Dog Breed") {
y[d] = d3.scale.ordinal()
.domain(dogData.map(function(p) { return p[d]; }))
.rangePoints([h, 0]); // quantitative color scale
}
Unfortunately, if the dog's breed is too long, the text gets cut off, making it hard to read the label (one has to move the axis in its entirety to read it, but when they let go of it, it goes right back to where it was initially).
My other change were the following:
var m = [30, 10, 10, 10],
w = screen.width - 150, // Make it 150px less than the screen's width.
h = 500 - m[0] - m[2];
The axis label code remains the same at:
// Add an axis and title.
g.append("svg:g")
.attr("class", "axis")
.each(function(d) { d3.select(this).call(axis.scale(y[d])); })
.append("svg:text")
.attr("text-anchor", "middle")
.attr("y", -9)
.text(String);
Is there any way to avoid the name-being-clipped-thing? Even shifting the graph itself over in its block about 20px would help, but I don't know where the code for that would be...
The fix was to manipulate var m to have more on the lefthand side in m[3].

Categories

Resources