Samuel
Samuel

Reputation: 108

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

I need to create a relative timeline-like axis in D3 (v5) that is zoomable and changes units and tick intervals on zoom change. It will be used for planning certain activities in time relative to the baseline - value 0.

Example Timepoints: at -8 days, 2 hrs, 10 & 20 days, 2 & 4 & 6 weeks, 4 months and so on (stored in millisecond offsets).

When zoomed out, the ticks would be formatted as years, and as the user begins to zoom in, these turn to months, then weeks, days, hours, down to the minutes.

Axis example screenshot CodePen Example

The example shows the approximate effect that I am going for (use the mousewheel scroll to zoom in or out on the axis).

I decided to use a linear scale with integer units - representing milliseconds. I am using tickFormat() where I find the distance among the ticks and calculate different tick formats based on that.
I probably cannot use D3’s scaleTime() because that’s based on real calendar dates (with variable months, gap years, etc). I need a fixed scale of offsets - 24-hour days, 7-day weeks, 30-day months, 365-days years.

The scale in the example is wrong - D3 generates the ticks automatically in nicely rounded values - and I need the tick intervals to be variable based on the zoom level. Meaning, when showing ticks in the format of months, the distance between 2 ticks should be one month (in ms) and when zooming down to hours, the distance between 2 ticks should be exactly one hour (in ms) and so on.

I guess I will need to create some algorithm that generates the variable ticks but I am not sure how would it look like, and what are the limitations of the D3 API because I haven't found any methods that would allow for something like this. I couldn't find any examples of this effect anywhere and I was hoping that someone here could point me in the right direction or give some tips on how to achieve it.

Upvotes: 5

Views: 3503

Answers (1)

Robin Mackenzie
Robin Mackenzie

Reputation: 19319

You can use scaleTime by overriding the tick label after each zoom. Since time scales are a variant of linear scales, I feel this meets the question:

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

First you need an origin to map to the baseline value of 0. In the example below I've selected 2021-03-01 and arbitrarily picked an end date a few days later.

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

You also need a reference scale to determine what interval the d3 axis generator has decided to use at that zoom level:

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

Which is like saying if the interval is between 0 and 0.03 then it's minutes; between 0.03 and 1 it's hours; between 1 and 7 it's days. I've not put in 1/2 days, decades etc because I'm using the moment diff function which doesn't know about those intervals. YMMV with other libraries.

On initialisation of the axis, and on zoom, call a custom function instead of .call(d3.axisBottom(xScale)) as this is where you get to figure out what the labels should be per the relative time between the tick values and the origin. I've called this customizedXTicks:

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

In the custom function:

  • t1 and t2 are the 2nd and 3rd d3 generated tick values converted back to dates. Using 2nd and 3rd ticks is not special - just my choice.
  • interval is t2 - t1 in days
  • intervalType uses the intervalScale above and we can now determine if the zoom on scaleTime() is in e.g. hours, days, weeks etc.
  • newTicks maps over the tick values in the scale and finds the difference between each tick value and the origin using the diff function in the moment library which accepts the value from the intervalScale range as an argument so you get the diff at the correct interval for the zoom level
  • render the axis...
  • then override the labels with the new ones you just computed

The override accesses the tick classed groups directly - I don't believe you can pass arbitrary text values to a scaleTime() via tickValues() and happy to be corrected otherwise:

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

Working example sourcing from here 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>

At a deep level of zoom, where you pan to the left and right a fair way you will get a sizable number of minutes (relative to the origin). You should be able to extend the newTicks logic to get labels like e.g. 10d4h28m instead of 14668 minutes if that suits your use case.

Edit

Follow-up question:

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 ?

I've included a second axis in the snippet showing the original tick values that are being post-processed to relative intervals by the customizedXTicks function. I've also changed that to include an inner function - diffEx - to return a decimal interval if the interval is non-integer.

My understanding is that this effect of double 0 weeks/ quarters/ years/ etc is because of D3 auto-selecting the intervals. Note the lower axis:

  • When D3 has decided on 2 days intervals - that the day of the week can be any day of the week
  • When D3 has decided on 7 day intervals - that the day of the week is a Sunday
  • When D3 has decided on month intervals - that the day of the week is whatever it happens to be for that month

So what this means is that if your origin is e.g. a Tuesday, then for some intervals immediately before and after the origin they will be both less than 1 interval away from the origin e.g. where the interval is such that every Sunday is rendered as tick.

In my first answer, this rendered as both e.g. 0 and 0 (and arguably could be -0 and 0). I've changed it to a decimal to make it clearer.

HTH

Upvotes: 5

Related Questions