Force collision for y position in bubble chart - d3.js

I am using attempting to use d3.forceSimulation that applies a force to the y position of the chart circles to keep them from overlapping.
the final chart would look something like this -
I have been following some examples but am unable to get the y positions to adjust in the right way. Unfortunately, I have no idea where this is going wrong. Any hint in the right direction would be greatly appreciated!
So far, this is my code:
//ADDING SKELETON FOR THE CHART//
let width = 900;
let height = 300;
let margin = {x: 50, y:20};
let chartDiv = d3.select('body').append('div').attr('id', 'bubble-chart');
let svg = chartDiv.append('svg');
svg.attr('height', height).attr('width', width + margin.x);
//SCALES FOR X POSITION//
let posScale = d3.scaleLinear().domain([(0-overallMax), overallMax]);
posScale.range([0, width]);
//SCALES FOR COLOR//
let colorScale = d3.scaleOrdinal().domain(groupData.map(g=> g[0])).range(d3.schemeSet3);
//SCALE FOR CIRCLE SIZE//
let circleScale = d3.scaleLinear().domain([d3.min(data.map(d=> +d.total)), d3.max(data.map(d=> +d.total))])
.range([3, 10]);
//SIMULATION PART
let simulation = d3.forceSimulation().nodes(data)
.force('center', d=> d3.forceCenter(posScale(d.position), height/2))
//.force('charge', d3.forceManyBody().strength(.1))
.force('collision', d3.forceCollide().radius( d => circleScale(+d.total)))
.on('tick',ticked)
let circleGroup = svg.append('g').attr('transform', `translate(${margin.x / 2})`);
let circles = circleGroup.selectAll('circle').data(data).join('circle');
circles.attr('r', (d)=> circleScale(+d.total))//.attr('cx', (d) => posScale(d.position)).attr('cy', 50);
.attr("cx", d=> posScale(d.position))
.attr("cy", height / 2)
circles.attr('fill', (d)=> colorScale(d.category));
circles.style('opacity', '0.5');
// Apply these forces to the nodes and update their positions.
// Once the force algorithm is happy with positions ('alpha' value is low enough), simulations will stop.
function ticked(){
circles.attr("cy", d=> d.y).attr('cx', d=> posScale(d.position));
}
This is what my chart looks like with the above code:
Thank you in advance!

This is not how one creates a beeswarm chart (the technical name of this kind of data visualisation). You should use forceX and forceY in the simulation to set the positions. In your case:
let simulation = d3.forceSimulation().nodes(data)
.force("x", d3.forceX(function(d) {
return posScale(d.position);
}).strength(foo))
.force("y", d3.forceY(50).strength(bar))
.force('collision', d3.forceCollide().radius( d => circleScale(d.total)))
.on('tick',ticked)
Then, adjust the strengths (foo and bar) according to your needs, and change the ticked function to use the x and y properties provided by the simulation.

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>

D3js Zoom With Manually Drawn Circle

I am working on a d3 scatter plot where an area of the chart will be circled (a Youden Plot). Based on available samples, I have been able to add zoom to both my data points and my axis. However, I am unable to get the circle to zoom correctly.
I suspect that I need to set up some kind of scale (scaleSqrt, possibly), but I am struggling to find documentation on this that is written at a beginner level.
My current circle code is very straightforward
var circle = drawCircle();
function drawCircle() {
return svg
.append('g')
.attr('class', 'scatter-group')
.append('circle')
.attr("r", 75 )
.attr('cx', 200 + margin.left) //suspect this needs to be related to a scale
.attr('cy', 200 + margin.top) //suspect this needs to be related to
.attr('r', 75)//suspect this needs to be related to a scale
.attr('stroke', 'red')
.attr('stroke-width', 3)
.style('fill', 'none')
}
As is the zoomed function
function zoomed() {
var new_xScale = d3.event.transform.rescaleX(xScale);
var new_yScale = d3.event.transform.rescaleY(yScale);
// update axes
gX.call(xAxis.scale(new_xScale));
gY.call(yAxis.scale(new_yScale));
//redraw data ppints
points.data(data)
.attr('cx', function(d) {return new_xScale(d.x)})
.attr('cy', function(d) {return new_yScale(d.y)});
//redraw circle
}
My work in progress is available in this fiddle . Can someone possible point me in the right direction?
I believe this will get you most of the way there. You need to update your circle attributes in the zoomed function along with the other elements:
function zoomed() {
var new_xScale = d3.event.transform.rescaleX(xScale);
var new_yScale = d3.event.transform.rescaleY(yScale);
// update axes
gX.call(xAxis.scale(new_xScale));
gY.call(yAxis.scale(new_yScale));
//redraw data ppints
points.data(data)
.attr('cx', function(d) {return new_xScale(d.x)})
.attr('cy', function(d) {return new_yScale(d.y)});
// The new part:
// the transform
let trans = d3.event.transform
// the approximate domain value of the circle 'cx' for converting later
let cx_domain = xScale.invert(200 + margin.left)
// the approximate domain value of the circle 'cy' for converting later
let cy_domain = yScale.invert(200 + margin.top)
// the circle
let circ = d3.select('.scatter-group circle')
// the radius
let rad = 75
// reset the circle 'cx' and 'cy' according to the transform
circ
.attr('cx',function(d) { return new_xScale(cx_domain)})
.attr('cy',function(d) { return new_yScale(cy_domain)})
// reset the radius by the scaling factor
.attr('r', function(d) { return rad*trans.k })
}
See this fiddle
You'll notice the circle does not scale or move at quite the same rate as the scatter dots. This is possibly because of the use of the invert function, because the conversion from range to domain and back to range is imperfect. This issue is documented
For a valid value y in the range, continuous(continuous.invert(y)) approximately equals y; similarly, for a valid value x in the domain, continuous.invert(continuous(x)) approximately equals x. The scale and its inverse may not be exact due to the limitations of floating point precision.
Your original idea to assign dynamic values to cx, cy and r will likely compensate for this, because you can then avoid the inversion.

