I've spent several days trying to create a real-time timeline where data is appended asynchronously. It seems impossible to get things working smoothly without breaking something else each time.
I had a look at several examples but none of them seem to match my case. Namely, most of the real-time examples either rely on the data to increment the timeline by a step each time, or either they all assume that the data comes continuously in regular intervals.
Issues I'm having:
Points slide nicely. However if I switch a tab and switch back they continue from where they left, and thus not matching the current ticks in the x axis
The ticks in the timeline every now and then get some weird transition that looks like shuffling. After the shuffling the actual points are out of sync with the timeline.
Here's a fiddle
<!doctype html><html lang="en">
<head><script src="https://d3js.org/d3.v5.min.js"></script></head>
<body><script>
const LENGTH = 10 // in seconds
const TICKS = 10 // num of ticks in time axis
const HEIGHT = 240
const WIDTH = 950
const MARGIN_LEFT = 40
const MARGIN_TOP = 40
var datapoints = []
// Create root element + background rect
svg = d3.select('body').append('svg')
.attr('width', WIDTH)
.attr('height', HEIGHT)
svg.append('rect')
.attr('width', '100%')
.attr('height', '100%')
.attr('fill', 'rgba(59, 58, 52, 0.8)')
$graphs = svg.append('g')
$slidables = $graphs.append('g')
// We use two scalers for x. This solves the issue with the axis being out
// of sync
scaleX = d3.scaleTime().range([MARGIN_LEFT, WIDTH-MARGIN_LEFT])
scaleY = d3.scaleLinear().range([MARGIN_TOP, HEIGHT-MARGIN_TOP])
updateTimeScale()
// -------------------------------------------------------------------------
function logDate(date){
console.log(date.toTimeString().split(' GMT')[0] + ' ' + date.getMilliseconds() + 'ms')
}
function logPoint(point){
const date = point[0]
console.log(
date.toTimeString().split(' GMT')[0] + ' ' + date.getMilliseconds() + 'ms, ',
point[1]
)
}
function oneSecondAgo(){
d = new Date()
d.setSeconds(d.getSeconds() - 1)
return d
}
function leftDate(){
d = new Date()
d.setSeconds(d.getSeconds() - LENGTH)
return d
}
function tickDist(){
return scaleX(new Date()) - scaleX(oneSecondAgo())
}
// -------------------------------- Init -----------------------------------
/* Resets timescale to the current time */
function updateTimeScale(){
right = new Date()
left = new Date()
right.setSeconds(right.getSeconds())
left.setSeconds(right.getSeconds()-LENGTH)
scaleX.domain([left, right])
}
function init(){
// Setup axis
xaxis = d3.axisBottom(scaleX).ticks(TICKS)
$xaxis = svg.append('g')
.attr('transform', 'translate(0, ' + (HEIGHT - MARGIN_TOP) + ')')
.call(xaxis)
yaxis = d3.axisLeft(scaleY)
$yaxis = svg.append('g')
.attr('transform', 'translate(' + MARGIN_LEFT + ', 0)')
.call(yaxis)
// Garbage collect old points every second
setInterval(function(){
while (datapoints.length > 0 && scaleX(datapoints[0][0]) <= MARGIN_LEFT){
datapoints.shift()
}
$slidables.selectAll('circle')
.data(datapoints, d=>d)
.exit()
.remove()
}, 1000)
// Slide axis at interval
function tick(){
right = new Date()
left = new Date()
right.setSeconds(right.getSeconds()+1)
left.setSeconds(right.getSeconds()-LENGTH)
scaleX.domain([left, right])
$xaxis.transition()
.ease(d3.easeLinear)
.duration(new Date() - oneSecondAgo())
.call(xaxis)
}
tick()
setInterval(tick, 1000)
}
// ------------------------------ Update -----------------------------------
/* Update graph with points
We always set right edge to current time
*/
function update(points){
datapoints = datapoints.concat(points)
logPoint(points[0])
updateTimeScale()
// Add new points, transition until left edge and then remove
$slidablesEnter = $slidables.selectAll('circle')
.data(datapoints, d=>d)
.enter()
$slidablesEnter
.append("circle")
.style("fill", "rgb(74, 255, 0)")
.attr("r", 2)
.attr("cx", p=>scaleX(p[0])) // put it at right
.attr("cy", p=>scaleY(p[1]))
.transition()
.duration(function(p){
remainingTime = p[0] - leftDate()
return remainingTime
})
.ease(d3.easeLinear)
.attr("cx", p => MARGIN_LEFT)
.remove()
}
// Start everything with two random datapoints
init()
d1 = new Date()
d2 = new Date()
d2.setMilliseconds(d2.getMilliseconds()-1500)
update([[d1, Math.random()]])
update([[d2, Math.random()]])
</script></body></html>
Just some updates. My workaround is to repaint everything on a tiny interval (i.e. 20ms).
This solves all the syncing issues but not sure if there will be a difference on performance.
Something like this
function tick(){
// Redraw all elements
scaleX.domain([now(-TIMELINE_LENGTH_MS), now()])
$slidables.selectAll('circle')
.data(datapoints, d=>d)
.attr("cx", p => scaleX(p[0]))
.transition()
.duration(0)
}
setInterval(tick, REFRESH_INTERVAL)
Setting REFRESH_INTERVAL to 20ms, looks pretty much the same as having a transition. Anything above starts looking chopppy, but at least is more accurate than before.
Related
I'm using trying to create a multi-level donut chart in d3 version5
This image is drawn by d3 version3. it is working fine in version3. I decided to upgrade d3 to the latest version. now, donut chart is not drawn by d3(also no errors in the console)
D3 version 3 > version 5
Here is the sample dataset I used:
Hint: first value in the array is used storage and second is free storage
{
average: [30.012, 69.988],
minimum: [10, 90],
maximum: [40, 60]
}
Note: Above data is just a sample this is not exact data.
Here is the code I tried:
var width = 300;
var height = 300;
var radius = Math.floor((width / 6) - 2);
var classFn = function(a, b) {
return a === 0 ? classes[b] : 'default';
};
var pie = d3.layout.pie().sort(null);
var arc = d3.svg.arc();
var svg = d3.select(selector).append("svg");
svg.attr("width", width);
svg.attr("height", height);
svg = svg.append("g");
svg.attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");
var gs = svg.selectAll("g").data(d3.values(dataset)).enter().append("g");
var path = gs.selectAll("path");
path = path.data(function(d) {
return pie(d);
});
path.enter().append("path");
path.attr("class", function(d, i, j) {
return classFn(i, j);
})
path.attr("d", function(d, i, j) {
return arc.innerRadius((j === 0 ? 0 : 2) + radius * j).outerRadius(radius * (j + 1))(d);
});
Note: This code is working fine in d3 version3.
2. Update:
I've updated the answer with a better solution. I didn't do this at first, because I didn't grasp you structure. I've updated it to being more D3 idiomatic. Plus it does away with the hack I made in my first update :)
var dataset = {
average: [0, 100],
minimum: [0, 100],
maximum: [0, 100]
}
var width = 300;
var height = 300;
var radius = Math.floor((width / 6) - 2);
var pie = d3.pie().sort(null);
var arc = d3.arc();
var svg = d3.select('body').append("svg");
svg.attr("width", width);
svg.attr("height", height);
svg = svg.append("g");
svg.attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");
var gs = svg.selectAll("g").data(d3.values(dataset)).enter().append("g");
gs.each(function (d, j) {
d3.select(this).selectAll('path')
.data(pie(d)).enter()
.append('path')
.attr("class", function(d, i) {
// return classFn(i);
})
.attr('d', function (d) {
return arc
.innerRadius((j === 0 ? 0 : 2) + radius * j)
.outerRadius(radius * (j + 1))(d);
})
})
The updated code uses the index (here j) that is available when appending the g elements, which corresponds to you original j index. This makes it possible to calculate the radii in the original way.
To achieve this, the arc appending code is wrapped into a .each function that iterates over the g elements, making j available to us.
The class application should work as well, but I've commented it out, as the classFn function doesn't work, since the classes variable is not present.
1. Update:
Besides the original answer, when calculating the arc radii you rely on a j value that is different from D3 v3 and v5. I summise that j is used the index of the d3.values array, so I've cooked up a way to reverse look-up that index based on the input values.
First create a map for reverse mapping data values into their corresponding index:
var dataValueJoinChar = '¤'
var datasetValuesToIndex = d3.values(dataset).reduce((acc, curr, i) => {
acc[`0${dataValueJoinChar}${curr[0]}`] = i
acc[`1${dataValueJoinChar}${curr[1]}`] = i
return acc
}, {})
Then change the last part of your code to:
path = path.data(function(d) {
return pie(d);
}).enter().append("path");
path.attr("class", function(d, i, j) {
return classFn(i, j);
})
path.attr("d", function(d, i, j) {
var orgIndex = datasetValuesToIndex[`${i}${dataValueJoinChar}${d.data}`]
return arc
.innerRadius((orgIndex === 0 ? 0 : 2) + radius * orgIndex)
.outerRadius(radius * (orgIndex + 1))(d);
});
It might not be too pretty, but it's a simple adaption of your code that works.
------- Original answer --------
In D3 v5 pie and arc are found at d3.pie and d3.arc respectively. Therefore, try changing:
var pie = d3.layout.pie().sort(null);
var arc = d3.svg.arc();
To this instead:
var pie = d3.pie().sort(null);
var arc = d3.arc();
Pie API reference: https://github.com/d3/d3-shape/blob/v1.3.4/README.md#pie
Arc API reference: https://github.com/d3/d3-shape/blob/v1.3.4/README.md#arc
If you use a bundler to bundle sub-modules, both are part of the d3-shape module. If not they are both available in the full D3 library.
Hope this helps!
I have a D3.js line chart that I want to update on user-input. One path is for ‘total price’, made up of a fixed price + a variable cost. I also show a ’fixed price’ line (not a path).
I have a slider to change the value of the fixed cost and then update the path and line.
The line takes the new inputted slider value and updates as expected. The path, however, starts being plotted with very negative y values and so doesn’t show on the chart.
Am I missing some logic to this?
If I hard-code a new value for ‘fixedCost’ the path updates as expected, but as soon as I substitute it for document.getElementById('fixed').value - it gives me a negative plot. The same problem occurs on first draw if I use the slider value.
I've successfully updated line charts in D3 before but that's usually loading new data set on a change event. I haven't encountered this problem with paths before. I'm using D3 V4. Below is the code for the slider and for the update function. Thanks
...javascript
var slider = d3.select("#chart").append("p").attr('id', 'slider')
.style('position', 'absolute')
.style('top', height + margin.top + 60 + 'px')
.style('left', margin.left + 'px')
.append("input")
.attr("type", "range")
.attr('id', 'fixed')
.attr("value", 408000)
.attr("min", 0)
.attr("max", 1000000)
.style("width", sliderWidth)
.on("input", updateFixed);
// set starting parameters
// var fixedCost = document.getElementById('fixed').value; // doesn't behave as expected when plotting path.
var fixedCost = 408000;
var valuelineTotCost = d3.line()
.x(function(d) { return x(d.subs); })
.y(function(d) { return y(d.variCost + fixedCost); });
function updateFixed() {
var thisValue = document.getElementById('fixed').value;
d3.select('#sliderText')
.text("Fixed Costs: " + format(thisValue) ); // displays as expected
console.log(thisValue); // returns as expected
svg.select('#fixedCostLine')
.attr("x1", 0)
.attr("x2", width)
.attr("y1", y(thisValue))
.attr("y2", y(thisValue)); // this line updates as expected
// var fixedCost = document.getElementById('fixed').value; // tried this instead of using thisValue but still not behaving as expected
// var fixedCost = thisValue; // also not behaving as expected
var fixedCost = 508000; // behaves as expected
// adjust totCost path
var valuelineTotCost = d3.line()
.x(function(d) { return x(d.subs); })
.y(function(d) { return y(d.variCost + fixedCost); });
svg.select("#totalPath")
.style("stroke", "purple")
.attr("d", valuelineTotCost);
};
...
Here are the original path co-ordinates generated by d3.line followed by the negative y plots given when adjusted (even minutely) using the slider value.
Lastly, using a hard-coded value for the update, switching from the starting point of 408000 to 508000 - gave the third set of plots.
The path generator was reading the value from the input as a string.
thisValue = parseInt(thisValue);
solved the problem.
I have a tree visualisation in which I am trying to display paths between nodes that represent a distribution with multiple classes. I want to split the path lengthwise into multiple colours to represent the frequency of each distribution.
For example: say we have Class A (red) and Class B (black), that each have a frequency of 50. Then I would like a path that is half red and half black between the nodes. The idea is to represent the relative frequencies of the classes, so the frequencies would be normalised.
My current (naive) attempt is to create a separate path for each class and then use an x-offset. It looks like this.
However, as shown in the image, the lines do not maintain an equal distance for the duration of the path.
The relevant segment of code:
linkGroup.append("path").attr("class", "link")
.attr("d", diagonal)
.style("stroke", "red")
.style("stroke-width", 5)
.attr("transform", function(d) {
return "translate(" + -2.5 + "," + 0.0 + ")"; });
linkGroup.append("path").attr("class", "link")
.attr("d", diagonal)
.style("stroke", "black")
.style("stroke-width", 5)
.attr("transform", function(d) {
return "translate(" + 2.5 + "," + 0.0 + ")"; });
It would be great if anyone has some advice.
Thanks!
A possible solution is to calculate the individual paths and fill with the required color.
Using the library svg-path-properties from geoexamples.com you can calculate properties (x,y,tangent) of a path without creating it first like it is done in this SO answer (this does not calculate the tangent).
The code snippet does it for 2 colors but it can be easy generalized for more.
You specify the colors, percentage and width of the stroke with a dictionary
var duoProp = { color: ["red", "black"], percent: 0.30, width: 15 };
percent is the amount color[0] takes from the stroke width.
var duoPath = pathPoints("M30,30C160,30 150,90 250,90S350,210 250,210", 10, duoProp);
duoPath.forEach( (d, i) => {
svg.append("path")
.attr("d", d)
.attr("fill", duoProp.color[i])
.attr("stroke", "none");
});
The pathPoints parameters
path that needs to be stroked, can be generated by d3.line path example from SO answer
var lineGenerator = d3.line().x(d=>d[0]).y(d=>d[1]).curve(d3.curveNatural);
var curvePoints = [[0,0],[0,10],[20,30]];
var duoPath = pathPoints(lineGenerator(curvePoints), 10, duoProp);
path length interval at which to sample (unit pixels). Every 10 pixels gives a good approximation
dictionary with the percent and width of the stroke
It returns an array with the paths to be filled, 1 for each color.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
<script src="https://d3js.org/d3.v5.min.js"></script>
<script src="https://unpkg.com/svg-path-properties#0.4.4/build/path-properties.min.js"></script>
</head>
<body>
<svg id="chart" width="350" height="350"></svg>
<script>
var svg = d3.select("#chart");
function pathPoints(path, stepLength, duoProp) {
var props = spp.svgPathProperties(path);
var length = props.getTotalLength();
var tList = d3.range(0, length, stepLength);
tList.push(length);
var tProps = tList.map(d => props.getPropertiesAtLength(d));
var pFactor = percent => (percent - 0.5) * duoProp.width;
tProps.forEach(p => {
p.x0 = p.x - pFactor(0) * p.tangentY;
p.y0 = p.y + pFactor(0) * p.tangentX;
p.xP = p.x - pFactor(duoProp.percent) * p.tangentY;
p.yP = p.y + pFactor(duoProp.percent) * p.tangentX;
p.x1 = p.x - pFactor(1) * p.tangentY;
p.y1 = p.y + pFactor(1) * p.tangentX;
});
var format1d = d3.format(".1f");
var createPath = (forward, backward) => {
var fp = tProps.map(p => forward(p));
var bp = tProps.map(p => backward(p));
bp.reverse();
return 'M' + fp.concat(bp).map(p => `${format1d(p[0])},${format1d(p[1])}`).join(' ') + 'z';
}
return [createPath(p => [p.x0, p.y0], p => [p.xP, p.yP]), createPath(p => [p.xP, p.yP], p => [p.x1, p.y1])]
}
var duoProp = { color: ["red", "black"], percent: 0.30, width: 15 };
var duoPath = pathPoints("M30,30C160,30 150,90 250,90S350,210 250,210", 10, duoProp);
duoPath.forEach( (d, i) => {
svg.append("path")
.attr("d", d)
.attr("fill", duoProp.color[i])
.attr("stroke", "none");
});
</script>
</body>
</html>
As a quick follow-up to rioV8's excellent answer, I was able to get their code working but needed to generalise it to work with more than two colours. In case someone else has a similar requirement, here is the code:
function pathPoints(path, stepLength, duoProp) {
// get the properties of the path
var props = spp.svgPathProperties(path);
var length = props.getTotalLength();
// build a list of segments to use as approximation points
var tList = d3.range(0, length, stepLength);
tList.push(length);
var tProps = tList.map(function (d) {
return props.getPropertiesAtLength(d);
});
// incorporate the percentage
var pFactor = function pFactor(percent) {
return (percent - 0.5) * duoProp.width;
};
// for each path segment, calculate offset points
tProps.forEach(function (p) {
// create array to store modified points
p.x_arr = [];
p.y_arr = [];
// calculate offset at 0%
p.x_arr.push(p.x - pFactor(0) * p.tangentY);
p.y_arr.push(p.y + pFactor(0) * p.tangentX);
// calculate offset at each specified percent
duoProp.percents.forEach(function(perc) {
p.x_arr.push(p.x - pFactor(perc) * p.tangentY);
p.y_arr.push(p.y + pFactor(perc) * p.tangentX);
});
// calculate offset at 100%
p.x_arr.push(p.x - pFactor(1) * p.tangentY);
p.y_arr.push(p.y + pFactor(1) * p.tangentX);
});
var format1d = d3.format(".1f");
var createPath = function createPath(forward, backward) {
var fp = tProps.map(function (p) {
return forward(p);
});
var bp = tProps.map(function (p) {
return backward(p);
});
bp.reverse();
return 'M' + fp.concat(bp).map(function (p) {
return format1d(p[0]) + "," + format1d(p[1]);
}).join(' ') + 'z';
};
// create a path for each projected point
var paths = [];
for(var i=0; i <= duoProp.percents.length; i++) {
paths.push(createPath(function (p) { return [p.x_arr[i], p.y_arr[i]]; }, function (p) { return [p.x_arr[i+1], p.y_arr[i+1]]; }));
}
return paths;
}
// generate the line
var duoProp = { color: ["red", "blue", "green"], percents: [0.5, 0.7], width: 15 };
var duoPath = pathPoints("M30,30C160,30 150,90 250,90S350,210 250,210", 10, duoProp);
duoPath.forEach( (d, i) => {
svg.append("path")
.attr("d", d)
.attr("fill", duoProp.color[i])
.attr("stroke", "none");
});
Note that the percents array specifies the cumulative percentage of the stroke, not the individual percentages of the width. E.g. in the example above, the red stroke will span 0% to 50% width, the blue stroke 50% to 70% width and the green stroke 70% to 100% width.
I am trying to get zoom to work by dragging a rectangle over my series plot to identify the interval of zooming. Here is my plunkr
http://plnkr.co/edit/isaHzvCO6fTNlXpE18Yt?p=preview
You can see the issue by drawing a rectangle with the mouse over the chart - The new chart overshoots the boundary of the X and Y axes. I thought my group under the svg would take care of the bounds of the series (path) but I am clearly mistaken. After staring at it for a long time, I could not figure it out. Please ignore the angular aspect of the plunkr. I think the issue is somewhere in the
//Build series group
var series = svgGroup.selectAll(".series")
.data(data)
.enter().append("g")
.attr("class", "series");
//Build each series using the line function
series.append("path")
.attr("class", "line")
.attr("d", function (d) {
return line(d.series);
})
.attr("id", function (d) {
//While generating the id for each series, map series name to the path element.
//This is useful later on for dealing with legend clicks to enable/disable plots
legendMap[d.name] = this;
//Build series id
return buildPathId(d.name);
})
.style("stroke", function (d) {
//Use series name to get the color for plotting
return colorFcn(d.name);
})
.style("stroke-width", "1px")
.style("fill", "none");
Any help with this is appreciated.
Thank you very much.
I think the method renderChartWithinSpecifiedInterval(minX, maxX, minY, maxY, pixelCoordinates) maybe has some problem there.
It seems the parameter like max_x passed in line 130 are a very big value like time seconds
var svg = renderChartWithinSpecifiedInterval(min_X, max_X, min_Y, max_Y, false);
max_X,min_X are value like 1415171404335
min_Y = 0, max_Y = 100
But in dragDrop call in line 192
function gEnd(d,i){
svg.selectAll(".zoom-rect").remove();
var svgGp = svg.select("g");
var groupTransform = d3.transform(svgGp.attr("transform"));
var xOffset = groupTransform.translate[0];
var yOffset = groupTransform.translate[1];
var xBegin = Math.min(xStart,xDyn) - xOffset;
var xEnd = Math.max(xStart,xDyn) - xOffset;
var yBegin = Math.min(yStart,yDyn) - yOffset;
var yEnd = Math.max(yStart,yDyn) - yOffset;
renderChartWithinSpecifiedInterval(xBegin, xEnd, yBegin, yEnd, true);
//It seems here the parameters values are all pixels
like xBegin = 100, xEnd = 200
}
hope it helps!
I have been struggling with this issue for the past couple days: I have a force directed graph that labels its edges just like this example does it. The problem I am facing is that when the graph updates (ie: a node on the graph is added upon a user's click) it updates the graph but it leaves the old edge labels that I wrote previously behind:
BEFORE & AFTER A NEW GRAPH IS APPENDED:
As you can see, my edge labels are hanging around after an update. I have a function that is called everytime new data comes in, and in this function I have the following code that draws the labels:
path_text = svg.selectAll(".path")
.data(force.links(), function(d){ return d.name;})
.enter().append("svg:g");
path_text.append("svg:text")
.attr("class","path-text")
.text(function(d) { return d.data.label; });
The svg variable is declared once at a top level closure like so:
var svg = d3.select("body").append("svg:svg")
.attr("viewBox", "0 0 " + width + " " + height)
.attr("preserveAspectRatio", "xMidYMid meet");
My graph has a tick() function that calculates the location of each label like so:
function tick()
{
// Line label
path_text.attr("transform", function(d)
{
var dx = (d.target.x - d.source.x),
dy = (d.target.y - d.source.y);
var dr = Math.sqrt(dx * dx + dy * dy);
var sinus = dy/dr;
var cosinus = dx/dr;
var l = d.data.label.length * 6;
var offset = (1 - (l / dr )) / 2;
var x=(d.source.x + dx*offset);
var y=(d.source.y + dy*offset);
return "translate(" + x + "," + y + ") matrix("+cosinus+", "+sinus+",
"+-sinus+", "+cosinus+", 0 , 0)";
});
.
.
.
I have tried moving this svg declaration down into the update function, so that this is instantiated each time there is a graph change. This actually works - but it makes an entire duplicate of the entire graph. The first, original copy still keeps the old labels - but the second copy acts exactly how I want it to. Is there a way, perhaps, instead of appending svg, there is a way of replacing? I have also tried calling exit().remove() without any luck as well.
Thank you so much for your time. This has been killing me as to how I'm supposed to do this.
I placed the svg declaration inside my graph update function, attached it to a div, and clear the div before appending it again:
jQuery('#v').empty();
var svg = d3.select("#v").append("svg:svg")
.attr("viewBox", "0 0 " + width + " " + height)
.attr("preserveAspectRatio", "xMidYMid meet");
Not the cleanest solution in my opinion, but will go with this unless you all have a better solution!