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 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.
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'],
bandsToHide: 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: !isValueHidden(d.value, bandsToHide),
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 isValueHidden(value: number, rangesToHide: Array<[number, number]>) {
return rangesToHide.some(([min, max]) => min <= value && value < max);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,30 +12,24 @@ 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 }, deselectedDataSeries): LegendItem[] => {
if (spec === null) {
return EMPTY_LEGEND;
}

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

return bands.map(({ label, color }) => {
return {
// the band label is considered unique by construction
seriesIdentifiers: [{ key: label, specId: label }],
color,
label: `> ${spec.valueFormatter ? spec.valueFormatter(tick) : tick}`,
seriesIdentifiers: [seriesIdentifier],
isSeriesHidden: deselectedDataSeries.some((dataSeries) => dataSeries.key === seriesIdentifier.key),
label,
isSeriesHidden: deselectedDataSeries.some((dataSeries) => dataSeries.key === label),
isToggleable: true,
path: [{ index: 0, value: seriesIdentifier.key }],
path: [{ index: 0, value: label }],
keys: [],
};
});
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,25 @@ export const geometries = createCustomCachedSelector(
chartDimensions,
settingSpec,
heatmapTable,
colorScale,
legendItems,
{ bands, scale: colorScale },
deselectedSeries,
gridHeightParams,
): ShapeViewModel => {
const deselectedTicks = new Set(
// instead of using the specId, each legend item is associated with an unique band label
const disabledBandLabels = new Set(
deselectedSeries.map(({ specId }) => {
return Number(specId);
return 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]);
}
return acc;
}, []);

const bandsToHide: Array<[number, number]> = bands
.filter(({ label }) => {
return disabledBandLabels.has(label);
})
.map(({ start, end }) => [start, end]);

return heatmapSpec
? render(heatmapSpec, settingSpec, chartDimensions, heatmapTable, colorScale, ranges, gridHeightParams)
? render(heatmapSpec, settingSpec, chartDimensions, heatmapTable, colorScale, bandsToHide, 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,107 @@ import {

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 { 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;
colorThresholds: 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 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;

/**
* @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 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 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 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()];
}

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 color threshold 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 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);
monfera marked this conversation as resolved.
Show resolved Hide resolved
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 ticks;
return { scale, colorThresholds };
}
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'],
bandsToHide: Array<[number, number]>,
gridHeightParams: GridHeightParams,
): ShapeViewModel {
const textMeasurer = document.createElement('canvas');
Expand All @@ -46,7 +46,7 @@ export function render(
chartDimensions,
heatmapTable,
colorScale,
filterRanges,
bandsToHide,
gridHeightParams,
);
}
Loading