Problem with adding/removing bubbles on a force bubble chart - javascript

I'm trying to create a dynamic bubble chart where bubbles are added/removed based on selected filters. Eventually the data is going to be called from my server through Ajax, but currently I just have a set array of objects that i add to or remove from with buttons.
I have two problems that i cannot figure out at the moment.
When I click the Add Bubble button a bubble is added but the it doesn't appear to be added to the force simulation?
When i click the Remove Bubble button the bubble with the data that is removed isn't being removed with exit.remove()
The full code can be found here https://jsfiddle.net/codered1988/s2z63crL/
function drawBubbles() {
let root = d3.hierarchy({ children: data })
.sum(d => d.value);
let nodes = pack(root).leaves().map(node => {
const data = node.data;
return {
x: centerX + (node.x - centerX) * 3,
y: centerY + (node.y - centerY) * 3,
r: 10, // for tweening
radius: node.r, //original radius
id: data.id,
type: data.type,
name: data.name,
value: data.value,
}
});
simulation.nodes(nodes).on('tick', ticked);
node = svg.selectAll('.node')
.data(nodes, d => d.id)
.join(
function(n){ // enter
enter = n.append('g')
.attr('class', 'node')
.call(d3.drag()
.on('start', (event, d) => {
if (!event.active) simulation.alphaTarget(0.2).restart();
d.fx = d.x;
d.fy = d.y;
})
.on('drag', (event, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on('end', (event, d) => {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}))
// Create Circles
enter.append('circle')
.attr('id', d => d.id)
.attr('r', d => d.radius)
//.style('fill', d => scaleColor(d.type))
.style('fill', d => fill(d.type))
.attr("stroke", "black")
.style("stroke-width", 2)
.transition().duration(2000).ease(d3.easeElasticOut)
.tween('circleIn', (d) => {
console.log('tween');
let i = d3.interpolateNumber(d.r, d.radius);
return (t) => {
d.r = i(t);
simulation.force('collide', forceCollide);
}
})
enter.append('clipPath')
.attr('id', d => `clip-${d.id}`)
.append('use')
.attr('xlink:href', d => `#${d.id}`);
// display text as circle icon
enter.filter(d => !String(d.name).includes('img/'))
.append('text')
.classed('node-icon', true)
.attr('clip-path', d => `url(#clip-${d.id})`)
.selectAll('tspan')
.data(d => d.name.split(';'))
.enter()
.append('tspan')
.attr('x', 0)
.attr('y',(d, i, nodes) => (13 + (i - nodes.length / 2 - 0.5) * 10))
.text(name => name);
return enter;
},
update => update
.attr("fill", "black")
.attr("y", 0)
.call(update => update.transition(t)
.attr("x", (d, i) => i * 16)),
exit => exit
.attr("fill", "brown")
.call(exit => exit.transition(t)
.attr("y", 30)
.remove())
);
//simulation.nodes(nodes).alpha(1).restart();
}

Related

Select Dropdown not Updating

Trying to practice with some D3 and using select dropdowns to change the colors, etc. So I've been practicing with the Sankey (Parallel Sets example on the D3 website).
There are two buttons - one basically is selecting different columns from the data, and the second button populates the unique values under that column.
Right now, I'm just trying to get the 2nd button to update with the unique value when the first button changes. I know I'm getting the new unique values but the 2nd button never changes.
https://jsfiddle.net/jimdholland/vrq7mw3g/4/
var dataRaw = 'Survived,Sex,Age,Class,value\nPerished,Male,Adult,Crew,670\nPerished,Male,Adult,Third Class,387\nPerished,Male,Adult,Second Class,154\nPerished,Male,Adult,First Class,118\nPerished,Male,Child,Third Class,35\nPerished,Female,Adult,Crew,3\nPerished,Female,Adult,Third Class,89\nPerished,Female,Adult,Second Class,13\nPerished,Female,Adult,First Class,4\nPerished,Female,Child,Third Class,17\nSurvived,Male,Adult,Crew,192\nSurvived,Male,Adult,Third Class,75\nSurvived,Male,Adult,Second Class,14\nSurvived,Male,Adult,First Class,57\nSurvived,Male,Child,Third Class,13\nSurvived,Male,Child,Second Class,11\nSurvived,Male,Child,First Class,5\nSurvived,Female,Adult,Crew,20\nSurvived,Female,Adult,Third Class,76\nSurvived,Female,Adult,Second Class,80\nSurvived,Female,Adult,First Class,140\nSurvived,Female,Child,Third Class,14\nSurvived,Female,Child,Second Class,13\nSurvived,Female,Child,First Class,1'
var data = d3.csvParse(dataRaw);
for (const d of data) {d.value = +d.value; }
var keys = data.columns.slice(0, -1)
let dropDefault = 'Survived'
var subOptions = [...new Set(data.map(d => d[dropDefault]))];
var mainButton = d3.select("#selectButton")
.selectAll('option')
.data(keys)
.enter()
.append('option')
.text(function (d) { return d; }) // text showed in the menu
.attr("value", function (d) { return d; }) // corresponding value returned
// add the options to the button
var subButton = d3.select("#selectButtonAfter")
.selectAll('option')
.data(subOptions)
.enter()
.append('option')
.text(function (d) { return d; }) // text showed in the menu
.attr("value", function (d) { return d; }) // corresponding value returned
// Chart stuff here - see Fiddle below with all of it.
// Second issue, doing mainButton.on("change"...) doesn't work, have to do the select.
d3.select("#selectButton").on("change", function(d){
var newOption = d3.select(this).property('value');
console.log(newOption);
var NewsubOptions = [...new Set(data.map(d => d[newOption]))];
console.log(NewsubOptions);
d3.select("#selectButtonAfter").data(NewsubOptions)
.exit()
.enter()
.append('option')
.text(function (d) { return d; }) // text showed in the menu
.attr("value", function (d) { return d; }) // corresponding value returned
});
There are several problems here:
d3.select("#selectButtonAfter").data(NewsubOptions)
.exit()
.enter()
.append('option')
.text(function (d) { return d; }) // text showed in the menu
.attr("value", function (d) { return d; }) // corresponding value returned
Some of them are:
you cannot use data with d3.select()
You need to have a key function, otherwise you're binding by index
You cannot chain data, exit and enter like you did here.
A conventional approach would be:
let select = d3.select("#selectButtonAfter").selectAll("option")
.data(NewsubOptions, d => d);
select.exit().remove();
const selectEnter = select.enter()
.append('option');
select = selectEnter.merge(select);
select.text(function(d) {
return d;
}) // text showed in the menu
.attr("value", function(d) {
return d;
}) // corresponding value returned
It could be even shorter, but the snippet above allows for selects with different number of options.
Here's your code with that change:
var dataRaw = 'Survived,Sex,Age,Class,value\nPerished,Male,Adult,Crew,670\nPerished,Male,Adult,Third Class,387\nPerished,Male,Adult,Second Class,154\nPerished,Male,Adult,First Class,118\nPerished,Male,Child,Third Class,35\nPerished,Female,Adult,Crew,3\nPerished,Female,Adult,Third Class,89\nPerished,Female,Adult,Second Class,13\nPerished,Female,Adult,First Class,4\nPerished,Female,Child,Third Class,17\nSurvived,Male,Adult,Crew,192\nSurvived,Male,Adult,Third Class,75\nSurvived,Male,Adult,Second Class,14\nSurvived,Male,Adult,First Class,57\nSurvived,Male,Child,Third Class,13\nSurvived,Male,Child,Second Class,11\nSurvived,Male,Child,First Class,5\nSurvived,Female,Adult,Crew,20\nSurvived,Female,Adult,Third Class,76\nSurvived,Female,Adult,Second Class,80\nSurvived,Female,Adult,First Class,140\nSurvived,Female,Child,Third Class,14\nSurvived,Female,Child,Second Class,13\nSurvived,Female,Child,First Class,1'
var data = d3.csvParse(dataRaw);
for (const d of data) {
d.value = +d.value;
}
var keys = data.columns.slice(0, -1)
let dropDefault = 'Survived'
var subOptions = [...new Set(data.map(d => d[dropDefault]))];
var mainButton = d3.select("#selectButton")
.selectAll('option')
.data(keys)
.enter()
.append('option')
.text(function(d) {
return d;
}) // text showed in the menu
.attr("value", function(d) {
return d;
}) // corresponding value returned
console.log(subOptions);
console.log("Just loaded subs");
// add the options to the button
var subButton = d3.select("#selectButtonAfter")
.selectAll('option')
.data(subOptions)
.enter()
.append('option')
.text(function(d) {
return d;
}) // text showed in the menu
.attr("value", function(d) {
return d;
}) // corresponding value returned
var width = 975;
var height = 720;
var color = d3.scaleOrdinal(["Perished"], ["#da4f81"]).unknown("#ccc")
var sankey = d3.sankey()
.nodeSort(null)
.linkSort(null)
.nodeWidth(4)
.nodePadding(20)
.extent([
[0, 5],
[width, height - 5]
])
let index = -1;
const nodes = [];
const nodeByKey = new Map;
const indexByKey = new Map;
const links = [];
for (const k of keys) {
for (const d of data) {
const key = JSON.stringify([k, d[k]]);
if (nodeByKey.has(key)) continue;
const node = {
name: d[k]
};
nodes.push(node);
nodeByKey.set(key, node);
indexByKey.set(key, ++index);
}
};
for (let i = 1; i < keys.length; ++i) {
const a = keys[i - 1];
const b = keys[i];
const prefix = keys.slice(0, i + 1);
const linkByKey = new Map;
for (const d of data) {
const names = prefix.map(k => d[k]);
const key = JSON.stringify(names);
const value = d.value || 1;
let link = linkByKey.get(key);
if (link) {
link.value += value;
continue;
}
link = {
source: indexByKey.get(JSON.stringify([a, d[a]])),
target: indexByKey.get(JSON.stringify([b, d[b]])),
names,
value
};
links.push(link);
linkByKey.set(key, link);
}
};
let graphData = {
nodes,
links
};
const svg = d3.select("#chart").append("svg")
.attr("viewBox", [0, 0, width, height]);
let g = sankey(graphData);
let gtest = sankey({
nodes: graphData.nodes.map(d => Object.assign({}, d)),
links: graphData.links.map(d => Object.assign({}, d))
});
svg.append("g")
.selectAll("rect")
.data(g.nodes)
.join("rect")
.attr("x", d => d.x0)
.attr("y", d => d.y0)
.attr("height", d => d.y1 - d.y0)
.attr("width", d => d.x1 - d.x0)
.append("title")
.text(d => `${d.name}\n${d.value.toLocaleString()}`);
svg.append("g")
.attr("fill", "none")
.selectAll("g")
.data(g.links)
.join("path")
.attr("d", d3.sankeyLinkHorizontal())
.attr("stroke", d => color(d.names[0]))
.attr("stroke-width", d => d.width)
.style("mix-blend-mode", "multiply")
.append("title")
.text(d => `${d.names.join(" → ")}\n${d.value.toLocaleString()}`);
svg.append("g")
.style("font", "10px sans-serif")
.selectAll("text")
.data(g.nodes)
.join("text")
.attr("x", d => d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6)
.attr("y", d => (d.y1 + d.y0) / 2)
.attr("dy", "0.35em")
.attr("text-anchor", d => d.x0 < width / 2 ? "start" : "end")
.text(d => d.name)
.append("tspan")
.attr("fill-opacity", 0.7)
.text(d => ` ${d.value.toLocaleString()}`);
d3.select("#selectButton").on("change", function(d) {
var newOption = d3.select(this).property('value');
console.log(newOption);
var NewsubOptions = [...new Set(data.map(d => d[newOption]))];
console.log(NewsubOptions);
let select = d3.select("#selectButtonAfter").selectAll("option")
.data(NewsubOptions, d => d);
select.exit().remove();
const selectEnter = select.enter()
.append('option');
select = selectEnter.merge(select);
select.text(function(d) {
return d;
}) // text showed in the menu
.attr("value", function(d) {
return d;
}) // corresponding value returned
});
<body>
<!-- Initialize a select button -->
<select id="selectButton"></select>
<select id="selectButtonAfter"></select>
<div id="chart"></div>
<!-- load the d3.js library -->
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://unpkg.com/d3-sankey#0"></script>
</body>

D3.js nodes jump when restarting simulation to add or remove nodes

I'm using d3.js v6 with a force layout to represent a network graph.
I'm adding and removing nodes but when I restart the simulation all the nodes jump to an upper left position and then come back to the original position.
I have this following code snippet that shows exactly what I mean, I've seen other examples online that work fine but haven't been able to find what I am doing wrong, any help is really appreciated.
var dataset = {
nodes: [
{
id: 1
},
{
id: 2
}
],
links: [{
id: 1,
source: 1,
target: 2
}]
};
let switchBool = false;
let svg = d3.select('svg')
.attr('width', '100%')
.attr('height', '100%');
const width = svg.node()
.getBoundingClientRect().width;
const height = svg.node()
.getBoundingClientRect().height;
console.log(`${width}, ${height}`);
svg = svg.append('g');
svg.append('g')
.attr('class', 'links');
svg.append('g')
.attr('class', 'nodes');
const simulation = d3.forceSimulation();
initSimulation();
let link = svg.select('.links')
.selectAll('line');
loadLinks();
let node = svg.select('.nodes')
.selectAll('.node');
loadNodes();
restartSimulation();
function initSimulation() {
simulation
.force('link', d3.forceLink())
.force('charge', d3.forceManyBody())
.force('collide', d3.forceCollide())
.force('center', d3.forceCenter())
.force('forceX', d3.forceX())
.force('forceY', d3.forceY());
simulation.force('center')
.x(width * 0.5)
.y(height * 0.5);
simulation.force('link')
.id((d) => d.id)
.distance(100)
.iterations(1);
simulation.force('collide')
.radius(10);
simulation.force('charge')
.strength(-100);
}
function loadLinks() {
link = svg.select('.links')
.selectAll('line')
.data(dataset.links, (d) => d.id)
.join(
enter => enter.append('line').attr('stroke', '#000000'),
);
}
function loadNodes() {
node = svg.select('.nodes')
.selectAll('.node')
.data(dataset.nodes, (d) => d.id)
.join(
enter => {
const nodes = enter.append('g')
.attr('class', 'node')
nodes.append('circle').attr('r', 10);
return nodes;
},
);
}
function restartSimulation() {
simulation.nodes(dataset.nodes);
simulation.force('link').links(dataset.links);
simulation.alpha(1).restart();
simulation.on('tick', ticked);
}
function ticked() {
link
.attr('x1', (d) => d.source.x)
.attr('y1', (d) => d.source.y)
.attr('x2', (d) => d.target.x)
.attr('y2', (d) => d.target.y);
node.attr('transform', (d) => `translate(${d.x},${d.y})`);
}
function updateData() {
switchBool = !switchBool;
if (switchBool) {
dataset.nodes.push({id: 3});
dataset.links.push({id: 2, source: 1, target: 3});
} else {
dataset.nodes.pop();
dataset.links.pop();
}
loadLinks();
loadNodes();
restartSimulation();
}
<script src="https://d3js.org/d3.v6.min.js"></script>
<div>
<button onclick="updateData()">Add/Remove</button>
<svg></svg>
</div>
It's because you use d3.forceCenter() which does not coerce nodes to a center point:
The centering force translates nodes uniformly so that the mean
position of all nodes (the center of mass if all nodes have equal
weight) is at the given position ⟨x,y⟩. (docs)
So if your two nodes are located at directly and equally below/above the centering point for d3.forceCenter, the mass is balanced. Introduce a new node and the entire force has to be transalted so that the center of mass is the center. This translation is the jump you are seeing.
Remove forceCenter and specify the center values with d3.forceX and d3.forceY, which do nudge nodes towards the specified x and y values:
var dataset = {
nodes: [
{
id: 1
},
{
id: 2
}
],
links: [{
id: 1,
source: 1,
target: 2
}]
};
let switchBool = false;
let svg = d3.select('svg')
.attr('width', '100%')
.attr('height', '100%');
const width = svg.node()
.getBoundingClientRect().width;
const height = svg.node()
.getBoundingClientRect().height;
console.log(`${width}, ${height}`);
svg = svg.append('g');
svg.append('g')
.attr('class', 'links');
svg.append('g')
.attr('class', 'nodes');
const simulation = d3.forceSimulation();
initSimulation();
let link = svg.select('.links')
.selectAll('line');
loadLinks();
let node = svg.select('.nodes')
.selectAll('.node');
loadNodes();
restartSimulation();
function initSimulation() {
simulation
.force('link', d3.forceLink())
.force('charge', d3.forceManyBody())
.force('collide', d3.forceCollide())
.force('forceX', d3.forceX().x(width/2))
.force('forceY', d3.forceY().y(height/2));
simulation.force('link')
.id((d) => d.id)
.distance(100)
.iterations(1);
simulation.force('collide')
.radius(10);
simulation.force('charge')
.strength(-100);
}
function loadLinks() {
link = svg.select('.links')
.selectAll('line')
.data(dataset.links, (d) => d.id)
.join(
enter => enter.append('line').attr('stroke', '#000000'),
);
}
function loadNodes() {
node = svg.select('.nodes')
.selectAll('.node')
.data(dataset.nodes, (d) => d.id)
.join(
enter => {
const nodes = enter.append('g')
.attr('class', 'node')
nodes.append('circle').attr('r', 10);
return nodes;
},
);
}
function restartSimulation() {
simulation.nodes(dataset.nodes);
simulation.force('link').links(dataset.links);
simulation.alpha(1).restart();
simulation.on('tick', ticked);
}
function ticked() {
link
.attr('x1', (d) => d.source.x)
.attr('y1', (d) => d.source.y)
.attr('x2', (d) => d.target.x)
.attr('y2', (d) => d.target.y);
node.attr('transform', (d) => `translate(${d.x},${d.y})`);
}
function updateData() {
switchBool = !switchBool;
if (switchBool) {
dataset.nodes.push({id: 3});
dataset.links.push({id: 2, source: 1, target: 3});
} else {
dataset.nodes.pop();
dataset.links.pop();
}
loadLinks();
loadNodes();
restartSimulation();
}
<script src="https://d3js.org/d3.v6.min.js"></script>
<div>
<button onclick="updateData()">Add/Remove</button>
<svg></svg>
</div>
I've actually just found the solution to the problem.
I had both forceX and forceY with default paramaters, which meant that there was a force pushing node towards (0,0), changing this bit of code I was able to fix it:
.force('x', d3.forceX().x(width * 0.5))
.force('y', d3.forceY().y(height * 0.5));

D3.Js Link Tree Connections Not Showing [Angular]

I'm having issues where my D3 Force Graph is showing with nodes but not connecting the links.
I'm not sure what the issue is because my strokes are defined.
I'm not sure if it's an issue with the JSON Data format or what it could be. Where could the issue be?
I am using Angular D3 with D3.Js & what I am trying to build is a Force Directed Network Graph.
JSON Data I'm using:
https://gist.github.com/KoryJCampbell/f18f8a11030269739eabc7de05b38b11
graph.ts
loadForceDirectedGraph(nodes: Node[], links: Link[]) {
const svg = d3.select('svg');
const width = +svg.attr('width');
const height = +svg.attr('height');
const color = d3.scaleOrdinal(d3.schemeTableau10);
const simulation = d3.forceSimulation()
.force('link', d3.forceLink().id((d: Node) => d.name))// the id of the node
.force("charge", d3.forceManyBody().strength(-5).distanceMax(0.1 * Math.min(width, height)))
.force('center', d3.forceCenter(width / 2, height / 2));
console.log(nodes, links);
const link = svg.append('g')
.attr('class', 'links')
.selectAll('line')
.data(links)
.enter()
.append('line')
.attr('stroke-width', d => Math.sqrt(d.index))
.attr('stroke', 'black');
const node = svg.append('g')
.attr('class', 'nodes')
.selectAll('circle')
.data(nodes)
.enter()
.append('circle')
.attr('r', 5)
.attr("fill", function(d) { return color(d.company); })
.call(d3.drag()
.on('start', dragStarted)
.on('drag', dragged)
.on('end', dragEnded)
);
node.append('title').text((d) => d.name);
simulation
.nodes(nodes)
.on('tick', ticked);
simulation.force<d3.ForceLink<any, any>>('link')
.links(links);
function ticked() {
node
.attr('cx', d => d.x)
.attr('cy', d => d.y);
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
}
function dragStarted(event) {
if (!event.active) { simulation.alphaTarget(0.3).restart(); }
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
function dragEnded(event) {
if (!event.active) { simulation.alphaTarget(0); }
event.subject.fx = null;
event.subject.fy = null;
}
}
I think your links format in json file is wrong.
change links to this format.
links: [
{
source: "<paste source's name property>",
target: "<paste target's name property>",
index: 0
},
]
because you are doing:
.force('link', d3.forceLink().id((d: Node) => d.name))// the name of the node

Persisting the state of d3 treemap on drilling down to child nodes

I am working on the d3 treemap v5 in which I need to persist the state of the treemap in localstorage on each user click. My code is in https://codesandbox.io/s/d3-treemap-wfbtg
When a user clicks the top parent tile it is drilled down to the children tiles. How to persist that in local storage when the user reloads the browser he wants to see the drilled down children tiles.
class Treegraph extends React.Component {
createTreeChart = () => {
const width = 550;
const height = 500;
var paddingAllowance = 2;
const format = d3.format(",d");
const checkLowVal = d => {
console.log("ChecklowVal", d);
if (d.value < 2) {
return true;
}
};
const name = d =>
d
.ancestors()
.reverse()
.map(d => d.data.name)
.join(" / ");
function tile(node, x0, y0, x1, y1) {
d3.treemapBinary(node, 0, 0, width, height);
for (const child of node.children) {
child.x0 = x0 + (child.x0 / width) * (x1 - x0);
child.x1 = x0 + (child.x1 / width) * (x1 - x0);
child.y0 = y0 + (child.y0 / height) * (y1 - y0);
child.y1 = y0 + (child.y1 / height) * (y1 - y0);
}
}
const treemap = data =>
d3.treemap().tile(tile)(
d3
.hierarchy(data)
.sum(d => d.value)
.sort((a, b) => b.value - a.value)
);
const svg = d3
.select("#chart")
.append("svg")
.attr("viewBox", [0.5, -30.5, width, height + 30])
.style("font", "16px sans-serif");
const x = d3.scaleLinear().rangeRound([0, width]);
const y = d3.scaleLinear().rangeRound([0, height]);
let group = svg.append("g").call(render, treemap(data));
function render(group, root) {
const node = group
.selectAll("g")
.data(root.children.concat(root))
.join("g");
node
.filter(d => (d === root ? d.parent : d.children))
.attr("cursor", "pointer")
.on("click", d => (d === root ? zoomout(root) : zoomin(d)));
var tool = d3
.select("body")
.append("div")
.attr("class", "toolTip");
d3.select(window.frameElement).style("height", height - 20 + "px");
d3.select(window.frameElement).style("width", width - 20 + "px");
node
.append("rect")
.attr("id", d => (d.leafUid = "leaf"))
.attr("fill", d =>
d === root ? "#fff" : d.children ? "#045c79" : "#045c79"
)
.attr("stroke", "#fff")
.on("mousemove", function(d) {
tool.style("left", d3.event.pageX + 10 + "px");
tool.style("top", d3.event.pageY - 20 + "px");
tool.style("display", "inline-block");
tool.html(`${d.data.name}<br />(${format(d.data.value)})`);
})
.on("click", function(d) {
tool.style("display", "none");
})
.on("mouseout", function(d) {
tool.style("display", "none");
});
node
.append("foreignObject")
.attr("class", "foreignObject")
.attr("width", function(d) {
return d.dx - paddingAllowance;
})
.attr("height", function(d) {
return d.dy - paddingAllowance;
})
.append("xhtml:body")
.attr("class", "labelbody")
.append("div")
.attr("class", "label")
.text(function(d) {
return d.name;
})
.attr("text-anchor", "middle");
node
.append("clipPath")
.attr("id", d => (d.clipUid = "clip"))
.append("use")
.attr("xlink:href", d => d.leafUid.href);
node
.append("text")
.attr("clip-path", d => d.clipUid)
.attr("font-weight", d => (d === root ? "bold" : null))
.attr("font-size", d => {
if (d === root) return "0.8em";
const width = x(d.x1) - x(d.x0),
height = y(d.y1) - y(d.y0);
return Math.max(
Math.min(
width / 5,
height / 2,
Math.sqrt(width * width + height * height) / 25
),
9
);
})
.attr("text-anchor", d => (d === root ? null : "middle"))
.attr("transform", d =>
d === root
? null
: `translate(${(x(d.x1) - x(d.x0)) / 2}, ${(y(d.y1) - y(d.y0)) /
2})`
)
.selectAll("tspan")
.data(d =>
d === root
? name(d).split(/(?=\/)/g)
: checkLowVal(d)
? d.data.name.split(/(\s+)/).concat(format(d.data.value))
: d.data.name.split(/(\s+)/).concat(format(d.data.value))
)
.join("tspan")
.attr("x", 3)
.attr(
"y",
(d, i, nodes) =>
`${(i === nodes.length - 1) * 0.3 + (i - nodes.length / 2) * 0.9}em`
)
.text(d => d);
node
.selectAll("text")
.classed("text-title", d => d === root)
.classed("text-tile", d => d !== root)
.filter(d => d === root)
.selectAll("tspan")
.attr("y", "1.1em")
.attr("x", undefined);
group.call(position, root);
}
function position(group, root) {
group
.selectAll("g")
.attr("transform", d =>
d === root ? `translate(0,-30)` : `translate(${x(d.x0)},${y(d.y0)})`
)
.select("rect")
.attr("width", d => (d === root ? width : x(d.x1) - x(d.x0)))
.attr("height", d => (d === root ? 30 : y(d.y1) - y(d.y0)));
}
// When zooming in, draw the new nodes on top, and fade them in.
function zoomin(d) {
console.log("The zoomin func", d.data);
x.domain([d.x0, d.x1]);
y.domain([d.y0, d.y1]);
const group0 = group.attr("pointer-events", "none");
const group1 = (group = svg.append("g").call(render, d));
svg
.transition()
.duration(750)
.call(t =>
group0
.transition(t)
.remove()
.call(position, d.parent)
)
.call(t =>
group1
.transition(t)
.attrTween("opacity", () => d3.interpolate(0, 1))
.call(position, d)
);
}
// When zooming out, draw the old nodes on top, and fade them out.
function zoomout(d) {
console.log("The zoomout func", d.parent.data);
x.domain([d.parent.x0, d.parent.x1]);
y.domain([d.parent.y0, d.parent.y1]);
const group0 = group.attr("pointer-events", "none");
const group1 = (group = svg.insert("g", "*").call(render, d.parent));
svg
.transition()
.duration(750)
.call(t =>
group0
.transition(t)
.remove()
.attrTween("opacity", () => d3.interpolate(1, 0))
.call(position, d)
)
.call(t => group1.transition(t).call(position, d.parent));
}
return svg.node();
};
componentDidMount() {
this.createTreeChart();
}
render() {
return (
<React.Fragment>
<div id="chart" />
</React.Fragment>
);
}
}
Here is a quick idea, store the name as ID on local storage. Hopefully name is unique, otherwise make sure you have an unique ID for each node.
https://codesandbox.io/s/d3-treemap-4ehbf?file=/src/treegraph.js
first load localstorage for lastSelection with last selected node name
componentDidMount() {
const lastSelection = localStorage.getItem("lastSelection");
console.log("lastSelection:", lastSelection);
this.createTreeChart(lastSelection);
}
add a parameter to you createTreeChart so when it loads and there is a lastaSelection you have to reload that state
createTreeChart = (lastSelection = null) => {
decouple your treemap in a variable, filter the lastSelection node and zoomin into it. this step could be improved, not sure if the refreshing zoomin is interesting.
const tree = treemap(data);
let group = svg.append("g").call(render, tree);
if (lastSelection !== null) {
const lastNode = tree
.descendants()
.find(e => e.data.name === lastSelection);
zoomin(lastNode);
}
finally update localStorage on node click.
node
.filter(d => (d === root ? d.parent : d.children))
.attr("cursor", "pointer")
.on("click", d => {
if (d === root) {
localStorage.setItem("lastSelection", null);
zoomout(root);
} else {
localStorage.setItem("lastSelection", d.data.name);
zoomin(d);
}
});
This could be improved, but it's a starting point.

D3.js hierarchical edge bundling coloring by group

I am trying to color the connections in my hierarchical edge bundling visualization based on the groups they are connecting to. An example of this can be seen here.
Here is my current mouseover function:
function mouseover(d) {
svg.selectAll("path.link.target-" + d.key)
.classed("target", true)
.each(updateNodes("source", true));
svg.selectAll("path.link.source-" + d.key)
.classed("source", true)
.each(updateNodes("target", true));
}
And here is the mouseover function from the example I've posted:
function mouseovered(d)
{
// Handle tooltip
// Tooltips should avoid crossing into the center circle
d3.selectAll("#tooltip").remove();
d3.selectAll("#vis")
.append("xhtml:div")
.attr("id", "tooltip")
.style("opacity", 0)
.html(d.title);
var mouseloc = d3.mouse(d3.select("#vis")[0][0]),
my = ((rotateit(d.x) > 90) && (rotateit(d.x) < 270)) ? mouseloc[1] + 10 : mouseloc[1] - 35,
mx = (rotateit(d.x) < 180) ? (mouseloc[0] + 10) : Math.max(130, (mouseloc[0] - 10 - document.getElementById("tooltip").offsetWidth));
d3.selectAll("#tooltip").style({"top" : my + "px", "left": mx + "px"});
d3.selectAll("#tooltip")
.transition()
.duration(500)
.style("opacity", 1);
node.each(function(n) { n.target = n.source = false; });
currnode = d3.select(this)[0][0].__data__;
link.classed("link--target", function(l) {
if (l.target === d)
{
return l.source.source = true;
}
if (l.source === d)
{
return l.target.target = true;
}
})
.filter(function(l) { return l.target === d || l.source === d; })
.attr("stroke", function(d){
if (d[0].name == currnode.name)
{
return color(d[2].cat);
}
return color(d[0].cat);
})
.each(function() { this.parentNode.appendChild(this); });
d3.selectAll(".link--clicked").each(function() { this.parentNode.appendChild(this); });
node.classed("node--target", function(n) {
return (n.target || n.source);
});
}
I am somewhat new to D3, but I am assuming what I'll need to do is check the group based on the key and then match it to the same color as that group.
My full code is here:
<script type="text/javascript">
color = d3.scale.category10();
var w = 840,
h = 800,
rx = w / 2,
ry = h / 2,
m0,
rotate = 0
pi = Math.PI;
var splines = [];
var cluster = d3.layout.cluster()
.size([360, ry - 180])
.sort(function(a, b) {
return d3.ascending(a.key, b.key);
});
var bundle = d3.layout.bundle();
var line = d3.svg.line.radial()
.interpolate("bundle")
.tension(.5)
.radius(function(d) {
return d.y;
})
.angle(function(d) {
return d.x / 180 * Math.PI;
});
// Chrome 15 bug: <http://code.google.com/p/chromium/issues/detail?id=98951>
var div = d3.select("#bundle")
.style("width", w + "px")
.style("height", w + "px")
.style("position", "absolute");
var svg = div.append("svg:svg")
.attr("width", w)
.attr("height", w)
.append("svg:g")
.attr("transform", "translate(" + rx + "," + ry + ")");
svg.append("svg:path")
.attr("class", "arc")
.attr("d", d3.svg.arc().outerRadius(ry - 180).innerRadius(0).startAngle(0).endAngle(2 * Math.PI))
.on("mousedown", mousedown);
d3.json("TASKS AND PHASES.json", function(classes) {
var nodes = cluster.nodes(packages.root(classes)),
links = packages.imports(nodes),
splines = bundle(links);
var path = svg.selectAll("path.link")
.data(links)
.enter().append("svg:path")
.attr("class", function(d) {
return "link source-" + d.source.key + " target-" + d.target.key;
})
.attr("d", function(d, i) {
return line(splines[i]);
});
var groupData = svg.selectAll("g.group")
.data(nodes.filter(function(d) {
return (d.key == 'Department' || d.key == 'Software' || d.key == 'Tasks' || d.key == 'Phases') && d.children;
}))
.enter().append("group")
.attr("class", "group");
var groupArc = d3.svg.arc()
.innerRadius(ry - 177)
.outerRadius(ry - 157)
.startAngle(function(d) {
return (findStartAngle(d.__data__.children) - 2) * pi / 180;
})
.endAngle(function(d) {
return (findEndAngle(d.__data__.children) + 2) * pi / 180
});
svg.selectAll("g.arc")
.data(groupData[0])
.enter().append("svg:path")
.attr("d", groupArc)
.attr("class", "groupArc")
.attr("id", function(d, i) {console.log(d.__data__.key); return d.__data__.key;})
.style("fill", function(d, i) {return color(i);})
.style("fill-opacity", 0.5)
.each(function(d,i) {
var firstArcSection = /(^.+?)L/;
var newArc = firstArcSection.exec( d3.select(this).attr("d") )[1];
newArc = newArc.replace(/,/g , " ");
svg.append("path")
.attr("class", "hiddenArcs")
.attr("id", "hidden"+d.__data__.key)
.attr("d", newArc)
.style("fill", "none");
});
svg.selectAll(".arcText")
.data(groupData[0])
.enter().append("text")
.attr("class", "arcText")
.attr("dy", 15)
.append("textPath")
.attr("startOffset","50%")
.style("text-anchor","middle")
.attr("xlink:href",function(d,i){return "#hidden" + d.__data__.key;})
.text(function(d){return d.__data__.key;});
svg.selectAll("g.node")
.data(nodes.filter(function(n) {
return !n.children;
}))
.enter().append("svg:g")
.attr("class", "node")
.attr("id", function(d) {
return "node-" + d.key;
})
.attr("transform", function(d) {
return "rotate(" + (d.x - 90) + ")translate(" + d.y + ")";
})
.append("svg:text")
.attr("dx", function(d) {
return d.x < 180 ? 25 : -25;
})
.attr("dy", ".31em")
.attr("text-anchor", function(d) {
return d.x < 180 ? "start" : "end";
})
.attr("transform", function(d) {
return d.x < 180 ? null : "rotate(180)";
})
.text(function(d) {
return d.key.replace(/_/g, ' ');
})
.on("mouseover", mouseover)
.on("mouseout", mouseout);
d3.select("input[type=range]").on("change", function() {
line.tension(this.value / 100);
path.attr("d", function(d, i) {
return line(splines[i]);
});
});
});
d3.select(window)
.on("mousemove", mousemove)
.on("mouseup", mouseup);
function mouse(e) {
return [e.pageX - rx, e.pageY - ry];
}
function mousedown() {
m0 = mouse(d3.event);
d3.event.preventDefault();
}
function mousemove() {
if (m0) {
var m1 = mouse(d3.event),
dm = Math.atan2(cross(m0, m1), dot(m0, m1)) * 180 / Math.PI;
div.style("-webkit-transform", "translate3d(0," + (ry - rx) + "px,0)rotate3d(0,0,0," + dm + "deg)translate3d(0," + (rx - ry) + "px,0)");
}
}
function mouseup() {
if (m0) {
var m1 = mouse(d3.event),
dm = Math.atan2(cross(m0, m1), dot(m0, m1)) * 180 / Math.PI;
rotate += dm;
if (rotate > 360) rotate -= 360;
else if (rotate < 0) rotate += 360;
m0 = null;
div.style("-webkit-transform", "rotate3d(0,0,0,0deg)");
svg.attr("transform", "translate(" + rx + "," + ry + ")rotate(" + rotate + ")")
.selectAll("g.node text")
.attr("dx", function(d) {
return (d.x + rotate) % 360 < 180 ? 25 : -25;
})
.attr("text-anchor", function(d) {
return (d.x + rotate) % 360 < 180 ? "start" : "end";
})
.attr("transform", function(d) {
return (d.x + rotate) % 360 < 180 ? null : "rotate(180)";
});
}
}
function mouseover(d) {
svg.selectAll("path.link.target-" + d.key)
.classed("target", true)
.each(updateNodes("source", true));
svg.selectAll("path.link.source-" + d.key)
.classed("source", true)
.each(updateNodes("target", true));
}
function mouseout(d) {
svg.selectAll("path.link.source-" + d.key)
.classed("source", false)
.each(updateNodes("target", false));
svg.selectAll("path.link.target-" + d.key)
.classed("target", false)
.each(updateNodes("source", false));
}
function updateNodes(name, value) {
return function(d) {
if (value) this.parentNode.appendChild(this);
svg.select("#node-" + d[name].key).classed(name, value);
};
}
function cross(a, b) {
return a[0] * b[1] - a[1] * b[0];
}
function dot(a, b) {
return a[0] * b[0] + a[1] * b[1];
}
function findStartAngle(children) {
var min = children[0].x;
children.forEach(function(d) {
if (d.x < min)
min = d.x;
});
return min;
}
function findEndAngle(children) {
var max = children[0].x;
children.forEach(function(d) {
if (d.x > max)
max = d.x;
});
return max;
}
</script>
Here's an example solution in D3 v6 adapting the Observable example plus my answer to this other question. Basic points:
You will to add the 'group' into the input data - for the data you mention in the comments I've defined group as the 2nd element (per dot separation) of the name. The hierarchy function in the Observable appears to strip this.
It's probably fortunate that all the name values are e.g. root.parent.child - this makes the leafGroups work quite well for your data (but might not for asymmetric hierarchies).
Define a colour range e.g. const colors = d3.scaleOrdinal().domain(leafGroups.map(d => d[0])).range(d3.schemeTableau10); which you can use for arcs, label text (nodes), paths (links)
I've avoided using the mix-blend-mode styling with the example as it doesn't look good to me.
I'm applying the styles in overed and outed - see below for the logic.
See the comments in overed for styling logic on mouseover:
function overed(event, d) {
//link.style("mix-blend-mode", null);
d3.select(this)
// set dark/ bold on hovered node
.style("fill", colordark)
.attr("font-weight", "bold");
d3.selectAll(d.incoming.map(d => d.path))
// each link has data with source and target so you can get group
// and therefore group color; 0 for incoming and 1 for outgoing
.attr("stroke", d => colors(d[0].data.group))
// increase stroke width for emphasis
.attr("stroke-width", 4)
.raise();
d3.selectAll(d.outgoing.map(d => d.path))
// each link has data with source and target so you can get group
// and therefore group color; 0 for incoming and 1 for outgoing
.attr("stroke", d => colors(d[1].data.group))
// increase stroke width for emphasis
.attr("stroke-width", 4)
.raise()
d3.selectAll(d.incoming.map(([d]) => d.text))
// source and target nodes to go dark and bold
.style("fill", colordark)
.attr("font-weight", "bold");
d3.selectAll(d.outgoing.map(([, d]) => d.text))
// source and target nodes to go dark and bold
.style("fill", colordark)
.attr("font-weight", "bold");
}
See the comments in outed for styling logic on mouseout:
function outed(event, d) {
//link.style("mix-blend-mode", "multiply");
d3.select(this)
// hovered node to revert to group colour on mouseout
.style("fill", d => colors(d.data.group))
.attr("font-weight", null);
d3.selectAll(d.incoming.map(d => d.path))
// incoming links to revert to 'colornone' and width 1 on mouseout
.attr("stroke", colornone)
.attr("stroke-width", 1);
d3.selectAll(d.outgoing.map(d => d.path))
// incoming links to revert to 'colornone' and width 1 on mouseout
.attr("stroke", colornone)
.attr("stroke-width", 1);
d3.selectAll(d.incoming.map(([d]) => d.text))
// incoming nodes to revert to group colour on mouseout
.style("fill", d => colors(d.data.group))
.attr("font-weight", null);
d3.selectAll(d.outgoing.map(([, d]) => d.text))
// incoming nodes to revert to group colour on mouseout
.style("fill", d => colors(d.data.group))
.attr("font-weight", null);
}
Working example with the data you mentioned in the comments:
const url = "https://gist.githubusercontent.com/robinmackenzie/5c5d2af4e3db47d9150a2c4ba55b7bcd/raw/9f9c6b92d24bd9f9077b7fc6c4bfc5aebd2787d5/harvard_vis.json";
const colornone = "#ccc";
const colordark = "#222";
const width = 600;
const radius = width / 2;
d3.json(url).then(json => {
// hack in the group name to each object
json.forEach(o => o.group = o.name.split(".")[1]);
// then render
render(json);
});
function render(data) {
const line = d3.lineRadial()
.curve(d3.curveBundle.beta(0.85))
.radius(d => d.y)
.angle(d => d.x);
const tree = d3.cluster()
.size([2 * Math.PI, radius - 100]);
const root = tree(bilink(d3.hierarchy(hierarchy(data))
.sort((a, b) => d3.ascending(a.height, b.height) || d3.ascending(a.data.name, b.data.name))));
const svg = d3.select("body")
.append("svg")
.attr("width", width)
.attr("height", width)
.append("g")
.attr("transform", `translate(${radius},${radius})`);
const arcInnerRadius = radius - 100;
const arcWidth = 20;
const arcOuterRadius = arcInnerRadius + arcWidth;
const arc = d3
.arc()
.innerRadius(arcInnerRadius)
.outerRadius(arcOuterRadius)
.startAngle((d) => d.start)
.endAngle((d) => d.end);
const leafGroups = d3.groups(root.leaves(), d => d.parent.data.name);
const arcAngles = leafGroups.map(g => ({
name: g[0],
start: d3.min(g[1], d => d.x),
end: d3.max(g[1], d => d.x)
}));
const colors = d3.scaleOrdinal().domain(leafGroups.map(d => d[0])).range(d3.schemeTableau10);
svg
.selectAll(".arc")
.data(arcAngles)
.enter()
.append("path")
.attr("id", (d, i) => `arc_${i}`)
.attr("d", (d) => arc({start: d.start, end: d.end}))
.attr("fill", d => colors(d.name))
svg
.selectAll(".arcLabel")
.data(arcAngles)
.enter()
.append("text")
.attr("x", 5)
.attr("dy", (d) => ((arcOuterRadius - arcInnerRadius) * 0.8))
.append("textPath")
.attr("class", "arcLabel")
.attr("xlink:href", (d, i) => `#arc_${i}`)
.text((d, i) => ((d.end - d.start) < (6 * Math.PI / 180)) ? "" : d.name);
// add nodes
const node = svg.append("g")
.attr("font-family", "sans-serif")
.attr("font-size", 10)
.selectAll("g")
.data(root.leaves())
.join("g")
.attr("transform", d => `rotate(${d.x * 180 / Math.PI - 90}) translate(${d.y}, 0)`)
.append("text")
.attr("dy", "0.31em")
.attr("x", d => d.x < Math.PI ? (arcWidth + 5) : (arcWidth + 5) * -1)
.attr("text-anchor", d => d.x < Math.PI ? "start" : "end")
.attr("transform", d => d.x >= Math.PI ? "rotate(180)" : null)
.text(d => d.data.name)
.style("fill", d => colors(d.data.group))
.each(function(d) { d.text = this; })
.on("mouseover", overed)
.on("mouseout", outed)
.call(text => text.append("title").text(d => `${id(d)} ${d.outgoing.length} outgoing ${d.incoming.length} incoming`));
// add edges
const link = svg.append("g")
.attr("stroke", colornone)
.attr("fill", "none")
.selectAll("path")
.data(root.leaves().flatMap(leaf => leaf.outgoing))
.join("path")
//.style("mix-blend-mode", "multiply")
.attr("d", ([i, o]) => line(i.path(o)))
.each(function(d) { d.path = this; });
function overed(event, d) {
//link.style("mix-blend-mode", null);
d3.select(this)
.style("fill", colordark)
.attr("font-weight", "bold");
d3.selectAll(d.incoming.map(d => d.path))
.attr("stroke", d => colors(d[0].data.group))
.attr("stroke-width", 4)
.raise();
d3.selectAll(d.outgoing.map(d => d.path))
.attr("stroke", d => colors(d[1].data.group))
.attr("stroke-width", 4)
.raise()
d3.selectAll(d.incoming.map(([d]) => d.text))
.style("fill", colordark)
.attr("font-weight", "bold");
d3.selectAll(d.outgoing.map(([, d]) => d.text))
.style("fill", colordark)
.attr("font-weight", "bold");
}
function outed(event, d) {
//link.style("mix-blend-mode", "multiply");
d3.select(this)
.style("fill", d => colors(d.data.group))
.attr("font-weight", null);
d3.selectAll(d.incoming.map(d => d.path))
.attr("stroke", colornone)
.attr("stroke-width", 1);
d3.selectAll(d.outgoing.map(d => d.path))
.attr("stroke", colornone)
.attr("stroke-width", 1);
d3.selectAll(d.incoming.map(([d]) => d.text))
.style("fill", d => colors(d.data.group))
.attr("font-weight", null);
d3.selectAll(d.outgoing.map(([, d]) => d.text))
.style("fill", d => colors(d.data.group))
.attr("font-weight", null);
}
function id(node) {
return `${node.parent ? id(node.parent) + "." : ""}${node.data.name}`;
}
function bilink(root) {
const map = new Map(root.leaves().map(d => [id(d), d]));
for (const d of root.leaves()) d.incoming = [], d.outgoing = d.data.imports.map(i => [d, map.get(i)]);
for (const d of root.leaves()) for (const o of d.outgoing) o[1].incoming.push(o);
return root;
}
function hierarchy(data, delimiter = ".") {
let root;
const map = new Map;
data.forEach(function find(data) {
const {name} = data;
if (map.has(name)) return map.get(name);
const i = name.lastIndexOf(delimiter);
map.set(name, data);
if (i >= 0) {
find({name: name.substring(0, i), children: []}).children.push(data);
data.name = name.substring(i + 1);
} else {
root = data;
}
return data;
});
return root;
}
}
.node {
font: 300 11px "Helvetica Neue", Helvetica, Arial, sans-serif;
fill: #fff;
}
.arcLabel {
font: 300 14px "Helvetica Neue", Helvetica, Arial, sans-serif;
fill: #fff;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.0.0/d3.min.js"></script>

Categories

Resources