diff --git a/superset/assets/javascripts/explore/stores/controls.jsx b/superset/assets/javascripts/explore/stores/controls.jsx index f2d68a4b38..c8852336ec 100644 --- a/superset/assets/javascripts/explore/stores/controls.jsx +++ b/superset/assets/javascripts/explore/stores/controls.jsx @@ -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', diff --git a/superset/assets/javascripts/explore/stores/visTypes.js b/superset/assets/javascripts/explore/stores/visTypes.js index fad261c13b..4f9ebb8d0e 100644 --- a/superset/assets/javascripts/explore/stores/visTypes.js +++ b/superset/assets/javascripts/explore/stores/visTypes.js @@ -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', + }, }, }, diff --git a/superset/assets/javascripts/modules/colors.js b/superset/assets/javascripts/modules/colors.js index 8594e17aee..7fd585f370 100644 --- a/superset/assets/javascripts/modules/colors.js +++ b/superset/assets/javascripts/modules/colors.js @@ -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); }; diff --git a/superset/assets/package.json b/superset/assets/package.json index 8733abac0f..002d0e8e39 100644 --- a/superset/assets/package.json +++ b/superset/assets/package.json @@ -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", diff --git a/superset/assets/visualizations/heatmap.css b/superset/assets/visualizations/heatmap.css index bfcc327e65..79542e27e5 100644 --- a/superset/assets/visualizations/heatmap.css +++ b/superset/assets/visualizations/heatmap.css @@ -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; +} diff --git a/superset/assets/visualizations/heatmap.js b/superset/assets/visualizations/heatmap.js index af03739b54..1f76a3acba 100644 --- a/superset/assets/visualizations/heatmap.js +++ b/superset/assets/visualizations/heatmap.js @@ -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 += '
' + fd.all_columns_x + ': ' + obj.x + '
'; s += '
' + fd.all_columns_y + ': ' + obj.y + '
'; - s += '
' + fd.metric + ': ' + obj.v + '
'; - s += '
%: ' + fp(obj.perc) + '
'; + s += '
' + fd.metric + ': ' + valueFormatter(obj.v) + '
'; + if (fd.show_perc) { + s += '
%: ' + fp(obj.perc) + '
'; + } tip.style('display', null); } else { // this is a hack to hide the tooltip because we have map it to a single @@ -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); diff --git a/superset/viz.py b/superset/viz.py index 7a2b22ac7f..bb4c59469e 100644 --- a/superset/viz.py +++ b/superset/viz.py @@ -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):