d3.v4: How to set ticks every Math.PI/2

In the d3.v4 documentation the following is stated:
To generate ticks every fifteen minutes with a time scale, say:
axis.tickArguments([d3.timeMinute.every(15)]);
Is there a similar approach that can be used with values other than time? I am plotting sine and cosine curves, so I'd like the ticks to begin at -2*Math.PI, end at 2*Math.PI, and between these values I'd like a tick to occur every Math.PI/2. I could, of course, explicitly compute the tick values and supply them to the tickValue method; however, if there is a simpler way to accomplish this, as in the time-related example quoted above, I'd prefer to use that.
Setting the end ticks and specifying the precise space of the ticks in a linear scale is a pain in the neck. The reason is that D3 axis generator was created in such a way that the ticks are automatically generated and spaced. So, what is handy for someone who doesn't care too much for customisation can be a nuisance for those that want a precise customisation.
My solution here is a hack: create two scales, one linear scale that you'll use to plot your data, and a second scale, that you'll use only to make the axis and whose values you can set at your will. Here, I choose a scalePoint() for the ordinal scale.
Something like this:
var realScale = d3.scaleLinear()
.range([10,width-10])
.domain([-2*Math.PI, 2*Math.PI]);
var axisScale = d3.scalePoint()
.range([10,width-10])
.domain(["-2 \u03c0", "-1.5 \u03c0", "-\u03c0", "-0.5 \u03c0", "0",
"0.5 \u03c0", "\u03c0", "1.5 \u03c0", "2 \u03c0"]);
Don't mind the \u03c0, that's just π (pi) in Unicode.
Check this demo, hover over the circles to see their positions:
var width = 500,
height = 150;
var data = [-2, -1, 0, 0.5, 1.5];
var realScale = d3.scaleLinear()
.range([10, width - 10])
.domain([-2 * Math.PI, 2 * Math.PI]);
var axisScale = d3.scalePoint()
.range([10, width - 10])
.domain(["-2 \u03c0", "-1.5 \u03c0", "-\u03c0", "-0.5 \u03c0", "0", "0.5 \u03c0", "\u03c0", "1.5 \u03c0", "2 \u03c0"]);
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height);
var circles = svg.selectAll("circle").data(data)
.enter()
.append("circle")
.attr("r", 8)
.attr("fill", "teal")
.attr("cy", 50)
.attr("cx", function(d) {
return realScale(d * Math.PI)
})
.append("title")
.text(function(d) {
return "this circle is at " + d + " \u03c0"
});
var axis = d3.axisBottom(axisScale);
var gX = svg.append("g")
.attr("transform", "translate(0,100)")
.call(axis);
<script src="https://d3js.org/d3.v4.min.js"></script>
I was able to implement an x axis in units of PI/2, under program control (not manually laid out), by targetting the D3 tickValues and tickFormat methods. The call to tickValues sets the ticks at intervals of PI/2. The call to tickFormat generates appropriate tick labels. You can view the complete code on GitHub:
https://github.com/quantbo/sine_cosine
My solution is to customise tickValues and tickFormat. Only 1 scale is needed, and delegate d3.ticks function to give me the new tickValues that are proportional to Math.PI.
const piChar = String.fromCharCode(960);
const tickFormat = val => {
const piVal = val / Math.PI;
return piVal + piChar;
};
const convertSIToTrig = siDomain => {
const trigMin = siDomain[0] / Math.PI;
const trigMax = siDomain[1] / Math.PI;
return d3.ticks(trigMin, trigMax, 10).map(v => v * Math.PI);
};
const xScale = d3.scaleLinear().domain([-Math.PI * 2, Math.PI * 2]).range([0, 600]);
const xAxis = d3.axisBottom(xScale)
.tickValues(convertSIToTrig(xScale.domain()))
.tickFormat(tickFormat);
This way if your xScale's domain were changed via zoom/pan, the new tickValues are nicely generated with smaller/bigger interval

