【问题标题】:Choppy transitions: translate multiple paths (and xaxis) concurrently断断续续的过渡:同时转换多个路径(和 xaxis)
【发布时间】:2021-10-05 11:28:21
【问题描述】:

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

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

如果你在 sn-p 中设置N_CH = 1,事情就会顺利进行。当它设置为N_CH = 4 时,动画会变得不稳定(似乎过渡没有完全完成),而且(有趣的是)x 轴滚动似乎比N_CH = 1 时快 4 倍。

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

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

  • 向组添加跟踪并尝试翻译组
  • 重构data 对象并允许d3 使用selectAll() 调用遍历数据结构

...结果似乎总是一样的。

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

【问题讨论】:

    标签: javascript d3.js real-time


    【解决方案1】:

    这里有几个问题:

    • xScale 与 iScale:

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

    • transition.end() 的误用

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

    在重新阅读该问题时,您发现了调用刻度函数 4x 而不是一次的问题:

    您可以通过更改 tick() 中的变换来恢复平滑度 匹配通道数的函数(即 N_CH = 的 iScale(-4) 4)但这不是“正确的”,因为翻译速度是人为的 快。

    如果我们解决这个问题,以便我们调用一次 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() 当所有选定的元素完成转换时返回一个承诺。我已经升级了你的 D3 版本,因为这是一个更新的功能:

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

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

    创建要开始的行:

    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 的设计原则背道而驰。请参阅here,了解为什么这可能很重要以及它如何有用。

    连同移除 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>

    【讨论】:

    • 非常感谢您的精彩回答。我(也许很明显)对 javascript 和 d3 比较陌生,所以我非常感谢这些解释。添加iScale 是为了解决我认为是由于边距引起的另一个生涩动画问题——我的范围定义在某个地方导致了问题——看起来你已经在域调用中使用offset 处理了这个问题。我也很欣赏建议的性能改进和 d3 “最佳实践”。 :)
    • 很高兴我能帮上忙 - 我看到 iScale 会如何将其融入其中,希望这不会太难用于真实数据源(分解 dt 并计算 dx 在这里很有用因为它将有助于处理不规则的时间、丢失的数据等)。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2022-04-10
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多