blob: d86253d63f83ab94891c659e30fd3504ff2b1ca7 [file] [log] [blame]
import d3 from 'd3';
// 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';
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;
const margin = {
top: 10,
right: 10,
bottom: 35,
left: 35,
};
const valueFormatter = d3.format(fd.y_axis_format);
// Dynamically adjusts based on max x / y category lengths
function adjustMargins() {
const pixelsPerCharX = 4.5; // approx, depends on font size
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];
longestX = Math.max(longestX, datum.x.toString().length || 1);
longestY = Math.max(longestY, datum.y.toString().length || 1);
}
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;
}
if (fd.bottom_margin === 'auto') {
margin.bottom = Math.ceil(Math.max(margin.bottom, pixelsPerCharX * longestX));
} else {
margin.bottom = fd.bottom_margin;
}
}
function ordScale(k, rangeBands, sortMethod) {
let domain = {};
const actualKeys = {}; // hack to preserve type of keys when number
data.forEach((d) => {
domain[d[k]] = (domain[d[k]] || 0) + d.v;
actualKeys[d[k]] = d[k];
});
// Not usgin object.keys() as it converts to strings
const keys = Object.keys(actualKeys).map(s => actualKeys[s]);
if (sortMethod === 'alpha_asc') {
domain = keys.sort(cmp);
} else if (sortMethod === 'alpha_desc') {
domain = keys.sort(cmp).reverse();
} else if (sortMethod === 'value_desc') {
domain = Object.keys(domain).sort((a, b) => domain[a] > domain[b] ? -1 : 1);
} else if (sortMethod === 'value_asc') {
domain = Object.keys(domain).sort((a, b) => domain[b] > domain[a] ? -1 : 1);
}
if (k === 'y' && rangeBands) {
domain.reverse();
}
if (rangeBands) {
return d3.scale.ordinal().domain(domain).rangeBands(rangeBands);
}
return d3.scale.ordinal().domain(domain).range(d3.range(domain.length));
}
slice.container.html('');
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('.3p');
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];
const color = colorScalerFactory(fd.linear_color_scheme);
const scale = [
d3.scale.linear()
.domain([0, heatmapDim[X]])
.range([0, hmWidth]),
d3.scale.linear()
.domain([0, heatmapDim[Y]])
.range([0, hmHeight]),
];
const container = d3.select(slice.selector);
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('left', margin.left + 'px')
.style('top', margin.top + 'px')
.style('position', 'absolute');
const svg = container.append('svg')
.attr('width', width)
.attr('height', height)
.style('position', 'relative');
if (fd.show_values) {
const cells = svg.selectAll('rect')
.data(data)
.enter()
.append('g')
.attr('transform', `translate(${margin.left}, ${margin.top})`);
cells.append('text')
.attr('transform', d => `translate(${xRbScale(d.x)}, ${yRbScale(d.y)})`)
.attr('y', yRbScale.rangeBand() / 2)
.attr('x', xRbScale.rangeBand() / 2)
.attr('text-anchor', 'middle')
.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');
}
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')
.offset(function () {
const k = d3.mouse(this);
const x = k[0] - (hmWidth / 2);
return [k[1] - 20, x];
})
.html(function () {
let s = '';
const k = d3.mouse(this);
const m = Math.floor(scale[0].invert(k[0]));
const n = Math.floor(scale[1].invert(k[1]));
if (m in matrix && n in matrix[m]) {
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>' + 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>
// d3-tip toggles opacity and calling hide here is undone by the lib after this call
tip.style('display', 'none');
}
return s;
});
const rect = svg.append('g')
.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);
rect.call(tip);
const xAxis = d3.svg.axis()
.scale(xRbScale)
.tickValues(xRbScale.domain().filter(
function (d, i) {
return !(i % (parseInt(fd.xscale_interval, 10)));
}))
.orient('bottom');
const yAxis = d3.svg.axis()
.scale(yRbScale)
.tickValues(yRbScale.domain().filter(
function (d, i) {
return !(i % (parseInt(fd.yscale_interval, 10)));
}))
.orient('left');
svg.append('g')
.attr('class', 'x axis')
.attr('transform', 'translate(' + margin.left + ',' + (margin.top + hmHeight) + ')')
.call(xAxis)
.selectAll('text')
.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;
// Compute the pixel colors; scaled by CSS.
function createImageObj() {
const imageObj = new Image();
const image = context.createImageData(heatmapDim[0], heatmapDim[1]);
const pixs = {};
data.forEach((d) => {
const c = d3.rgb(color(d.perc));
const x = xScale(d.x);
const y = yScale(d.y);
pixs[x + (y * xScale.domain().length)] = c;
if (matrix[x] === undefined) {
matrix[x] = {};
}
if (matrix[x][y] === undefined) {
matrix[x][y] = d;
}
});
let p = -1;
for (let i = 0; i < heatmapDim[0] * heatmapDim[1]; i++) {
let c = pixs[i];
let alpha = 255;
if (c === undefined) {
c = d3.rgb('#F00');
alpha = 0;
}
image.data[++p] = c.r;
image.data[++p] = c.g;
image.data[++p] = c.b;
image.data[++p] = alpha;
}
context.putImageData(image, 0, 0);
imageObj.src = canvas.node().toDataURL();
}
createImageObj();
}
module.exports = heatmapVis;