When using D3.js, is there a method to automatically get the closest `key` that has data?

Viewed 451

When we use D3.js, let's say for simplicity, we have data, which are stock prices:

2021-03-18   $38.10
2021-03-19   $38.60
2021-03-22   $38.80

and we use D3 to plot a line chart for the stock price, and then move the mouse around to "hover" above the prices, and it'd show the price for that date.

Right now I am using

d3.select("svg").on("mousemove", (ev) => {
  const hour = xScale.invert(ev.offsetX - dimensions.margin.left).getHours();

to get the hour of where the user is hovering on. The xScale is a scale function scaleTime() from domain to range, and xScale.invert is the function that convert the range back to domain.

  1. If the hour is 12pm or later, I consider it the next day, and if it is before or equal, I consider it the same day. This is because the stock price of 2021-03-19 is considered to be at 12:00am (the midnight), so if I am getting to 9pm, for example, the mouse cursor is really close to the next day.

  2. And then, let's say I identified that it is 2021-03-20, then I check whether there is stock price data. But since it was a Saturday and has no stock data, I use a function to check by the following method:

  3. I first would go back to 2021-03-19 and see if there is a stock price. (I first build a lookup table to map date to data). If there is, then use it.

  4. But if there isn't, I just use the delta of 1 day and move further and further, so it would go to 2021-03-21 and then increment the delta and use -delta to check for 2021-03-18, so I just use a point and go "later" and "before" with an increasing delta, until I am able to find a price

  5. In other words, I have a "first candidate" and a "second candidate". If the first candidate has data, then use it. Otherwise, try the second candidate. If still not work, then work from the first candidate and use delta of 1 day and move "later" or "before", and if not work, use a delta of 2 days, and 3 days, until I am able to find a date with data.

  6. Then I use this price to show on screen, to report what the date and price is

But this method is a bit low level. Does D3.js already have a method to directly do that: to spit out an invert number, which is closest to the key that has data in the dataset?

1 Answers

There are several functions provided by d3.js which can be used, depending on the exact situation:

1. You operate in screen space and want a mapping of the current mouse position on the closest point of the visualization which represents a single data object

In that case, you would probably want to use d3-delaunay.

d3-delaunay is a fast library for computing the Voronoi diagram of a set of two-dimensional points. One can use delaunay.find to identify the data point closest to the pointer. Here is one example.

2. If you operate in the data domain (e.g. because you have already inverted the mouse position to the data domain)

d3.bisect finds the position into which a given value can be inserted into a sorted array while maintaining sorted order. If the value already exists in the array, d3.bisect will find its position efficiently. Here is one example.

See also: D3: What is a Bisector? and d3.bisector using Date() Object does not resolve

Threshold scales allow you to map arbitrary subsets of the domain to discrete values in the range. The input domain is still continuous, and divided into slices based on a set of threshold values.

The idea is the following:

You create a d3.scaleThreshold to map any date (= continuous domain) to the fixed set of valid dates given your data by mapping it to the closest date. For that you have to specify the domain as an array of n - 1 dates which are residing in between the n valid dates. The range is the array of the valid dates. It might not be as efficient as d3.bisect depending on your data.

const data_original = [{ date: "2021-03-18", value: "38.10"},
         { date: "2021-03-19", value: "38.60"},
         { date: "2021-03-22", value: "38.80"},
];
const data_types_converted = data_original.map(d => ({"date": new Date(d.date), "value": +d.value}));
const data_just_dates = data_types_converted.map(d => d.date);

let newDate = new Date("2021-03-17");
console.log(newDate + " -> " + getClosestDate(newDate, data_just_dates));
newDate = new Date("2021-03-18");
console.log(newDate + " -> " + getClosestDate(newDate, data_just_dates));
newDate = new Date("2021-03-19");
console.log(newDate + " -> " + getClosestDate(newDate, data_just_dates));
newDate = new Date("2021-03-20");
console.log(newDate + " -> " + getClosestDate(newDate, data_just_dates));
newDate = new Date("2021-03-21");
console.log(newDate + " -> " + getClosestDate(newDate, data_just_dates));
newDate = new Date("2021-03-22");
console.log(newDate + " -> " + getClosestDate(newDate, data_just_dates));
newDate = new Date("2021-03-23");
console.log(newDate + " -> " + getClosestDate(newDate, data_just_dates));

function getClosestDate(newDate, validDates) {
  const domain = [];
  let midday_local;
  let midday_UTC;
  
  validDates.forEach((d,i) => {
    if (i < validDates.length - 1) {
      midday_local = new Date((validDates[i].getTime() + validDates[i + 1].getTime()) / 2); // midday in local time
      midday_UTC = convertDateToUTC(midday_local); // midday in UTC time
      domain.push(midday_UTC);
    }
  });

  const scale = d3.scaleThreshold()
    .domain(domain)
    .range(validDates);

  return scale(newDate)
}

function convertDateToUTC(date) { 
  return new Date(
    date.getUTCFullYear(), 
    date.getUTCMonth(), 
    date.getUTCDate(), 
    date.getUTCHours(), 
    date.getUTCMinutes(), 
    date.getUTCSeconds()
    ); 
}
<script src="https://d3js.org/d3.v6.min.js"></script>

Related