断断续续的过渡:同时翻译多条路径(和xaxis)

Choppy transitions: translate multiple paths (and xaxis) concurrently

目标是创建一个具有多条轨迹的平滑滚动实时图。

我能够对单个轨迹执行此操作,但是当我添加更多行进行过渡时,动画似乎变得一团糟。我有一种感觉,过渡正在循环和碰撞,但我不知道如何防止这种情况。

如果您在代码段中设置 N_CH = 1,事情 运行 会很顺利。当它设置为 N_CH = 4 时,动画变得不稳定(似乎转换没有完全完成)并且(有趣的是)x 轴滚动似乎比 N_CH = 1 时快 4 倍。

您可以通过更改 tick() 函数中的变换以匹配通道数(即 iScale(-4) 对应 N_CH = 4)来恢复平滑度,但这不是“正确的”因为翻译速度人为地快。最后,我需要实时准确的时间测量。

我尝试过各种不同的方法,包括:

...结果好像总是一样

// 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>

这里有几个问题:

  • xScale 与 iScale:

你根据iScale 绘制数据,但根据xScale 绘制轴:这里马上就有一个差异:每个刻度的范围不同。但是没有理由不对两者使用相同的比例:这样你就不会在绘图和轴之间有任何差异。如果您删除剪辑路径并删除刻度函数,您会注意到您的线条最初并未呈现在您期望的位置:

  • 滥用transition.end()

D3 的转换事件侦听器用于每个转换。您正在转换许多元素,这会在每一行结束时触发。因此,在四行完成第一次转换后,您触发了四次 tick 函数:这会导致各种混乱,因为该函数旨在一次调用一次以一次转换所有行。

重新阅读问题后,您发现了调用 tick 函数 4 次而不是一次的问题:

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.

如果我们修复此问题以便我们调用 tick 函数一次,当所有线转换完成时,我们解决平滑问题:

// 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>

在上面我使用 transition.end() 到 return 当所有选定元素完成转换时的承诺。我已经升级了你的 D3 版本,因为这是一个较新的功能:

 .end().then(tick);
  • 改进:

您的代码使用循环来追加和修改元素。这会产生额外的开销:选择 DOM 中的元素需要时间,您必须识别每一行以便再次重新选择它,并且您必须在绑定数据时做一些额外的工作。让我们用 d3 enter/update 循环来简化这个:

创建要开始的行:

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)");

现在在update/tick函数中我们可以轻松修改绑定数据:

  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))

我们可以删除每行的第一个数据点:

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

一般来说(显式)循环在使用 D3 处理 SVG 元素时非常罕见,因为它违背了 D3 的设计原则。请参阅 以了解关于为什么这很重要以及它可能如何有用的一些讨论。

连同删除 iScale 并使用 transition.end(),我们可能会得到如下内容:

// 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>