diff --git a/packages/osd-charts/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-heatmap-alpha-categorical-visually-looks-correct-1-snap.png b/packages/osd-charts/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-heatmap-alpha-categorical-visually-looks-correct-1-snap.png index 59aceb0db04c..7938bc4318d2 100644 Binary files a/packages/osd-charts/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-heatmap-alpha-categorical-visually-looks-correct-1-snap.png and b/packages/osd-charts/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-heatmap-alpha-categorical-visually-looks-correct-1-snap.png differ diff --git a/packages/osd-charts/packages/charts/src/chart_types/heatmap/layout/types/viewmodel_types.ts b/packages/osd-charts/packages/charts/src/chart_types/heatmap/layout/types/viewmodel_types.ts index 15ac98e8f842..c1e3705fa3ad 100644 --- a/packages/osd-charts/packages/charts/src/chart_types/heatmap/layout/types/viewmodel_types.ts +++ b/packages/osd-charts/packages/charts/src/chart_types/heatmap/layout/types/viewmodel_types.ts @@ -19,10 +19,12 @@ import { ChartType } from '../../..'; import { Pixels } from '../../../../common/geometry'; +import { Box } from '../../../../common/text_utils'; import { Fill, Line, Stroke } from '../../../../geoms/types'; import { Point } from '../../../../utils/point'; +import { PrimitiveValue } from '../../../partition_chart/layout/utils/group_by_rollup'; import { config } from '../config/config'; -import { HeatmapCellDatum, TextBox } from '../viewmodel/viewmodel'; +import { HeatmapCellDatum } from '../viewmodel/viewmodel'; import { Config, HeatmapBrushEvent } from './config_types'; /** @internal */ @@ -47,6 +49,13 @@ export interface Cell { datum: HeatmapCellDatum; } +/** @internal */ +export interface TextBox extends Box { + value: NonNullable; + x: number; + y: number; +} + /** @internal */ export interface HeatmapViewModel { gridOrigin: { diff --git a/packages/osd-charts/packages/charts/src/chart_types/heatmap/layout/viewmodel/viewmodel.ts b/packages/osd-charts/packages/charts/src/chart_types/heatmap/layout/viewmodel/viewmodel.ts index 8fbb8d6ef8d4..c949ba967b7c 100644 --- a/packages/osd-charts/packages/charts/src/chart_types/heatmap/layout/viewmodel/viewmodel.ts +++ b/packages/osd-charts/packages/charts/src/chart_types/heatmap/layout/viewmodel/viewmodel.ts @@ -27,7 +27,9 @@ 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 { Dimensions } from '../../../../utils/dimensions'; +import { PrimitiveValue } from '../../../partition_chart/layout/utils/group_by_rollup'; import { HeatmapSpec } from '../../specs'; import { HeatmapTable } from '../../state/selectors/compute_chart_dimensions'; import { ColorScaleType } from '../../state/selectors/get_color_scale'; @@ -39,21 +41,25 @@ import { PickDragShapeFunction, PickHighlightedArea, ShapeViewModel, + TextBox, } from '../types/viewmodel_types'; /** @public */ export interface HeatmapCellDatum { - x: string | number; - y: string | number; + x: NonNullable; + y: NonNullable; value: number; originalIndex: number; } -/** @internal */ -export interface TextBox extends Box { - value: string | number; - x: number; - y: number; +function getValuesInRange( + values: NonNullable[], + startValue: NonNullable, + endValue: NonNullable, +) { + const startIndex = values.indexOf(startValue); + const endIndex = Math.min(values.indexOf(endValue) + 1, values.length); + return values.slice(startIndex, endIndex); } /** @@ -93,7 +99,7 @@ export function shapeViewModel( const { table, yValues, xDomain } = heatmapTable; // measure the text width of all rows values to get the grid area width - const boxedYValues = yValues.map((value) => { + const boxedYValues = yValues.map }>((value) => { return { text: config.yAxisLabel.formatter(value), value, @@ -102,9 +108,9 @@ export function shapeViewModel( }); // compute the scale for the rows positions - const yScale = scaleBand().domain(yValues).range([0, height]); + const yScale = scaleBand>().domain(yValues).range([0, height]); - const yInvertedScale = scaleQuantize().domain([0, height]).range(yValues); + const yInvertedScale = scaleQuantize>().domain([0, height]).range(yValues); // TODO: Fix domain type to be `Array` let xValues = xDomain.domain as any[]; @@ -137,9 +143,9 @@ export function shapeViewModel( } // compute the scale for the columns positions - const xScale = scaleBand().domain(xValues).range([0, chartDimensions.width]); + const xScale = scaleBand>().domain(xValues).range([0, chartDimensions.width]); - const xInvertedScale = scaleQuantize().domain([0, chartDimensions.width]).range(xValues); + const xInvertedScale = scaleQuantize>().domain([0, chartDimensions.width]).range(xValues); // compute the cell width (can be smaller then the available size depending on config const cellWidth = @@ -150,6 +156,8 @@ export function shapeViewModel( // compute the cell height (we already computed the max size for that) const cellHeight = yScale.bandwidth(); + const currentGridHeight = cellHeight * pageSize; + const getTextValue = ( formatter: (v: any, options: any) => string, scaleCallback: (x: any) => number | undefined | null = xScale, @@ -258,19 +266,20 @@ export function shapeViewModel( const pickDragArea: PickDragFunction = (bound) => { const [start, end] = bound; - const { left, top } = chartDimensions; - const invertedBounds = { - startX: xInvertedScale(Math.min(start.x, end.x) - left), - startY: yInvertedScale(Math.min(start.y, end.y) - top), - endX: xInvertedScale(Math.max(start.x, end.x) - left), - endY: yInvertedScale(Math.max(start.y, end.y) - top), - }; + const { left, top, width } = chartDimensions; + const topLeft = [Math.min(start.x, end.x) - left, Math.min(start.y, end.y) - top]; + const bottomRight = [Math.max(start.x, end.x) - left, Math.max(start.y, end.y) - top]; - let allXValuesInRange = []; - const invertedXValues: Array = []; - const { startX, endX, startY, endY } = invertedBounds; - invertedXValues.push(startX); - if (typeof endX === 'number') { + const startX = xInvertedScale(clamp(topLeft[0], 0, width)); + const endX = xInvertedScale(clamp(bottomRight[0], 0, width)); + const startY = yInvertedScale(clamp(topLeft[1], 0, currentGridHeight - 1)); + const endY = yInvertedScale(clamp(bottomRight[1], 0, currentGridHeight - 1)); + + let allXValuesInRange: Array> = []; + const invertedXValues: Array> = []; + + if (timeScale && typeof endX === 'number') { + invertedXValues.push(startX); invertedXValues.push(endX + xDomain.minInterval); let [startXValue] = invertedXValues; if (typeof startXValue === 'number') { @@ -280,19 +289,11 @@ export function shapeViewModel( } } } else { - invertedXValues.push(endX); - const startXIndex = xValues.indexOf(startX); - const endXIndex = Math.min(xValues.indexOf(endX) + 1, xValues.length); - allXValuesInRange = xValues.slice(startXIndex, endXIndex); + allXValuesInRange = getValuesInRange(xValues, startX, endX); invertedXValues.push(...allXValuesInRange); } - const invertedYValues: Array = []; - - const startYIndex = yValues.indexOf(startY); - const endYIndex = Math.min(yValues.indexOf(endY) + 1, yValues.length); - const allYValuesInRange = yValues.slice(startYIndex, endYIndex); - invertedYValues.push(...allYValuesInRange); + const allYValuesInRange: Array> = getValuesInRange(yValues, startY, endY); const cells: Cell[] = []; @@ -306,7 +307,7 @@ export function shapeViewModel( return { cells: cells.filter(Boolean), x: invertedXValues, - y: invertedYValues, + y: allYValuesInRange, }; }; @@ -315,26 +316,22 @@ export function shapeViewModel( * @param x * @param y */ - const pickHighlightedArea: PickHighlightedArea = (x: Array, y: Array) => { - if (xDomain.type !== ScaleType.Time) { - return null; - } - const [startValue, endValue] = x; - - if (typeof startValue !== 'number' || typeof endValue !== 'number') { - return null; - } - const start = Math.min(startValue, endValue); - const end = Math.max(startValue, endValue); + const pickHighlightedArea: PickHighlightedArea = ( + x: Array>, + y: Array>, + ) => { + const startValue = x[0]; + const endValue = x[x.length - 1]; // find X coordinated based on the time range - const leftIndex = bisectLeft(xValues, start); - const rightIndex = bisectLeft(xValues, end); + const leftIndex = typeof startValue === 'number' ? bisectLeft(xValues, startValue) : xValues.indexOf(startValue); + const rightIndex = typeof endValue === 'number' ? bisectLeft(xValues, endValue) : xValues.indexOf(endValue); - const isOutOfRange = rightIndex > xValues.length - 1; + const isRightOutOfRange = rightIndex > xValues.length - 1 || rightIndex < 0; + const isLeftOutOfRange = leftIndex > xValues.length - 1 || leftIndex < 0; - const startFromScale = xScale(xValues[leftIndex]); - const endFromScale = xScale(isOutOfRange ? xValues[xValues.length - 1] : xValues[rightIndex]); + const startFromScale = xScale(isLeftOutOfRange ? xValues[0] : xValues[leftIndex]); + const endFromScale = xScale(isRightOutOfRange ? xValues[xValues.length - 1] : xValues[rightIndex]); if (startFromScale === undefined || endFromScale === undefined) { return null; @@ -343,7 +340,7 @@ export function shapeViewModel( const xStart = chartDimensions.left + startFromScale; // extend the range in case the right boundary has been selected - const width = endFromScale - startFromScale + (isOutOfRange ? cellWidth : 0); + const width = endFromScale - startFromScale + cellWidth; // (isRightOutOfRange || isLeftOutOfRange ? cellWidth : 0); // resolve Y coordinated making sure the order is correct const { y: yStart, totalHeight } = y @@ -358,7 +355,6 @@ export function shapeViewModel( }, { y: 0, totalHeight: 0 }, ); - return { x: xStart, y: yStart, @@ -417,7 +413,7 @@ export function shapeViewModel( }; } -function getCellKey(x: string | number, y: string | number) { +function getCellKey(x: NonNullable, y: NonNullable) { return [String(x), String(y)].join('&_&'); } diff --git a/packages/osd-charts/packages/charts/src/chart_types/heatmap/state/selectors/get_brushed_highlighted_shapes.test.ts b/packages/osd-charts/packages/charts/src/chart_types/heatmap/state/selectors/get_brushed_highlighted_shapes.test.ts new file mode 100644 index 000000000000..29de297ab65e --- /dev/null +++ b/packages/osd-charts/packages/charts/src/chart_types/heatmap/state/selectors/get_brushed_highlighted_shapes.test.ts @@ -0,0 +1,89 @@ +/* + * 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 { Store } from 'redux'; + +import { MockGlobalSpec, MockSeriesSpec } from '../../../../mocks/specs/specs'; +import { MockStore } from '../../../../mocks/store/store'; +import { ScaleType } from '../../../../scales/constants'; +import { onMouseDown, onMouseUp, onPointerMove } from '../../../../state/actions/mouse'; +import { GlobalChartState } from '../../../../state/chart_state'; +import { createOnBrushEndCaller } from './on_brush_end_caller'; + +describe('Heatmap brush', () => { + let store: Store; + let onBrushEndMock = jest.fn(); + + beforeEach(() => { + store = MockStore.default({ width: 300, height: 300, top: 0, left: 0 }, 'chartId'); + onBrushEndMock = jest.fn(); + MockStore.addSpecs( + [ + MockGlobalSpec.settingsNoMargins(), + MockSeriesSpec.heatmap({ + xScaleType: ScaleType.Ordinal, + data: [ + { x: 'a', y: 'ya', value: 1 }, + { x: 'b', y: 'ya', value: 2 }, + { x: 'c', y: 'ya', value: 3 }, + { x: 'a', y: 'yb', value: 4 }, + { x: 'b', y: 'yb', value: 5 }, + { x: 'c', y: 'yb', value: 6 }, + { x: 'a', y: 'yc', value: 7 }, + { x: 'b', y: 'yc', value: 8 }, + { x: 'c', y: 'yc', value: 9 }, + ], + config: { + grid: { + cellHeight: { + max: 'fill', + }, + cellWidth: { + max: 'fill', + }, + }, + xAxisLabel: { + visible: false, + }, + yAxisLabel: { + visible: false, + }, + margin: { top: 0, bottom: 0, left: 0, right: 0 }, + onBrushEnd: onBrushEndMock, + }, + }), + ], + store, + ); + }); + + it('should brush on categorical scale', () => { + const caller = createOnBrushEndCaller(); + store.dispatch(onPointerMove({ x: 50, y: 50 }, 0)); + store.dispatch(onMouseDown({ x: 50, y: 50 }, 100)); + store.dispatch(onPointerMove({ x: 150, y: 250 }, 200)); + store.dispatch(onMouseUp({ x: 150, y: 250 }, 300)); + caller(store.getState()); + expect(onBrushEndMock).toBeCalledTimes(1); + const brushEvent = onBrushEndMock.mock.calls[0][0]; + expect(brushEvent.cells).toHaveLength(6); + expect(brushEvent.x).toEqual(['a', 'b']); + expect(brushEvent.y).toEqual(['ya', 'yb', 'yc']); + }); +}); diff --git a/packages/osd-charts/packages/charts/src/chart_types/heatmap/state/selectors/get_brushed_highlighted_shapes.ts b/packages/osd-charts/packages/charts/src/chart_types/heatmap/state/selectors/get_brushed_highlighted_shapes.ts index 22a170cb5c28..c7e62c16e6d6 100644 --- a/packages/osd-charts/packages/charts/src/chart_types/heatmap/state/selectors/get_brushed_highlighted_shapes.ts +++ b/packages/osd-charts/packages/charts/src/chart_types/heatmap/state/selectors/get_brushed_highlighted_shapes.ts @@ -29,24 +29,11 @@ function getCurrentPointerStates(state: GlobalChartState) { /** @internal */ export const getBrushedHighlightedShapesSelector = createCustomCachedSelector( [geometries, getCurrentPointerStates], - (geoms, pointerStates): DragShape | null => { + (geoms, pointerStates): DragShape => { if (!pointerStates.dragging || !pointerStates.down) { return null; } - const { - down: { - position: { x: startX, y: startY }, - }, - current: { - position: { x: endX, y: endY }, - }, - } = pointerStates; - - const shape = geoms.pickDragShape([ - { x: startX, y: startY }, - { x: endX, y: endY }, - ]); - return shape; + return geoms.pickDragShape([pointerStates.down.position, pointerStates.current.position]); }, ); diff --git a/packages/osd-charts/packages/charts/src/chart_types/heatmap/state/selectors/picked_shapes.ts b/packages/osd-charts/packages/charts/src/chart_types/heatmap/state/selectors/picked_shapes.ts index 2dc55fdf9980..4993474d5cef 100644 --- a/packages/osd-charts/packages/charts/src/chart_types/heatmap/state/selectors/picked_shapes.ts +++ b/packages/osd-charts/packages/charts/src/chart_types/heatmap/state/selectors/picked_shapes.ts @@ -19,8 +19,7 @@ import { GlobalChartState } from '../../../../state/chart_state'; import { createCustomCachedSelector } from '../../../../state/create_selector'; -import { Cell } from '../../layout/types/viewmodel_types'; -import { TextBox } from '../../layout/viewmodel/viewmodel'; +import { Cell, TextBox } from '../../layout/types/viewmodel_types'; import { geometries } from './geometries'; function getCurrentPointerPosition(state: GlobalChartState) { diff --git a/packages/osd-charts/packages/charts/src/mocks/specs/specs.ts b/packages/osd-charts/packages/charts/src/mocks/specs/specs.ts index 6aae423b601d..e47a27299920 100644 --- a/packages/osd-charts/packages/charts/src/mocks/specs/specs.ts +++ b/packages/osd-charts/packages/charts/src/mocks/specs/specs.ts @@ -18,6 +18,7 @@ */ import { ChartType } from '../../chart_types'; +import { X_SCALE_DEFAULT } from '../../chart_types/heatmap/specs/scale_defaults'; import { config, percentFormatter } from '../../chart_types/partition_chart/layout/config'; import { PartitionLayout } from '../../chart_types/partition_chart/layout/types/config_types'; import { ShapeTreeNode } from '../../chart_types/partition_chart/layout/types/viewmodel_types'; @@ -42,7 +43,15 @@ import { } from '../../chart_types/xy_chart/utils/specs'; import { Predicate } from '../../common/predicate'; import { ScaleType } from '../../scales/constants'; -import { SettingsSpec, SpecType, DEFAULT_SETTINGS_SPEC, SmallMultiplesSpec, GroupBySpec, Spec } from '../../specs'; +import { + SettingsSpec, + SpecType, + DEFAULT_SETTINGS_SPEC, + SmallMultiplesSpec, + GroupBySpec, + Spec, + HeatmapSpec, +} from '../../specs'; import { Datum, mergePartial, Position, RecursivePartial } from '../../utils/common'; import { LIGHT_THEME } from '../../utils/themes/light_theme'; @@ -172,6 +181,23 @@ export class MockSeriesSpec { data: [], }; + private static readonly heatmapBase: HeatmapSpec = { + id: 'spec1', + chartType: ChartType.Heatmap, + specType: SpecType.Series, + data: [], + colors: ['red', 'yellow', 'green'], + colorScale: ScaleType.Linear, + xAccessor: ({ x }: { x: string | number }) => x, + yAccessor: ({ y }: { y: string | number }) => y, + xScaleType: X_SCALE_DEFAULT.type, + valueAccessor: ({ value }: { value: string | number }) => value, + valueFormatter: (value: number) => `${value}`, + xSortPredicate: Predicate.AlphaAsc, + ySortPredicate: Predicate.AlphaAsc, + config: {}, + }; + static bar(partial?: Partial): BarSeriesSpec { return mergePartial(MockSeriesSpec.barBase, partial as RecursivePartial, { mergeOptionalPartialValues: true, @@ -218,6 +244,12 @@ export class MockSeriesSpec { }); } + static heatmap(partial?: Partial): HeatmapSpec { + return mergePartial(MockSeriesSpec.heatmapBase, partial as RecursivePartial, { + mergeOptionalPartialValues: true, + }); + } + static byType(type?: SeriesType | 'histogram'): BasicSeriesSpec { switch (type) { case SeriesType.Line: diff --git a/packages/osd-charts/stories/heatmap/2_categorical.tsx b/packages/osd-charts/stories/heatmap/2_categorical.tsx index e251aeaa8f2a..6eb98207e260 100644 --- a/packages/osd-charts/stories/heatmap/2_categorical.tsx +++ b/packages/osd-charts/stories/heatmap/2_categorical.tsx @@ -30,13 +30,7 @@ export const Example = () => { const [min, max] = extent(values); return ( - + { }, yAxisLabel: { visible: true, - width: 100, }, + onBrushEnd: action('onBrushEnd'), }} />