Skip to content

Commit

Permalink
fix: heatmap snap domain to interval (#1253)
Browse files Browse the repository at this point in the history
Heatmaps with a time scale on the X-axis now adjust the rendered time range to fully cover the edges when a custom domain is used. We also took this opportunity to clean and abstract the code used for computing and handling date and time. The library is now able to abstract from the underlying implementation library (moment or luxon at the moment), allowing us to experiment and work with diverse libraries removing some tech debt.

fix #1165
  • Loading branch information
markov00 committed Jul 30, 2021
1 parent 873f4e0 commit b439182
Show file tree
Hide file tree
Showing 11 changed files with 594 additions and 18 deletions.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Expand Up @@ -16,8 +16,10 @@ import { ScaleContinuous } from '../../../../scales';
import { ScaleType } from '../../../../scales/constants';
import { SettingsSpec } from '../../../../specs';
import { CanvasTextBBoxCalculator } from '../../../../utils/bbox/canvas_text_bbox_calculator';
import { clamp } from '../../../../utils/common';
import { snapDateToESInterval } from '../../../../utils/chrono/elasticsearch';
import { clamp, range } from '../../../../utils/common';
import { Dimensions } from '../../../../utils/dimensions';
import { ContinuousDomain } from '../../../../utils/domain';
import { PrimitiveValue } from '../../../partition_chart/layout/utils/group_by_rollup';
import { HeatmapSpec } from '../../specs';
import { HeatmapTable } from '../../state/selectors/compute_chart_dimensions';
Expand Down Expand Up @@ -101,9 +103,6 @@ export function shapeViewModel(

const yInvertedScale = scaleQuantize<NonNullable<PrimitiveValue>>().domain([0, height]).range(yValues);

// TODO: Fix domain type to be `Array<number | string>`
let xValues = xDomain.domain as any[];

const timeScale =
xDomain.type === ScaleType.Time
? new ScaleContinuous(
Expand All @@ -120,17 +119,17 @@ export function shapeViewModel(
)
: null;

if (timeScale) {
const result = [];
let [timePoint] = xValues;
while (timePoint < xValues[1]) {
result.push(timePoint);
timePoint += xDomain.minInterval;
}

xValues = result;
}

const xValues = timeScale
? range(
snapDateToESInterval(
(xDomain.domain as ContinuousDomain)[0],
{ type: 'fixed', unit: 'ms', quantity: xDomain.minInterval },
'start',
),
(xDomain.domain as ContinuousDomain)[1],
xDomain.minInterval,
)
: xDomain.domain;
// compute the scale for the columns positions
const xScale = scaleBand<NonNullable<PrimitiveValue>>().domain(xValues).range([0, chartDimensions.width]);

Expand Down Expand Up @@ -297,9 +296,10 @@ export function shapeViewModel(
const startValue = x[0];
const endValue = x[x.length - 1];

// find X coordinated based on the time range
const leftIndex = typeof startValue === 'number' ? bisectLeft(xValues, startValue) : xValues.indexOf(startValue);
const rightIndex = typeof endValue === 'number' ? bisectLeft(xValues, endValue) : xValues.indexOf(endValue) + 1;
const leftIndex =
typeof startValue === 'number' ? bisectLeft(xValues as number[], startValue) : xValues.indexOf(startValue);
const rightIndex =
typeof endValue === 'number' ? bisectLeft(xValues as number[], endValue) : xValues.indexOf(endValue) + 1;

const isRightOutOfRange = rightIndex > xValues.length - 1 || rightIndex < 0;
const isLeftOutOfRange = leftIndex > xValues.length - 1 || leftIndex < 0;
Expand Down
89 changes: 89 additions & 0 deletions packages/charts/src/utils/chrono/chrono.ts
@@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

// NOTE: to switch implementation just change the imported file (moment,luxon)
import {
addTimeToObj,
timeObjToUnixTimestamp,
startTimeOfObj,
endTimeOfObj,
timeObjFromAny,
timeObjToUTCOffset,
subtractTimeToObj,
formatTimeObj,
diffTimeObjs,
} from './moment';
import { CalendarIntervalUnit, CalendarObj, DateTime, FixedIntervalUnit, Minutes, UnixTimestamp } from './types';

/** @internal */
export function addTime(
dateTime: DateTime,
timeZone: string | undefined,
unit: keyof CalendarObj,
count: number,
): UnixTimestamp {
return timeObjToUnixTimestamp(addTimeToObj(getTimeObj(dateTime, timeZone), unit, count));
}

/** @internal */
export function subtractTime(
dateTime: DateTime,
timeZone: string | undefined,
unit: keyof CalendarObj,
count: number,
): UnixTimestamp {
return timeObjToUnixTimestamp(subtractTimeToObj(getTimeObj(dateTime, timeZone), unit, count));
}

/** @internal */
export function getUnixTimestamp(dateTime: DateTime, timeZone?: string): UnixTimestamp {
return timeObjToUnixTimestamp(getTimeObj(dateTime, timeZone));
}

/** @internal */
export function startOf(
dateTime: DateTime,
timeZone: string | undefined,
unit: CalendarIntervalUnit | FixedIntervalUnit,
): UnixTimestamp {
return timeObjToUnixTimestamp(startTimeOfObj(getTimeObj(dateTime, timeZone), unit));
}

/** @internal */
export function endOf(
dateTime: DateTime,
timeZone: string | undefined,
unit: CalendarIntervalUnit | FixedIntervalUnit,
): UnixTimestamp {
return timeObjToUnixTimestamp(endTimeOfObj(getTimeObj(dateTime, timeZone), unit));
}

function getTimeObj(dateTime: DateTime, timeZone?: string) {
return timeObjFromAny(dateTime, timeZone);
}

/** @internal */
export function getUTCOffset(dateTime: DateTime, timeZone?: string): Minutes {
return timeObjToUTCOffset(getTimeObj(dateTime, timeZone));
}

/** @internal */
export function formatTime(dateTime: DateTime, timeZone: string | undefined, format: string) {
return formatTimeObj(getTimeObj(dateTime, timeZone), format);
}

/** @internal */
export function diff(
dateTime1: DateTime,
timeZone1: string | undefined,
dateTime2: DateTime,
timeZone2: string | undefined,
unit: CalendarIntervalUnit | FixedIntervalUnit,
) {
return diffTimeObjs(getTimeObj(dateTime1, timeZone1), getTimeObj(dateTime2, timeZone2), unit);
}
57 changes: 57 additions & 0 deletions packages/charts/src/utils/chrono/elasticsearch.test.ts
@@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { DateTime } from 'luxon';

import { snapDateToESInterval } from './elasticsearch';

describe('snap to interval', () => {
it('should snap to begin of calendar interval', () => {
const initialDate = DateTime.fromISO('2020-01-03T07:00:01Z');
const snappedDate = snapDateToESInterval(
initialDate.toMillis(),
{ type: 'calendar', unit: 'd', quantity: 1 },
'start',
'UTC',
);
expect(DateTime.fromMillis(snappedDate, { zone: 'utc' }).toISO()).toBe('2020-01-03T00:00:00.000Z');
});

it('should snap to end of calendar interval', () => {
const initialDate = DateTime.fromISO('2020-01-03T07:00:01Z');
const snappedDate = snapDateToESInterval(
initialDate.toMillis(),
{ type: 'calendar', unit: 'd', quantity: 1 },
'end',
'UTC',
);
expect(DateTime.fromMillis(snappedDate, { zone: 'utc' }).toISO()).toBe('2020-01-03T23:59:59.999Z');
});

it('should snap to begin of fixed interval', () => {
const initialDate = DateTime.fromISO('2020-01-03T07:00:01Z');
const snappedDate = snapDateToESInterval(
initialDate.toMillis(),
{ type: 'fixed', unit: 'm', quantity: 30 },
'start',
'UTC',
);
expect(DateTime.fromMillis(snappedDate, { zone: 'utc' }).toISO()).toBe('2020-01-03T07:00:00.000Z');
});

it('should snap to end of fixed interval', () => {
const initialDate = DateTime.fromISO('2020-01-03T07:00:01Z');
const snappedDate = snapDateToESInterval(
initialDate.toMillis(),
{ type: 'fixed', unit: 'm', quantity: 30 },
'end',
'UTC',
);
expect(DateTime.fromMillis(snappedDate, { zone: 'utc' }).toISO()).toBe('2020-01-03T07:29:59.999Z');
});
});
109 changes: 109 additions & 0 deletions packages/charts/src/utils/chrono/elasticsearch.ts
@@ -0,0 +1,109 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { TimeMs } from '../../common/geometry';
import { endOf, getUnixTimestamp, startOf } from './chrono';
import { CalendarIntervalUnit, FixedIntervalUnit, UnixTimestamp } from './types';

/** @internal */
export type ESCalendarIntervalUnit =
| 'minute'
| 'm'
| 'hour'
| 'h'
| 'day'
| 'd'
| 'week'
| 'w'
| 'month'
| 'M'
| 'quarter'
| 'q'
| 'year'
| 'y';

type ESFixedIntervalUnit = 'ms' | 's' | 'm' | 'h' | 'd';

/** @internal */
export const ES_FIXED_INTERVAL_UNIT_TO_BASE: Record<ESFixedIntervalUnit, TimeMs> = {
ms: 1,
s: 1000,
m: 1000 * 60,
h: 1000 * 60 * 60,
d: 1000 * 60 * 60 * 24,
};

/** @internal */
export type ESCalendarInterval = {
type: 'calendar';
unit: ESCalendarIntervalUnit;
quantity: number;
};

/** @internal */
export interface ESFixedInterval {
type: 'fixed';
unit: ESFixedIntervalUnit;
quantity: number;
}

const esCalendarIntervalToChronoInterval: Record<ESCalendarIntervalUnit, CalendarIntervalUnit | FixedIntervalUnit> = {
minute: 'minute',
m: 'minute',
hour: 'hour',
h: 'hour',
day: 'day',
d: 'day',
week: 'week',
w: 'week',
month: 'month',
M: 'month',
quarter: 'quarter',
q: 'quarter',
year: 'year',
y: 'year',
};

/** @internal */
export function snapDateToESInterval(
date: number | Date,
interval: ESCalendarInterval | ESFixedInterval,
snapTo: 'start' | 'end',
timeZone?: string,
): UnixTimestamp {
return isCalendarInterval(interval)
? esCalendarIntervalSnap(date, interval, snapTo, timeZone)
: esFixedIntervalSnap(date, interval, snapTo, timeZone);
}

function isCalendarInterval(interval: ESCalendarInterval | ESFixedInterval): interval is ESCalendarInterval {
return interval.type === 'calendar';
}

function esCalendarIntervalSnap(
date: number | Date,
interval: ESCalendarInterval,
snapTo: 'start' | 'end',
timeZone?: string,
) {
return snapTo === 'start'
? startOf(date, timeZone, esCalendarIntervalToChronoInterval[interval.unit])
: endOf(date, timeZone, esCalendarIntervalToChronoInterval[interval.unit]);
}

function esFixedIntervalSnap(
date: number | Date,
interval: ESFixedInterval,
snapTo: 'start' | 'end',
timeZone?: string,
): UnixTimestamp {
const unitMultiplier = interval.quantity * ES_FIXED_INTERVAL_UNIT_TO_BASE[interval.unit];
const unixTimestamp = getUnixTimestamp(date, timeZone);
const roundedDate = Math.floor(unixTimestamp / unitMultiplier) * unitMultiplier;
return snapTo === 'start' ? roundedDate : roundedDate + unitMultiplier - 1;
}
69 changes: 69 additions & 0 deletions packages/charts/src/utils/chrono/luxon.ts
@@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

// eslint-disable-next-line import/no-extraneous-dependencies
import { DateTime as LuxonDateTime } from 'luxon';

import { CalendarIntervalUnit, CalendarObj, DateTime, FixedIntervalUnit, Minutes, UnixTimestamp } from './types';

/** @internal */
export const timeObjFromCalendarObj = (
yearMonthDayHour: Partial<CalendarObj>,
timeZone: string = 'local',
): LuxonDateTime => LuxonDateTime.fromObject({ ...yearMonthDayHour, zone: timeZone });
/** @internal */
export const timeObjFromUnixTimestamp = (unixTimestamp: UnixTimestamp, timeZone: string = 'local'): LuxonDateTime =>
LuxonDateTime.fromMillis(unixTimestamp, { zone: timeZone });

/** @internal */
export const timeObjFromDate = (date: Date, timeZone: string = 'local'): LuxonDateTime =>
LuxonDateTime.fromJSDate(date, { zone: timeZone });

/** @internal */
export const timeObjFromAny = (time: DateTime, timeZone: string = 'local'): LuxonDateTime => {
return typeof time === 'number'
? timeObjFromUnixTimestamp(time, timeZone)
: time instanceof Date
? timeObjFromDate(time, timeZone)
: timeObjFromCalendarObj(time, timeZone);
};

/** @internal */
export const timeObjToSeconds = (t: LuxonDateTime) => t.toSeconds();
/** @internal */
export const timeObjToUnixTimestamp = (t: LuxonDateTime): UnixTimestamp => t.toMillis();
/** @internal */
export const timeObjToWeekday = (t: LuxonDateTime) => t.weekday;
/** @internal */
export const timeObjToYear = (t: LuxonDateTime) => t.year;
/** @internal */
export const addTimeToObj = (obj: LuxonDateTime, unit: CalendarIntervalUnit | FixedIntervalUnit, count: number) =>
obj.plus({ [unit]: count });

/** @internal */
export const subtractTimeToObj = (obj: LuxonDateTime, unit: CalendarIntervalUnit | FixedIntervalUnit, count: number) =>
obj.minus({ [unit]: count });

/** @internal */
export const startTimeOfObj = (obj: LuxonDateTime, unit: CalendarIntervalUnit | FixedIntervalUnit) => obj.startOf(unit);

/** @internal */
export const endTimeOfObj = (obj: LuxonDateTime, unit: CalendarIntervalUnit | FixedIntervalUnit) => obj.endOf(unit);

/** @internal */
export const timeObjToUTCOffset = (obj: LuxonDateTime): Minutes => obj.offset;

/** @internal */
export const formatTimeObj = (obj: LuxonDateTime, format: string): string => obj.toFormat(format);

/** @internal */
export const diffTimeObjs = (
obj1: LuxonDateTime,
obj2: LuxonDateTime,
unit: CalendarIntervalUnit | FixedIntervalUnit,
): number => obj1.diff(obj2, unit).as(unit);

0 comments on commit b439182

Please sign in to comment.