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): compute nice legend items from color scale #1273

Merged
merged 17 commits into from
Aug 16, 2021
Merged
Show file tree
Hide file tree
Changes from 13 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.
33 changes: 33 additions & 0 deletions integration/tests/heatmap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* 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 { common } from '../page_objects/common';

describe('Heatmap color scale', () => {
it('quantile', async () => {
await common.expectChartAtUrlToMatchScreenshot(
'http://localhost:9001/?path=/story/heatmap-alpha--categorical&globals=backgrounds.value:transparent;themes.value:Light&knob-color scale=quantile&knob-ranges=auto&knob-colors=green, yellow, red',
);
});
it('quantize', async () => {
await common.expectChartAtUrlToMatchScreenshot(
'http://localhost:9001/?path=/story/heatmap-alpha--categorical&globals=backgrounds.value:transparent;themes.value:Light&knob-color scale=quantize&knob-ranges=auto&knob-colors=green, yellow, red',
);
});
it('threshold', async () => {
await common.expectChartAtUrlToMatchScreenshot(
'http://localhost:9001/?path=/story/heatmap-alpha--categorical&globals=backgrounds.value:transparent;themes.value:Light&knob-color scale=threshold&knob-ranges=10000, 40000&knob-colors=green, yellow, red',
);
});

it('linear', async () => {
await common.expectChartAtUrlToMatchScreenshot(
'http://localhost:9001/?path=/story/heatmap-alpha--categorical&globals=backgrounds.value:transparent;themes.value:Light&knob-color scale=linear&knob-ranges=0, 10000, 25000, 50000, 100000&knob-colors=green, yellow, red, purple',
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ export function shapeViewModel(
settingsSpec: SettingsSpec,
chartDimensions: Dimensions,
heatmapTable: HeatmapTable,
colorScale: ColorScaleType,
filterRanges: Array<[number, number | null]>,
colorScale: ColorScaleType['scale'],
visibilityFilterRanges: Array<[number, number]>,
{ height, pageSize }: GridHeightParams,
): ShapeViewModel {
const gridStrokeWidth = config.grid.stroke.width ?? 1;
Expand Down Expand Up @@ -187,7 +187,7 @@ export function shapeViewModel(
const x = xScale(String(d.x));
const y = yScale(String(d.y))! + gridStrokeWidth;
const yIndex = yValues.indexOf(d.y);
const color = colorScale.config(d.value);
const color = colorScale(d.value);
if (x === undefined || y === undefined || yIndex === -1) {
return acc;
}
Expand All @@ -208,7 +208,7 @@ export function shapeViewModel(
width: config.cell.border.strokeWidth,
},
value: d.value,
visible: !isFilteredValue(filterRanges, d.value),
visible: !isValueVisible(d.value, visibilityFilterRanges),
formatted: spec.valueFormatter(d.value),
};
return acc;
Expand Down Expand Up @@ -391,11 +391,6 @@ function getCellKey(x: NonNullable<PrimitiveValue>, y: NonNullable<PrimitiveValu
return [String(x), String(y)].join('&_&');
}

function isFilteredValue(filterRanges: Array<[number, number | null]>, value: number) {
return filterRanges.some(([min, max]) => {
if (max !== null && value > min && value < max) {
return true;
}
return max === null && value > min;
});
function isValueVisible(value: number, visibilityFilterRanges: Array<[number, number]>) {
return visibilityFilterRanges.some(([min, max]) => min <= value && value < max);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,25 @@ import { getDeselectedSeriesSelector } from '../../../../state/selectors/get_des
import { getColorScale } from './get_color_scale';
import { getSpecOrNull } from './heatmap_spec';

const EMPTY_LEGEND: LegendItem[] = [];
/** @internal */
export const computeLegendSelector = createCustomCachedSelector(
[getSpecOrNull, getColorScale, getDeselectedSeriesSelector],
(spec, colorScale, deselectedDataSeries): LegendItem[] => {
const legendItems: LegendItem[] = [];

if (colorScale === null || spec === null) {
return legendItems;
(spec, { bands, scale }, deselectedDataSeries): LegendItem[] => {
if (spec === null) {
return EMPTY_LEGEND;
}

return colorScale.ticks.map((tick) => {
const color = colorScale.config(tick);
return bands.map(({ start, formattedStart }, i) => {
const color = scale(start);
const seriesIdentifier = {
key: String(tick),
specId: String(tick),
key: String(start),
specId: String(start),
};

return {
color,
label: `> ${spec.valueFormatter ? spec.valueFormatter(tick) : tick}`,
label: `${i === 0 ? '≥' : '>'} ${formattedStart}`,
seriesIdentifiers: [seriesIdentifier],
isSeriesHidden: deselectedDataSeries.some((dataSeries) => dataSeries.key === seriesIdentifier.key),
isToggleable: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import { getColorScale } from './get_color_scale';
import { getGridHeightParamsSelector } from './get_grid_full_height';
import { getHeatmapSpecSelector } from './get_heatmap_spec';
import { getHeatmapTableSelector } from './get_heatmap_table';
import { getLegendItemsLabelsSelector } from './get_legend_items_labels';
import { render } from './scenegraph';

const getDeselectedSeriesSelector = (state: GlobalChartState) => state.interactions.deselectedDataSeries;
Expand All @@ -28,7 +27,6 @@ export const geometries = createCustomCachedSelector(
getSettingsSpecSelector,
getHeatmapTableSelector,
getColorScale,
getLegendItemsLabelsSelector,
getDeselectedSeriesSelector,
getGridHeightParamsSelector,
],
Expand All @@ -37,27 +35,33 @@ export const geometries = createCustomCachedSelector(
chartDimensions,
settingSpec,
heatmapTable,
colorScale,
legendItems,
{ bands, scale: colorScale },
deselectedSeries,
gridHeightParams,
): ShapeViewModel => {
const deselectedTicks = new Set(
const deselectedRanges = new Set(
deselectedSeries.map(({ specId }) => {
return Number(specId);
}),
);
const { ticks } = colorScale;
const ranges = ticks.reduce<Array<[number, number | null]>>((acc, d, i) => {
if (deselectedTicks.has(d)) {
const rangeEnd = i + 1 === ticks.length ? null : ticks[i + 1];
acc.push([d, rangeEnd]);
const visibilityFilterRanges = bands.reduce<Array<[number, number]>>((acc, { start }, i) => {
if (deselectedRanges.has(start)) {
const rangeEnd = bands.length === i + 1 ? Infinity : bands[i + 1].start;
acc.push([start, rangeEnd]);
}
return acc;
}, []);

return heatmapSpec
? render(heatmapSpec, settingSpec, chartDimensions, heatmapTable, colorScale, ranges, gridHeightParams)
? render(
heatmapSpec,
settingSpec,
chartDimensions,
heatmapTable,
colorScale,
visibilityFilterRanges,
gridHeightParams,
)
: nullShapeViewModel();
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
* Side Public License, v 1.
*/

import { extent as d3Extent } from 'd3-array';
import { interpolateHcl } from 'd3-interpolate';
import {
ScaleLinear,
Expand All @@ -21,63 +20,113 @@ import {

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

type ScaleModelType<Type, Config> = {
type: Type;
config: Config;
ticks: number[];
type ScaleModelType<S> = {
scale: S;
bands: Array<{ start: number }>;
};
type ScaleLinearType = ScaleModelType<typeof ScaleType.Linear, ScaleLinear<string, string>>;
type ScaleQuantizeType = ScaleModelType<typeof ScaleType.Quantize, ScaleQuantize<string>>;
type ScaleQuantileType = ScaleModelType<typeof ScaleType.Quantile, ScaleQuantile<string>>;
type ScaleThresholdType = ScaleModelType<typeof ScaleType.Threshold, ScaleThreshold<number, string>>;

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;

/**
* @internal
* Gets color scale based on specification and values range.
*/
export const getColorScale = createCustomCachedSelector(
[getHeatmapSpecSelector, getHeatmapTableSelector],
(spec, heatmapTable) => {
const { colors, colorScale: colorScaleSpec } = spec;

// compute the color scale based domain and colors
const { ranges = heatmapTable.extent } = spec;
const colorRange = colors ?? ['green', 'red'];

const colorScale = {
type: colorScaleSpec,
} as ColorScaleType;
if (colorScale.type === ScaleType.Quantize) {
colorScale.config = scaleQuantize<string>()
.domain(d3Extent(ranges) as [number, number])
.range(colorRange);
colorScale.ticks = colorScale.config.ticks(spec.colors.length);
} else if (colorScale.type === ScaleType.Quantile) {
colorScale.config = scaleQuantile<string>().domain(ranges).range(colorRange);
colorScale.ticks = colorScale.config.quantiles();
} else if (colorScale.type === ScaleType.Threshold) {
colorScale.config = scaleThreshold<number, string>().domain(ranges).range(colorRange);
colorScale.ticks = colorScale.config.domain();
} else {
colorScale.config = scaleLinear<string>().domain(ranges).interpolate(interpolateHcl).range(colorRange);
colorScale.ticks = addBaselineOnLinearScale(ranges[0], ranges[1], colorScale.config.ticks(6));
}
return colorScale;
const { scale, bands } = SCALE_TYPE_TO_SCALE_FN[spec.colorScale ?? DEFAULT_COLOR_SCALE_TYPE](spec, heatmapTable);
return {
scale,
bands: dedupBands(bands, spec),
};
},
);

function addBaselineOnLinearScale(min: number, max: number, ticks: Array<number>): Array<number> {
if (min < 0 && max < 0) {
return [...ticks, 0];
}
if (min >= 0 && max >= 0) {
return [0, ...ticks];
}
function dedupBands(bands: Array<{ start: number }>, spec: HeatmapSpec) {
const formatter = spec.valueFormatter ?? identity;
const bandsWithFormattedStarts = bands.reduce<Map<string, { start: number; formattedStart: string }>>(
(acc, { start }) => {
const formattedStart = `${formatter(start)}`;
acc.set(formattedStart, { start, formattedStart });
return acc;
},
new Map(),
);
return [...bandsWithFormattedStarts.values()];
}

monfera marked this conversation as resolved.
Show resolved Hide resolved
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 band corresponding to the quantized segments
const numOfSegments = colors.length;
const interval = (domain[1] - domain[0]) / numOfSegments;
monfera marked this conversation as resolved.
Show resolved Hide resolved
const bands = colors.map((color, i) => ({ start: domain[0] + interval * i }));

return {
scale,
bands,
};
}

function getQuantileScale(spec: HeatmapSpec, heatmapTable: HeatmapTable): ScaleQuantileType {
const colors = spec.colors ?? DEFAULT_COLORS;
const domain = heatmapTable.table.map(({ value }) => value);
monfera marked this conversation as resolved.
Show resolved Hide resolved
const scale = scaleQuantile<string>().domain(domain).range(colors);
// the bands array should contain all quantiles + the minimum value
const bands = [...new Set([heatmapTable.extent[0], ...scale.quantiles()])].map((start) => ({ start }));
return {
scale,
bands,
};
}

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 start band array should contain all the thresholds + the minimum value
const bands = [...new Set([heatmapTable.extent[0], ...domain])].map((start) => ({ start }));
return {
scale,
bands,
};
}

return ticks;
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 bands = [...new Set([domain[0], ...scale.ticks(6)])].map((start) => ({ start }));
return {
scale,
bands,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ export function render(
settingsSpec: SettingsSpec,
chartDimensions: Dimensions,
heatmapTable: HeatmapTable,
colorScale: ColorScaleType,
filterRanges: Array<[number, number | null]>,
colorScale: ColorScaleType['scale'],
visibilityFilterRanges: Array<[number, number]>,
gridHeightParams: GridHeightParams,
): ShapeViewModel {
const textMeasurer = document.createElement('canvas');
Expand All @@ -46,7 +46,7 @@ export function render(
chartDimensions,
heatmapTable,
colorScale,
filterRanges,
visibilityFilterRanges,
gridHeightParams,
);
}
6 changes: 3 additions & 3 deletions storybook/stories/heatmap/1_basic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export const Example = () => {
[],
);

const logDebugstate = debounce(() => {
const logDebugState = debounce(() => {
if (!debugState) return;

const statusEl = document.querySelector<HTMLDivElement>('.echChartStatus');
Expand All @@ -102,9 +102,9 @@ export const Example = () => {
<Chart>
<Settings
onElementClick={onElementClick}
onRenderChange={logDebugstate}
onRenderChange={logDebugState}
showLegend
legendPosition="top"
legendPosition="right"
onBrushEnd={action('onBrushEnd')}
brushAxis="both"
xDomain={{ min: 1572825600000, max: 1572912000000, minInterval: 1800000 }}
Expand Down
Loading