首页 > 解决方案 > 域更改后平移缩放

问题描述

我有一些时间序列数据的域发生变化:我可以使用过去 6 个月、去年、过去 2 年等等。我创建了一个仅显示数据的 D3 图表。

但是,您也可以缩放此图表,但是当您缩放然后更改域时,缩放“重置”但在您单击时再次起作用。

当域发生变化时,我想保持当前的缩放:因为它是时间序列数据,我希望它在同一个地方。我怎样才能做到这一点?

<head>
  <script src="https://d3js.org/d3.v6.min.js"></script>
  <div class="buttons">
    <button id="sixmo">Last 6 months</button>
    <button id="oneyear">Last year</button>
    <button id="twoyears">Last 2 years</button>
  </div>
</head>

<body>
  <script>
    // Random data
    function randomData() {
      function randn_bm() {
        var u = 0, v = 0;
        while (u === 0) u = Math.random();
        while (v === 0) v = Math.random();
        return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
      }
      let days = []
      let endDate = new Date(2020, 1, 0)
      for (var d = new Date(2018, 0, 0); d <= endDate; d.setDate(d.getDate() + 1)) {
        days.push(new Date(d));
      }

      return days.map(d => ({
        date: d,
        value: randn_bm()
      }))
    }

    // Chart
    const height = 600
    const width = 800
    const margin = { top: 20, right: 0, bottom: 30, left: 40 }

    let x;
    let y;
    const zoomed = (event) => {
      let xz = event.transform.rescaleX(x);
      gX.call(xAxis, xz);
      gLine.selectAll("path")
        .data([data])
        .join("path")
        .attr("fill", "none")
        .attr("stroke", "steelblue")
        .attr("stroke-width", 1.5)
        .attr("d", d3.line()
          .x(d => xz(d.date))
          .y(d => y(d.value)))
    }
    const zoom = d3.zoom()
      .scaleExtent([1, 32])
      .extent([[margin.left, 0], [width - margin.right, height]])
      .translateExtent([[margin.left, -Infinity], [width - margin.right, Infinity]])
      .on("zoom", zoomed);

    const svg = d3.select("body").append("svg")
      .attr("viewBox", [0, 0, width, height]);

    svg.call(zoom)

    const gLine = svg.append("g").attr("class", "series").attr("clip-path", "url(#clip)")
    const gX = svg.append("g").attr("class", "x-axis")
    const gY = svg.append("g").attr("class", "y-axis")

    const xAxis = (g, x) => g
      .attr("transform", `translate(0,${height - margin.bottom})`)
      .call(d3.axisBottom(x).tickSizeOuter(0))

    const yAxis = (g, y) => g
      .attr("transform", `translate(${margin.left},0)`)
      .call(d3.axisLeft(y))
      .call(g => g.select(".domain").remove())

    svg.append("clipPath")
      .attr("id", "clip")
      .append("rect")
      .attr("x", margin.left)
      .attr("y", margin.top)
      .attr("width", width - margin.left - margin.right)
      .attr("height", height - margin.top - margin.bottom);

    function renderChart(data) {
      x = d3.scaleTime()
        .domain(d3.extent(data, d => d.date))
        .range([margin.left, width - margin.right])

      y = d3.scaleLinear()
        .domain(d3.extent(data, d => d.value)).nice()
        .range([height - margin.bottom, margin.top])

      gX.call(xAxis, x);

      gY.call(yAxis, y);

      gLine.selectAll("path")
        .data([data])
        .join("path")
        .attr("fill", "none")
        .attr("stroke", "steelblue")
        .attr("stroke-width", 1.5)
        .attr("d", d3.line()
          .x(d => x(d.date))
          .y(d => y(d.value)))
    }

    // Buttons
    const data = randomData()
    const lastDataDate = new Date(2020, 1, 0)
    const buttons = d3.select(".buttons")
      .selectAll("button")
      .data([6, 12, 24])
      .join("button")
      .on("click", (_, months) => {
        const startDate = new Date(lastDataDate)
        startDate.setMonth(startDate.getMonth() - months)
        const filteredData = data.filter(d => d.date > startDate)
        renderChart(filteredData)
      })

    renderChart(data)
  </script>
</body>

标签: javascriptd3.js

解决方案


问题

如果您使用 d3.zoom 进行缩放/平移,则需要让 d3.zoom 知道您何时手动进行并更改了平移/缩放。它不“知道”你在它之外做了什么样的篡改。此外,如果您要更新元素的缩放状态以便 d3.zoom “知道”更改,为什么不使用 d3.zoom 来实际进行缩放和平移呢?

在您的示例中,您使用缩放来设置数据的比例,但是当您单击按钮时,您仅通过过滤数据来设置缩放。d3.zoom 一点也不聪明。这就是为什么当您使用按钮然后使用缩放时会发生跳跃的原因 - 缩放行为会从上次离开的地方开始。

最后,您已经编写了两种缩放和平移方法,您可以通过 d3.zoom 来运行它们。

这不是一个不常见的问题 - 这是一个相同原理的示例。

解决方案

仅使用一种方法进行缩放/平移。这样就无需同步缩放/平移的两个独立机制的行为和状态。您可以很容易地将 d3.zoom 用于程序化缩放和标准缩放。

