I'm working on a tag visualization where tags transition between different force-directed layouts.
I had few issues figuring out how to transition from a bubble chart to a node chart, but I'm a bit stuck as to how to get the charts to transition into a word cloud. My difficulties largely stem from my inexperience at writing custom clustering/collision detection functions.
I declare the forces as globals and then stop and start them when the user clicks a button:
var force1 = d3.layout.force()
.size([width, height])
.charge(0)
.gravity(0.02)
.on("tick", ticka);
//layout for node chart
var force2 = d3.layout.force()
.size([width, height])
.charge(-50)
.gravity(0.005)
.linkDistance(120)
.on("tick", tickb);
//layout for bubble chart
var force3 = d3.layout.force()
.size([width, height])
.charge(0)
.gravity(0.02)
.on("tick", tickc);
Relevant node/link functions are added to the force when the function that draws the nodes is called (as data changes according to a slider value).
The code for creating node data is as follows:
nodes = splicedCounts.map(function(d, e) {
var choice;
var i = 0,
r = d[1],
d = { count: d[1],
sentiment: d[2]/d[1],
cluster: i,
radius: radScale(r),
name: d[0],
index: e,
x: Math.cos(i / m * 2 * Math.PI) * 200 + width / 2 + Math.random(),
y: Math.sin(i / m * 2 * Math.PI) * 200 + height / 2 + Math.random()
};
if (!clusters[i] || (r > clusters[i].radius))
clusters[i] = d;
return d;
});
In order to keep this question relatively brief, the code I use for drawing the bubble chart is derivative of this example: http://bl.ocks.org/mbostock/7881887 and the code for drawing the node chart are similarly generic (I am happy to provide this code if it would help to solve my issue).
This is where my issue comes in:
I found this nice example for collision detection between rectangles and incorporated it into my code. However, since I'm using SVG text and the font size changes on transition, I opted to estimate the text size/bounding box size based on text-length and radius.
The entire "tick" functions for the word chart are below.
function tickc(e) {
node = nodeGroup.selectAll(".node");
var nodeText = nodeGroup.selectAll(".node text");
node.each(cluster(5 * e.alpha * e.alpha));
var k = e.alpha;
nodeText.each(function(a, i) {
var compWidth = d3.select(this).attr("bWidth");
var compHeight = d3.select(this).attr("bHeight");
nodes.slice(i + 1).forEach(function(b) {
// console.log(a);
var lineWidthA = a["name"].length * a["radius"]/2.5;
var lineHeightA = a["radius"]/0.9;
var lineWidthB = b["name"].length * b["radius"]/2.5;
var lineHeightB = b["radius"]/0.9;
dx = (a.x - b.x)
dy = (a.y - b.y)
adx = Math.abs(dx)
ady = Math.abs(dy)
mdx = (1 + 0.07) * (lineWidthA + lineWidthB)/2
mdy = (1 + 0.07) * (lineHeightA + lineHeightB)/2
if (adx < mdx && ady < mdy) {
l = Math.sqrt(dx * dx + dy * dy)
lx = (adx - mdx) / l * k
ly = (ady - mdy) / l * k
// choose the direction with less overlap
if (lx > ly && ly > 0)
lx = 0;
else if (ly > lx && lx > 0)
ly = 0;
dx *= lx
dy *= ly
a.x -= dx
a.y -= dy
b.x += dx
b.y += dy
}
});
});
node.select("circle")
.attr("cx", function(d) { return d.x; })
.attr("cy", function(d) { return d.y; });
node.select("text")
.attr("x", function(d) { return d.x; })
.attr("y", function(d) { return d.y; });
}
// Move d to be adjacent to the cluster node.
function cluster2(alpha) {
return function(d) {
var cluster = clusters[d.cluster];
if (cluster === d) return;
var x = d.x - cluster.x,
y = d.y - cluster.y,
l = Math.sqrt(x * x + y * y),
r = (d["name"].length * d["radius"]) + (cluster["name"].length * cluster["radius"]);
};
}
I was unsure of how to conclude the clustering function so as to move the nodes appropriately. I tried to adapt the standard cluster function, i.e.
// Move d to be adjacent to the cluster node.
function cluster(alpha) {
return function(d) {
var cluster = clusters[d.cluster];
if (cluster === d) return;
var x = d.x - cluster.x,
y = d.y - cluster.y,
l = Math.sqrt(x * x + y * y),
r = d.radius + cluster.radius;
if (l != r) {
l = (l - r) / l * alpha;
d.x -= x *= l;
d.y -= y *= l;
cluster.x += x;
cluster.y += y;
}
};
}
to be more similar to the aforementioned rectangular cluster force layout but without luck (I'm afraid I no longer have copies of my exact attempts).
I'm afraid I can't attach images due to my lack of reputation but I can try to find a way to provide them if it would help. The overlap problem with the word cloud is minor (most words resolve into adjacent but not touching positions) but, if possible, I'd like it to resolve as perfectly as the bubble chart. I'm pretty sure that these issues arose from a.) the unfinished cluster function and b.) my hack at using text length and radius to estimate text size rather than proper bounding box coords, but I'm not sure exactly how to fix these things.
I'd recommend using the d3-cloud package which should do a lot of what you need. If not, then at least it's a good starting point https://github.com/jasondavies/d3-cloud
The way it seems to work is by calculating a bounds for each word and then resolving collisions between those bounds. You can see that here
Related
I am trying to draw a circle equally segmented into an indeterminate amount of "quadrants" based on the length of Array "categories" using the JavaScript library D3.js. An example is linked below, with 4 quadrants.
Example
I am struggling with the method of drawing the grid lines dividing each of the "quadrants". My current approach is to draw a line per unique category, emanating from the origin (the center of the circle) to a point on the circumference of the circle. However, I am unsure as to (1) the method by which I can obtain the coordinates of the points on the circumference and (2) whether there is a better approach.
As of right now, I have the following code.
var svg = d3.select("svg#radar")
var radar = svg.append("g");
var grid = radar.append("g");
categories = Array(["cat_1", "cat_2", "cat_3"])
function getX1 (i) {
//get necessary coordinate
}
function getY1 (i) {
//get necessary coordinate
}
for (var i; i < categories.length; i++) {
grid.append("line")
.attr("x1", getX1(i))
.attr("y1", getY1(i))
.attr("x2", 0)
.attr("y2", 0)
.style("stroke", config.colors.grid)
.style("stroke-width", 1);
}
Any help would be greatly appreciated.
I figured it out.
Just use trigonometry (sin and cosine) like so:
var svg = d3.select("svg#radar")
var radar = svg.append("g");
var grid = radar.append("g");
categories = Array(["cat_1", "cat_2", "cat_3"])
function getX1 (i) {
var deg = (360 / categories.length) * i;
var percent_of_circle = deg / 360
var radians = percent_of_circle * 2 * Math.PI
return 400 * Math.cos(radians);
}
function getY1 (i) {
var deg = (360 / categories.length) * i;
var percent_of_circle = deg / 360
var radians = percent_of_circle * 2 * Math.PI
return 400 * Math.sin(radians); }
for (var i; i < categories.length; i++) {
grid.append("line")
.attr("x1", getX1(i))
.attr("y1", getY1(i))
.attr("x2", 0)
.attr("y2", 0)
.style("stroke", config.colors.grid)
.style("stroke-width", 1);
}
I have a bubble chart in D3 and I'm using it to show how many bubbles there are per group. This version has about 500 bubbles to start with and my full version has about 3,000.
I am struggling along two dimension. I'm trying to get the bubbles to stay put when they're not transitioning between states and I'm also trying to get the bubbles to create a rectangular shape.
This is a demo of the bubble chart. I'll add the code and then go through what I've tried.
This is the code for my bubbles.
// Initial time and quarter
let time_so_far = 0;
let quarter = 0;
const tick_time = 100
// Forces
const radius = 1.5
const padding1 = 10;
const padding2 = 2;
const strength = 50
const veloc_decay = .99
const alpha = .05
const alpha_decay = 0
const alpha_min = 0.001
const alpha_Collision = .08;
const charge_strength = -.5
const charge_theta = .9
// Load data
Promise.all([
d3.tsv("stages.tsv", d3.autoType),
d3.tsv("customers.tsv", d3.autoType),
])
// Once data is loaded...
.then(function(files){
// Prepare the data...
const stage_data = files[0]
const customer_data = files[1]
// Consolidate stages by id.
stage_data.forEach(d => {
if (d3.keys(stakeholders).includes(d.id+"")) {
stakeholders[d.id+""].push(d);
} else {
stakeholders[d.id+""] = [d];
}
});
// Consolidate customers by week.
customer_data.forEach(d => {
if (d3.keys(customers).includes(d.week+"")) {
customers[d.week+""].push(d);
} else {
customers[d.week+""] = [d];
}
});
// Create node data.
var nodes = d3.keys(stakeholders).map(function(d) {
// Initialize count for each group.
groups[stakeholders[d][0].stage].cnt += 1;
return {
id: "node"+d,
x: groups[stakeholders[d][0].stage].x + Math.random(),
y: groups[stakeholders[d][0].stage].y + Math.random(),
r: radius,
color: groups[stakeholders[d][0].stage].color,
group: stakeholders[d][0].stage,
timeleft: stakeholders[d][0].weeks,
istage: 0,
stages: stakeholders[d]
}
});
// Circle for each node.
const circle = svg.append("g")
.selectAll("circle")
.data(nodes)
.join("circle")
.attr("cx", d => d.x)
.attr("cy", d => d.y)
.attr("fill", d => d.color)
.attr("r", d => d.r);
// Forces
const simulation = d3.forceSimulation(nodes)
// .force("bounds", boxingForce)
.force("x", d => d3.forceX(d.x))
.force("y", d => d3.forceY(d.y))
.force("cluster", forceCluster())
.force("collide", forceCollide())
.force("charge", d3.forceManyBody().strength(charge_strength).theta(charge_theta))
// .force('center', d3.forceCenter(center_x, center_y))
.alpha(alpha)
.alphaDecay(alpha_decay)
.alphaMin(alpha_min)
.velocityDecay(veloc_decay)
// Adjust position of circles.
simulation.on("tick", () => {
circle
.attr("cx", d => Math.max(r, Math.min(500 - r, d.x)))
.attr("cy", d => Math.max(r, Math.min(500 - r, d.y)))
.attr("fill", d => groups[d.group].color);
});
// Force to increment nodes to groups.
function forceCluster() {
let nodes;
function force(alpha) {
const l = alpha * strength;
for (const d of nodes) {
d.vx -= (d.x - groups[d.group].x) * l;
d.vy -= (d.y - groups[d.group].y) * l;
}
}
force.initialize = _ => nodes = _;
return force;
}
// Force for collision detection.
function forceCollide() {
let nodes;
let maxRadius;
function force() {
const quadtree = d3.quadtree(nodes, d => d.x, d => d.y);
for (const d of nodes) {
const r = d.r + maxRadius;
const nx1 = d.x - r, ny1 = d.y - r;
const nx2 = d.x + r, ny2 = d.y + r;
quadtree.visit((q, x1, y1, x2, y2) => {
if (!q.length) do {
if (q.data !== d) {
const r = d.r + q.data.r + (d.group === q.data.group ? padding1 : padding2);
let x = d.x - q.data.x, y = d.y - q.data.y, l = Math.hypot(x, y);
if (l < r) {
l = (l - r) / l * alpha_Collision;
d.x -= x *= l, d.y -= y *= l;
q.data.x += x, q.data.y += y;
}
}
} while (q = q.next);
return x1 > nx2 || x2 < nx1 || y1 > ny2 || y2 < ny1;
});
}
}
force.initialize = _ => maxRadius = d3.max(nodes = _, d => d.r) + Math.max(padding1, padding2);
return force;
}
// Make time pass. Adjust node stage as necessary.
function timer() {
// Ticker...
nodes.forEach(function(o,i) {
o.timeleft -= 1;
if (o.timeleft == 0 && o.istage < o.stages.length-1) {
// Decrease counter for previous group.
groups[o.group].cnt -= 1;
// Update current node to new group.
o.istage += 1;
o.group = o.stages[o.istage].stage;
o.timeleft = o.stages[o.istage].weeks;
// Increment counter for new group.
groups[o.group].cnt += 1;
}
});
// Previous quarter
quarter = Math.floor(time_so_far / 12)
// Increment time.
time_so_far += 1;
// goes by week, timer updates every quarter
var current_quarter = Math.floor(time_so_far / 13) + 1
// stop on the last quarter
if(time_so_far == d3.keys(customers).length) { return }
d3.select("#timecount .cnt").text(quarters[current_quarter]);
// update counter
d3.selectAll(".counter")
.text(d => d.cnt)
// Define length of a tick
d3.timeout(timer, tick_time);
} // #end timer()
timer()
}); // end TSV
Right now, my bubbles are constantly moving. Even if I make the space for the bubbles really large and the padding really small, they keep moving.
I've tried to set .alphaDecay() to a value greater than 0 and it gets the bubbles to stop moving and they look quite good, but then they don't have energy to transition between states.
I'd like to set it so that the bubbles find their spot when the page loads and then they don't move, except to change from no interactions to portfolio to partner similar to the bubble chart here.
The other problem is that the bubbles congregate as circles. I'd like to get them to fill in the whole rectangular backdrop for each of the states.
Per Mike Bostock's comments, I added boundaries in the simulation.on function. It works to set boundaries on the whole space, but it doesn't apply the boundaries to each state individually, so they still end up clustering as circles.
I've also tried John Guerra's d3.forceBoundary but I run into the same problem.
How can I force the bubbles to stay in one position and only move when a transition in states occurs and how can I get the bubbles to congregate in rectangles on each state?
Edit: I tried to set alphaDecay > 0 so the bubbles would initialize and stop moving and then I added a new alpha value in the .on("tick", function, but that just let them keep energy.
The core of the problem is that I don't know how to apply force that lets them move across the viz from one state to another state, but doesn't cause them to jumble around.
My next try is going to be to create a different force for changing states than for getting created.
Edit2: I've got a solution going for the energy problem. It's a bit hacky.
I added o.statechange = 3 within the if loop inside nodes.forEach(function(o,i) { and I added o.statechange -= 1 right above the if loop. And then, in forceCluster I added
for (var i = 0, n = nodes.length, node, k = alpha * strength; i < n; ++i) {
node = nodes[i];
if(node.statechange <= 0) { continue }
node.vx -= (node.x - groups[node.group].x) * k;
node.vy -= (node.y - groups[node.group].y) * k;
}
This gives circles energy for three ticks if they need to make a move. Otherwise, they don't get any. (Last edit, this work-around worked for a small number of nodes but it fails as the number of nodes gets larger)
I want to create the following:
Make a dynamic graph
It is Zoomable (Zooms at the center of currently seen display) (Zoom when certain buttons are clicked, mouse wheel are disabled for zoom)
Elements are draggable (When dragged it is not affected by force graph arrangement) (When elements are dragged outside of the svg the svg grows in size)
It has Scrollbar used as pan
So far I am already successful with
Creating a force graph
Creating zoom
Elements are already draggable and not included in force after dragged
Scrollbar also
I have two problems with these combination items:
Having dragged elements, it is not included in force graph anymore. Which would lead to possible overlap of other elements if new ones.
Scrollbar with zoom is not working wonders, when you zoom->scroll->zoom it zooms at the old location where the first zoom happened.
I would really need help for these two problems. I have not seen any example for zoom and scrollbar combination.
Here is the code.
function drawGraph(Data){
setDefault();
svg = d3.select("#graphingArea").append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.call(zoom)
.on("dblclick.zoom", false)
.on("mousewheel.zoom", false)
.on("DOMMouseScroll.zoom", false) // disables older versions of Firefox
.on("wheel.zoom", false); // disables newer versions of Firefox;
//Needed for canvas to be dragged
rect = svg.append("rect")
.attr("width", width)
.attr("height", height)
.style("fill", "none")
.style("pointer-events", "all");
//Holds all that is to be dragged by the canvas
container = svg.append("g");
//Call zoom before drawing
svg.call(zoomUpdate);
//FOR DRAG
var drag = d3.behavior.drag()
.origin(function(d) { return d; })
.on("dragstart", dragstarted)
.on("drag", dragged)
.on("dragend", dragended);
//Creating data that is drawn
populateD3Data(container, drag);
// Set data to be Force Arranged
force = self.force = d3.layout.force()
.nodes(nodes)
.links(links)
.distance(150)
.charge(-1000)
.size([width,height])
.start();
//Event to call arrange
force.on("tick", tick);
}
Zooming js:
var zoom = d3.behavior.zoom()
.scaleExtent([zoom_min_scale, zoom_max_scale])
.on("zoom", zoomed);
function zoomed() {
if(container != null && container != undefined) {
var translate = zoom.translate(),
scale = zoom.scale();
tx = Math.min(0, Math.max(width * (1 - scale), translate[0]));
ty = Math.min(0, Math.max(height * (1 - scale), translate[1]));
zoom.translate([tx, ty]);
container.attr("transform", "translate(" + [0,0] + ")scale(" + zoom.scale() + ")");
svg.attr("width", AreaWidth_ * zoom.scale());
svg.attr("height", AreaHeight_ * zoom.scale());
$("#graphingArea").scrollLeft(Math.abs(zoom.translate()[0]));
$("#graphingArea").scrollTop(Math.abs(zoom.translate()[1]));
}
}
//Button event for zoom in
d3.select("#zoom_in")
.on("click", zoomInOrOut);
//Button event for zoom out
d3.select("#zoom_out")
.on("click", zoomInOrOut);
//Gets the center of currently seen display
function interpolateZoom (translate, scale) {
return d3.transition().duration(1).tween("zoom", function () {
var iTranslate = d3.interpolate(zoom.translate(), translate),
iScale = d3.interpolate(zoom.scale(), scale);
return function (t) {
zoom
//Round number to nearest int because expected scale for now is whole number
.scale(Math.floor(iScale(t)))
.translate(iTranslate(t));
zoomed();
};
});
}
function zoomInOrOut() {
var direction = 1,
target_zoom = 1,
center = [graph_area_width / 2, graph_area_height / 2],
extent = zoom.scaleExtent(),
translate = zoom.translate(),
translate0 = [],
l = [],
view = {x: translate[0], y: translate[1], k: zoom.scale()};
d3.event.preventDefault();
direction = (this.id === 'zoom_in') ? 1 : -1;
target_zoom = zoom.scale() + (direction * zoom_scale);
if (target_zoom < extent[0] || target_zoom > extent[1]) { return false; }
translate0 = [(center[0] - view.x) / view.k, (center[1] - view.y) / view.k];
view.k = target_zoom;
l = [translate0[0] * view.k + view.x, translate0[1] * view.k + view.y];
view.x += center[0] - l[0];
view.y += center[1] - l[1];
interpolateZoom([view.x, view.y], view.k);
}
function zoomUpdate() {
var target_zoom = 1,
center = [graph_area_width / 2, graph_area_height / 2],
extent = zoom.scaleExtent(),
translate = zoom.translate(),
translate0 = [],
l = [],
view = {x: translate[0], y: translate[1], k: zoom.scale()};
target_zoom = zoom.scale();
if (target_zoom < extent[0] || target_zoom > extent[1]) { return false; }
translate0 = [(center[0] - view.x) / view.k, (center[1] - view.y) / view.k];
view.k = target_zoom;
l = [translate0[0] * view.k + view.x, translate0[1] * view.k + view.y];
view.x += center[0] - l[0];
view.y += center[1] - l[1];
interpolateZoom([view.x, view.y], view.k);
}
Here is my take on combining d3-zoom with scrollbars:
https://stackblitz.com/edit/d3-pan-and-zoom
Apart from handling d3's zoom to update scrollbar positions, you also need to handle scrolling with scrollbars to update d3's internal zoom representation by calling translateTo().
I'm using a d3 attrTween to translate a circle over a path smoothly, similar to this example and as shown in the picture below:
The circle's transition is defined here:
function transition() {
circle.transition()
.duration(2051)
.ease("linear")
.attrTween("transform", translateAlong(path.node()))
}
And the attribute tween is shown here:
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 + ")";
};
};
}
This works well thanks to the SVG method getPointAtLength, which allows us to retrieve coordinates at different lengths of the path. However, I need a different kind of behavior and I've been unable to come up with a solution so far.
I need the circle to animate along the path, but at a steady horizontal speed. Meaning that the circle ought to take as much time to navigate this slice:
As it does with this slice:
Because both slices encompass the same width. On a low level, what I need is to be able to translate any X coordinate with its corresponding Y coordinate along the path. I've looked at all the SVG path methods and I haven't found anything particularly useful here. I'm hoping there's some way in D3 to feed an X coordinate to a d3 line and retrieve its corresponding Y coordinate.
Here's a JSFiddle working as described above. I'd really appreciate any help I can get on this. Thanks!
I ended up creating a lookup array for all my points along the line using getPointAtLength:
var lookup = [];
var granularity = 1000;
var l = path.node().getTotalLength();
for(var i = 1; i <= granularity; i++) {
var p = path.node().getPointAtLength(l * (i/granularity))
lookup.push({
x: p.x,
y: p.y
})
}
Once I had all those points in my lookup table, I used a bisector in my translate tween:
var xBisect = d3.bisector(function(d) { return d.x; }).left;
function translateAlong(path) {
var l = path.getTotalLength();
return function (d, i, a) {
return function (t) {
var index = xBisect(lookup, l * t);
var p = lookup[index];
return "translate(" + p.x + "," + p.y + ")";
};
};
}
And it works as expected! Yahoo!
Fiddle
I'm interested in tweaking the radius of the circles on the circle pack layout. For that I need to know how the original radius is calculated.
By reading the d3.js source code for pack layout it seems the default radius function is simply Math.sqrt of value for each node. But that is not really the case because I modified the D3.js original circle pack example adding a .radius(function(d){return Math.sqrt(d);}) and as you can see at bl.ocks.org/ecerulm/f0a36710e3 the radius of the circles are not the same.
The d3.layout.pack() uses Math.sqrt as radius function. But pack.nodes will apply a scale transform d3_layout_packTransform(node, x, y, k) to make the whole circle pack chart to fit if radius wasn't explicitly set. That is why if you apply you own function (even if its radius(Math.sqrt)) you will need to apply your own scaling after if you want to get the same result as with implicit radius.
In the example below I explicitly set Math.sqrt as the radius function and then scale afterward to fit [diameter,diameter] with my own function pack_transform since d3_layout_packTranform is not accesible:
var pack = d3.layout.pack()
.value(function(d) { return d.size; })
.radius(Math.sqrt)
.size([diameter - 4, diameter - 4]);
var packnodes = pack.nodes(root);
var packroot = packnodes[0];
var w = diameter, h = diameter;
function pack_transform(node, k) {
function inner_transform(node,cx,cy,k) {
var children = node.children;
node.x = cx + k * (node.x-cx);
node.y = cy + k * ( node.y-cy);
node.r *= k;
if (children) {
var i = -1, n = children.length;
while (++i < n) inner_transform(children[i],cx,cy, k);
}
}
return inner_transform(node,node.x,node.y,k);
}
pack_transform(packroot, 1 / Math.max(2 * packroot.r / w, 2 * packroot.r / h));