diff --git a/superset/assets/src/visualizations/heatmap.css b/superset/assets/src/visualizations/heatmap.css index 79542e27e5..597a48fd4b 100644 --- a/superset/assets/src/visualizations/heatmap.css +++ b/superset/assets/src/visualizations/heatmap.css @@ -6,20 +6,24 @@ } .heatmap .axis text { - font: 10px sans-serif; + font: 12px sans-serif; text-rendering: optimizeLegibility; + fill: #555; +} + +.heatmap .background-rect { + stroke: #ddd; + fill-opacity: 0; + pointer-events: all; } .heatmap .axis path, .heatmap .axis line { fill: none; - stroke: #000; + stroke: #ddd; shape-rendering: crispEdges; } -.heatmap svg { -} - .heatmap canvas, .heatmap img { image-rendering: optimizeSpeed; /* Older versions of FF */ image-rendering: -moz-crisp-edges; /* FF 6.0+ */ @@ -41,3 +45,8 @@ .heatmap .legendCells .cell:last-child text { opacity: 1; } + +.dashboard .heatmap .axis text { + font-size: 10px; + opacity: .75; +} diff --git a/superset/assets/src/visualizations/heatmap.js b/superset/assets/src/visualizations/heatmap.js index c26291ffca..3a6b351678 100644 --- a/superset/assets/src/visualizations/heatmap.js +++ b/superset/assets/src/visualizations/heatmap.js @@ -1,21 +1,85 @@ import d3 from 'd3'; -// eslint-disable-next-line no-unused-vars -import d3legend from 'd3-svg-legend'; +import PropTypes from 'prop-types'; +import 'd3-svg-legend'; import d3tip from 'd3-tip'; import { colorScalerFactory } from '../modules/colors'; import '../../stylesheets/d3tip.css'; import './heatmap.css'; +const propTypes = { + data: PropTypes.shape({ + records: PropTypes.arrayOf(PropTypes.shape({ + x: PropTypes.string, + y: PropTypes.string, + v: PropTypes.number, + perc: PropTypes.number, + rank: PropTypes.number, + })), + extents: PropTypes.arrayOf(PropTypes.number), + }), + width: PropTypes.number, + height: PropTypes.number, + bottomMargin: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + ]), + colorScheme: PropTypes.string, + columnX: PropTypes.string, + columnY: PropTypes.string, + leftMargin: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + ]), + metric: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.object, + ]), + normalized: PropTypes.bool, + numberFormat: PropTypes.string, + showLegend: PropTypes.bool, + showPercentage: PropTypes.bool, + showValues: PropTypes.bool, + sortXAxis: PropTypes.string, + sortYAxis: PropTypes.string, + xScaleInterval: PropTypes.number, + yScaleInterval: PropTypes.number, + yAxisBounds: PropTypes.arrayOf(PropTypes.number), +}; + function cmp(a, b) { return a > b ? 1 : -1; } // Inspired from http://bl.ocks.org/mbostock/3074470 // https://jsfiddle.net/cyril123/h0reyumq/ -function heatmapVis(slice, payload) { - const data = payload.data.records; - const fd = slice.formData; +function Heatmap(element, props) { + PropTypes.checkPropTypes(propTypes, props, 'prop', 'Heatmap'); + + const { + data, + width, + height, + bottomMargin, + canvasImageRendering, + colorScheme, + columnX, + columnY, + leftMargin, + metric, + normalized, + numberFormat, + showLegend, + showPercentage, + showValues, + sortXAxis, + sortYAxis, + xScaleInterval, + yScaleInterval, + yAxisBounds, + } = props; + + const { records, extents } = data; const margin = { top: 10, @@ -23,7 +87,7 @@ function heatmapVis(slice, payload) { bottom: 35, left: 35, }; - const valueFormatter = d3.format(fd.y_axis_format); + const valueFormatter = d3.format(numberFormat); // Dynamically adjusts based on max x / y category lengths function adjustMargins() { @@ -31,33 +95,32 @@ function heatmapVis(slice, payload) { const pixelsPerCharY = 6; // approx, depends on font size let longestX = 1; let longestY = 1; - let datum; - for (let i = 0; i < data.length; i++) { - datum = data[i]; + for (let i = 0; i < records.length; i++) { + const datum = records[i]; longestX = Math.max(longestX, datum.x.toString().length || 1); longestY = Math.max(longestY, datum.y.toString().length || 1); } - if (fd.left_margin === 'auto') { + if (leftMargin === 'auto') { margin.left = Math.ceil(Math.max(margin.left, pixelsPerCharY * longestY)); - if (fd.show_legend) { - margin.left += 40; - } } else { - margin.left = fd.left_margin; + margin.left = leftMargin; } - if (fd.bottom_margin === 'auto') { - margin.bottom = Math.ceil(Math.max(margin.bottom, pixelsPerCharX * longestX)); - } else { - margin.bottom = fd.bottom_margin; + + if (showLegend) { + margin.right += 40; } + + margin.bottom = (bottomMargin === 'auto') + ? Math.ceil(Math.max(margin.bottom, pixelsPerCharX * longestX)) + : bottomMargin; } function ordScale(k, rangeBands, sortMethod) { let domain = {}; const actualKeys = {}; // hack to preserve type of keys when number - data.forEach((d) => { + records.forEach((d) => { domain[d[k]] = (domain[d[k]] || 0) + d.v; actualKeys[d[k]] = d[k]; }); @@ -83,46 +146,45 @@ function heatmapVis(slice, payload) { return d3.scale.ordinal().domain(domain).range(d3.range(domain.length)); } - slice.container.html(''); + // eslint-disable-next-line no-param-reassign + element.innerHTML = ''; const matrix = {}; adjustMargins(); - const width = slice.width(); - const height = slice.height(); const hmWidth = width - (margin.left + margin.right); const hmHeight = height - (margin.bottom + margin.top); const fp = d3.format('.2%'); - 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 xScale = ordScale('x', null, sortXAxis); + const yScale = ordScale('y', null, sortYAxis); + const xRbScale = ordScale('x', [0, hmWidth], sortXAxis); + const yRbScale = ordScale('y', [hmHeight, 0], sortYAxis); const X = 0; const Y = 1; const heatmapDim = [xRbScale.domain().length, yRbScale.domain().length]; - const minBound = fd.y_axis_bounds[0] || 0; - const maxBound = fd.y_axis_bounds[1] || 1; - const colorScaler = colorScalerFactory(fd.linear_color_scheme, null, null, [minBound, maxBound]); + const minBound = yAxisBounds[0] || 0; + const maxBound = yAxisBounds[1] || 1; + const colorScaler = colorScalerFactory(colorScheme, null, null, [minBound, maxBound]); const scale = [ d3.scale.linear() - .domain([0, heatmapDim[X]]) - .range([0, hmWidth]), + .domain([0, heatmapDim[X]]) + .range([0, hmWidth]), d3.scale.linear() - .domain([0, heatmapDim[Y]]) - .range([0, hmHeight]), + .domain([0, heatmapDim[Y]]) + .range([0, hmHeight]), ]; - const container = d3.select(slice.selector); + const container = d3.select(element); const canvas = container.append('canvas') .attr('width', heatmapDim[X]) .attr('height', heatmapDim[Y]) .style('width', hmWidth + 'px') .style('height', hmHeight + 'px') - .style('image-rendering', fd.canvas_image_rendering) + .style('image-rendering', canvasImageRendering) .style('left', margin.left + 'px') .style('top', margin.top + 'px') .style('position', 'absolute'); @@ -132,9 +194,9 @@ function heatmapVis(slice, payload) { .attr('height', height) .style('position', 'relative'); - if (fd.show_values) { + if (showValues) { const cells = svg.selectAll('rect') - .data(data) + .data(records) .enter() .append('g') .attr('transform', `translate(${margin.left}, ${margin.top})`); @@ -147,22 +209,22 @@ function heatmapVis(slice, payload) { .attr('dy', '.35em') .text(d => valueFormatter(d.v)) .attr('font-size', Math.min(yRbScale.rangeBand(), xRbScale.rangeBand()) / 3 + 'px') - .attr('fill', d => d.v >= payload.data.extents[1] / 2 ? 'white' : 'black'); + .attr('fill', d => d.v >= extents[1] / 2 ? 'white' : 'black'); } - if (fd.show_legend) { + if (showLegend) { const colorLegend = d3.legend.color() - .labelFormat(valueFormatter) - .scale(colorScaler) - .shapePadding(0) - .cells(50) - .shapeWidth(10) - .shapeHeight(3) - .labelOffset(2); + .labelFormat(valueFormatter) + .scale(colorScaler) + .shapePadding(0) + .cells(10) + .shapeWidth(10) + .shapeHeight(10) + .labelOffset(3); svg.append('g') - .attr('transform', 'translate(10, 5)') - .call(colorLegend); + .attr('transform', `translate(${width - 40}, ${margin.top})`) + .call(colorLegend); } const tip = d3tip() @@ -177,14 +239,14 @@ function heatmapVis(slice, payload) { const k = d3.mouse(this); const m = Math.floor(scale[0].invert(k[0])); const n = Math.floor(scale[1].invert(k[1])); - const metric = typeof fd.metric === 'object' ? fd.metric.label : fd.metric; + const metricLabel = typeof metric === 'object' ? metric.label : metric; if (m in matrix && n in matrix[m]) { const obj = matrix[m][n]; - s += '
' + fd.all_columns_x + ': ' + obj.x + '
'; - s += '
' + fd.all_columns_y + ': ' + obj.y + '
'; - s += '
' + metric + ': ' + valueFormatter(obj.v) + '
'; - if (fd.show_perc) { - s += '
%: ' + fp(fd.normalized ? obj.rank : obj.perc) + '
'; + s += '
' + columnX + ': ' + obj.x + '
'; + s += '
' + columnY + ': ' + obj.y + '
'; + s += '
' + metricLabel + ': ' + valueFormatter(obj.v) + '
'; + if (showPercentage) { + s += '
%: ' + fp(normalized ? obj.rank : obj.perc) + '
'; } tip.style('display', null); } else { @@ -196,48 +258,50 @@ function heatmapVis(slice, payload) { }); const rect = svg.append('g') - .attr('transform', `translate(${margin.left}, ${margin.top})`) + .attr('transform', `translate(${margin.left}, ${margin.top})`) .append('rect') - .attr('pointer-events', 'all') - .on('mousemove', tip.show) - .on('mouseout', tip.hide) - .style('fill-opacity', 0) - .attr('stroke', 'black') - .attr('width', hmWidth) - .attr('height', hmHeight); + .classed('background-rect', true) + .on('mousemove', tip.show) + .on('mouseout', tip.hide) + .attr('width', hmWidth) + .attr('height', hmHeight); rect.call(tip); const xAxis = d3.svg.axis() .scale(xRbScale) + .outerTickSize(0) .tickValues(xRbScale.domain().filter( function (d, i) { - return !(i % (parseInt(fd.xscale_interval, 10))); + return !(i % (xScaleInterval)); })) .orient('bottom'); const yAxis = d3.svg.axis() .scale(yRbScale) + .outerTickSize(0) .tickValues(yRbScale.domain().filter( function (d, i) { - return !(i % (parseInt(fd.yscale_interval, 10))); + return !(i % (yScaleInterval)); })) .orient('left'); svg.append('g') .attr('class', 'x axis') .attr('transform', 'translate(' + margin.left + ',' + (margin.top + hmHeight) + ')') - .call(xAxis) + .call(xAxis) .selectAll('text') - .style('text-anchor', 'end') - .attr('transform', 'rotate(-45)'); + .attr('x', -4) + .attr('y', 10) + .attr('dy', '0.3em') + .style('text-anchor', 'end') + .attr('transform', 'rotate(-45)'); svg.append('g') .attr('class', 'y axis') .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')') .call(yAxis); - const context = canvas.node().getContext('2d'); context.imageSmoothingEnabled = false; @@ -246,8 +310,8 @@ function heatmapVis(slice, payload) { const imageObj = new Image(); const image = context.createImageData(heatmapDim[0], heatmapDim[1]); const pixs = {}; - data.forEach((d) => { - const c = d3.rgb(colorScaler(fd.normalized ? d.rank : d.perc)); + records.forEach((d) => { + const c = d3.rgb(colorScaler(normalized ? d.rank : d.perc)); const x = xScale(d.x); const y = yScale(d.y); pixs[x + (y * xScale.domain().length)] = c; @@ -278,4 +342,53 @@ function heatmapVis(slice, payload) { createImageObj(); } -module.exports = heatmapVis; +Heatmap.propTypes = propTypes; + +function adaptor(slice, payload) { + const { selector, formData } = slice; + const { + bottom_margin: bottomMargin, + canvas_image_rendering: canvasImageRendering, + all_columns_x: columnX, + all_columns_y: columnY, + linear_color_scheme: colorScheme, + left_margin: leftMargin, + metric, + normalized, + show_legend: showLegend, + show_perc: showPercentage, + show_values: showValues, + sort_x_axis: sortXAxis, + sort_y_axis: sortYAxis, + xscale_interval: xScaleInterval, + yscale_interval: yScaleInterval, + y_axis_bounds: yAxisBounds, + y_axis_format: numberFormat, + } = formData; + const element = document.querySelector(selector); + + return Heatmap(element, { + data: payload.data, + width: slice.width(), + height: slice.height(), + bottomMargin, + canvasImageRendering, + colorScheme, + columnX, + columnY, + leftMargin, + metric, + normalized, + numberFormat, + showLegend, + showPercentage, + showValues, + sortXAxis, + sortYAxis, + xScaleInterval: parseInt(xScaleInterval, 10), + yScaleInterval: parseInt(yScaleInterval, 10), + yAxisBounds, + }); +} + +export default adaptor;