在处理轴和刻度时,您会发现使用参考刻度最容易 - 这样缩放是相对于原始缩放状态而不是最后一个缩放状态(这可能会导致问题)。我们使用每个缩放事件的参考比例来重新调整我们的工作比例。工作标尺被传递到轴生成器并用于定位数据。

因此,在您的情况下,我们的缩放功能看起来像:

const zoomed = (event) => {
  xScale.domain(event.transform.rescaleX(xReference).domain());
  draw(data);
}

我们每次都重新缩放 xScale 以反映缩放事件提供的缩放变换所显示的新域。

这适用于鼠标交互,无需进一步修改。我们可以使用 调用程序化缩放svg.call(zoom.transform, someZoomTransform),我们所要做的就是计算正确的变换,以您的代码为例,它看起来像:

    const endDate = lastDataDate;
    const startDate = d3.timeMonth.offset(endDate,-months);

    // k = width of range needed for data set / width of range needed for area of interest         
    const k = (xReference.range()[1] - xReference.range()[0]) / (xReference(endDate) - xReference(startDate))\
    // translate to account for starting point of area of interest.
    const tx = xReference(startDate); 
    
    // let the zoom handle it.
    svg.call(zoom.transform, d3.zoomIdentity
        .scale(k)
        .translate(-tx+margin.left/k, 0) // margin.left/k : account for scale range not starting at 0.
        );

综上所述,我们得到:

const height = 500;
const width = 500;
const margin = { top: 20, right: 0, bottom: 30, left: 40 }

const svg = d3.select("body").append("svg")
  .attr("width",width)
  .attr("height",height);

var data = randomData();

 
// Set up Scales:
let xScale = d3.scaleTime()
  .domain(d3.extent(data, d => d.date))
  .range([margin.left, width - margin.right])

   // Reference to hold starting version of scale:
const xReference = xScale.copy();

let yScale = d3.scaleLinear()
  .domain(d3.extent(data, d => d.value)).nice()
  .range([height - margin.bottom, margin.top])

// Set up Zoom:
const zoomed = (event) => {
  xScale.domain(event.transform.rescaleX(xReference).domain());
  draw(data);
}

const zoom = d3.zoom()
  .scaleExtent([1, 32])
  .extent([[margin.left, 0], [width - margin.right, height]])
  .translateExtent([[margin.left, -Infinity], [width - margin.right, Infinity]])
  .on("zoom", zoomed);

svg.call(zoom);

 

// Set up axes and miscellania
const gLine = svg.append("g").attr("class", "series").attr("clip-path", "url(#clip)")
const gX = svg.append("g").attr("class", "x-axis")
const gY = svg.append("g").attr("class", "y-axis")
const xAxis = (g, x) => g
  .attr("transform", `translate(0,${height - margin.bottom})`)
  .call(d3.axisBottom(xScale).tickSizeOuter(0))
  
const yAxis = (g, y) => g
  .attr("transform", `translate(${margin.left},0)`)
  .call(d3.axisLeft(yScale))
  .call(g => g.select(".domain").remove())
  
svg.append("clipPath")
  .attr("id", "clip")
  .append("rect")
  .attr("x", margin.left)
  .attr("y", margin.top)
  .attr("width", width - margin.left - margin.right)
  .attr("height", height - margin.top - margin.bottom);

// Draw:
function draw(data) {
  gX.call(xAxis, xScale);
  gY.call(yAxis, yScale);
  gLine.selectAll("path")
    .data([data])
    .join("path")
    .attr("fill", "none")
    .attr("stroke", "steelblue")
    .attr("stroke-width", 1.5)
    .attr("d", d3.line()
      .x(d => xScale(d.date))
      .y(d => yScale(d.value)))  

}

// Button Behavior
const lastDataDate = new Date(2020, 1, 0)
const buttons = d3.select(".buttons")
  .selectAll("button")
  .data([6, 12, 24])
  .join("button")
  .on("click", (_, months) => {
        const endDate = lastDataDate;
        const startDate = d3.timeMonth.offset(endDate,-months);

        // k = width of range needed for data set / width of range needed for area of interest         
        const k = (xReference.range()[1] - xReference.range()[0]) / (xReference(endDate) - xReference(startDate))
        // translate to account for starting point of area of interest.
        const tx = xReference(startDate); 
        
        // let the zoom handle it.
        svg.call(zoom.transform, d3.zoomIdentity
            .scale(k)
            .translate(-tx+margin.left/k, 0) // account for scale range not starting at 0.
            );
      })

draw(data);


    // Random data
    function randomData() {
      function randn_bm() {
        var u = 0, v = 0;
        while (u === 0) u = Math.random();
        while (v === 0) v = Math.random();
        return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
      }
      let days = []
      let endDate = new Date(2020, 1, 0)
      for (var d = new Date(2018, 0, 0); d <= endDate; d.setDate(d.getDate() + 1)) {
        days.push(new Date(d));
      }

      return days.map(d => ({
        date: d,
        value: randn_bm()
      }))
    }
<script src="https://d3js.org/d3.v6.min.js"></script>
<div class="buttons">
  <button id="sixmo">Last 6 months</button>
  <button id="oneyear">Last year</button>
  <button id="twoyears">Last 2 years</button>
</div>


推荐阅读