Personally, I'd stick with the timeScale and fiddle with that one.
What's done in there?
The 'zero date' is set up, based on the current timestamp. Note that the real zero base timestamp used in the D3 code is the one derived *off that one*: it's thee current date @ 00:00:00AM.
This will give better ticks, as those are "absolute date/time" based and wee can then tweak the tick labels to print relative tick labels.
I fiddled with the tickFormat for a while as I was looking for something that might work (or gets close): relative time distances don't work when you're zoomed in at a far distance for zero (you'ld get labels like "2478350 seconds from now", "2478200 seconds from now", etc. which is useless, so the scale in the CodePen is a mix of "absolute dates" and relative dates. When you zoom in, the relative parts show up, while at far away distances for date zero, zooming in to within a day results in the day being listed at left and the time-in-day at the ticks, which should be more "legible" to humans than large, yet very slightly different, *relative distances* from day 0.
I'm still not happy with it for the labeling in the ranges close to day 0, but it's getting there.
The code includes a few rough heuristics to decide which unit to use for relative date label production, that can be improved.
Note that I consciously decided to print the date at the first and last tick (IFF the last tick includes a date part at all) for frame of reference: is "12 hours ago" yesterday night or is it this morning? Is that sort of question important? Do we need to know whether events on the timeline are in the morning, evening or at night? Etc. -- all these questions a viewer/reader might have are relevant how you proceed with this.
Note that the tickFormat() function uses the extra parameters idx and tickArray to obtain and use knowledge about which tick it is currently formatting and where the other ticks are in time (span of the entire tick range helps determine which time unit (day/hour/minute/second) we use for labeling)
If you need more advanced scales / ticks than these (particularly the D3-internal code deciding where to place the ticks can be improved, e.g. always placing a tick at the first of the month, or ticks at every monday morning if that is relevant (work/shift schedules!), coloring all historic ticks red) then you need to research a bit more and might end up with some custom tick filtering or custom scale coding -- which can be done by cloning the standard D3 code for these subjects and tweak/augment them where there's no easy API provided to achieve your goals. But that's out of scope and more work.
While I understand why you might have chosen the linearScale, I think you've painted yourself in a corner there as at least *I* would need to do more work to achieve this sort of thing as the first showstopper with a linearScale is the ticks filtering: dates/years/months/weeks/days/hours/minutes/seconds are not nicely divided by powers of N (N=10), so I would need to redo all that work that's already done in the timescale when I'ld go with a linear scale. From my perspective, if the timeScale can't deliver what you want, then personally I'ld rather rather go and fork+augment that one to fit my specific needs; even if I have to fiddle the data a bit by adding a fixed offset or some such trickery, as I would at least be working off a base that's half-way decent.
WARNING: I've been messing with the timestamps as the local vs. UTC was playing havoc on my initial attempt, so there's some very hacky timezone "corrections" in there so that I'ld get nice date@00:00:00 tick labels throughout the year while using local time (summer/winter times! Horror!) Treat this like the mess it is; it works, for now.
Here's a copy of the source code of that edited pen, including some fiddling/messing about (is: failed attempts) of mine commented out:
const width = 1000,
height = 200,
margin = 50
const DateTime = luxon.DateTime;
let last_daystr = null;
const zoom = d3.zoom()
.scaleExtent( [ 1, 10000 ] )
.translateExtent([[0, 0], [width, height]])
.on( 'zoom', () => onZoom() )
const svg = d3.select( '#d3' )
.append( 'svg' )
.attr( 'width', width )
.attr( 'height', height )
.attr( 'viewBox', `0 0 ${ width } ${ height }` )
.call( zoom )
const base0timestampRightNow = new Date();
// produces timestamp for LOCAL date @ 24:00 hours
// (last midnight)
function dateToMidnight(d) {
let tz_offset = d.getTimezoneOffset();
tz_offset *= 60000;
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() - tz_offset;
};
const base0timestamp = dateToMidnight(base0timestampRightNow);
console.log("ZERO DATE:", base0timestamp, new Date(base0timestamp));
function offsetToDate(offset) {
const t = base0timestamp + offset;
return new Date(t);
}
function dateToOffset(d) {
const t = d.getTime() - base0timestamp;
return t;
}
function dateNiceToStartOfMonth(d) {
// d3.timeMonth() works in UTC, hence doesn't do exactly what I want
const d1 = new Date(d.getUTCFullYear(), d.getUTCMonth(), 1);
let tz_offset = d1.getTimezoneOffset();
tz_offset *= 60000;
return new Date(d1.getTime() - tz_offset);
}
console.log("Offset test:", offsetToDate(37.5*3600*1000), dateToOffset(offsetToDate(37.5*3600*1000)) === 37.5*3600*1000 ? "OK" : "!FAIL!");
console.log("DOMAIN RAW:", offsetToDate(-2.628e9), offsetToDate(3.154e10))
console.log("DOMAIN NICE:", dateNiceToStartOfMonth(offsetToDate(-2.628e9)), dateNiceToStartOfMonth(offsetToDate(3.154e10)))
const x = d3.scaleTime()
.domain([dateNiceToStartOfMonth(offsetToDate(-2.628e9)), dateNiceToStartOfMonth(offsetToDate(3.154e10))])
.range([ margin, width-margin ])
.nice()
const xAxis = d3.axisBottom()
.scale( x )
.tickFormat( tickFormat )
const g = svg.append("g")
.attr("class", "axis axis--x")
.call( xAxis )
g.selectAll("text")
.style("text-anchor", "end")
.attr("dx", "-.8em")
.attr("dy", ".15em")
.attr("transform", "rotate(-75)");
function onZoom() {
const t = d3.event.transform,
xt = t.rescaleX( x );
g.call( xAxis.scale(xt) )
g.selectAll("text")
.style("text-anchor", "end")
.attr("dx", "-.8em")
.attr("dy", ".15em")
.attr("transform", "rotate(-75)");
}
function round(v, mult) {
v *= mult;
v = Math.round(v);
return v / mult;
}
function tickFormat(val, idx, ticksArray) {
//console.log(arguments)
//const delta = dateToOffset(val);
//let dist = Math.abs(delta);
//if (dist < 5000)
// return `${delta}ms`;
//if (dist < 2 * 60 * 1000)
// return `${round(delta / 1000, 20)}s`;
//if (dist < 2 * 3600 * 1000)
// return `${round(delta / (3600 * 1000), 20)}m`;
//if (dist < 24 * 3600 * 1000)
// return `${round(delta / (24 * 3600 * 1000), 2)}d`;
// RESET the hacky "don't repeat date in ticks" monitor
// code when we discover we're re-rendering the ticks:
// This results in the left-most tick always carrying
// the date, next to the time, no matter how far you zoom in.
if (idx === 0)
last_daystr = null;
// don't keep repeating the same day over and over in the ticks
let fmt = d3.timeFormat("%Y %b %-d");
let fmt_type = 1;
let daystr = fmt(val);
if (daystr === last_daystr) {
fmt = d3.timeFormat("%H:%M:%S");
fmt_type = 3;
}
else {
// don't print 00:00:00 time when we do print the date:
if (val.getHours() === 0 && val.getMinutes() === 0 && val.getSeconds() === 0 && val.getMilliseconds() === 0) {
fmt = d3.timeFormat("%Y %b %-d");
fmt_type = 1;
}
else {
fmt = d3.timeFormat("%Y %b %-d %H:%M:%S");
fmt_type = 2;
}
last_daystr = daystr;
}
let valstr = fmt(val);
if (0) {
// Rough example to tweak ticks for today/tomorrow/yesterday:
if (fmt_type < 3) {
let d0 = dateToMidnight(val);
//console.log("Day compare:", new Date(d0), new Date(base0timestamp), d0 === base0timestamp)
if (d0 === base0timestamp) {
if (fmt_type === 1)
valstr = "(today)";
else {
fmt = d3.timeFormat("(today) %H:%M:%S");
valstr = fmt(val);
}
}
else if (d0 === base0timestamp + 24 * 3600 * 1000) {
if (fmt_type === 1)
valstr = "(tomorrow)";
else {
fmt = d3.timeFormat("(tomorrow) %H:%M:%S");
valstr = fmt(val);
}
}
else if (d0 === base0timestamp - 24 * 3600 * 1000) {
if (fmt_type === 1)
valstr = "(yesterday)";
else {
fmt = d3.timeFormat("(yesterday) %H:%M:%S");
valstr = fmt(val);
}
}
}
}
else {
// Example using LUXON for printing relative ticks:
let dt = DateTime.fromJSDate(val);
let relstr = dt.toRelative({
base: DateTime.fromMillis(base0timestamp)
});
if (idx === 0) {
valstr = `${relstr}: ${valstr}`;
}
else if (idx === ticksArray.length - 1 && fmt_type < 3) {
// print date at end of range as it will be different
valstr = `${relstr}: ${valstr}`;
}
else {
// calculate timespan of shown scale:
let d1 = ticksArray[0].__data__;
let d2 = ticksArray[ticksArray.length - 1].__data__;
//console.log({d1, d2, idx, ticksArray})
const delta1 = dateToOffset(d1);
const delta2 = dateToOffset(d2);
let dist1 = Math.abs(delta1);
let dist2 = Math.abs(delta2);
let span = Math.abs(delta1 - delta2);
//console.log({span, dist1, dist2, d1, d2, delta1, delta2})
if (span <= 14 * 24 * 3600 * 1000 &&
dist1 <= 7 * 24 * 3600 * 1000 &&
dist2 <= 7 * 24 * 3600 * 1000) {
// we're looking at a 7 day range close to ZERO BASE:
// reckon in hours/minutes/... then!
let relstr = dt.toRelative({
//base: DateTime.fromMillis(base0timestamp),
unit: (span < 35 * 60 * 1000 ? "seconds" :
span < 35 * 3600 * 1000 ? "minutes" :
"hours")
});
valstr = `${relstr}: ${valstr}`;
}
}
}
return `${valstr}`;
//return `${round(delta / (24 * 3600 * 1000), 1)} days`;
}