[heatmap] numerous improvements (#3456)

* [heatmap] numerous improvements

* flexibility as to how to sort X and Y axis (alpha/value, desc/asc)
* option to show a legend
* fixed margins, maximize real estate
* allowed users to define bounds

* Tunning
This commit is contained in:
Maxime Beauchemin 2017-09-13 16:13:45 -07:00 committed by GitHub
parent 8223729e1e
commit 49f24d128b
7 changed files with 141 additions and 42 deletions

View File

@ -36,6 +36,12 @@ const timeColumnOption = {
'A reference to the [Time] configuration, taking granularity into ' +
'account'),
};
const sortAxisChoices = [
['alpha_asc', 'Alphabetical ascending'],
['alpha_desc', 'Alphabetical descending'],
['value_asc', 'Value ascending'],
['value_desc', 'Value descending'],
];
const groupByControl = {
type: 'SelectControl',
@ -156,6 +162,22 @@ export const controls = {
description: '',
},
sort_x_axis: {
type: 'SelectControl',
label: 'Sort X Axis',
choices: sortAxisChoices,
clearable: false,
default: 'alpha_asc',
},
sort_y_axis: {
type: 'SelectControl',
label: 'Sort Y Axis',
choices: sortAxisChoices,
clearable: false,
default: 'alpha_asc',
},
linear_color_scheme: {
type: 'ColorSchemeControl',
label: 'Linear Color Scheme',
@ -202,6 +224,7 @@ export const controls = {
canvas_image_rendering: {
type: 'SelectControl',
label: 'Rendering',
renderTrigger: true,
choices: [
['pixelated', 'pixelated (Sharp)'],
['auto', 'auto (Smooth)'],
@ -236,6 +259,14 @@ export const controls = {
default: false,
},
show_perc: {
type: 'CheckboxControl',
label: 'Show percentage',
renderTrigger: true,
description: 'Whether to include the percentage in the tooltip',
default: true,
},
bar_stacked: {
type: 'CheckboxControl',
label: 'Stacked Bars',

View File

@ -929,10 +929,10 @@ export const visTypes = {
label: 'Heatmap',
controlPanelSections: [
{
label: 'Axis & Metrics',
label: 'Query',
expanded: true,
controlSetRows: [
['all_columns_x'],
['all_columns_y'],
['all_columns_x', 'all_columns_y'],
['metric'],
],
},
@ -941,9 +941,11 @@ export const visTypes = {
controlSetRows: [
['linear_color_scheme'],
['xscale_interval', 'yscale_interval'],
['canvas_image_rendering'],
['normalize_across'],
['canvas_image_rendering', 'normalize_across'],
['left_margin', 'bottom_margin'],
['y_axis_bounds', 'y_axis_format'],
['show_legend', 'show_perc'],
['sort_x_axis', 'sort_y_axis'],
],
},
],
@ -954,6 +956,18 @@ export const visTypes = {
all_columns_y: {
validators: [v.nonEmpty],
},
y_axis_bounds: {
label: 'Value bounds',
renderTrigger: false,
description: (
'Hard value bounds applied for color coding. Is only relevant ' +
'and applied when the normalization is applied against the whole ' +
'heatmap.'
),
},
y_axis_format: {
label: 'Value Format',
},
},
},

View File

@ -116,17 +116,20 @@ export const getColorFromScheme = (function () {
};
}());
export const colorScalerFactory = function (colors, data, accessor) {
export const colorScalerFactory = function (colors, data, accessor, extents) {
// Returns a linear scaler our of an array of color
if (!Array.isArray(colors)) {
/* eslint no-param-reassign: 0 */
colors = spectrums[colors];
}
let ext = [0, 1];
if (data !== undefined) {
if (extents) {
ext = extents;
}
if (data) {
ext = d3.extent(data, accessor);
}
const chunkSize = (ext[1] - ext[0]) / (colors.length - 1);
const points = colors.map((col, i) => i * chunkSize);
return d3.scale.linear().domain(points).range(colors);
const points = colors.map((col, i) => ext[0] + (i * chunkSize));
return d3.scale.linear().domain(points).range(colors).clamp(true);
};

View File

@ -49,6 +49,7 @@
"d3": "^3.5.17",
"d3-cloud": "^1.2.1",
"d3-sankey": "^0.4.2",
"d3-svg-legend": "^1.x",
"d3-tip": "^0.6.7",
"datamaps": "^0.5.8",
"datatables.net-bs": "^1.10.15",

View File

@ -1,4 +1,4 @@
.heatmap .slice_container {
.heatmap {
position: relative;
top: 0;
left: 0;
@ -28,3 +28,16 @@
image-rendering: pixelated; /* Awesome future-browsers */
-ms-interpolation-mode: nearest-neighbor; /* IE */
}
.heatmap .legendCells text {
font-size: 10px;
font-weight: normal;
opacity: 0;
}
.heatmap .legendCells .cell:first-child text {
opacity: 1;
}
.heatmap .legendCells .cell:last-child text {
opacity: 1;
}

View File

@ -1,31 +1,30 @@
import d3 from 'd3';
import $ from 'jquery';
// eslint-disable-next-line no-unused-vars
import d3legend from 'd3-svg-legend';
import d3tip from 'd3-tip';
import { colorScalerFactory } from '../javascripts/modules/colors';
import '../stylesheets/d3tip.css';
import './heatmap.css';
// Inspired from http://bl.ocks.org/mbostock/3074470
// https://jsfiddle.net/cyril123/h0reyumq/
function heatmapVis(slice, payload) {
// Header for panel in explore v2
const header = document.getElementById('slice-header');
const headerHeight = header ? 30 + header.getBoundingClientRect().height : 0;
const data = payload.data.records;
const fd = slice.formData;
const margin = {
top: headerHeight,
top: 10,
right: 10,
bottom: 35,
left: 35,
};
const valueFormatter = d3.format(fd.y_axis_format);
const data = payload.data;
const fd = slice.formData;
// Dynamically adjusts based on max x / y category lengths
function adjustMargins() {
const pixelsPerCharX = 4.5; // approx, depends on font size
const pixelsPerCharY = 10; // approx, depends on font size
const pixelsPerCharY = 6; // approx, depends on font size
let longestX = 1;
let longestY = 1;
let datum;
@ -38,6 +37,9 @@ function heatmapVis(slice, payload) {
if (fd.left_margin === 'auto') {
margin.left = Math.ceil(Math.max(margin.left, pixelsPerCharY * longestY));
if (fd.show_legend) {
margin.left += 40;
}
} else {
margin.left = fd.left_margin;
}
@ -48,19 +50,29 @@ function heatmapVis(slice, payload) {
}
}
function ordScale(k, rangeBands, reverse = false) {
function ordScale(k, rangeBands, sortMethod) {
let domain = {};
$.each(data, function (i, d) {
domain[d[k]] = true;
data.forEach((d) => {
domain[d[k]] = domain[d[k]] || 0 + d.v;
});
domain = Object.keys(domain).sort();
if (reverse) {
if (sortMethod === 'alpha_asc') {
domain = Object.keys(domain).sort();
} else if (sortMethod === 'alpha_desc') {
domain = Object.keys(domain).sort().reverse();
} else if (sortMethod === 'value_desc') {
domain = Object.keys(domain).sort((d1, d2) => domain[d2] - domain[d1]);
} else if (sortMethod === 'value_asc') {
domain = Object.keys(domain).sort((d1, d2) => domain[d1] - domain[d2]);
}
if (k === 'y' && rangeBands) {
domain.reverse();
}
if (rangeBands === undefined) {
return d3.scale.ordinal().domain(domain).range(d3.range(domain.length));
if (rangeBands) {
return d3.scale.ordinal().domain(domain).rangeBands(rangeBands);
}
return d3.scale.ordinal().domain(domain).rangeBands(rangeBands);
return d3.scale.ordinal().domain(domain).range(d3.range(domain.length));
}
slice.container.html('');
@ -74,10 +86,10 @@ function heatmapVis(slice, payload) {
const hmHeight = height - (margin.bottom + margin.top);
const fp = d3.format('.3p');
const xScale = ordScale('x');
const yScale = ordScale('y', undefined, true);
const xRbScale = ordScale('x', [0, hmWidth]);
const yRbScale = ordScale('y', [hmHeight, 0]);
const xScale = ordScale('x', null, fd.sort_x_axis);
const yScale = ordScale('y', null, fd.sort_y_axis);
const xRbScale = ordScale('x', [0, hmWidth], fd.sort_x_axis);
const yRbScale = ordScale('y', [hmHeight, 0], fd.sort_y_axis);
const X = 0;
const Y = 1;
const heatmapDim = [xRbScale.domain().length, yRbScale.domain().length];
@ -102,15 +114,30 @@ function heatmapVis(slice, payload) {
.style('height', hmHeight + 'px')
.style('image-rendering', fd.canvas_image_rendering)
.style('left', margin.left + 'px')
.style('top', margin.top + headerHeight + 'px')
.style('top', margin.top + 'px')
.style('position', 'absolute');
const svg = container.append('svg')
.attr('width', width)
.attr('height', height)
.style('left', '0px')
.style('top', headerHeight + 'px')
.style('position', 'absolute');
.style('position', 'relative');
if (fd.show_legend) {
const legendScaler = colorScalerFactory(
fd.linear_color_scheme, null, null, payload.data.extents);
const colorLegend = d3.legend.color()
.labelFormat(valueFormatter)
.scale(legendScaler)
.shapePadding(0)
.cells(50)
.shapeWidth(10)
.shapeHeight(3)
.labelOffset(2);
svg.append('g')
.attr('transform', 'translate(10, 5)')
.call(colorLegend);
}
const tip = d3tip()
.attr('class', 'd3-tip')
@ -128,8 +155,10 @@ function heatmapVis(slice, payload) {
const obj = matrix[m][n];
s += '<div><b>' + fd.all_columns_x + ': </b>' + obj.x + '<div>';
s += '<div><b>' + fd.all_columns_y + ': </b>' + obj.y + '<div>';
s += '<div><b>' + fd.metric + ': </b>' + obj.v + '<div>';
s += '<div><b>%: </b>' + fp(obj.perc) + '<div>';
s += '<div><b>' + fd.metric + ': </b>' + valueFormatter(obj.v) + '<div>';
if (fd.show_perc) {
s += '<div><b>%: </b>' + fp(obj.perc) + '<div>';
}
tip.style('display', null);
} else {
// this is a hack to hide the tooltip because we have map it to a single <rect>
@ -190,7 +219,7 @@ function heatmapVis(slice, payload) {
const imageObj = new Image();
const image = context.createImageData(heatmapDim[0], heatmapDim[1]);
const pixs = {};
$.each(data, function (i, d) {
data.forEach((d) => {
const c = d3.rgb(color(d.perc));
const x = xScale(d.x);
const y = yScale(d.y);

View File

@ -1479,6 +1479,13 @@ class HeatmapViz(BaseViz):
df.columns = ['x', 'y', 'v']
norm = fd.get('normalize_across')
overall = False
max_ = df.v.max()
min_ = df.v.min()
bounds = fd.get('y_axis_bounds')
if bounds and bounds[0]:
min_ = bounds[0]
if bounds and bounds[1]:
max_ = bounds[1]
if norm == 'heatmap':
overall = True
else:
@ -1491,10 +1498,11 @@ class HeatmapViz(BaseViz):
lambda x: (x.v - x.v.min()) / (x.v.max() - x.v.min()))
)
if overall:
v = df.v
min_ = v.min()
df['perc'] = (v - min_) / (v.max() - min_)
return df.to_dict(orient="records")
df['perc'] = (df.v - min_) / (max_ - min_)
return {
'records': df.to_dict(orient="records"),
'extents': [min_, max_],
}
class HorizonViz(NVD3TimeSeriesViz):