Skip to content

Commit

Permalink
fix(heatmap): improve legend item (#1317)
Browse files Browse the repository at this point in the history
This fixed duplicated legend items in the heatmap legend, refactoring the current API and logic for handling color bands
  • Loading branch information
markov00 committed Aug 19, 2021
1 parent 6e78d0a commit 49c35ce
Show file tree
Hide file tree
Showing 20 changed files with 152 additions and 197 deletions.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
33 changes: 0 additions & 33 deletions integration/tests/heatmap.test.ts

This file was deleted.

27 changes: 19 additions & 8 deletions packages/charts/api/charts.api.md
Expand Up @@ -435,6 +435,14 @@ export function childrenAccessor(n: ArrayEntry): HierarchyOfArrays;
// @public (undocumented)
export type Color = string;

// @alpha (undocumented)
export type ColorBand = {
start: number;
end: number;
color: Color;
label?: string;
};

// @public (undocumented)
export interface ColorConfig {
// (undocumented)
Expand Down Expand Up @@ -873,7 +881,16 @@ export interface GroupBySpec extends Spec {
export type GroupId = string;

// @alpha (undocumented)
export const Heatmap: React_2.FunctionComponent<Pick<HeatmapSpec, 'id' | 'data'> & Partial<Omit<HeatmapSpec, 'chartType' | 'specType' | 'id' | 'data'>>>;
export const Heatmap: React_2.FunctionComponent<Pick<HeatmapSpec, 'id' | 'data' | 'colorScale'> & Partial<Omit<HeatmapSpec, 'chartType' | 'specType' | 'id' | 'data'>>>;

// @alpha (undocumented)
export interface HeatmapBandsColorScale {
// (undocumented)
bands: Array<ColorBand>;
labelFormatter?: (start: number, end: number) => string;
// (undocumented)
type: 'bands';
}

// @public (undocumented)
export type HeatmapBrushEvent = {
Expand Down Expand Up @@ -997,11 +1014,7 @@ export interface HeatmapSpec extends Spec {
// (undocumented)
chartType: typeof ChartType.Heatmap;
// (undocumented)
colors: Color[];
// Warning: (ae-forgotten-export) The symbol "HeatmapScaleType" needs to be exported by the entry point index.d.ts
//
// (undocumented)
colorScale?: HeatmapScaleType;
colorScale: HeatmapBandsColorScale;
// (undocumented)
config: RecursivePartial<HeatmapConfig>;
// (undocumented)
Expand All @@ -1014,8 +1027,6 @@ export interface HeatmapSpec extends Spec {
// (undocumented)
name?: string;
// (undocumented)
ranges?: number[] | [number, number];
// (undocumented)
specType: typeof SpecType.Series;
// (undocumented)
valueAccessor: Accessor | AccessorFn;
Expand Down
1 change: 0 additions & 1 deletion packages/charts/package.json
Expand Up @@ -42,7 +42,6 @@
"d3-interpolate": "^1.4.0",
"d3-scale": "^1.0.7",
"d3-shape": "^1.3.4",
"newtype-ts": "^0.2.4",
"prop-types": "^15.7.2",
"re-reselect": "^3.4.0",
"react-redux": "^7.1.0",
Expand Down
Expand Up @@ -23,7 +23,7 @@ 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';
import { ColorScaleType } from '../../state/selectors/get_color_scale';
import { ColorScale } from '../../state/selectors/get_color_scale';
import { GridHeightParams } from '../../state/selectors/get_grid_full_height';
import { Config } from '../types/config_types';
import {
Expand Down Expand Up @@ -81,7 +81,7 @@ export function shapeViewModel(
settingsSpec: SettingsSpec,
chartDimensions: Dimensions,
heatmapTable: HeatmapTable,
colorScale: ColorScaleType['scale'],
colorScale: ColorScale,
bandsToHide: Array<[number, number]>,
{ height, pageSize }: GridHeightParams,
): ShapeViewModel {
Expand Down
50 changes: 50 additions & 0 deletions packages/charts/src/chart_types/heatmap/scales/band_color_scale.ts
@@ -0,0 +1,50 @@
/*
* 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 { getPredicateFn } from '../../../common/predicate';
import { Color } from '../../../utils/common';
import { ColorBand, HeatmapBandsColorScale } from '../specs/heatmap';
import { ColorScale } from '../state/selectors/get_color_scale';

const TRANSPARENT_COLOR: Color = 'rgba(0, 0, 0, 0)';

function defaultColorBandFormatter(start: number, end: number) {
const finiteStart = Number.isFinite(start);
const finiteEnd = Number.isFinite(end);
return !finiteStart && finiteEnd ? `< ${end}` : finiteStart && !finiteEnd ? `≥ ${start}` : `${start} - ${end}`;
}

/** @internal */
export function getBandsColorScale(
colorScale: HeatmapBandsColorScale,
): { scale: ColorScale; bands: Required<ColorBand>[] } {
const labelFormatter = colorScale.labelFormatter ?? defaultColorBandFormatter;
const ascendingSortFn = getPredicateFn('numAsc', 'start');
const bands = colorScale.bands
.reduce<Required<ColorBand>[]>((acc, { start, end, color, label }) => {
// admit only proper bands
if (start < end) acc.push({ start, end, color, label: label ?? labelFormatter(start, end) });
return acc;
}, [])
.sort(ascendingSortFn);

const scale = getBandScale(bands);
return { scale, bands };
}

function getBandScale(bands: ColorBand[]): ColorScale {
return (value) => {
for (let i = 0; i < bands.length; i++) {
const { start, end, color } = bands[i];
if (start <= value && value < end) {
return color;
}
}
return TRANSPARENT_COLOR;
};
}
25 changes: 18 additions & 7 deletions packages/charts/src/chart_types/heatmap/specs/heatmap.ts
Expand Up @@ -24,8 +24,6 @@ const defaultProps = {
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,
Expand All @@ -43,14 +41,28 @@ export type HeatmapScaleType =
| typeof ScaleType.Quantize
| typeof ScaleType.Threshold;

/** @alpha */
export type ColorBand = {
start: number;
end: number;
color: Color;
label?: string;
};

/** @alpha */
export interface HeatmapBandsColorScale {
type: 'bands';
bands: Array<ColorBand>;
/** called on ColorBands without a provided label */
labelFormatter?: (start: number, end: number) => string;
}

/** @alpha */
export interface HeatmapSpec extends Spec {
specType: typeof SpecType.Series;
chartType: typeof ChartType.Heatmap;
data: Datum[];
colorScale?: HeatmapScaleType;
ranges?: number[] | [number, number];
colors: Color[];
colorScale: HeatmapBandsColorScale;
xAccessor: Accessor | AccessorFn;
yAccessor: Accessor | AccessorFn;
valueAccessor: Accessor | AccessorFn;
Expand All @@ -65,14 +77,13 @@ export interface HeatmapSpec extends Spec {

/** @alpha */
export const Heatmap: React.FunctionComponent<
Pick<HeatmapSpec, 'id' | 'data'> & Partial<Omit<HeatmapSpec, 'chartType' | 'specType' | 'id' | 'data'>>
Pick<HeatmapSpec, 'id' | 'data' | 'colorScale'> & Partial<Omit<HeatmapSpec, 'chartType' | 'specType' | 'id' | 'data'>>
> = getConnect()(
specComponentFactory<
HeatmapSpec,
| 'xAccessor'
| 'yAccessor'
| 'valueAccessor'
| 'colors'
| 'data'
| 'ySortPredicate'
| 'xSortPredicate'
Expand Down
Expand Up @@ -6,121 +6,20 @@
* Side Public License, v 1.
*/

import { interpolateHcl } from 'd3-interpolate';
import {
ScaleLinear,
scaleLinear,
ScaleQuantile,
scaleQuantile,
ScaleQuantize,
scaleQuantize,
ScaleThreshold,
scaleThreshold,
} from 'd3-scale';

import { ScaleType } from '../../../../scales/constants';
import { createCustomCachedSelector } from '../../../../state/create_selector';
import { Color, identity } from '../../../../utils/common';
import { HeatmapSpec } from '../../specs/heatmap';
import { HeatmapTable } from './compute_chart_dimensions';
import { Color } from '../../../../utils/common';
import { getBandsColorScale } from '../../scales/band_color_scale';
import { ColorBand } from '../../specs/heatmap';
import { getHeatmapSpecSelector } from './get_heatmap_spec';
import { getHeatmapTableSelector } from './get_heatmap_table';

type ScaleModelType<S> = {
scale: S;
colorThresholds: number[];
};

type Band = { start: number; end: number; label: string; color: Color };

type ScaleLinearType = ScaleModelType<ScaleLinear<string, string>>;
type ScaleQuantizeType = ScaleModelType<ScaleQuantize<string>>;
type ScaleQuantileType = ScaleModelType<ScaleQuantile<string>>;
type ScaleThresholdType = ScaleModelType<ScaleThreshold<number, string>>;

/** @internal */
export type ColorScaleType = ScaleLinearType | ScaleQuantizeType | ScaleQuantileType | ScaleThresholdType;

const DEFAULT_COLORS = ['green', 'red'];

const SCALE_TYPE_TO_SCALE_FN = {
[ScaleType.Linear]: getLinearScale,
[ScaleType.Quantile]: getQuantileScale,
[ScaleType.Quantize]: getQuantizedScale,
[ScaleType.Threshold]: getThresholdScale,
};
const DEFAULT_COLOR_SCALE_TYPE = ScaleType.Linear;
export type ColorScale = (value: number) => Color;

/**
* @internal
* Gets color scale based on specification and values range.
*/
export const getColorScale = createCustomCachedSelector(
[getHeatmapSpecSelector, getHeatmapTableSelector],
(spec, heatmapTable) => {
const colorScaleType = spec.colorScale ?? DEFAULT_COLOR_SCALE_TYPE;
const { scale, colorThresholds } = SCALE_TYPE_TO_SCALE_FN[colorScaleType](spec, heatmapTable);
const bands = bandsFromThresholds(colorThresholds, spec, scale);
return { scale, bands };
},
);

function bandsFromThresholds(
colorThresholds: number[],
spec: HeatmapSpec,
scale: ScaleLinear<string, string> | ScaleQuantile<string> | ScaleQuantize<string> | ScaleThreshold<number, string>,
) {
const formatter = spec.valueFormatter ?? identity;
const bands = colorThresholds.reduce<Map<string, Band>>((acc, threshold, i, thresholds) => {
const label = `≥ ${formatter(threshold)}`;
const end = thresholds.length === i + 1 ? Infinity : thresholds[i + 1];
acc.set(label, { start: threshold, end, label, color: scale(threshold) });
return acc;
}, new Map());
return [...bands.values()];
}

function getQuantizedScale(spec: HeatmapSpec, heatmapTable: HeatmapTable): ScaleQuantizeType {
const domain =
Array.isArray(spec.ranges) && spec.ranges.length > 1 ? (spec.ranges as [number, number]) : heatmapTable.extent;
const colors = spec.colors?.length > 0 ? spec.colors : DEFAULT_COLORS;
// we use the data extent or only the first two values in the `ranges` prop
const scale = scaleQuantize<string>().domain(domain).range(colors);
// quantize scale works as the linear one, we should manually
// compute the start of each color threshold corresponding to the quantized segments
const numOfSegments = colors.length;
const interval = (domain[1] - domain[0]) / numOfSegments;
const colorThresholds = colors.map((color, i) => domain[0] + interval * i);

return { scale, colorThresholds };
}

function getQuantileScale(spec: HeatmapSpec, heatmapTable: HeatmapTable): ScaleQuantileType {
const colors = spec.colors ?? DEFAULT_COLORS;
const domain = heatmapTable.table.map(({ value }) => value);
const scale = scaleQuantile<string>().domain(domain).range(colors);
// the colorThresholds array should contain all quantiles + the minimum value
const colorThresholds = [...new Set([heatmapTable.extent[0], ...scale.quantiles()])];

return { scale, colorThresholds };
}

function getThresholdScale(spec: HeatmapSpec, heatmapTable: HeatmapTable): ScaleThresholdType {
const colors = spec.colors ?? DEFAULT_COLORS;
const domain = spec.ranges ?? heatmapTable.extent;
const scale = scaleThreshold<number, string>().domain(domain).range(colors);
// the colorThresholds array should contain all the thresholds + the minimum value
const colorThresholds = [...new Set([heatmapTable.extent[0], ...domain])];

return { scale, colorThresholds };
}

function getLinearScale(spec: HeatmapSpec, heatmapTable: HeatmapTable): ScaleLinearType {
const domain = spec.ranges ?? heatmapTable.extent;
const colors = spec.colors ?? DEFAULT_COLORS;
const scale = scaleLinear<string>().domain(domain).interpolate(interpolateHcl).range(colors).clamp(true);
// adding initial and final range/extent value if they are rounded values.
const colorThresholds = [...new Set([domain[0], ...scale.ticks(6)])];

return { scale, colorThresholds };
}
export const getColorScale = createCustomCachedSelector([getHeatmapSpecSelector], (spec): {
scale: ColorScale;
bands: Required<ColorBand>[];
} => getBandsColorScale(spec.colorScale));
Expand Up @@ -16,7 +16,7 @@ import { ShapeViewModel, nullShapeViewModel } from '../../layout/types/viewmodel
import { shapeViewModel } from '../../layout/viewmodel/viewmodel';
import { HeatmapSpec } from '../../specs';
import { HeatmapTable } from './compute_chart_dimensions';
import { ColorScaleType } from './get_color_scale';
import { ColorScale } from './get_color_scale';
import { GridHeightParams } from './get_grid_full_height';

/** @internal */
Expand All @@ -25,7 +25,7 @@ export function render(
settingsSpec: SettingsSpec,
chartDimensions: Dimensions,
heatmapTable: HeatmapTable,
colorScale: ColorScaleType['scale'],
colorScale: ColorScale,
bandsToHide: Array<[number, number]>,
gridHeightParams: GridHeightParams,
): ShapeViewModel {
Expand Down
1 change: 1 addition & 0 deletions packages/charts/src/index.ts
Expand Up @@ -66,6 +66,7 @@ export * from './chart_types/partition_chart/layout/utils/group_by_rollup';
// heatmap
export { Cell } from './chart_types/heatmap/layout/types/viewmodel_types';
export { Config as HeatmapConfig, HeatmapBrushEvent } from './chart_types/heatmap/layout/types/config_types';
export { ColorBand, HeatmapBandsColorScale } from './chart_types/heatmap/specs/heatmap';

// utilities
export {
Expand Down
10 changes: 8 additions & 2 deletions packages/charts/src/mocks/specs/specs.ts
Expand Up @@ -175,8 +175,14 @@ export class MockSeriesSpec {
chartType: ChartType.Heatmap,
specType: SpecType.Series,
data: [],
colors: ['red', 'yellow', 'green'],
colorScale: ScaleType.Linear,
colorScale: {
type: 'bands',
bands: [
{ start: 0, end: 10, color: 'red' },
{ start: 10, end: 20, color: 'yellow' },
{ start: 20, end: 30, color: 'green' },
],
},
xAccessor: ({ x }: { x: string | number }) => x,
yAccessor: ({ y }: { y: string | number }) => y,
xScaleType: X_SCALE_DEFAULT.type,
Expand Down

0 comments on commit 49c35ce

Please sign in to comment.