diff --git a/src/ui/public/time_buckets/calc_auto_interval.js b/src/ui/public/time_buckets/calc_auto_interval.js deleted file mode 100644 index 4ed5d9cd22f16e..00000000000000 --- a/src/ui/public/time_buckets/calc_auto_interval.js +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import moment from 'moment'; -const { duration: d } = moment; - -// these are the rounding rules used by roundInterval() - -const roundingRules = [ - [ d(500, 'ms'), d(100, 'ms') ], - [ d(5, 'second'), d(1, 'second') ], - [ d(7.5, 'second'), d(5, 'second') ], - [ d(15, 'second'), d(10, 'second') ], - [ d(45, 'second'), d(30, 'second') ], - [ d(3, 'minute'), d(1, 'minute') ], - [ d(9, 'minute'), d(5, 'minute') ], - [ d(20, 'minute'), d(10, 'minute') ], - [ d(45, 'minute'), d(30, 'minute') ], - [ d(2, 'hour'), d(1, 'hour') ], - [ d(6, 'hour'), d(3, 'hour') ], - [ d(24, 'hour'), d(12, 'hour') ], - [ d(1, 'week'), d(1, 'd') ], - [ d(3, 'week'), d(1, 'week') ], - [ d(1, 'year'), d(1, 'month') ], - [ Infinity, d(1, 'year') ] -]; - -const revRoundingRules = roundingRules.slice(0).reverse(); - -function find(rules, check, last) { - function pick(buckets, duration) { - const target = duration / buckets; - let lastResp; - - for (let i = 0; i < rules.length; i++) { - const rule = rules[i]; - const resp = check(rule[0], rule[1], target); - - if (resp == null) { - if (!last) continue; - if (lastResp) return lastResp; - break; - } - - if (!last) return resp; - lastResp = resp; - } - - // fallback to just a number of milliseconds, ensure ms is >= 1 - const ms = Math.max(Math.floor(target), 1); - return moment.duration(ms, 'ms'); - } - - return function (buckets, duration) { - const interval = pick(buckets, duration); - if (interval) return moment.duration(interval._data); - }; -} - -export const calcAutoInterval = { - near: find(revRoundingRules, function near(bound, interval, target) { - if (bound > target) return interval; - }, true), - - lessThan: find(revRoundingRules, function (bound, interval, target) { - if (interval < target) return interval; - }), - - atLeast: find(revRoundingRules, function atLeast(bound, interval, target) { - if (interval <= target) return interval; - }), -}; diff --git a/src/ui/public/time_buckets/calc_auto_interval.test.ts b/src/ui/public/time_buckets/calc_auto_interval.test.ts new file mode 100644 index 00000000000000..7c95da6a74dd37 --- /dev/null +++ b/src/ui/public/time_buckets/calc_auto_interval.test.ts @@ -0,0 +1,136 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import moment from 'moment'; + +import { calcAutoIntervalLessThan, calcAutoIntervalNear } from './calc_auto_interval'; + +describe('calcAutoIntervalNear', () => { + test('1h/0 buckets = 0ms buckets', () => { + const interval = calcAutoIntervalNear(0, Number(moment.duration(1, 'h'))); + expect(interval.asMilliseconds()).toBe(0); + }); + + test('undefined/100 buckets = 0ms buckets', () => { + const interval = calcAutoIntervalNear(0, undefined as any); + expect(interval.asMilliseconds()).toBe(0); + }); + + test('1ms/100 buckets = 1ms buckets', () => { + const interval = calcAutoIntervalNear(100, Number(moment.duration(1, 'ms'))); + expect(interval.asMilliseconds()).toBe(1); + }); + + test('200ms/100 buckets = 2ms buckets', () => { + const interval = calcAutoIntervalNear(100, Number(moment.duration(200, 'ms'))); + expect(interval.asMilliseconds()).toBe(2); + }); + + test('1s/1000 buckets = 1ms buckets', () => { + const interval = calcAutoIntervalNear(1000, Number(moment.duration(1, 's'))); + expect(interval.asMilliseconds()).toBe(1); + }); + + test('1000h/1000 buckets = 1h buckets', () => { + const interval = calcAutoIntervalNear(1000, Number(moment.duration(1000, 'hours'))); + expect(interval.asHours()).toBe(1); + }); + + test('1h/100 buckets = 30s buckets', () => { + const interval = calcAutoIntervalNear(100, Number(moment.duration(1, 'hours'))); + expect(interval.asSeconds()).toBe(30); + }); + + test('1d/25 buckets = 1h buckets', () => { + const interval = calcAutoIntervalNear(25, Number(moment.duration(1, 'day'))); + expect(interval.asHours()).toBe(1); + }); + + test('1y/1000 buckets = 12h buckets', () => { + const interval = calcAutoIntervalNear(1000, Number(moment.duration(1, 'year'))); + expect(interval.asHours()).toBe(12); + }); + + test('1y/10000 buckets = 1h buckets', () => { + const interval = calcAutoIntervalNear(10000, Number(moment.duration(1, 'year'))); + expect(interval.asHours()).toBe(1); + }); + + test('1y/100000 buckets = 5m buckets', () => { + const interval = calcAutoIntervalNear(100000, Number(moment.duration(1, 'year'))); + expect(interval.asMinutes()).toBe(5); + }); +}); + +describe('calcAutoIntervalLessThan', () => { + test('1h/0 buckets = 0ms buckets', () => { + const interval = calcAutoIntervalLessThan(0, Number(moment.duration(1, 'h'))); + expect(interval.asMilliseconds()).toBe(0); + }); + + test('undefined/100 buckets = 0ms buckets', () => { + const interval = calcAutoIntervalLessThan(0, undefined as any); + expect(interval.asMilliseconds()).toBe(0); + }); + + test('1ms/100 buckets = 1ms buckets', () => { + const interval = calcAutoIntervalLessThan(100, Number(moment.duration(1, 'ms'))); + expect(interval.asMilliseconds()).toBe(1); + }); + + test('200ms/100 buckets = 2ms buckets', () => { + const interval = calcAutoIntervalLessThan(100, Number(moment.duration(200, 'ms'))); + expect(interval.asMilliseconds()).toBe(2); + }); + + test('1s/1000 buckets = 1ms buckets', () => { + const interval = calcAutoIntervalLessThan(1000, Number(moment.duration(1, 's'))); + expect(interval.asMilliseconds()).toBe(1); + }); + + test('1000h/1000 buckets = 1h buckets', () => { + const interval = calcAutoIntervalLessThan(1000, Number(moment.duration(1000, 'hours'))); + expect(interval.asHours()).toBe(1); + }); + + test('1h/100 buckets = 30s buckets', () => { + const interval = calcAutoIntervalLessThan(100, Number(moment.duration(1, 'hours'))); + expect(interval.asSeconds()).toBe(30); + }); + + test('1d/25 buckets = 30m buckets', () => { + const interval = calcAutoIntervalLessThan(25, Number(moment.duration(1, 'day'))); + expect(interval.asMinutes()).toBe(30); + }); + + test('1y/1000 buckets = 3h buckets', () => { + const interval = calcAutoIntervalLessThan(1000, Number(moment.duration(1, 'year'))); + expect(interval.asHours()).toBe(3); + }); + + test('1y/10000 buckets = 30m buckets', () => { + const interval = calcAutoIntervalLessThan(10000, Number(moment.duration(1, 'year'))); + expect(interval.asMinutes()).toBe(30); + }); + + test('1y/100000 buckets = 5m buckets', () => { + const interval = calcAutoIntervalLessThan(100000, Number(moment.duration(1, 'year'))); + expect(interval.asMinutes()).toBe(5); + }); +}); diff --git a/src/ui/public/time_buckets/calc_auto_interval.ts b/src/ui/public/time_buckets/calc_auto_interval.ts new file mode 100644 index 00000000000000..c3478772669c49 --- /dev/null +++ b/src/ui/public/time_buckets/calc_auto_interval.ts @@ -0,0 +1,145 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import moment from 'moment'; + +const boundsDescending = [ + { + bound: Infinity, + interval: Number(moment.duration(1, 'year')), + }, + { + bound: Number(moment.duration(1, 'year')), + interval: Number(moment.duration(1, 'month')), + }, + { + bound: Number(moment.duration(3, 'week')), + interval: Number(moment.duration(1, 'week')), + }, + { + bound: Number(moment.duration(1, 'week')), + interval: Number(moment.duration(1, 'd')), + }, + { + bound: Number(moment.duration(24, 'hour')), + interval: Number(moment.duration(12, 'hour')), + }, + { + bound: Number(moment.duration(6, 'hour')), + interval: Number(moment.duration(3, 'hour')), + }, + { + bound: Number(moment.duration(2, 'hour')), + interval: Number(moment.duration(1, 'hour')), + }, + { + bound: Number(moment.duration(45, 'minute')), + interval: Number(moment.duration(30, 'minute')), + }, + { + bound: Number(moment.duration(20, 'minute')), + interval: Number(moment.duration(10, 'minute')), + }, + { + bound: Number(moment.duration(9, 'minute')), + interval: Number(moment.duration(5, 'minute')), + }, + { + bound: Number(moment.duration(3, 'minute')), + interval: Number(moment.duration(1, 'minute')), + }, + { + bound: Number(moment.duration(45, 'second')), + interval: Number(moment.duration(30, 'second')), + }, + { + bound: Number(moment.duration(15, 'second')), + interval: Number(moment.duration(10, 'second')), + }, + { + bound: Number(moment.duration(7.5, 'second')), + interval: Number(moment.duration(5, 'second')), + }, + { + bound: Number(moment.duration(5, 'second')), + interval: Number(moment.duration(1, 'second')), + }, + { + bound: Number(moment.duration(500, 'ms')), + interval: Number(moment.duration(100, 'ms')), + }, +]; + +function getPerBucketMs(count: number, duration: number) { + const ms = duration / count; + return isFinite(ms) ? ms : NaN; +} + +function normalizeMinimumInterval(targetMs: number) { + const value = isNaN(targetMs) ? 0 : Math.max(Math.floor(targetMs), 1); + return moment.duration(value); +} + +/** + * Using some simple rules we pick a "pretty" interval that will + * produce around the number of buckets desired given a time range. + * + * @param targetBucketCount desired number of buckets + * @param duration time range the agg covers + */ +export function calcAutoIntervalNear(targetBucketCount: number, duration: number) { + const targetPerBucketMs = getPerBucketMs(targetBucketCount, duration); + + // Find the first bound which is smaller than our target. + const lowerBoundIndex = boundsDescending.findIndex(({ bound }) => { + const boundMs = Number(bound); + return boundMs <= targetPerBucketMs; + }); + + // The bound immediately preceeding that lower bound contains the + // interval most closely matching our target. + if (lowerBoundIndex !== -1) { + const nearestInterval = boundsDescending[lowerBoundIndex - 1].interval; + return moment.duration(nearestInterval); + } + + // If the target is smaller than any of our bounds, then we'll use it for the interval as-is. + return normalizeMinimumInterval(targetPerBucketMs); +} + +/** + * Pick a "pretty" interval that produces no more than the maxBucketCount + * for the given time range. + * + * @param maxBucketCount maximum number of buckets to create + * @param duration amount of time covered by the agg + */ +export function calcAutoIntervalLessThan(maxBucketCount: number, duration: number) { + const maxPerBucketMs = getPerBucketMs(maxBucketCount, duration); + + for (const { interval } of boundsDescending) { + // Find the highest interval which meets our per bucket limitation. + if (interval <= maxPerBucketMs) { + return moment.duration(interval); + } + } + + // If the max is smaller than any of our intervals, then we'll use it for the interval as-is. + return normalizeMinimumInterval(maxPerBucketMs); +} diff --git a/src/ui/public/time_buckets/time_buckets.js b/src/ui/public/time_buckets/time_buckets.js index a5af6c3294ed35..0da2fc868c9770 100644 --- a/src/ui/public/time_buckets/time_buckets.js +++ b/src/ui/public/time_buckets/time_buckets.js @@ -21,7 +21,7 @@ import _ from 'lodash'; import moment from 'moment'; import chrome from '../chrome'; import { parseInterval } from '../utils/parse_interval'; -import { calcAutoInterval } from './calc_auto_interval'; +import { calcAutoIntervalLessThan, calcAutoIntervalNear } from './calc_auto_interval'; import { convertDurationToNormalizedEsInterval, convertIntervalToEsInterval, @@ -231,7 +231,7 @@ TimeBuckets.prototype.getInterval = function (useNormalizedEsInterval = true) { function readInterval() { const interval = self._i; if (moment.isDuration(interval)) return interval; - return calcAutoInterval.near(config.get('histogram:barTarget'), duration); + return calcAutoIntervalNear(config.get('histogram:barTarget'), Number(duration)); } // check to see if the interval should be scaled, and scale it if so @@ -243,7 +243,7 @@ TimeBuckets.prototype.getInterval = function (useNormalizedEsInterval = true) { let scaled; if (approxLen > maxLength) { - scaled = calcAutoInterval.lessThan(maxLength, duration); + scaled = calcAutoIntervalLessThan(maxLength, Number(duration)); } else { return interval; }