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

D3.js v5 - Creating a relative, zoomable timeline-like axis on a linear scale

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

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

缩小时,刻度将被格式化为年,当用户开始放大时,这些刻度将变为月、周、日、小时,直至分钟。

CodePen Example

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

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

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

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

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

Time scales are a variant of linear scales that have a temporal domain:

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

const origin = new Date(2021, 02, 01)
const xScale = d3.scaleTime()
  .domain([origin, new Date(2021, 02, 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 天、几十年等,因为我使用的是 moment diff 函数,它不知道这些时间间隔。 YMMV 与其他图书馆。

在轴初始化和缩放时,调用自定义函数而不是 .call(d3.axisBottom(xScale)),因为这是您可以根据刻度值和origin。我将其命名为 customizedXTicks:

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

在自定义函数中:

  • t1t2 是转换回日期的第二个和第三个 d3 生成的刻度值。使用第 2 和第 3 个刻度并不特别 - 只是我的选择。
  • intervalt2 - t1
  • intervalType 使用上面的 intervalScale,我们现在可以确定 scaleTime() 上的 zoom 是否在例如小时、天、周等
  • newTicks 映射刻度中的刻度值,并使用时刻库​​中的 diff 函数找到每个刻度值与原点之间的差异,该函数接受 intervalScale range 作为参数,因此您可以在缩放级别
  • 的正确间隔处获得 diff
  • 渲染轴...
  • 然后用您刚刚计算的新标签覆盖标签

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

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

来自 and here 的工作示例:

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, 02, 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, new Date(2021, 02, 11)])
  .range([0, 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, 1, 7, 28, 90, 365, Infinity])
  .range(["minute", "hour", "day", "week", "month", "quarter", "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", "url(#clip)") 
  .attr("transform", `translate(0,${height - 10})`)
  .call(d3.axisBottom(xScale)); 
  
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);
  }
}
<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 而不是 14668 minutes 如果这适合您的用例。

编辑

后续问题:

when zoomed, there are often two ticks marked '0 weeks', '0 quarters', '0 years' as well. Any idea why is that / if it is possible to eliminate that ?

我在片段中包含了第二个轴,显示了 post 由 customizedXTicks 函数处理成相对间隔的原始刻度值。我还更改了它以包含一个内部函数 - diffEx - 如果间隔是非整数,则 return 一个小数间隔。

我的理解是,双 0 周/季度/年/等的影响是因为 D3 自动选择间隔。注意下轴:

  • 当 D3 决定以 2 天为间隔时 - 星期几可以是一周中的任何一天
  • 当 D3 决定以 7 天为间隔时 - 星期几是星期日
  • 当 D3 决定月份间隔时 - 星期几就是那个月恰好是星期几

所以这意味着如果您的来源是例如星期二,然后在原点前后的 some 间隔内,它们与原点的距离都小于 1 个间隔,例如间隔是这样的,每个星期天都呈现为滴答声。

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

HTH