D3.js v5 - 在线性尺度上创建一个相对的、可缩放的类似时间轴的轴

问题描述

我需要在 D3 (v5) 中创建一个类似时间轴的相对轴,该轴可缩放并在缩放更改时更改单位和刻度间隔。 它将用于相对于基线及时规划某些活动 - 值 0。

示例时间点:在 -8 天、2 小时、10 和 20 天、2、4 和 6 周、4 个月等(以毫秒偏移量存储)。

缩小时,刻度会被格式化为年,当用户开始放大时,这些会变成月,然后是周、天、小时,再到分钟。

Axis example screenshot

CodePen Example

该示例显示了我想要的近似效果(使用鼠标滚轮滚动放大或缩小轴)。

我决定使用带有整数单位的线性刻度 - 代表毫秒。 我正在使用 tickFormat() 找到刻度之间的距离并根据它计算不同的刻度格式。
我可能无法使用 D3 的 scaleTime(),因为它基于真实日历日期(具有可变月份、间隔年等)。我需要固定比例的偏移量 - 每天 24 小时、每周 7 天、每月 30 天、每年 365 天。

示例中的比例是错误的 - D3 自动以四舍五入的值自动生成刻度 - 我需要刻度间隔根据缩放级别是可变的。意思是,当以月份格式显示刻度时,2 个刻度之间的距离应该是一个月(以毫秒为单位),当缩小到小时时,2 个刻度之间的距离应该正好是一个小时(以毫秒为单位)等等。

我想我需要创建一些生成变量刻度的算法,但我不确定它会是什么样子,以及 D3 API 的限制是什么,因为我还没有找到任何允许某些事情的方法像这样。 我在任何地方都找不到这种效果的任何示例,我希望这里有人可以为我指明正确的方向或提供有关如何实现它的提示

解决方法

您可以通过在每次缩放后覆盖刻度标签来使用 scaleTime。由于 time scales 是线性尺度的变体,我觉得这符合问题:

时间尺度是线性尺度的变体,具有时间 域:

首先,您需要一个原点来映射到基线值 0。在下面的示例中,我选择了 2021-03-01 并任意选择了几天后的结束日期。

const origin = new Date(2021,02,01)
const xScale = d3.scaleTime()
  .domain([origin,new Date(2021,11)])
  .range([0,width]);

您还需要一个参考比例来确定 d3 轴生成器决定在该缩放级别使用的间隔:

const intervalScale = d3.scaleThreshold()
  .domain([0.03,1,7,28,90,365,Infinity])
  .range(["minute","hour","day","week","month","quarter","year"]);

这就像说如果间隔在 0 到 0.03 之间,那么它是分钟;在 0.03 和 1 之间是小时; 1 到 7 天之间。我没有放入 1/2 天、几十年等,因为我使用的是不知道这些间隔的时刻 diff 函数。 YMMV 与其他图书馆。

在轴的初始化和缩放时,调用自定义函数而不是 .call(d3.axisBottom(xScale)) 因为这是您可以根据刻度值和 {{ 之间的相对时间找出标签应该是什么的地方1}}。我称之为origin

customizedXTicks

在自定义函数中:

  • const zoom = d3.zoom() .on("zoom",() => svg.select(".x-axis") .call(customizedXTicks,d3.event.transform.rescaleX(xScale2))) .scaleExtent([-Infinity,Infinity]); t1 是转换回日期的第二个和第三个 d3 生成的刻度值。使用第 2 个和第 3 个刻度并不特别 - 只是我的选择。
  • t2interval
  • t2 - t1 使用上面的 intervalType,我们现在可以确定 intervalScale 上的 zoom 是否在例如小时、天、周等
  • scaleTime() 映射比例尺中的刻度值,并使用矩库中的 newTicks 函数找到每个刻度值与原点之间的差异,该函数接受来自 diff 的值intervalScale 作为参数,以便您以正确的缩放级别间隔获得 range
  • 渲染轴...
  • 然后用您刚刚计算的新标签覆盖标签

覆盖直接访问 diff 分类组 - 我不相信您可以通过 tick 将任意文本值传递给 scaleTime(),否则很高兴得到更正:

tickValues()

herehere 采购的工作示例:

