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