Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: auto interval approximation #1294

Closed
wants to merge 6 commits into from

Conversation

nickofthyme
Copy link
Collaborator

@nickofthyme nickofthyme commented Aug 10, 2021

Summary

The PR adds auto approximation of the minInterval when not directly inferable. This includes both time and non-time based data.

image

Details

This PR adds a simplified approximation of the minInterval for single point datasets when a min and max xDomain is provided without a minInterval. Currently, we default the interval in such cases to 1. This is problematic as this often prohibits rendering the bar given its infinitesimal width.

The solution in this case is to determine the best unit interval that would reasonably display the single data point. This is done by looping through different time units of decreasing scale to determine the best time unit for the provided domain extents. Some examples include:

domain extents approx. minInterval
40 years 1 year
10 years 1 year
52 weeks 1 week
4 weeks 1 week
1 week 1 day
8 days 1 day
7 days 1 day
1 days 1 hour
24 hours 1 hour
1 hour 1 minute
60 minutes 1 minute

Notice all approximate minIntervals are a single unit of time. It has been noted as a future enhancement to this logic to account for multi-factor units such as 12 hours, to best fit the given data.

A development warning is surfaced in the console to promote adding the actual minInterval based on the data.

image

Issues

fixes #840, fixes #1184

Checklist

  • The proper chart type label was added (e.g. :xy, :partition) if the PR involves a specific chart type
  • The proper feature label was added (e.g. :interactions, :axis) if the PR involves a specific chart feature
  • Whenever possible, please check if the closing issue is connected to a running GH project
  • Proper documentation or storybook story was added for features that require explanation or tutorials
  • Unit tests were updated or added to match the most common scenarios

@nickofthyme nickofthyme added enhancement New feature or request :data Data/series/scales related issue :xy Bar/Line/Area chart related labels Aug 10, 2021
@nickofthyme nickofthyme reopened this Aug 11, 2021
@elastic elastic deleted a comment from github-actions bot Aug 11, 2021
@elastic elastic deleted a comment from github-actions bot Aug 11, 2021
@elastic elastic deleted a comment from github-actions bot Aug 11, 2021