make a circle progress bar, but animate arc.endAngle (angle >180 degree) stops working in d3.js

I am running D3.js to draw a progress bar in circle shape, which you will see the demo on jsfiddle , the progress bar has a transition animation.
The main code is
var width = 960,
height = 500,
twoPi = 2 * Math.PI,
progress = 0,
total = 1308573, // must be hard-coded if server doesn't report Content-Length
formatPercent = d3.format(".0%");
var arc = d3.svg.arc()
.startAngle(0)
.innerRadius(0)
.outerRadius(240);
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height)
.append("g")
.attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");
var meter = svg.append("g")
.attr("class", "progress-meter");
meter.append("path")
.attr("class", "background")
.attr("d", arc.endAngle(twoPi));
var foreground = meter.append("path")
.attr("class", "foreground");
foreground.attr("d", arc.endAngle(twoPi * 0))
foreground.transition().duration(1500).attr("d", arc.endAngle( twoPi * 2/3 ));
var text = meter.append("text")
.attr("text-anchor", "middle")
.attr("dy", ".35em");
to make the progress bar move, we only need to change to the arc.endAngle(), which is on the line.
foreground.transition().duration(1500).attr("d", arc.endAngle( twoPi * 2/3 ));
if the angle is less than 180, ( endangle < twoPi*1/2), then the animation works fine, but when the angle is larger than 180, so means endangle >= twoPi*1/2. then the animation would not show, and if you look at the console, you will find many errors on d3.js
Error: Problem parsing d="M1.1633760361312584e-14,-190A190,190 0 1.481481481481482e-7,1 -0.000022772330200401806,-189.9999883969182L0,0Z" meeting.html:1
2
Error: Problem parsing d="M1.1633760361312584e-14,-190A190,190 0 2.56e-7,1 -0.00003935058659476369,-189.99997994987467L0,0Z"
so what is the exact problem for this, how to solve it
It doesn't work because you can't use the standard transition for radial paths. By default, it simply interpolates the numbers without knowing what they represent, so in your case, you end up with some really small numbers (e.g. 1.1633760361312584e-14) which Javascript represents in exponential notation which is not valid for SVG paths.
The solution is to use a custom tween function that knows how to interpolate arcs:
function arcTween() {
var i = d3.interpolate(0, twoPi * 2/3);
return function(t) {
return arc.endAngle(i(t))();
};
}
Complete example here. You may also be interested in this example, which shows how to do it with data bound to the paths.

d3 pie chart transition with attrtween

i'm trying to somehow sweep in a half-donut-chart, meaning starting with a blank screen the chart starts drawing at -90 degree (or 270) and performs a halfcircle until reaching 90 degree. the code looks like:
var width = 800;
var height = 400;
var radius = 300;
var grad=Math.PI/180;
var data = [30, 14, 4, 4, 5];
var color = d3.scale.category20();
var svg = d3.select("body").append("svg").attr("width", width).attr("height",
`height).append("g").attr("transform", "translate(" + radius*1.5 + "," + radius*1.5 +
")");
var arc = d3.svg.arc().innerRadius(radius - 100).outerRadius(radius - 20);
var pie = d3.layout.pie().sort(null);
svg.selectAll("path").data(pie(data)).enter().append("path").attr("d",
arc).attr("fill",
function(d, i) { return color(i); }).transition().duration(500).attrTween("d", sweep);
function sweep(a) {
var i = d3.interpolate({startAngle: -90*grad, endAngle: -90*grad},{startAngle: -90*grad, endAngle: 90*grad});
return function(t) {
return arc(i(t));
};
}
looking at several examples i managed to get the animation, however, i fail at binding (or converting) the data to the arc. my feeling is that there is only one path drawn and then it stops.
if i change the interpolation to start/end -90/90 and a, i get different colors but not all of them. adding the start/end-angle to the pie-var gives me a transition where a one-colored-arc is shown at the beginning and then the other parts slide in (which would be correct if there was no arc at the beginning - the proportions also seem a bit wrong). setting the initial color to white does not help because then everything stays white.
i'm afraid i'm missing an obvious point, but so far i'm stuck, maybe someone can point me in the right direction.
after quite some variations and tests it somehow started to work, using these to lines of code:
var pie = d3.layout.pie().sort(null).startAngle(-90*grad).endAngle(90*grad);
var i = d3.interpolate({startAngle: -90*grad, endAngle: -90*grad},a);
one final "problem" was that the height of the svg was too small and so some segments got cut off, so changing it to
var height = 800;
ended my search. thanks for any considerations.
A small typo on the
var svg = d3.select("body").append("svg").attr("width", width).attr("height", `height)
should be:
var svg = d3.select("body").append("svg").attr("width", width).attr("height", height)

Resources