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

fix(heatmap): snap time bucket to calendar/fixed intervals #1462

Merged
merged 16 commits into from
Nov 9, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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';
Comment on lines +722 to +723
Copy link
Contributor

Choose a reason for hiding this comment

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

Not necessarily in this PR, we could eventually depend on types in elasticsearch.js as an authoritative source

Copy link
Contributor

Choose a reason for hiding this comment

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

Just to clarify, as we have an elasticsearch.ts, I meant, at some point directly devDepending on https://github.com/elastic/elasticsearch-js for these types. This way, if elasticsearch adds a new unit, and we bump the dependency, we'll get automatic static errors saying that we don't cover the new unit option(s)


// @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