I'm trying to build a force layout that will allow me to visualize the flow of objects in a system.
I want to show how many objects are on a specific state and when a state change I want to update my graph.
I've built a prototype, but I've noticed that D3.js is recalculating transform of each node even when they don't need to move:
can this be fixed? maybe there is an option to add minimum value for an update?
I've declared force layout this way:
const force = d3.forceSimulation()
.force('link', d3.forceLink().id((d) => d.id).distance(150))
.force('charge', d3.forceManyBody().strength(-500))
.force('x', d3.forceX(width / 2))
.force('y', d3.forceY(height / 2))
.on('tick', tick);
After changing alphaTarget to alpha recalculation stopped, but I got another bug:
I've added drag functionality and it stopped working with above changes.
Here is the version with fixed recalculation but with drag problem.
The culprit is in your restart() function:
force.alphaTarget(0.3).restart();
The way you are reheating your simulation by setting .alphaTarget(0.3) is not correct. alphaTarget is a configuration parameter which controls the way alpha decreases. Figuratively speaking, alpha—for as long as it is greater than alphaMin— is headed towards alphaTarget. The heat in the system is measured by alpha which can be thought of as dynamic data; alphaTarget, on the other hand, resembles more or less static data.
Furthermore, it is important to have alphaTarget set to a value less than alphaMin or else your simulation is going to run indefinitely because alpha, while on its way towards alphaTarget so to speak, is never going to be less than alphaMin.
Thus, if you want to reheat your system, you have to manipulate alpha instead of alphaTarget. Changing above mentioned line to the following is all it takes to get the desired effect.
force.alpha(0.3).restart();
Have a look at the following snippet, which is basically a fork of your JSFiddle, to see it in action.
document.getElementById("a").addEventListener("click", function() {
AddNewLink(null, 1);
});
document.getElementById("b").addEventListener("click", function() {
AddNewLink(1, 2);
});
document.getElementById("c").addEventListener("click", function() {
AddNewLink(2, 3);
});
document.getElementById("d").addEventListener("click", function() {
AddNewLink(1, 3);
});
document.getElementById("e").addEventListener("click", function() {
AddNewLink(3, 4);
});
document.getElementById("f").addEventListener("click", function() {
AddNewLink(4, 5);
});
function AddNewLink(from, to) {
var startNode;
var start = availableNodes.find(x => x.id === from);
if (start !== undefined) {
//check if this node is already added
var foundStart = nodes.find(x => x.id == start.id);
if (foundStart === undefined) {
nodes.push(start);
startNode = start;
} else {
foundStart.value--;
if (foundStart.value < 0) foundStart.value = 0;
startNode = foundStart;
}
}
var endNode;
var end = availableNodes.find(x => x.id === to);
if (end !== undefined) {
//check if this node is already added
var foundEnd = nodes.find(x => x.id == end.id);
if (foundEnd === undefined) {
nodes.push(end);
endNode = end;
end.value++;
} else {
foundEnd.value++;
endNode = foundEnd;
}
}
//console.log(startNode, endNode);
if (startNode !== undefined && endNode !== undefined) {
links.push({
source: startNode,
target: endNode
});
}
restart();
}
// set up SVG for D3
const width = 400;
const height = 400;
const colors = d3.scaleOrdinal(d3.schemeCategory10);
const svg = d3.select('svg')
.on('contextmenu', () => {
d3.event.preventDefault();
})
.attr('width', width)
.attr('height', height);
var availableNodes = [{
id: 1,
name: "Start",
value: 0,
reflexive: false
}, {
id: 2,
name: "Node 1",
value: 0,
reflexive: false
}, {
id: 3,
name: "Node 2",
value: 0,
reflexive: false
}, {
id: 4,
name: "Node 3",
value: 0,
reflexive: false
}, {
id: 5,
name: "Finish",
value: 0,
reflexive: false
}];
// set up initial nodes and links
// - nodes are known by 'id', not by index in array.
// - reflexive edges are indicated on the node (as a bold black circle).
// - links are always source < target; edge directions are set by 'left' and 'right'.
let nodes = [
availableNodes[0], availableNodes[1], availableNodes[2]
];
let links = [{
source: nodes[0],
target: nodes[1]
},
{
source: nodes[1],
target: nodes[2]
}
];
// init D3 force layout
const force = d3.forceSimulation()
.force('link', d3.forceLink().id((d) => d.id).distance(150))
.force('charge', d3.forceManyBody().strength(-500))
.force('x', d3.forceX(width / 2))
.force('y', d3.forceY(height / 2))
.on('tick', tick);
// define arrow markers for graph links
svg.append('svg:defs').append('svg:marker')
.attr('id', 'end-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('markerWidth', 3)
.attr('markerHeight', 3)
.attr('orient', 'auto')
.append('svg:path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', '#000');
// handles to link and node element groups
let path = svg.append('svg:g').attr('id', 'lines').selectAll('path');
let circle = svg.append('svg:g').attr('id', 'circles').selectAll('g');
// update force layout (called automatically each iteration)
function tick() {
// draw directed edges with proper padding from node centers
path.attr('d', (d) => {
const deltaX = d.target.x - d.source.x;
const deltaY = d.target.y - d.source.y;
const dist = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
const normX = deltaX / dist;
const normY = deltaY / dist;
const sourcePadding = d.left ? 17 : 12;
const targetPadding = d.right ? 17 : 12;
const sourceX = d.source.x + (sourcePadding * normX);
const sourceY = d.source.y + (sourcePadding * normY);
const targetX = d.target.x - (targetPadding * normX);
const targetY = d.target.y - (targetPadding * normY);
return `M${sourceX},${sourceY}L${targetX},${targetY}`;
});
circle.attr('transform', (d) => `translate(${d.x},${d.y})`);
}
// update graph (called when needed)
function restart() {
// path (link) group
path = path.data(links);
// remove old links
path.exit().remove();
// add new links
path = path.enter().append('svg:path')
.attr('class', 'link')
.style('marker-end', 'url(#end-arrow)')
.merge(path);
// circle (node) group
// NB: the function arg is crucial here! nodes are known by id, not by index!
circle = circle.data(nodes, (d) => d.id);
// update existing nodes (reflexive & selected visual states)
circle.selectAll('circle')
.style('fill', (d) => colors(d.id))
.classed('reflexive', (d) => d.reflexive);
circle.selectAll('text.value').text((d) => d.value);
// remove old nodes
circle.exit().remove();
// add new nodes
const g = circle.enter().append('svg:g');
g.append('svg:circle')
.attr('class', 'node')
.attr('r', 12)
.style('fill', (d) => colors(d.id))
.style('stroke', (d) => d3.rgb(colors(d.id)).darker().toString())
.classed('reflexive', (d) => d.reflexive)
// show node IDs
g.append('svg:text')
.attr('x', 0)
.attr('y', 4)
.attr('class', 'value')
.text((d) => d.value);
g.append('svg:text')
.attr('x', 20)
.attr('y', 4)
.attr('class', 'name')
.text((d) => d.name);
circle = g.merge(circle);
// set the graph in motion
force
.nodes(nodes)
.force('link').links(links);
force.alpha(0.3).restart();
}
restart();
svg {
background-color: #FFF;
cursor: default;
user-select: none;
}
path.link {
fill: none;
stroke: #000;
stroke-width: 3px;
cursor: default;
}
path.link.selected {
stroke-dasharray: 10, 2;
}
path.link.dragline {
pointer-events: none;
}
path.link.hidden {
stroke-width: 0;
}
circle.node.reflexive {
stroke: #000 !important;
stroke-width: 2.5px;
}
text {
font: 12px sans-serif;
pointer-events: none;
}
text.value {
text-anchor: middle;
font-weight: bold;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.9.1/d3.js"></script>
<button id='a'>1</button>
<button id='b'>1>2</button>
<button id='c'>2>3</button>
<button id='d'>1>3</button>
<button id='e'>3>4</button>
<button id='f'>4>5</button>
<svg width="400" height="400"></svg>
Related
For d3, given an array of nested objects.
Is it possible to only render a certain amount of depth?
I am basing off of some sunburst examples online like :
https://github.com/Nikhilkoneru/react-d3-zoomable-sunburst/blob/master/src/index.js
Using the .selectAll method from d3, can I only limit the sunburst to render 'X' depths, instead of rendering the entire array of nested objects?
I'm trying to render a really large array, and it causes a really laggy experience.
This is the selectAll that I'm using from the github example.
svg.selectAll('path')
.data(partition(root)
.descendants())
.enter()
.append('path')
.style('fill', (d) => {
let hue;
const current = d;
if (current.depth === 0) {
return '#33cccc';
}
if (current.depth <= 1) {
hue = hueDXScale(d.x0);
current.fill = d3.hsl(hue, 0.5, 0.6);
return current.fill;
}
if(current.depth <= 2){
console.log("depth > 2: ", d)
current.fill = current.parent.fill.brighter(0.5);
const hsl = d3.hsl(current.fill);
hue = hueDXScale(current.x0);
const colorshift = hsl.h + (hue / 4);
return d3.hsl(colorshift, hsl.s, hsl.l);
}
})
.attr('stroke', '#fff')
.attr('stroke-width', '1')
.on('click', d => click(d, node, svg, x, y, radius, arc))
.on('mouseover', function (d) {
if (props.tooltip) {
d3.select(this).style('cursor', 'pointer');
tooltip.html(() => {
const name = utils.formatNameTooltip(d);
return name;
});
return tooltip.transition().duration(50).style('opacity', 1);
}
return null;
})
.on('mousemove', () => {
if (props.tooltip) {
tooltip
.style('top', `${d3.event.pageY - 50}px`)
.style('left', `${props.tooltipPosition === 'right' ? d3.event.pageX - 100 : d3.event.pageX - 50}px`);
}
return null;
})
.on('mouseout', function () {
if (props.tooltip) {
d3.select(this).style('cursor', 'default');
tooltip.transition().duration(50).style('opacity', 0);
}
return null;
});
As you can see, from the current.depth, i'm able to filter out what depth is more than 2.
Is it possible to add display: none to anything more than depth of 2, or is there a better way to not render anything more than depth of 2 from current
when using the force layout in d3.js it is possible to push nodes away with the help of the collision force by increasing an imaginary radius that surrounds the nodes.
I created a seperate button named button and i want to use .data() (to select a whole array) to increase the collide radius into 40 of many nodes when click on that button. For example when a filtered number of nodes is stored in an array called abc, i tried this code:
var node =......
.on("click", function(d, i) {
abc = start && start.path(d) || [];
node.style("fill", function(n) {
if (n == start ) {
return "yellow";
} else if ( n == d) {
return "green"
} else if (abc.includes(n)) {
return "red"
} else {
return "lightgrey"
}
.....
}});
button.on("click", function(d) {
d3.selectAll("circle").data().forEach(d => d.r = 6);
d3.select(abc).data().r = 40;
simulation.nodes(data);
simulation.alpha(0.8).restart();
})
I am able to click on 2 nodes and store these 2 nodes and all the nodes between them in the arrayabc. This is possible with the help of the d3.js function path()which returns the shortest way between 2 nodes.
But unfortunally it does not work. Maybe there is someone who can help me with the problem. The basic idea of pushing nodes away is already discussed in here: Using the force-layout physics for seperated elements
Thanks so much!
After several comments I finally have an idea of how you are filtering the node selection.
In the following demo, the circles have 4 different colours:
var colours = ["blue", "red", "green", "yellow"];
node.attr("fill", (d, i) => colours[i%4]);
So, when you click the button, we simply filter the nodes with "red" colour and increase their r property, making the collide radius increase, using each:
node.filter(function() {
return d3.select(this).attr("fill") === "red"
}).each(d => d.r = 40);
If you want use data as a getter, you can do it with a forEach:
node.filter(function() {
return d3.select(this).attr("fill") === "red"
}).data().forEach(d => d.r = 40);
Which has the same result.
Here is a demo, all red nodes will push away the other nodes after the click, with a collide radius of 40:
var svg = d3.select("svg");
var colours = ["blue", "red", "green", "yellow"];
var data = d3.range(30).map(d => ({
r: 6
}));
var simulation = d3.forceSimulation(data)
.force("x", d3.forceX(150).strength(0.05))
.force("y", d3.forceY(75).strength(0.05))
.force("collide", d3.forceCollide(function(d) {
return d.r + 1;
}));
var node = svg.selectAll(".circles")
.data(data)
.enter()
.append("circle")
.attr("r", d => d.r)
.attr("fill", (d, i) => colours[i%4]);
d3.select("button").on("click", function(d) {
node.filter(function(){
return d3.select(this).attr("fill") === "red"
}).each(d=>d.r = 40);
simulation.nodes(data);
simulation.alpha(0.8).restart();
})
simulation.nodes(data)
.on("tick", d => {
node.attr("cx", d => d.x).attr("cy", d => d.y);
});
<script src="https://d3js.org/d3.v4.min.js"></script>
<button>Click me</button>
<br>
<svg></svg>
I am building a "spring" using the d3-force layout. I want to manipulate it's properties like "strength" and "distance" via user input. For that I am currently using an "input range slider". For better understanding I set up a working draft on codepen where this question is related to: http://codepen.io/bitHugger/pen/XNqGNE?editors=1010
The HTML:
<input id="strengthElem" step="0.1" type="range" min="0" max="2"/>
I wanted to do the event handling something along like this:
let strengthElem = window.document.getElementById('strengthElem');
let strength;
strengthElem.addEventListener('click', function(evt) {
strength = strengthElem.value;
console.log('strength', strength);
}, false);
Now I would like to restart or recalculate the d3.simulation object when some interaction happens with the range slider. This is my current simulation:
let simulation = d3.forceSimulation().nodes(nodes)
.force("link", d3.forceLink()
.id(function(d) { return d.index; })
.strength(function(d) { return 2; })
.distance(function(d) { return 2; }))
.force("charge", d3.forceManyBody());
For the strength and the distance the values are currently hard coded.I would like to change it to e.g.:
.strength(function(d) { return strength; })
.distance(function(d) { return distance; })
I tried to setup a d3.call().on() function but could not get it working. I wonder how I can manipulate the simulation based on unser input, that happens outside of the force() function / outside of the svg container.
Sadly I can't get something working and I don't know how to setup a proper d3 event listener that reacts on the input button and then recalculates the force layout based on the changed values. Any ideas?
Instead of creating a link force in-place without keeping a reference to the force, you should first create the force and just pass the reference to the simulation. That way, you are later on able to manipulate the force according to your sliders' values:
// Create as before, but keep a reference for later manipulations.
let linkForce = d3.forceLink()
.id(function(d) { return d.index; })
.strength(2)
.distance(2);
let simulation = d3.forceSimulation().nodes(nodes)
.force("link", linkForce)
.force("charge", d3.forceManyBody());
When registering the event handlers on the sliders you may also want to use d3.select() for ease of use, and assign the functions using selection.on().
d3.select('#strengthElem')
.on('click', function() {
// Set the slider's value. This will re-initialize the force's strenghts.
linkForce.strength(this.value);
simulation.alpha(0.5).restart(); // Re-heat the simulation
}, false);
d3.select('#distanceElem')
.on('click', function(evt) {
// Set the slider's value. This will re-initialize the force's strenghts
linkForce.distance(this.value);
simulation.alpha(0.5).restart(); // Re-heat the simulation
}, false);
Within the handler functions this points to the actual DOM element, whereby allowing to easily access the slider's value. The link force's parameters may now be updated using the previously kept reference. All that is left to do, is to re-heat the simulation to continue its calculations.
Have a look at this snippet for a working demo:
'use strict';
var route = [[30, 30],[192, 172],[194, 170],[197, 167],[199, 164],[199, 161],[199, 157],[199, 154],[199, 150],[199, 147],[199, 143],[199, 140],[200, 137],[202, 134],[204, 132],[207, 129],[207, 126],[200, 200]];
let distance = 1;
let createNode = function(id, coords) {
return {
radius: 4,
x: coords[0],
y: coords[1],
};
};
let getNodes = (route) => {
let d = [];
let i = 0;
route.forEach(function(coord) {
if(i === 0 || i === route.length-1) {
d.push(createNode(i, coord));
d[i].fx = coord[0];
d[i].fy = coord[1];
}
else {
d.push(createNode(i, coord));
}
++i;
});
return d;
};
let getLinks = (nodes) => {
let next = 1;
let prev = 0;
let obj = [];
while(next < nodes.length) {
obj.push({source: prev, target: next, value: 1});
prev = next;
++next;
}
return obj;
};
let force = function(route) {
let width = 900;
let height = 700;
let nodes = getNodes(route);
let links = getLinks(nodes);
d3.select('#strengthElem')
.on('click', function() {
linkForce.strength(this.value); // Set the slider's value. This will re-initialize the force's strenghts
simulation.alpha(0.5).restart(); // Re-heat the simulation
}, false);
d3.select('#distanceElem')
.on('click', function(evt) {
linkForce.distance(this.value); // Set the slider's value. This will re-initialize the force's strenghts
simulation.alpha(0.5).restart(); // Re-heat the simulation
}, false);
let linkForce = d3.forceLink()
.id(function(d) { return d.index; })
.strength(2)
.distance(2);
let simulation = d3.forceSimulation().nodes(nodes)
.force("link", linkForce)
.force("charge", d3.forceManyBody());
let svg = d3.select('svg').append('svg')
.attr('width', width)
.attr('height', height);
let link = svg.append("g")
.attr('class', 'link')
.selectAll('.link')
.data(links)
.enter().append('line')
.attr("stroke-width", 1);
let node = svg.append("g")
.attr("class", "nodes")
.selectAll("circle")
.data(nodes)
.enter().append("circle")
.attr("r", function(d) { return d.radius; })
.attr("fill", function(d) { return '#fabfab'; });
simulation.nodes(nodes).on("tick", ticked);
simulation.force("link").links(links);
function ticked() {
link
.attr("x1", function(d) { return d.source.x; })
.attr("y1", function(d) { return d.source.y; })
.attr("x2", function(d) { return d.target.x; })
.attr("y2", function(d) { return d.target.y; });
node
.attr("cx", function(d) { return d.x; })
.attr("cy", function(d) { return d.y; });
}
};
force(route);
.link {
stroke: #777;
stroke-width: 2px;
}
.links line {
stroke: #999;
stroke-opacity: 0.6;
}
.nodes circle {
stroke: #fff;
stroke-width: 1.5px;
}
<script src="https://d3js.org/d3.v4.js"></script>
<div>Strength <input id="strengthElem" step="0.1" type="range" min="0" max="2"/></div>
<div>Distance <input id="distanceElem" step="1" type="range" min="0" max="50"/></div>
<svg style="width: 900; height: 700;"></svg>
I have also updated the codepen accordingly.
One way to do is is removing the contents of the svg and re-drawing it with your desired constants.
I didn't understand where you got stuck because I only changed few lines like you said in your question.
Inside your click handlers I cleared the contents of the svg and called the "draw" function:
strengthElem.addEventListener('click', function(evt) {
strength = strengthElem.value;
console.log('strength', strength);
d3.select('svg').selectAll("*").remove();
force(route);
}, false);
Moved your config variables to a global scope:
var distance = 1;
let distElem = window.document.getElementById('distanceElem');
let strengthElem = window.document.getElementById('strengthElem');
var strength = strengthElem.value;
distance = distElem.value;
And just like you've said I've changed to return parameters:
.strength(function(d) { return strength; })
.distance(function(d) { return distance; }))
Full example: http://codepen.io/anon/pen/ObZYLo?editors=1010
I made a simple animated pie/donut chart with d3 and I was wondering if it would be possible to animate the radius and the grow at the same time.
As you can see from the example or the snippet below, only the grow is animated.
const dataset = {
apples: [{
label: 'Category A',
value: 53245,
isSelected: true
}, {
label: 'Category B',
value: 28479,
isSelected: false
}, {
label: 'Category C',
value: 24037,
isSelected: false
}, {
label: 'Category D',
value: 40245,
isSelected: false
}, {
label: 'Category E',
value: 30245,
isSelected: false
}],
oranges: [{
label: 'Category A',
value: 200,
isSelected: false
}, {
label: 'Category B',
value: 200,
isSelected: true
}, {
label: 'Category C',
value: 200,
isSelected: false
}, {
label: 'Category D',
value: 200,
isSelected: false
}]
};
/**
* Pie chart class
*/
function PieChart(options) {
// Observable stream source
this.selectionSource = new Rx.Subject();
// Observable stream
this.selection = this.selectionSource.asObservable();
// Chart options/settings
this.width = options.width;
this.height = options.height;
this.radius = Math.min(this.width, this.height) / 2;
this.multiple = options.multiple;
this.legend = options.legend;
this.colorRange = d3.scale.category20();
this.color = d3.scale.ordinal()
.range(this.colorRange.range());
// Animation directions
this.clockwise = {
startAngle: 0,
endAngle: 0
};
this.counterclock = {
startAngle: Math.PI * 2,
endAngle: Math.PI * 2
};
// Create the SVG on which the plot is painted.
this.svg = d3.select(options.target)
.append('svg:svg')
.attr('width', this.width)
.attr('height', this.height)
.append('g')
.attr('transform', `translate(${this.width / 2}, ${this.height / 2})`);
// Initial path creation.
this.path = this.svg.selectAll('path');
// Create the pie layout.
this.pie = d3.layout.pie()
.value(function(d) {
return d.value;
})
.sort(null);
// Create arc functions.
this.arc = d3.svg.arc()
.innerRadius(this.radius - 100)
.outerRadius(this.radius - 20);
// Arc when a slice is selected/toggled on.
this.arcSelected = d3.svg.arc()
.innerRadius(this.radius - 90)
.outerRadius(this.radius - 10);
this.arcTween = arcTween;
this.arcTweenOut = arcTweenOut;
this.updateSelection = updateSelection;
// Used by some of the functions that get a different context when called by d3.
const thisRef = this;
// Store the displayed angles in `current`.
// Then, interpolate from `current` to the new angles.
// During the transition, `current` is updated in-place by d3.interpolate.
function arcTween(a) {
const i = d3.interpolate(this.current, a);
this.current = i(0);
const slice = d3.select(this);
return arcFn(slice, i);
}
function arcTweenOut() {
const i = d3.interpolate(this.current, {
startAngle: Math.PI * 2,
endAngle: Math.PI * 2,
value: 0
});
this.current = i(0);
const slice = d3.select(this);
return arcFn(slice, i);
}
function arcFn(slice, i) {
return function(t) {
if (slice.classed('selected')) {
return thisRef.arcSelected(i(t));
}
return thisRef.arc(i(t));
};
}
// NOTE: `this` will not be the class context,
// but the contaxt set
function updateSelection(d) {
const node = this;
const slice = d3.select(node);
const isToggled = slice.classed('selected');
const event = {
data: d.data
};
if (thisRef.multiple) {
// Allow multiple slice toggling.
toggle();
} else {
// Find previously selected slice.
const selected = thisRef.svg.selectAll('path')
.filter(function() {
return !this.isEqualNode(node) && d3.select(this).classed('selected');
});
// Deselect previous selection.
if (!selected.empty()) {
selected.classed('selected', false)
.transition()
.attr('d', thisRef.arc);
}
// Toggle current slice.
toggle();
}
function toggle() {
if (isToggled) {
event.selected = false;
slice.classed('selected', false)
.transition()
.attr('d', thisRef.arc)
.each('end', emit);
} else {
event.selected = true;
slice.classed('selected', true)
.transition()
.attr('d', thisRef.arcSelected)
.each('end', emit);
}
}
function emit() {
thisRef.selectionSource.onNext(event);
}
}
}
PieChart.prototype.direction = function direction() {
// Set the start and end angles to Math.PI * 2 so we can transition counterclockwise to the actual values later.
let direction = this.counterclock;
// Set the start and end angles to 0 so we can transition clockwise to the actual values later.
if (!this.painted) {
direction = this.clockwise;
}
return direction;
}
PieChart.prototype.update = function update(data) {
const direction = this.direction();
const thisRef = this;
this.path = this.path
.data(this.pie(data), function(d) {
return d.data.label;
})
.classed('selected', selected.bind(this));
function selected(datum) {
return datum.data.isSelected;
}
// Append slices when data is added.
this.path.enter()
.append('svg:path')
.attr('class', 'slice')
.style('stroke', '#f3f5f6')
.attr('stroke-width', 2)
.attr('fill', function(d, i) {
return thisRef.color(d.data.label);
})
.attr('d', this.arc(direction))
// Store the initial values.
.each(function(d) {
this.current = {
data: d.data,
value: d.value,
startAngle: direction.startAngle,
endAngle: direction.endAngle
};
})
.on('click', this.updateSelection);
// Remove slices when data is removed.
this.path.exit()
.transition()
.duration(450)
.attrTween('d', this.arcTweenOut)
// Now remove the exiting arcs.
.remove();
// Redraw the arcs.
this.path.transition()
.duration(450)
.attrTween('d', this.arcTween);
// Add legend
this.addLegend();
// Everything is painted now,
// we only do updates from this point on.
if (!this.painted) {
this.painted = true;
}
}
PieChart.prototype.addLegend = function addLegend() {
// The legend does not need to be repainted when we update the slices.
if (this.painted || !this.legend) {
return;
}
const thisRef = this;
const rect = this.radius * 0.04;
const spacing = this.radius * 0.02;
const legend = this.svg.selectAll('.legend')
.data(this.color.domain());
legend.enter()
.append('g')
.attr('class', 'legend')
.attr('fill-opacity', 0)
.attr('transform', function(d, i) {
const height = rect + spacing * 2;
const offset = height * thisRef.color.domain().length / 2;
const horizontal = -4 * rect;
const vertical = i * height - offset;
return `translate(${horizontal}, ${vertical})`;
});
legend.append('rect')
.attr('width', rect)
.attr('height', rect)
.style('fill', this.color);
legend.append('text')
.attr('x', rect + spacing)
.attr('y', rect)
.text(function(d) {
return d;
});
legend.transition()
.duration(450)
.attr('fill-opacity', 1);
};
// DEMO/USAGE
const pieChart = new PieChart({
target: '#chart',
multiple: true,
legend: true,
width: 400,
height: 400
});
console.log(pieChart);
pieChart.selection.subscribe(function(selection) {
console.log(selection);
});
// Paint the plot.
pieChart.update(dataset.apples);
// This is only here for demo purposes
d3.selectAll("input")
.on("change", update);
var timeout = setTimeout(function() {
d3.select("input[value=\"oranges\"]").property("checked", true).each(update);
}, 2000);
function update() {
clearTimeout(timeout); // This is only here for demo purposes
// Update the data.
pieChart.update(dataset[this.value]);
}
body {
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
.container {
position: relative;
}
form {
position: absolute;
right: 10px;
top: 10px;
}
// Graph
.slice {
cursor: pointer;
}
.legend {
font-size: 12px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/4.1.0/rx.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>
<div class="container">
<form>
<label>
<input type="radio" name="dataset" value="apples" checked> Apples</label>
<label>
<input type="radio" name="dataset" value="oranges"> Oranges</label>
</form>
<div id="chart"></div>
</div>
Here is a jsfiddle example showing how you can achieve that: https://jsfiddle.net/kmandov/9jrb1qLr/
I've used Mike Bostock's pie chart example as a base, but you can adapt the code to your PieChart implementation.
The basic idea is that as soon as you switch category(oranges/apples), the pie arcs are recalculated to match the new data. The animation is done via a transition in the change function:
function change() {
// ... calculate arcs
path.transition().duration(750).attrTween("d", arcTween(selected));
}
then the real magic is happening in the arcTween function. In the original example only the start and end angles are updated. You can store the target outerRadius and then update the arc generator on each step of the transition:
function arcTween(selected) {
return function(target, i) {
target.outerRadius = radius - (i === selected ? 0 : 20);
var arcInterpolator = d3.interpolate(this._current, target);
this._current = arcInterpolator(0);
return function(t) {
var interpolatedArc = arcInterpolator(t);
arc.outerRadius(interpolatedArc.outerRadius);
return arc(interpolatedArc);
};
}
}
We are trying to create a component/module/etc. that takes the value of a custom data attribute and creates a D3 based pie chart displaying a percentage based on that data-attribute.
Examples of the div elements with custom data-attributes:
////// HTML
<div class="donut" data-donut="22"></div>
<div class="donut" data-donut="48"></div>
<div class="donut" data-donut="75></div>
Here is the CSS for it:
////// CSS
body {
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
margin: auto;
width: 960px;
padding-top: 20px;
background-color: #012647;
}
text {
font-family: "RamaGothicM-Heavy",Impact,Haettenschweiler,"Franklin Gothic Bold",Charcoal,"Helvetica Inserat","Bitstream Vera Sans Bold","Arial Black",sans-serif;
font-size: 7rem;
font-weight: 400;
line-height: 16rem;
fill: #1072b8;
}
.donut {
width: 29rem;
height: 29rem;
margin: 0 auto;
}
path.color0 {
fill: #1072b8;
}
path.color1 {
fill: #35526b;
}
The D3.js / jQuery example we're trying to convert to a reusable compunent is below. (To see full working example go to this link - http://codepen.io/anon/pen/JgyCz)
////// D3.js
var duration = 500,
transition = 200;
drawDonutChart(
'.donut',
$('.donut').data('donut'),
290,
290,
".35em"
);
function drawDonutChart(element, percent, width, height, text_y) {
width = typeof width !== 'undefined' ? width : 290;
height = typeof height !== 'undefined' ? height : 290;
text_y = typeof text_y !== 'undefined' ? text_y : "-.10em";
var dataset = {
lower: calcPercent(0),
upper: calcPercent(percent)
},
radius = Math.min(width, height) / 2,
pie = d3.layout.pie().sort(null),
format = d3.format(".0%");
var arc = d3.svg.arc()
.innerRadius(radius - 20)
.outerRadius(radius);
var svg = d3.select(element).append("svg")
.attr("width", width)
.attr("height", height)
.append("g")
.attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");
var path = svg.selectAll("path")
.data(pie(dataset.lower))
.enter().append("path")
.attr("class", function(d, i) { return "color" + i })
.attr("d", arc)
.each(function(d) { this._current = d; }); // store the initial values
var text = svg.append("text")
.attr("text-anchor", "middle")
.attr("dy", text_y);
if (typeof(percent) === "string") {
text.text(percent);
}
else {
var progress = 0;
var timeout = setTimeout(function () {
clearTimeout(timeout);
path = path.data(pie(dataset.upper)); // update the data
path.transition().duration(duration).attrTween("d", function (a) {
// Store the displayed angles in _current.
// Then, interpolate from _current to the new angles.
// During the transition, _current is updated in-place by d3.interpolate.
var i = d3.interpolate(this._current, a);
var i2 = d3.interpolate(progress, percent)
this._current = i(0);
return function(t) {
text.text( format(i2(t) / 100) );
return arc(i(t));
};
}); // redraw the arcs
}, 200);
}
};
function calcPercent(percent) {
return [percent, 100-percent];
};
I experimented on using Backbone.js with my charts(I used NVD3.js).
Here is a question on SO regarding this
D3 + Backbone by #jtuulos (I personally found this link very useful to understand the whole concept of extending a parent class and instantiating )
Responsive Charts with D3 and Backbone
d3.js & backbone.js
I was able to create a parent chart class for the common chart types I use and then simply create an instance of that chart so I could re-use it when ever I want. Makes life easier when trying to maintain the charts.
May not be the best way of doing it, but I find this way very usefull.