for (let i = 0; i < approximatedOptions.length; i++) {
const { unit, min = 1, max = 10 } = approximatedOptions[i];
const unitInterval = moment.duration(1, unit).asMilliseconds();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we just add the millisecond resolution in the approximatedOptions?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what you are suggesting here. Do you want me to remove the milliseconds option altogether?

const valuesLength = xValues.length;
if (valuesLength <= 0) {
return 0;
}
if (valuesLength === 1) {
return 1;
return null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of returning null, can we just call guessMinInterval(seriesXComputedDomains, fallbackScale ?? type); here?

Suggested change
return null;
return guessMinInterval(seriesXComputedDomains, fallbackScale ?? type);

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about that but then I would need to add two more arguments to this function. Which would you prefer?

Copy link
Contributor

@monfera monfera Aug 18, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fwiw I tend to trade argument list brevity for return type simplicity so I wouldn't mind having two extra args, although there may be some better solution. Infinity would make it number consistently—see the minDelta suggestion below—, and then it needs a Number.isFinite check, or some other way of handling the infinity

Copy link
Contributor

@monfera monfera Aug 18, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Putting aside the question around null the function is basically a reduction:

export function findMinInterval(xValues: number[]): number | null {
  const sortedValues = xValues.slice().sort(compareByValueAsc);
  return xValues.length === 0
    ? 0
    : xValues.length === 1
    ? null
    : sortedValues.reduce(
        (p, n, i, a) => Math.min(p, (i < a.length - 1 ? a[i + 1] : Infinity) - n),
        sortedValues[1] - sortedValues[0],
      );
}

Somehow it feels asymmetrical that for a zero length array, we return zero, and for a 1-length array, it's null, then from 2, it's a number again

Copy link
Contributor

@monfera monfera Aug 18, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh my point about this being a reduction: probably it belongs in some basic array processing util, not x_domain.ts, so

export function findMinInterval(xValues: number[]): number | null {
  const sortedValues = xValues.slice().sort(compareByValueAsc);
  return xValues.length === 0
    ? 0
    : xValues.length === 1
    ? null
    : minDelta(sortedValues)
}

Or minDelta does the sorting too:

const minDelta = (values: number, predicate: Sorter): number =>
  values
    .slice()
    .sort(predicate)
    .reduce((p, n, i, a) => Math.min(p, (i < a.length - 1 ? a[i + 1] : Infinity) - n), Infinity);

It naturally yields Infinity if there element count is less than 2, so maybe that could be used instead of null

@@ -127,18 +133,77 @@ function getMinInterval(computedMinInterval: number, size: number, customMinInte
return customMinInterval;
}

const approximatedOptions: {
Copy link
Contributor

@monfera monfera Aug 18, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would there be a more telling name for anything in the code change with approx* in it, as what is going on here doesn't look like approximation, more like, inferred min interval? Yeah any min interval is inferred that's not expressly given, so if it's ambiguous, it could be more explicit, like singleton min interval or some such, easy to grep.

The preexisting minInterval itself is already a bit of a misnomer, esp. for the case of a singleton bar, when there's no interval by definition. I see such alternatives in dataviz land as

  • pitch, temporal pitch
  • cadence
  • bin width, temporal bin width

So it might be binWidthFromSinglePoint or binCountForSinglePoint even

Comment on lines +147 to +148
{ unit: 'second', max: 60 },
{ unit: 'millisecond', max: 1000 },
Copy link
Contributor

@monfera monfera Aug 18, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It means that if the specified time domain is such that there'd be 60 seconds in the domain, then it'll accept minute? So the entire chart width becomes one giant bar.

What drives the numbers?

Asking it as we already have problems with enormous bar widths that no dataviz designer would manually consider. I realize it's an edge case, but a minute-bar shown on a 61-second domain will be almost full Cartesian area width, or if there's some nicing of the time axis(?) 1/3rd to half of the Cartesian width.

Also, shouldn't the whole thing depend on the screenspace Cartesian width ie. pixels, or estimated bar width pixels or bar pitch pixels? It feels like showing a minute-bar in a 61-second domain is great if the chart is very narrow, but not so great if it spans a dashboard horizontally

Logger.warn(
`The minInterval, given only a single data point, has been approximated as ${
scaleType === ScaleType.Time ? moment.duration(value).humanize() : value
}. This is not ideal as this data point likely does not coincide with the proper interval. Please set the minInterval in the xDomain.`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the linter complains of line length but it's a template string, what do we want to generally do in cases like this?

const valuesLength = xValues.length;
if (valuesLength <= 0) {
return 0;
}
if (valuesLength === 1) {
return 1;
return null;
}
const sortedValues = xValues.slice().sort(compareByValueAsc);
let i;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might as well put the let inside the for

* @internal
*/
export function findMinInterval(xValues: number[]): number {
export function findMinInterval(xValues: number[]): number | null {
const valuesLength = xValues.length;
if (valuesLength <= 0) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preexisting: [].length can't be less than 0 (I'm OK with a <= convention though if we want to save on bytes)

const valuesLength = xValues.length;
if (valuesLength <= 0) {
return 0;
}
if (valuesLength === 1) {
return 1;
return null;
}
const sortedValues = xValues.slice().sort(compareByValueAsc);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we sort, there's no need for the Math.abs in const interval = Math.abs(next - current);

min?: number;
max?: number;
}[] = [
{ unit: 'year' },
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{ unit: 'year' , max: Infinity}

const unitInterval = moment.duration(1, unit).asMilliseconds();
const unitDiff = Math.floor(value / unitInterval);

if ((unitDiff > min && unitDiff <= max) || (unit === 'year' && unitDiff > max)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... then no need for the extra || (unit === 'year' && unitDiff > max) check

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: it's also fine, and maybe more intuitive here to permit the min value too, and using a math-like ordering min <= unitDiff <= max

min <= unitDiff && unitDiff <= max

@nickofthyme
Copy link
Collaborator Author

Closing in favor of different approach, that being to require min-interval on BarSeriesSpec and remove from xDomain. Keep comments for future consideration.

@nickofthyme nickofthyme deleted the fix-single-time-value branch August 30, 2021 14:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
:data Data/series/scales related issue enhancement New feature or request :xy Bar/Line/Area chart related
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Default min interval for single values [Heatmap] Time scale type with a single row of data breaks the chart
3 participants