Skip to content

Commit

Permalink
fix(heatmap): snap time bucket to calendar/fixed intervals (#1462)
Browse files Browse the repository at this point in the history
The commit introduces the ability to specify both calendars and fixed intervals as described in Elasticserach date_histogram aggregation. The fix is required to cover the Lens usage when calendar intervals are used in conjunction with the date_histogram aggs.

BREAKING CHANGE: The `xScaleType` is replaced by the prop `xScale`, which better describes a rasterized time scale with an Elasticsearch compliant interval.
  • Loading branch information
markov00 authored Nov 9, 2021
1 parent 379c2d6 commit b76c12c
Show file tree
Hide file tree
Showing 31 changed files with 4,661 additions and 872 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions integration/tests/heatmap_stories.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,11 @@ describe('Heatmap stories', () => {
'http://localhost:9001/?path=/story/heatmap-alpha--categorical&knob-use global min fontSize_labels=false',
);
});

it.each([[2], [3], [4], [5], [6], [7], [8], [9]])('time snap with dataset %i', async (dataset) => {
await page.setViewport({ width: 785, height: 600 });
await common.expectChartAtUrlToMatchScreenshot(
`http://localhost:9001/?path=/story/heatmap-alpha--time-snap&globals=theme:light&knob-dataset=${dataset}`,
);
});
});
52 changes: 51 additions & 1 deletion packages/charts/api/charts.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,32 @@ export const entryKey: ([key]: ArrayEntry) => string;
// @public (undocumented)
export const entryValue: ([, value]: ArrayEntry) => ArrayNode;

// @public (undocumented)
export interface ESCalendarInterval {
// (undocumented)
type: 'calendar';
// (undocumented)
unit: ESCalendarIntervalUnit;
// (undocumented)
value: number;
}

// @public (undocumented)
export type ESCalendarIntervalUnit = 'minute' | 'm' | 'hour' | 'h' | 'day' | 'd' | 'week' | 'w' | 'month' | 'M' | 'quarter' | 'q' | 'year' | 'y';

// @public (undocumented)
export interface ESFixedInterval {
// (undocumented)
type: 'fixed';
// (undocumented)
unit: ESFixedIntervalUnit;
// (undocumented)
value: number;
}

// @public (undocumented)
export type ESFixedIntervalUnit = 'ms' | 's' | 'm' | 'h' | 'd';