function customizedXTicks(selection,scale) {
  // get interval d3 has decided on by comparing 2nd and 3rd ticks
  const t1 = new Date(scale.ticks()[1]);
  const t2 = new Date(scale.ticks()[2]);
  // get interval as days
  const interval = (t2 - t1) /  86400000;
  // get interval scale to decide if minutes,days,hours,etc
  const intervalType = intervalScale(interval);
  // get new labels for axis
  newTicks = scale.ticks().map(t => `${diffEx(t,origin,intervalType)} ${intervalType}s`);
  // update axis - d3 will apply tick values based on dates
  selection.call(d3.axisBottom(scale));
  // now override the d3 default tick values with the new labels based on interval type
  d3.selectAll(".x-axis .tick > text").each(function(t,i) {
    d3.select(this)
      .text(newTicks[i])
      .style("text-anchor","end")
      .attr("dx","-.8em")
      .attr("dy",".15em")
      .attr("transform","rotate(-65)");
  });
  
  function diffEx(from,to,type) {
    let t = moment(from).diff(moment(to),type,true);
    return Number.isInteger(t) ? t : parseFloat(t).toFixed(1);
  }
}
const margin = {top: 0,right: 20,bottom: 60,left: 20}
const width = 600 - margin.left - margin.right;
const height = 160 - margin.top - margin.bottom;

// origin (and moment of origin)
const origin = new Date(2021,01)
const originMoment = moment(origin);

// zoom function 
const zoom = d3.zoom()
  .on("zoom",() => {
    svg.select(".x-axis")
      .call(customizedXTicks,d3.event.transform.rescaleX(xScale2));
    svg.select(".x-axis2")
      .call(d3.axisBottom(d3.event.transform.rescaleX(xScale2)));
  })
  .scaleExtent([-Infinity,Infinity]); 

// x scale
// use arbitrary end point a few days away
const xScale = d3.scaleTime()
  .domain([origin,width]);

// x scale copy for zoom rescaling
const xScale2 = xScale.copy();

// fixed scale for days,months,quarters etc
// domain is in days i.e. 86400000 milliseconds
const intervalScale = d3.scaleThreshold()
  .domain([0.03,"year"]);

// svg
const svg = d3.select("#scale")
  .append("svg")
  .attr("width",width + margin.left + margin.right)
  .attr("height",height + margin.top + margin.bottom)
  .call(zoom) 
  .append("g")
  .attr("transform",`translate(${margin.left},${margin.top})`);

// clippath 
svg.append("defs").append("clipPath")
  .attr("id","clip")
  .append("rect")
  .attr("x",0)
  .attr("width",width)
  .attr("height",height);
    
// render x-axis
svg.append("g")
  .attr("class","x-axis")
  .attr("clip-path","url(#clip)") 
  .attr("transform",`translate(0,${height / 4})`)
  .call(customizedXTicks,xScale); 

// render x-axis
svg.append("g")
  .attr("class","x-axis2")
  .attr("clip-path",${height - 10})`)
  .call(d3.axisBottom(xScale)); 
  
function customizedXTicks(selection,true);
    return Number.isInteger(t) ? t : parseFloat(t).toFixed(1);
  }
}

在更深层次的缩放中,您可以公平地向左和向右平移,您将获得相当多的分钟数(相对于原点)。您应该能够扩展 <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script> <div id="scale"></div> 逻辑以获取标签,例如newTicks 而不是 10d4h28m,如果这适合您的用例。

编辑

后续问题:

放大时,通常有两个刻度标记为“0 周”、“0 季度”、 “0年”也是如此。知道为什么是这样/如果有可能 消除那个?

我在代码段中包含了第二个轴,显示了由 14668 minutes 函数后处理到相对间隔的原始刻度值。我还更改了它以包含一个内部函数 - customizedXTicks - 如果间隔为非整数,则返回小数间隔。

我的理解是双0周/季度/年/等的这种效果是因为D3自动选择了间隔。注意下轴:

  • 当 D3 决定间隔 2 天时 - 一周中的哪一天可以是一周中的任何一天
  • 当 D3 决定以 7 天为间隔时 - 一周中的某一天是星期日
  • 当 D3 决定月份的间隔时 - 一周中的哪一天是那个月发生的事情

所以这意味着如果您的来源是例如一个星期二,然后在原点之前和之后的一些间隔,它们都将与原点相距不到 1 个间隔,例如其中间隔使得每个星期日都呈现为刻度。

在我的第一个答案中,这呈现为两者,例如diffEx0(可以说是 0-0)。为了更清楚,我把它改成了小数。

HTH