Choppy transitions: translate multiple paths (and xaxis) concurrently

Viewed 54

The goal is to create a smooth scrolling real time plot with multiple traces.

I was able to do this for a single trace, but when I add more lines to transition, the animation seems to get messed up. I have a feeling that transitions are being looped through and colliding, but I can't figure out how to prevent this.

If you set N_CH = 1 in the snippet, things run smoothly. When it's set to N_CH = 4 then the animation becomes jerky (seems like the transitions aren't fully completing) and also (interestingly) the x-axis scrolling appears to become 4 times faster than when N_CH = 1.

You can recover the smoothness by changing the transform in the tick() function to match the number of channels (i.e. iScale(-4) for N_CH = 4) but this isn't "correct" as the translation speed is artificially fast. In the end, I need accurate time measurement in real-time.

I've tried various different approaches including:

  • adding traces to a group and trying to translate the group
  • refactoring the data object and allowing d3 to iterate through the data structure with a selectAll() call

... the results always seem to be the same.

// set up some variables
const N_CH = 4;
const N_PTS = 40;
const margin = {top: 20, right: 30, bottom: 30, left: 40};
const width = 800;
const height = 300;
const colors = ['steelblue', 'red', 'orange', 'magenta']

// instantiate data array (timestamps)
var data = [];
var channelData = [];
for (let ch = 0; ch < N_CH; ch++) {
  channelData = [];
  for (let i = 0; i < N_PTS; i++) {
    channelData.push({
      x: Date.now() + i * 1000,
      y: ch + Math.random()
    })
  }
  data.push({
    name: "CH" + ch,
    values: channelData
  });
}

// initialize //////////////////////////////
// instantiate svg and attach to DOM element
var svg = d3
  .select("#chart")
  .append("svg")
  .attr("viewBox", `0 0 ${width} ${height}`)

// add clip path for smooth entry/exit
svg.append("defs").append("clipPath")
  .attr("id", "clip")
  .append("rect")
  .attr("x", margin.left)
  .attr("y", margin.bottom)
  .attr("width", width - margin.left - margin.right)
  .attr("height", height - margin.top - margin.bottom);

// set index scale for data buffer position/transition
var iScale = d3.scaleLinear()
  .range([0, width - margin.right])
  .domain([0, data[0].values.length - 1]);

// set up x-axis scale for data x units (time)
var xScale = d3.scaleUtc()
  .range([margin.left, width - margin.right])

// add x-axis to svg
var xAxis = svg.append("g")
  .attr("class", "x-axis")
  .attr("transform", `translate(0, ${height - margin.top})`)
  .call(d3.axisBottom(xScale));

// set up y-axis
var yScale = d3.scaleLinear()
  .range([height - margin.top, margin.bottom]);

// add y-axis to svg
var yAxis = svg.append("g")
  .attr("class", "y-axis")
  .attr("transform", `translate(${margin.left}, 0)`)
  .call(d3.axisLeft(yScale));

// set the domains
xScale.domain(d3.extent(this.data[0].values, d => d.x));

// get global y domain
var flatten = [].concat.apply([], data.map(o => o.values))
yScale.domain(d3.extent(flatten, d => d.y));

// define the line
var line = d3.line()
  .x((d, i) => iScale(i))
  .y(d => yScale(d.y));

// make a group where we will append our paths
traces = svg.append("g")
  .attr("clip-path", "url(#clip)")

for (let ch=0; ch<N_CH; ch++) {
  traces.append("path")
    .datum(data[ch].values)
    .attr("id", `trace-${ch}`)
    .attr("class", "trace")
    .attr("d", line)
    .attr("stroke", colors[ch])
    .attr("fill", "none")
    .attr("stroke-width", 1.5)
    .attr("transform", "translate(0)")
}
// end initialize ////////////////////

// animate
tick();