// @alpha
export interface ExternalPointerEventsSettings {
tooltip: TooltipPortalSettings<'chart'> & {
Expand Down Expand Up @@ -1062,7 +1088,7 @@ export interface HeatmapSpec extends Spec {
// (undocumented)
xAccessor: Accessor | AccessorFn;
// (undocumented)
xScaleType: SeriesScales['xScaleType'];
xScale: RasterTimeScale | OrdinalScale | LinearScale;
// (undocumented)
xSortPredicate: Predicate;
// (undocumented)
Expand Down Expand Up @@ -1300,6 +1326,12 @@ export interface LineAnnotationStyle {
line: StrokeStyle & Opacity & Partial<StrokeDashArray>;
}

// @public (undocumented)
export interface LinearScale {
// (undocumented)
type: typeof ScaleType.Linear;
}

// Warning: (ae-forgotten-export) The symbol "SpecRequiredProps" needs to be exported by the entry point index.d.ts
// Warning: (ae-forgotten-export) The symbol "SpecOptionalProps" needs to be exported by the entry point index.d.ts
//
Expand Down Expand Up @@ -1402,6 +1434,12 @@ export interface OrderBy {
// @public (undocumented)
export type OrdinalDomain = (number | string)[];

// @public (undocumented)
export interface OrdinalScale {
// (undocumented)
type: typeof ScaleType.Ordinal;
}

// @public (undocumented)
export type OutOfRoomCallback = (wordCount: number, renderedWordCount: number, renderedWords: string[]) => void;

Expand Down Expand Up @@ -1627,6 +1665,12 @@ export type ProjectedValues = {
// @public
export type ProjectionClickListener = (values: ProjectedValues) => void;

// @public (undocumented)
export interface RasterTimeScale extends TimeScale {
// (undocumented)
interval: ESCalendarInterval | ESFixedInterval;
}

// @public
export type Ratio = number;

Expand Down Expand Up @@ -2146,6 +2190,12 @@ export type TickStyle = StrokeStyle & Visible & {
// @public (undocumented)
export function timeFormatter(format: string): TickFormatter;

// @public (undocumented)
export interface TimeScale {
// (undocumented)
type: typeof ScaleType.Time;
}

// @public
export function toEntries<T extends Record<string, string>, S>(array: T[], accessor: keyof T, staticValue: S): Record<string, S>;

Expand Down
139 changes: 71 additions & 68 deletions packages/charts/src/chart_types/heatmap/layout/viewmodel/viewmodel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,19 @@
*/

import { bisectLeft } from 'd3-array';
import { scaleBand, scaleQuantize } from 'd3-scale';
import { ScaleBand, scaleBand, scaleQuantize } from 'd3-scale';

import { colorToRgba } from '../../../../common/color_library_wrappers';
import { fillTextColor } from '../../../../common/fill_text_color';
import { Pixels } from '../../../../common/geometry';
import { Box, maximiseFontSize, TextMeasure } from '../../../../common/text_utils';
import { ScaleContinuous } from '../../../../scales';
import { ScaleType } from '../../../../scales/constants';
import { SettingsSpec } from '../../../../specs';
import { LinearScale, OrdinalScale, RasterTimeScale, SettingsSpec } from '../../../../specs';
import { withTextMeasure } from '../../../../utils/bbox/canvas_text_bbox_calculator';
import { snapDateToESInterval } from '../../../../utils/chrono/elasticsearch';
import { clamp, range } from '../../../../utils/common';
import { addIntervalToTime } from '../../../../utils/chrono/elasticsearch';
import { clamp } from '../../../../utils/common';
import { Dimensions } from '../../../../utils/dimensions';
import { ContinuousDomain } from '../../../../utils/domain';
import { Logger } from '../../../../utils/logger';
import { Theme } from '../../../../utils/themes/theme';
import { PrimitiveValue } from '../../../partition_chart/layout/utils/group_by_rollup';
Expand Down Expand Up @@ -86,7 +85,7 @@ export function shapeViewModel(
): ShapeViewModel {
const gridStrokeWidth = config.grid.stroke.width ?? 1;

const { table, yValues, xDomain } = heatmapTable;
const { table, yValues, xValues } = heatmapTable;

// measure the text width of all rows values to get the grid area width
const boxedYValues = yValues.map<Box & { value: NonNullable<PrimitiveValue> }>((value) => ({
Expand All @@ -100,33 +99,6 @@ export function shapeViewModel(

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

const timeScale =
xDomain.type === ScaleType.Time
? new ScaleContinuous(
{
type: ScaleType.Time,
domain: xDomain.domain as number[],
range: [0, chartDimensions.width],
nice: false,
},
{
desiredTickCount: estimatedNonOverlappingTickCount(chartDimensions.width, config.xAxisLabel),
timeZone: config.timeZone,
},
)
: null;

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 All @@ -143,28 +115,8 @@ export function shapeViewModel(

const currentGridHeight = cellHeight * pageSize;

const getTextValue = (
formatter: (v: any, options: any) => string,
scaleCallback: (x: any) => number | undefined | null = xScale,
) => (value: any): TextBox => {
return {
text: formatter(value, { timeZone: config.timeZone }),
value,
...config.xAxisLabel,
x: chartDimensions.left + (scaleCallback(value) || 0),
y: cellHeight * pageSize + config.xAxisLabel.fontSize / 2 + config.xAxisLabel.padding,
};
};

// compute the position of each column label
const textXValues: Array<TextBox> = timeScale
? timeScale.ticks().map<TextBox>(getTextValue(config.xAxisLabel.formatter, (x: any) => timeScale.scale(x)))
: xValues.map<TextBox>((textBox: any) => {
return {
...getTextValue(config.xAxisLabel.formatter)(textBox),
x: chartDimensions.left + (xScale(textBox) || 0) + xScale.bandwidth() / 2,
};
});
const textXValues = getXTicks(spec, config, chartDimensions, xScale, heatmapTable, currentGridHeight);

const { padding } = config.yAxisLabel;
const rightPadding = typeof padding === 'number' ? padding : padding.right ?? 0;
Expand Down Expand Up @@ -291,8 +243,9 @@ export function shapeViewModel(
const allXValuesInRange: Array<NonNullable<PrimitiveValue>> = getValuesInRange(xValues, startX, endX);
const allYValuesInRange: Array<NonNullable<PrimitiveValue>> = getValuesInRange(yValues, startY, endY);
const invertedXValues: Array<NonNullable<PrimitiveValue>> =
timeScale && typeof endX === 'number' ? [startX, endX + xDomain.minInterval] : [...allXValuesInRange];

isRasterTimeScale(spec.xScale) && typeof endX === 'number'
? [startX, addIntervalToTime(endX, spec.xScale.interval, config.timeZone)]
: [...allXValuesInRange];
const cells: Cell[] = [];

allXValuesInRange.forEach((x) => {
Expand Down Expand Up @@ -371,19 +324,20 @@ export function shapeViewModel(
};

// vertical lines
const xLines = [];
for (let i = 0; i < xValues.length + 1; i++) {
const x = chartDimensions.left + i * cellWidth;
const y1 = chartDimensions.top;
const y2 = cellHeight * pageSize;
xLines.push({ x1: x, y1, x2: x, y2 });
}
const xLines = Array.from({ length: xValues.length + 1 }, (d, i) => ({
x1: chartDimensions.left + i * cellWidth,
x2: chartDimensions.left + i * cellWidth,
y1: chartDimensions.top,
y2: currentGridHeight,
}));

// horizontal lines
const yLines = [];
for (let i = 0; i < pageSize + 1; i++) {
const y = i * cellHeight;
yLines.push({ x1: chartDimensions.left, y1: y, x2: chartDimensions.width + chartDimensions.left, y2: y });
}
const yLines = Array.from({ length: pageSize + 1 }, (d, i) => ({
x1: chartDimensions.left,
x2: chartDimensions.left + chartDimensions.width,
y1: i * cellHeight,
y2: i * cellHeight,
}));

const cells = Object.values(cellMap);
const tableMinFontSize = cells.reduce((acc, { fontSize }) => Math.min(acc, fontSize), Infinity);
Expand Down Expand Up @@ -423,3 +377,52 @@ function getCellKey(x: NonNullable<PrimitiveValue>, y: NonNullable<PrimitiveValu
function isValueHidden(value: number, rangesToHide: Array<[number, number]>) {
return rangesToHide.some(([min, max]) => min <= value && value < max);
}

/** @internal */
export function isRasterTimeScale(scale: RasterTimeScale | OrdinalScale | LinearScale): scale is RasterTimeScale {
return scale.type === ScaleType.Time;
}

function getXTicks(
spec: HeatmapSpec,
config: Config,
chartDimensions: Dimensions,
xScale: ScaleBand<string | number>,
{ xValues, xNumericExtent }: HeatmapTable,
gridHeight: number,
): Array<TextBox> {
const getTextValue = (
formatter: Config['xAxisLabel']['formatter'],
scaleCallback: (x: string | number) => number | undefined | null,
) => (value: string | number): TextBox => {
return {
text: formatter(value),
value,
...config.xAxisLabel,
x: chartDimensions.left + (scaleCallback(value) ?? 0),
y: gridHeight + config.xAxisLabel.fontSize / 2 + config.xAxisLabel.padding,
};
};
if (isRasterTimeScale(spec.xScale)) {
const timeScale = new ScaleContinuous(
{
type: ScaleType.Time,
domain: xNumericExtent,
range: [0, chartDimensions.width],
nice: false,
},
{
desiredTickCount: estimatedNonOverlappingTickCount(chartDimensions.width, config.xAxisLabel),
timeZone: config.timeZone,
},
);
return timeScale.ticks().map<TextBox>(getTextValue(config.xAxisLabel.formatter, (x) => timeScale.scale(x)));
}

return xValues.map<TextBox>((textBox: string | number) => {
return {
...getTextValue(config.xAxisLabel.formatter, xScale)(textBox),
x: chartDimensions.left + (xScale(textBox) || 0) + xScale.bandwidth() / 2,
};
});
}
29 changes: 25 additions & 4 deletions packages/charts/src/chart_types/heatmap/specs/heatmap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import { ChartType } from '../..';
import { Color } from '../../../common/colors';
import { Predicate } from '../../../common/predicate';
import { ScaleType } from '../../../scales/constants';
import { SeriesScales, Spec } from '../../../specs';
import { Spec } from '../../../specs';
import { SpecType } from '../../../specs/constants';
import { getConnect, specComponentFactory } from '../../../state/spec_factory';
import { Accessor, AccessorFn } from '../../../utils/accessor';
import { ESCalendarInterval, ESFixedInterval } from '../../../utils/chrono/elasticsearch';
import { Datum, RecursivePartial } from '../../../utils/common';
import { config } from '../layout/config/config';
import { Config } from '../layout/types/config_types';
Expand All @@ -27,7 +28,7 @@ const defaultProps = {
data: [],
xAccessor: ({ x }: { x: string | number }) => x,
yAccessor: ({ y }: { y: string | number }) => y,
xScaleType: X_SCALE_DEFAULT.type,
xScale: { type: X_SCALE_DEFAULT.type },
valueAccessor: ({ value }: { value: string | number }) => value,
valueFormatter: (value: number) => `${value}`,
xSortPredicate: Predicate.AlphaAsc,
Expand Down Expand Up @@ -58,6 +59,26 @@ export interface HeatmapBandsColorScale {
labelFormatter?: (start: number, end: number) => string;
}

/** @public */
export interface TimeScale {
type: typeof ScaleType.Time;
}

/** @public */
export interface RasterTimeScale extends TimeScale {
interval: ESCalendarInterval | ESFixedInterval;
}

/** @public */
export interface LinearScale {
type: typeof ScaleType.Linear;
}

/** @public */
export interface OrdinalScale {
type: typeof ScaleType.Ordinal;
}

/** @alpha */
export interface HeatmapSpec extends Spec {
specType: typeof SpecType.Series;
Expand All @@ -70,7 +91,7 @@ export interface HeatmapSpec extends Spec {
valueFormatter: (value: number) => string;
xSortPredicate: Predicate;
ySortPredicate: Predicate;
xScaleType: SeriesScales['xScaleType'];
xScale: RasterTimeScale | OrdinalScale | LinearScale;
config: RecursivePartial<Config>;
highlightedData?: { x: Array<string | number>; y: Array<string | number> };
name?: string;
Expand All @@ -90,6 +111,6 @@ export const Heatmap: React.FunctionComponent<
| 'xSortPredicate'
| 'valueFormatter'
| 'config'
| 'xScaleType'
| 'xScale'
>(defaultProps),
);
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import { getLegendSizeSelector } from '../../../../state/selectors/get_legend_si
import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs';
import { Position } from '../../../../utils/common';
import { Dimensions } from '../../../../utils/dimensions';
import { XDomain } from '../../../xy_chart/domains/types';
import { HeatmapCellDatum } from '../../layout/viewmodel/viewmodel';
import { getGridHeightParamsSelector } from './get_grid_full_height';
import { getHeatmapConfigSelector } from './get_heatmap_config';
Expand All @@ -25,10 +24,9 @@ import { getXAxisRightOverflow } from './get_x_axis_right_overflow';
/** @internal */
export interface HeatmapTable {
table: Array<HeatmapCellDatum>;
// unique set of column values
xDomain: XDomain;
// unique set of row values
yValues: Array<string | number>;
xValues: Array<string | number>;
xNumericExtent: [number, number];
extent: [number, number];
}

Expand Down
Loading

0 comments on commit b76c12c

Please sign in to comment.