[SIP-5] Refactor and update heatmap (#5704)

* Extract slice and formData

* Define data shape

* update style

* organize imports

* fix heatmap axis labels

* add new line

* adjust indent
This commit is contained in:
Krist Wongsuphasawat 2018-08-27 21:49:54 -07:00 committed by Chris Williams
parent 4ae08c2c64
commit fcf2c756c0
2 changed files with 198 additions and 76 deletions

View File

@ -6,20 +6,24 @@
} }
.heatmap .axis text { .heatmap .axis text {
font: 10px sans-serif; font: 12px sans-serif;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
fill: #555;
}
.heatmap .background-rect {
stroke: #ddd;
fill-opacity: 0;
pointer-events: all;
} }
.heatmap .axis path, .heatmap .axis path,
.heatmap .axis line { .heatmap .axis line {
fill: none; fill: none;
stroke: #000; stroke: #ddd;
shape-rendering: crispEdges; shape-rendering: crispEdges;
} }
.heatmap svg {
}
.heatmap canvas, .heatmap img { .heatmap canvas, .heatmap img {
image-rendering: optimizeSpeed; /* Older versions of FF */ image-rendering: optimizeSpeed; /* Older versions of FF */
image-rendering: -moz-crisp-edges; /* FF 6.0+ */ image-rendering: -moz-crisp-edges; /* FF 6.0+ */
@ -41,3 +45,8 @@
.heatmap .legendCells .cell:last-child text { .heatmap .legendCells .cell:last-child text {
opacity: 1; opacity: 1;
} }
.dashboard .heatmap .axis text {
font-size: 10px;
opacity: .75;
}

View File

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