function tick() {
  // add data to buffer
  let lastData;
  for (let ch = 0; ch < N_CH; ch++) {
    lastData = data[ch].values[data[ch].values.length - 1];
    data[ch].values.push({
      x: lastData.x + 1000,
      y: ch + Math.random()
    });
  }

  // update individual trace path data
  for (let ch = 0; ch < N_CH; ch++) {
    traces.select(`#trace-${ch}`)
      .attr("d", line)
  }

  // animate transition
  traces
    .selectAll('.trace')
    .attr("transform", "translate(0)")
    .transition().duration(1000).ease(d3.easeLinear)
    .attr("transform", `translate(${iScale(-1)}, 0)`)
    .on("end", tick)
    
  // update the domain
  xScale.domain(d3.extent(data[0].values, d => d.x));
  
  // animate/redraw axis
  xAxis
    .transition().duration(1000).ease(d3.easeLinear)
    .call(d3.axisBottom(xScale));

  for (let ch=0; ch<N_CH; ch++) {
    data[ch].values.shift();
  }
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<div id="chart"></div>

1 Answers

There are a few issues here:

  • xScale vs iScale:

You draw your data based on iScale, but draw your axis based on xScale: there's a discrepancy here right away: the ranges of each scale are different. But there is no reason why you shouldn't use the same scale for both: this way you'll never have any discrepancy between drawing and axis. If you remove the clip path and remove the tick function, you'll notice your lines aren't initially rendered where you expect them:

enter image description here

  • Misuse of transition.end()

D3's transition event listeners are for each transition. You are transitioning many elements, this is triggered when every line finishes. So after the four lines finish transitioning the first time, you trigger the tick function four times: this results in all sorts of chaos since the function is intended to be called once to transition all lines at once.

On re-read of the question, you've spotted this issue of calling the tick function 4x instead of once:

You can recover the smoothness by changing the transform in the tick() function to match the number of channels (i.e. iScale(-4) for N_CH = 4) but this isn't "correct" as the translation speed is artificially fast.

If we fix this so that we call the tick function once, when all line transitions are complete, we address the smoothness issue:

// set up some variables
const N_CH = 4;
const N_PTS = 40;
const margin = {top: 20, right: 30, bottom: 30, left: 40};
const width = 800;
const height = 300;
const colors = ['steelblue', 'red', 'orange', 'magenta']

// instantiate data array (timestamps)
var data = [];
var channelData = [];
for (let ch = 0; ch < N_CH; ch++) {
  channelData = [];
  for (let i = 0; i < N_PTS; i++) {
    channelData.push({
      x: Date.now() + i * 1000,
      y: ch + Math.random()
    })
  }
  data.push({
    name: "CH" + ch,
    values: channelData
  });
}

// initialize //////////////////////////////
// instantiate svg and attach to DOM element
var svg = d3
  .select("#chart")
  .append("svg")
  .attr("viewBox", `0 0 ${width} ${height}`)

// add clip path for smooth entry/exit
svg.append("defs").append("clipPath")
  .attr("id", "clip")
  .append("rect")
  .attr("x", margin.left)
  .attr("y", margin.bottom)
  .attr("width", width - margin.left - margin.right)
  .attr("height", height - margin.top - margin.bottom);

// set index scale for data buffer position/transition
var iScale = d3.scaleLinear()
  .range([0, width - margin.right])
  .domain([0, data[0].values.length - 1]);

// set up x-axis scale for data x units (time)
var xScale = d3.scaleUtc()
  .range([margin.left, width - margin.right])

// add x-axis to svg
var xAxis = svg.append("g")
  .attr("class", "x-axis")
  .attr("transform", `translate(0, ${height - margin.top})`)
  .call(d3.axisBottom(xScale));

// set up y-axis
var yScale = d3.scaleLinear()
  .range([height - margin.top, margin.bottom]);

// add y-axis to svg
var yAxis = svg.append("g")
  .attr("class", "y-axis")
  .attr("transform", `translate(${margin.left}, 0)`)
  .call(d3.axisLeft(yScale));

// set the domains
xScale.domain(d3.extent(this.data[0].values, d => d.x));

// get global y domain
var flatten = [].concat.apply([], data.map(o => o.values))
yScale.domain(d3.extent(flatten, d => d.y));

// define the line
var line = d3.line()
  .x((d, i) => iScale(i))
  .y(d => yScale(d.y));

// make a group where we will append our paths
traces = svg.append("g")
  .attr("clip-path", "url(#clip)")

for (let ch=0; ch<N_CH; ch++) {
  traces.append("path")
    .datum(data[ch].values)
    .attr("id", `trace-${ch}`)
    .attr("class", "trace")
    .attr("d", line)
    .attr("stroke", colors[ch])
    .attr("fill", "none")
    .attr("stroke-width", 1.5)
    .attr("transform", "translate(0)")
}
// end initialize ////////////////////

// animate
tick();

function tick() {
  // add data to buffer
  let lastData;
  for (let ch = 0; ch < N_CH; ch++) {
    lastData = data[ch].values[data[ch].values.length - 1];
    data[ch].values.push({
      x: lastData.x + 1000,
      y: ch + Math.random()
    });
  }

  // update individual trace path data
  for (let ch = 0; ch < N_CH; ch++) {
    traces.select(`#trace-${ch}`)
      .attr("d", line)
  }

  // animate transition
  traces
    .selectAll('.trace')
    .attr("transform", "translate(0)")
    .transition().duration(1000).ease(d3.easeLinear)
    .attr("transform", `translate(${iScale(-1)}, 0)`)
    .end().then(tick);
    
  // update the domain
  xScale.domain(d3.extent(data[0].values, d => d.x));
  
  // animate/redraw axis
  xAxis
    .transition().duration(1000).ease(d3.easeLinear)
    .call(d3.axisBottom(xScale));

  for (let ch=0; ch<N_CH; ch++) {
    data[ch].values.shift();
  }
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
<div id="chart"></div>

In the above I use transition.end() to return a promise when all selected elements finish transitioning. I have upped your version of D3 as this is a newer function:

 .end().then(tick);
  • Improvements:

Your code makes use of loops to append and modify elements. This creates additional overhead: selecting elements in the DOM takes time, you have to identify each line so you can reselect it again, and you have to do some extra legwork in binding the data. Let's simplify this with the d3 enter/update cycle:

Create the lines to start:

let lines = traces.selectAll(null)
  .data(data)
  .enter()
  .append("path")
  .attr("d", d=>line(d.values))
  .attr("stroke", (d,i)=>colors[i])
  .attr("fill", "none")
  .attr("stroke-width", 1.5)
  .attr("transform","translate(0,0)");

And now in the update/tick function we can modify the bound data easily:

  lines.each(function(d,i) {
     d.values.push({
      x: d.values[d.values.length-1].x + dt,
      y: i + Math.random()
    })
  })
  .attr("d", d=>line(d.values))

We can remove the first data point of each line with:

 lines.each(d=>d.values.shift());

Generally speaking (explicit) loops are very rare in manipulating SVG elements with D3, as it runs counter to principles that D3 was designed with. See here for some discussion on why that might matter and how it might be useful.

Together with removing the iScale and using transition.end(), we might get something like:

// set up some variables
const N_CH = 4;
const N_PTS = 40;
const margin = {top: 20, right: 30, bottom: 30, left: 40};
const width = 800;
const height = 300;
const colors = ['steelblue', 'red', 'orange', 'magenta']

// instantiate data array (timestamps)
var data = [];
var channelData = [];
for (let ch = 0; ch < N_CH; ch++) {
  channelData = [];
  for (let i = 0; i < N_PTS; i++) {
    channelData.push({
      x: Date.now() + i * 1000,
      y: ch + Math.random()
    })
  }
  data.push({
    name: "CH" + ch,
    values: channelData
  });
}

// initialize //////////////////////////////
// instantiate svg and attach to DOM element
var svg = d3
  .select("#chart")
  .append("svg")
  .attr("viewBox", `0 0 ${width} ${height}`)

// add clip path for smooth entry/exit
svg.append("defs").append("clipPath")
  .attr("id", "clip")
  .append("rect")
  .attr("x", margin.left)
  .attr("y", margin.bottom)
  .attr("width", width - margin.left - margin.right)
  .attr("height", height - margin.top - margin.bottom);

// set up x-axis scale for data x units (time)
var xScale = d3.scaleTime()
  .range([margin.left, width - margin.right])
  .domain(d3.extent(data[0].values,d=>d.x))

// add x-axis to svg
var xAxis = svg.append("g")
  .attr("class", "x-axis")
  .attr("transform", `translate(0, ${height - margin.top})`)
  .call(d3.axisBottom(xScale));

// set up y-axis
var yScale = d3.scaleLinear()
  .range([height - margin.top, margin.bottom]);

// add y-axis to svg
var yAxis = svg.append("g")
  .attr("class", "y-axis")
  .attr("transform", `translate(${margin.left}, 0)`)
  .call(d3.axisLeft(yScale));

// set the domains
xScale.domain(d3.extent(this.data[0].values, d => d.x));

// get global y domain
var flatten = [].concat.apply([], data.map(o => o.values))
yScale.domain(d3.extent(flatten, d => d.y));

// define the line
var line = d3.line()
  .x(d => xScale(d.x))
  .y(d => yScale(d.y));

// make a group where we will append our paths
traces = svg.append("g")
  .attr("clip-path", "url(#clip)")

// Create lines:
let lines = traces.selectAll(null)
  .data(data)
  .enter()
  .append("path")
  .attr("d", d=>line(d.values))
  .attr("stroke", (d,i)=>colors[i])
  .attr("fill", "none")
  .attr("stroke-width", 1.5)
  .attr("transform","translate(0,0)");

transition();

function transition() {
  let dt = 1000; // difference in time.
  let dx = xScale(d3.timeMillisecond.offset(xScale.domain()[0],dt)) - xScale.range()[0]; // difference in pixels.

  lines.each(function(d,i) {
     d.values.push({
      x: d.values[d.values.length-1].x + dt,
      y: i + Math.random()
    })
  })
  .attr("d", d=>line(d.values))
  .transition()
  .duration(1000)
  .attr("transform",`translate(${-dx}, 0)`)  
  .ease(d3.easeLinear)
  .end().then(function() {
      lines.each(d=>d.values.shift())
       .attr("transform","translate(0,0)")
      transition();
  })
  
xScale.domain(xScale
         .domain()
         .map(d=>d3.timeMillisecond.offset(d,dt)))
  xAxis
    .transition().duration(1000).ease(d3.easeLinear)
    .call(d3.axisBottom(xScale))
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
<div id="chart"></div>

Related