Commit ddc83c2a by Marcus Efraimsson Committed by GitHub

Merge pull request #12897 from dehrax/12224-renderer

WIP Karma to Jest: heatmap renderer (refactor)
parents 77ee032e 53bab1a8
......@@ -19,56 +19,94 @@ let MIN_CARD_SIZE = 1,
Y_AXIS_TICK_PADDING = 5,
MIN_SELECTION_WIDTH = 2;
export default function link(scope, elem, attrs, ctrl) {
let data, timeRange, panel, heatmap;
export default function rendering(scope, elem, attrs, ctrl) {
return new HeatmapRenderer(scope, elem, attrs, ctrl);
}
export class HeatmapRenderer {
width: number;
height: number;
yScale: any;
xScale: any;
chartWidth: number;
chartHeight: number;
chartTop: number;
chartBottom: number;
yAxisWidth: number;
xAxisHeight: number;
cardPadding: number;
cardRound: number;
cardWidth: number;
cardHeight: number;
colorScale: any;
opacityScale: any;
mouseUpHandler: any;
data: any;
panel: any;
$heatmap: any;
tooltip: HeatmapTooltip;
heatmap: any;
timeRange: any;
selection: any;
padding: any;
margin: any;
dataRangeWidingFactor: number;
constructor(private scope, private elem, attrs, private ctrl) {
// $heatmap is JQuery object, but heatmap is D3
let $heatmap = elem.find('.heatmap-panel');
let tooltip = new HeatmapTooltip($heatmap, scope);
let width,
height,
yScale,
xScale,
chartWidth,
chartHeight,
chartTop,
chartBottom,
yAxisWidth,
xAxisHeight,
cardPadding,
cardRound,
cardWidth,
cardHeight,
colorScale,
opacityScale,
mouseUpHandler;
let selection = {
this.$heatmap = this.elem.find('.heatmap-panel');
this.tooltip = new HeatmapTooltip(this.$heatmap, this.scope);
this.selection = {
active: false,
x1: -1,
x2: -1,
};
let padding = { left: 0, right: 0, top: 0, bottom: 0 },
margin = { left: 25, right: 15, top: 10, bottom: 20 },
dataRangeWidingFactor = DATA_RANGE_WIDING_FACTOR;
this.padding = { left: 0, right: 0, top: 0, bottom: 0 };
this.margin = { left: 25, right: 15, top: 10, bottom: 20 };
this.dataRangeWidingFactor = DATA_RANGE_WIDING_FACTOR;
ctrl.events.on('render', () => {
render();
ctrl.renderingCompleted();
});
this.ctrl.events.on('render', this.onRender.bind(this));
function setElementHeight() {
this.ctrl.tickValueFormatter = this.tickValueFormatter.bind(this);
/////////////////////////////
// Selection and crosshair //
/////////////////////////////
// Shared crosshair and tooltip
appEvents.on('graph-hover', this.onGraphHover.bind(this), this.scope);
appEvents.on('graph-hover-clear', this.onGraphHoverClear.bind(this), this.scope);
// Register selection listeners
this.$heatmap.on('mousedown', this.onMouseDown.bind(this));
this.$heatmap.on('mousemove', this.onMouseMove.bind(this));
this.$heatmap.on('mouseleave', this.onMouseLeave.bind(this));
}
onGraphHoverClear() {
this.clearCrosshair();
}
onGraphHover(event) {
this.drawSharedCrosshair(event.pos);
}
onRender() {
this.render();
this.ctrl.renderingCompleted();
}
setElementHeight() {
try {
var height = ctrl.height || panel.height || ctrl.row.height;
var height = this.ctrl.height || this.panel.height || this.ctrl.row.height;
if (_.isString(height)) {
height = parseInt(height.replace('px', ''), 10);
}
height -= panel.legend.show ? 28 : 11; // bottom padding and space for legend
height -= this.panel.legend.show ? 28 : 11; // bottom padding and space for legend
$heatmap.css('height', height + 'px');
this.$heatmap.css('height', height + 'px');
return true;
} catch (e) {
......@@ -77,7 +115,7 @@ export default function link(scope, elem, attrs, ctrl) {
}
}
function getYAxisWidth(elem) {
getYAxisWidth(elem) {
let axis_text = elem.selectAll('.axis-y text').nodes();
let max_text_width = _.max(
_.map(axis_text, text => {
......@@ -89,7 +127,7 @@ export default function link(scope, elem, attrs, ctrl) {
return max_text_width;
}
function getXAxisHeight(elem) {
getXAxisHeight(elem) {
let axis_line = elem.select('.axis-x line');
if (!axis_line.empty()) {
let axis_line_position = parseFloat(elem.select('.axis-x line').attr('y2'));
......@@ -101,16 +139,16 @@ export default function link(scope, elem, attrs, ctrl) {
}
}
function addXAxis() {
scope.xScale = xScale = d3
addXAxis() {
this.scope.xScale = this.xScale = d3
.scaleTime()
.domain([timeRange.from, timeRange.to])
.range([0, chartWidth]);
.domain([this.timeRange.from, this.timeRange.to])
.range([0, this.chartWidth]);
let ticks = chartWidth / DEFAULT_X_TICK_SIZE_PX;
let grafanaTimeFormatter = ticksUtils.grafanaTimeFormat(ticks, timeRange.from, timeRange.to);
let ticks = this.chartWidth / DEFAULT_X_TICK_SIZE_PX;
let grafanaTimeFormatter = ticksUtils.grafanaTimeFormat(ticks, this.timeRange.from, this.timeRange.to);
let timeFormat;
let dashboardTimeZone = ctrl.dashboard.getTimezone();
let dashboardTimeZone = this.ctrl.dashboard.getTimezone();
if (dashboardTimeZone === 'utc') {
timeFormat = d3.utcFormat(grafanaTimeFormatter);
} else {
......@@ -118,100 +156,100 @@ export default function link(scope, elem, attrs, ctrl) {
}
let xAxis = d3
.axisBottom(xScale)
.axisBottom(this.xScale)
.ticks(ticks)
.tickFormat(timeFormat)
.tickPadding(X_AXIS_TICK_PADDING)
.tickSize(chartHeight);
.tickSize(this.chartHeight);
let posY = margin.top;
let posX = yAxisWidth;
heatmap
let posY = this.margin.top;
let posX = this.yAxisWidth;
this.heatmap
.append('g')
.attr('class', 'axis axis-x')
.attr('transform', 'translate(' + posX + ',' + posY + ')')
.call(xAxis);
// Remove horizontal line in the top of axis labels (called domain in d3)
heatmap
this.heatmap
.select('.axis-x')
.select('.domain')
.remove();
}
function addYAxis() {
let ticks = Math.ceil(chartHeight / DEFAULT_Y_TICK_SIZE_PX);
let tick_interval = ticksUtils.tickStep(data.heatmapStats.min, data.heatmapStats.max, ticks);
let { y_min, y_max } = wideYAxisRange(data.heatmapStats.min, data.heatmapStats.max, tick_interval);
addYAxis() {
let ticks = Math.ceil(this.chartHeight / DEFAULT_Y_TICK_SIZE_PX);
let tick_interval = ticksUtils.tickStep(this.data.heatmapStats.min, this.data.heatmapStats.max, ticks);
let { y_min, y_max } = this.wideYAxisRange(this.data.heatmapStats.min, this.data.heatmapStats.max, tick_interval);
// Rewrite min and max if it have been set explicitly
y_min = panel.yAxis.min !== null ? panel.yAxis.min : y_min;
y_max = panel.yAxis.max !== null ? panel.yAxis.max : y_max;
y_min = this.panel.yAxis.min !== null ? this.panel.yAxis.min : y_min;
y_max = this.panel.yAxis.max !== null ? this.panel.yAxis.max : y_max;
// Adjust ticks after Y range widening
tick_interval = ticksUtils.tickStep(y_min, y_max, ticks);
ticks = Math.ceil((y_max - y_min) / tick_interval);
let decimalsAuto = ticksUtils.getPrecision(tick_interval);
let decimals = panel.yAxis.decimals === null ? decimalsAuto : panel.yAxis.decimals;
let decimals = this.panel.yAxis.decimals === null ? decimalsAuto : this.panel.yAxis.decimals;
// Calculate scaledDecimals for log scales using tick size (as in jquery.flot.js)
let flot_tick_size = ticksUtils.getFlotTickSize(y_min, y_max, ticks, decimalsAuto);
let scaledDecimals = ticksUtils.getScaledDecimals(decimals, flot_tick_size);
ctrl.decimals = decimals;
ctrl.scaledDecimals = scaledDecimals;
this.ctrl.decimals = decimals;
this.ctrl.scaledDecimals = scaledDecimals;
// Set default Y min and max if no data
if (_.isEmpty(data.buckets)) {
if (_.isEmpty(this.data.buckets)) {
y_max = 1;
y_min = -1;
ticks = 3;
decimals = 1;
}
data.yAxis = {
this.data.yAxis = {
min: y_min,
max: y_max,
ticks: ticks,
};
scope.yScale = yScale = d3
this.scope.yScale = this.yScale = d3
.scaleLinear()
.domain([y_min, y_max])
.range([chartHeight, 0]);
.range([this.chartHeight, 0]);
let yAxis = d3
.axisLeft(yScale)
.axisLeft(this.yScale)
.ticks(ticks)
.tickFormat(tickValueFormatter(decimals, scaledDecimals))
.tickSizeInner(0 - width)
.tickFormat(this.tickValueFormatter(decimals, scaledDecimals))
.tickSizeInner(0 - this.width)
.tickSizeOuter(0)
.tickPadding(Y_AXIS_TICK_PADDING);
heatmap
this.heatmap
.append('g')
.attr('class', 'axis axis-y')
.call(yAxis);
// Calculate Y axis width first, then move axis into visible area
let posY = margin.top;
let posX = getYAxisWidth(heatmap) + Y_AXIS_TICK_PADDING;
heatmap.select('.axis-y').attr('transform', 'translate(' + posX + ',' + posY + ')');
let posY = this.margin.top;
let posX = this.getYAxisWidth(this.heatmap) + Y_AXIS_TICK_PADDING;
this.heatmap.select('.axis-y').attr('transform', 'translate(' + posX + ',' + posY + ')');
// Remove vertical line in the right of axis labels (called domain in d3)
heatmap
this.heatmap
.select('.axis-y')
.select('.domain')
.remove();
}
// Wide Y values range and anjust to bucket size
function wideYAxisRange(min, max, tickInterval) {
let y_widing = (max * (dataRangeWidingFactor - 1) - min * (dataRangeWidingFactor - 1)) / 2;
wideYAxisRange(min, max, tickInterval) {
let y_widing = (max * (this.dataRangeWidingFactor - 1) - min * (this.dataRangeWidingFactor - 1)) / 2;
let y_min, y_max;
if (tickInterval === 0) {
y_max = max * dataRangeWidingFactor;
y_min = min - min * (dataRangeWidingFactor - 1);
y_max = max * this.dataRangeWidingFactor;
y_min = min - min * (this.dataRangeWidingFactor - 1);
tickInterval = (y_max - y_min) / 2;
} else {
y_max = Math.ceil((max + y_widing) / tickInterval) * tickInterval;
......@@ -226,89 +264,91 @@ export default function link(scope, elem, attrs, ctrl) {
return { y_min, y_max };
}
function addLogYAxis() {
let log_base = panel.yAxis.logBase;
let { y_min, y_max } = adjustLogRange(data.heatmapStats.minLog, data.heatmapStats.max, log_base);
addLogYAxis() {
let log_base = this.panel.yAxis.logBase;
let { y_min, y_max } = this.adjustLogRange(this.data.heatmapStats.minLog, this.data.heatmapStats.max, log_base);
y_min = panel.yAxis.min && panel.yAxis.min !== '0' ? adjustLogMin(panel.yAxis.min, log_base) : y_min;
y_max = panel.yAxis.max !== null ? adjustLogMax(panel.yAxis.max, log_base) : y_max;
y_min =
this.panel.yAxis.min && this.panel.yAxis.min !== '0' ? this.adjustLogMin(this.panel.yAxis.min, log_base) : y_min;
y_max = this.panel.yAxis.max !== null ? this.adjustLogMax(this.panel.yAxis.max, log_base) : y_max;
// Set default Y min and max if no data
if (_.isEmpty(data.buckets)) {
if (_.isEmpty(this.data.buckets)) {
y_max = Math.pow(log_base, 2);
y_min = 1;
}
scope.yScale = yScale = d3
this.scope.yScale = this.yScale = d3
.scaleLog()
.base(panel.yAxis.logBase)
.base(this.panel.yAxis.logBase)
.domain([y_min, y_max])
.range([chartHeight, 0]);
.range([this.chartHeight, 0]);
let domain = yScale.domain();
let tick_values = logScaleTickValues(domain, log_base);
let domain = this.yScale.domain();
let tick_values = this.logScaleTickValues(domain, log_base);
let decimalsAuto = ticksUtils.getPrecision(y_min);
let decimals = panel.yAxis.decimals || decimalsAuto;
let decimals = this.panel.yAxis.decimals || decimalsAuto;
// Calculate scaledDecimals for log scales using tick size (as in jquery.flot.js)
let flot_tick_size = ticksUtils.getFlotTickSize(y_min, y_max, tick_values.length, decimalsAuto);
let scaledDecimals = ticksUtils.getScaledDecimals(decimals, flot_tick_size);
ctrl.decimals = decimals;
ctrl.scaledDecimals = scaledDecimals;
this.ctrl.decimals = decimals;
this.ctrl.scaledDecimals = scaledDecimals;
data.yAxis = {
this.data.yAxis = {
min: y_min,
max: y_max,
ticks: tick_values.length,
};
let yAxis = d3
.axisLeft(yScale)
.axisLeft(this.yScale)
.tickValues(tick_values)
.tickFormat(tickValueFormatter(decimals, scaledDecimals))
.tickSizeInner(0 - width)
.tickFormat(this.tickValueFormatter(decimals, scaledDecimals))
.tickSizeInner(0 - this.width)
.tickSizeOuter(0)
.tickPadding(Y_AXIS_TICK_PADDING);
heatmap
this.heatmap
.append('g')
.attr('class', 'axis axis-y')
.call(yAxis);
// Calculate Y axis width first, then move axis into visible area
let posY = margin.top;
let posX = getYAxisWidth(heatmap) + Y_AXIS_TICK_PADDING;
heatmap.select('.axis-y').attr('transform', 'translate(' + posX + ',' + posY + ')');
let posY = this.margin.top;
let posX = this.getYAxisWidth(this.heatmap) + Y_AXIS_TICK_PADDING;
this.heatmap.select('.axis-y').attr('transform', 'translate(' + posX + ',' + posY + ')');
// Set first tick as pseudo 0
if (y_min < 1) {
heatmap
this.heatmap
.select('.axis-y')
.select('.tick text')
.text('0');
}
// Remove vertical line in the right of axis labels (called domain in d3)
heatmap
this.heatmap
.select('.axis-y')
.select('.domain')
.remove();
}
function addYAxisFromBuckets() {
const tsBuckets = data.tsBuckets;
addYAxisFromBuckets() {
const tsBuckets = this.data.tsBuckets;
scope.yScale = yScale = d3
this.scope.yScale = this.yScale = d3
.scaleLinear()
.domain([0, tsBuckets.length - 1])
.range([chartHeight, 0]);
.range([this.chartHeight, 0]);
const tick_values = _.map(tsBuckets, (b, i) => i);
const decimalsAuto = _.max(_.map(tsBuckets, ticksUtils.getStringPrecision));
const decimals = panel.yAxis.decimals === null ? decimalsAuto : panel.yAxis.decimals;
ctrl.decimals = decimals;
const decimals = this.panel.yAxis.decimals === null ? decimalsAuto : this.panel.yAxis.decimals;
this.ctrl.decimals = decimals;
let tickValueFormatter = this.tickValueFormatter.bind(this);
function tickFormatter(valIndex) {
let valueFormatted = tsBuckets[valIndex];
if (!_.isNaN(_.toNumber(valueFormatted)) && valueFormatted !== '') {
......@@ -319,59 +359,59 @@ export default function link(scope, elem, attrs, ctrl) {
}
const tsBucketsFormatted = _.map(tsBuckets, (v, i) => tickFormatter(i));
data.tsBucketsFormatted = tsBucketsFormatted;
this.data.tsBucketsFormatted = tsBucketsFormatted;
let yAxis = d3
.axisLeft(yScale)
.axisLeft(this.yScale)
.tickValues(tick_values)
.tickFormat(tickFormatter)
.tickSizeInner(0 - width)
.tickSizeInner(0 - this.width)
.tickSizeOuter(0)
.tickPadding(Y_AXIS_TICK_PADDING);
heatmap
this.heatmap
.append('g')
.attr('class', 'axis axis-y')
.call(yAxis);
// Calculate Y axis width first, then move axis into visible area
const posY = margin.top;
const posX = getYAxisWidth(heatmap) + Y_AXIS_TICK_PADDING;
heatmap.select('.axis-y').attr('transform', 'translate(' + posX + ',' + posY + ')');
const posY = this.margin.top;
const posX = this.getYAxisWidth(this.heatmap) + Y_AXIS_TICK_PADDING;
this.heatmap.select('.axis-y').attr('transform', 'translate(' + posX + ',' + posY + ')');
// Remove vertical line in the right of axis labels (called domain in d3)
heatmap
this.heatmap
.select('.axis-y')
.select('.domain')
.remove();
}
// Adjust data range to log base
function adjustLogRange(min, max, logBase) {
adjustLogRange(min, max, logBase) {
let y_min, y_max;
y_min = data.heatmapStats.minLog;
if (data.heatmapStats.minLog > 1 || !data.heatmapStats.minLog) {
y_min = this.data.heatmapStats.minLog;
if (this.data.heatmapStats.minLog > 1 || !this.data.heatmapStats.minLog) {
y_min = 1;
} else {
y_min = adjustLogMin(data.heatmapStats.minLog, logBase);
y_min = this.adjustLogMin(this.data.heatmapStats.minLog, logBase);
}
// Adjust max Y value to log base
y_max = adjustLogMax(data.heatmapStats.max, logBase);
y_max = this.adjustLogMax(this.data.heatmapStats.max, logBase);
return { y_min, y_max };
}
function adjustLogMax(max, base) {
adjustLogMax(max, base) {
return Math.pow(base, Math.ceil(ticksUtils.logp(max, base)));
}
function adjustLogMin(min, base) {
adjustLogMin(min, base) {
return Math.pow(base, Math.floor(ticksUtils.logp(min, base)));
}
function logScaleTickValues(domain, base) {
logScaleTickValues(domain, base) {
let domainMin = domain[0];
let domainMax = domain[1];
let tickValues = [];
......@@ -393,8 +433,8 @@ export default function link(scope, elem, attrs, ctrl) {
return tickValues;
}
function tickValueFormatter(decimals, scaledDecimals = null) {
let format = panel.yAxis.format;
tickValueFormatter(decimals, scaledDecimals = null) {
let format = this.panel.yAxis.format;
return function(value) {
try {
return format !== 'none' ? kbn.valueFormats[format](value, decimals, scaledDecimals) : value;
......@@ -405,181 +445,178 @@ export default function link(scope, elem, attrs, ctrl) {
};
}
ctrl.tickValueFormatter = tickValueFormatter;
function fixYAxisTickSize() {
heatmap
fixYAxisTickSize() {
this.heatmap
.select('.axis-y')
.selectAll('.tick line')
.attr('x2', chartWidth);
.attr('x2', this.chartWidth);
}
function addAxes() {
chartHeight = height - margin.top - margin.bottom;
chartTop = margin.top;
chartBottom = chartTop + chartHeight;
if (panel.dataFormat === 'tsbuckets') {
addYAxisFromBuckets();
addAxes() {
this.chartHeight = this.height - this.margin.top - this.margin.bottom;
this.chartTop = this.margin.top;
this.chartBottom = this.chartTop + this.chartHeight;
if (this.panel.dataFormat === 'tsbuckets') {
this.addYAxisFromBuckets();
} else {
if (panel.yAxis.logBase === 1) {
addYAxis();
if (this.panel.yAxis.logBase === 1) {
this.addYAxis();
} else {
addLogYAxis();
this.addLogYAxis();
}
}
yAxisWidth = getYAxisWidth(heatmap) + Y_AXIS_TICK_PADDING;
chartWidth = width - yAxisWidth - margin.right;
fixYAxisTickSize();
this.yAxisWidth = this.getYAxisWidth(this.heatmap) + Y_AXIS_TICK_PADDING;
this.chartWidth = this.width - this.yAxisWidth - this.margin.right;
this.fixYAxisTickSize();
addXAxis();
xAxisHeight = getXAxisHeight(heatmap);
this.addXAxis();
this.xAxisHeight = this.getXAxisHeight(this.heatmap);
if (!panel.yAxis.show) {
heatmap
if (!this.panel.yAxis.show) {
this.heatmap
.select('.axis-y')
.selectAll('line')
.style('opacity', 0);
}
if (!panel.xAxis.show) {
heatmap
if (!this.panel.xAxis.show) {
this.heatmap
.select('.axis-x')
.selectAll('line')
.style('opacity', 0);
}
}
function addHeatmapCanvas() {
let heatmap_elem = $heatmap[0];
addHeatmapCanvas() {
let heatmap_elem = this.$heatmap[0];
width = Math.floor($heatmap.width()) - padding.right;
height = Math.floor($heatmap.height()) - padding.bottom;
this.width = Math.floor(this.$heatmap.width()) - this.padding.right;
this.height = Math.floor(this.$heatmap.height()) - this.padding.bottom;
cardPadding = panel.cards.cardPadding !== null ? panel.cards.cardPadding : CARD_PADDING;
cardRound = panel.cards.cardRound !== null ? panel.cards.cardRound : CARD_ROUND;
this.cardPadding = this.panel.cards.cardPadding !== null ? this.panel.cards.cardPadding : CARD_PADDING;
this.cardRound = this.panel.cards.cardRound !== null ? this.panel.cards.cardRound : CARD_ROUND;
if (heatmap) {
heatmap.remove();
if (this.heatmap) {
this.heatmap.remove();
}
heatmap = d3
this.heatmap = d3
.select(heatmap_elem)
.append('svg')
.attr('width', width)
.attr('height', height);
.attr('width', this.width)
.attr('height', this.height);
}
function addHeatmap() {
addHeatmapCanvas();
addAxes();
addHeatmap() {
this.addHeatmapCanvas();
this.addAxes();
if (panel.yAxis.logBase !== 1 && panel.dataFormat !== 'tsbuckets') {
let log_base = panel.yAxis.logBase;
let domain = yScale.domain();
let tick_values = logScaleTickValues(domain, log_base);
data.buckets = mergeZeroBuckets(data.buckets, _.min(tick_values));
if (this.panel.yAxis.logBase !== 1 && this.panel.dataFormat !== 'tsbuckets') {
let log_base = this.panel.yAxis.logBase;
let domain = this.yScale.domain();
let tick_values = this.logScaleTickValues(domain, log_base);
this.data.buckets = mergeZeroBuckets(this.data.buckets, _.min(tick_values));
}
let cardsData = data.cards;
let maxValueAuto = data.cardStats.max;
let maxValue = panel.color.max || maxValueAuto;
let minValue = panel.color.min || 0;
let cardsData = this.data.cards;
let maxValueAuto = this.data.cardStats.max;
let maxValue = this.panel.color.max || maxValueAuto;
let minValue = this.panel.color.min || 0;
let colorScheme = _.find(ctrl.colorSchemes, {
value: panel.color.colorScheme,
let colorScheme = _.find(this.ctrl.colorSchemes, {
value: this.panel.color.colorScheme,
});
colorScale = getColorScale(colorScheme, contextSrv.user.lightTheme, maxValue, minValue);
opacityScale = getOpacityScale(panel.color, maxValue);
setCardSize();
this.colorScale = getColorScale(colorScheme, contextSrv.user.lightTheme, maxValue, minValue);
this.opacityScale = getOpacityScale(this.panel.color, maxValue);
this.setCardSize();
let cards = heatmap.selectAll('.heatmap-card').data(cardsData);
let cards = this.heatmap.selectAll('.heatmap-card').data(cardsData);
cards.append('title');
cards = cards
.enter()
.append('rect')
.attr('x', getCardX)
.attr('width', getCardWidth)
.attr('y', getCardY)
.attr('height', getCardHeight)
.attr('rx', cardRound)
.attr('ry', cardRound)
.attr('x', this.getCardX.bind(this))
.attr('width', this.getCardWidth.bind(this))
.attr('y', this.getCardY.bind(this))
.attr('height', this.getCardHeight.bind(this))
.attr('rx', this.cardRound)
.attr('ry', this.cardRound)
.attr('class', 'bordered heatmap-card')
.style('fill', getCardColor)
.style('stroke', getCardColor)
.style('fill', this.getCardColor.bind(this))
.style('stroke', this.getCardColor.bind(this))
.style('stroke-width', 0)
.style('opacity', getCardOpacity);
.style('opacity', this.getCardOpacity.bind(this));
let $cards = $heatmap.find('.heatmap-card');
let $cards = this.$heatmap.find('.heatmap-card');
$cards
.on('mouseenter', event => {
tooltip.mouseOverBucket = true;
highlightCard(event);
this.tooltip.mouseOverBucket = true;
this.highlightCard(event);
})
.on('mouseleave', event => {
tooltip.mouseOverBucket = false;
resetCardHighLight(event);
this.tooltip.mouseOverBucket = false;
this.resetCardHighLight(event);
});
}
function highlightCard(event) {
highlightCard(event) {
let color = d3.select(event.target).style('fill');
let highlightColor = d3.color(color).darker(2);
let strokeColor = d3.color(color).brighter(4);
let current_card = d3.select(event.target);
tooltip.originalFillColor = color;
this.tooltip.originalFillColor = color;
current_card
.style('fill', highlightColor.toString())
.style('stroke', strokeColor.toString())
.style('stroke-width', 1);
}
function resetCardHighLight(event) {
resetCardHighLight(event) {
d3
.select(event.target)
.style('fill', tooltip.originalFillColor)
.style('stroke', tooltip.originalFillColor)
.style('fill', this.tooltip.originalFillColor)
.style('stroke', this.tooltip.originalFillColor)
.style('stroke-width', 0);
}
function setCardSize() {
let xGridSize = Math.floor(xScale(data.xBucketSize) - xScale(0));
let yGridSize = Math.floor(yScale(yScale.invert(0) - data.yBucketSize));
setCardSize() {
let xGridSize = Math.floor(this.xScale(this.data.xBucketSize) - this.xScale(0));
let yGridSize = Math.floor(this.yScale(this.yScale.invert(0) - this.data.yBucketSize));
if (panel.yAxis.logBase !== 1) {
let base = panel.yAxis.logBase;
let splitFactor = data.yBucketSize || 1;
yGridSize = Math.floor((yScale(1) - yScale(base)) / splitFactor);
if (this.panel.yAxis.logBase !== 1) {
let base = this.panel.yAxis.logBase;
let splitFactor = this.data.yBucketSize || 1;
yGridSize = Math.floor((this.yScale(1) - this.yScale(base)) / splitFactor);
}
cardWidth = xGridSize - cardPadding * 2;
cardHeight = yGridSize ? yGridSize - cardPadding * 2 : 0;
this.cardWidth = xGridSize - this.cardPadding * 2;
this.cardHeight = yGridSize ? yGridSize - this.cardPadding * 2 : 0;
}
function getCardX(d) {
getCardX(d) {
let x;
if (xScale(d.x) < 0) {
if (this.xScale(d.x) < 0) {
// Cut card left to prevent overlay
x = yAxisWidth + cardPadding;
x = this.yAxisWidth + this.cardPadding;
} else {
x = xScale(d.x) + yAxisWidth + cardPadding;
x = this.xScale(d.x) + this.yAxisWidth + this.cardPadding;
}
return x;
}
function getCardWidth(d) {
getCardWidth(d) {
let w;
if (xScale(d.x) < 0) {
if (this.xScale(d.x) < 0) {
// Cut card left to prevent overlay
let cutted_width = xScale(d.x) + cardWidth;
let cutted_width = this.xScale(d.x) + this.cardWidth;
w = cutted_width > 0 ? cutted_width : 0;
} else if (xScale(d.x) + cardWidth > chartWidth) {
} else if (this.xScale(d.x) + this.cardWidth > this.chartWidth) {
// Cut card right to prevent overlay
w = chartWidth - xScale(d.x) - cardPadding;
w = this.chartWidth - this.xScale(d.x) - this.cardPadding;
} else {
w = cardWidth;
w = this.cardWidth;
}
// Card width should be MIN_CARD_SIZE at least
......@@ -587,138 +624,117 @@ export default function link(scope, elem, attrs, ctrl) {
return w;
}
function getCardY(d) {
let y = yScale(d.y) + chartTop - cardHeight - cardPadding;
if (panel.yAxis.logBase !== 1 && d.y === 0) {
y = chartBottom - cardHeight - cardPadding;
getCardY(d) {
let y = this.yScale(d.y) + this.chartTop - this.cardHeight - this.cardPadding;
if (this.panel.yAxis.logBase !== 1 && d.y === 0) {
y = this.chartBottom - this.cardHeight - this.cardPadding;
} else {
if (y < chartTop) {
y = chartTop;
if (y < this.chartTop) {
y = this.chartTop;
}
}
return y;
}
function getCardHeight(d) {
let y = yScale(d.y) + chartTop - cardHeight - cardPadding;
let h = cardHeight;
getCardHeight(d) {
let y = this.yScale(d.y) + this.chartTop - this.cardHeight - this.cardPadding;
let h = this.cardHeight;
if (panel.yAxis.logBase !== 1 && d.y === 0) {
return cardHeight;
if (this.panel.yAxis.logBase !== 1 && d.y === 0) {
return this.cardHeight;
}
// Cut card height to prevent overlay
if (y < chartTop) {
h = yScale(d.y) - cardPadding;
} else if (yScale(d.y) > chartBottom) {
h = chartBottom - y;
} else if (y + cardHeight > chartBottom) {
h = chartBottom - y;
if (y < this.chartTop) {
h = this.yScale(d.y) - this.cardPadding;
} else if (this.yScale(d.y) > this.chartBottom) {
h = this.chartBottom - y;
} else if (y + this.cardHeight > this.chartBottom) {
h = this.chartBottom - y;
}
// Height can't be more than chart height
h = Math.min(h, chartHeight);
h = Math.min(h, this.chartHeight);
// Card height should be MIN_CARD_SIZE at least
h = Math.max(h, MIN_CARD_SIZE);
return h;
}
function getCardColor(d) {
if (panel.color.mode === 'opacity') {
return panel.color.cardColor;
getCardColor(d) {
if (this.panel.color.mode === 'opacity') {
return this.panel.color.cardColor;
} else {
return colorScale(d.count);
return this.colorScale(d.count);
}
}
function getCardOpacity(d) {
if (panel.color.mode === 'opacity') {
return opacityScale(d.count);
getCardOpacity(d) {
if (this.panel.color.mode === 'opacity') {
return this.opacityScale(d.count);
} else {
return 1;
}
}
/////////////////////////////
// Selection and crosshair //
/////////////////////////////
// Shared crosshair and tooltip
appEvents.on(
'graph-hover',
event => {
drawSharedCrosshair(event.pos);
},
scope
);
appEvents.on(
'graph-hover-clear',
() => {
clearCrosshair();
},
scope
);
function onMouseDown(event) {
selection.active = true;
selection.x1 = event.offsetX;
onMouseDown(event) {
this.selection.active = true;
this.selection.x1 = event.offsetX;
mouseUpHandler = function() {
onMouseUp();
this.mouseUpHandler = () => {
this.onMouseUp();
};
$(document).one('mouseup', mouseUpHandler);
$(document).one('mouseup', this.mouseUpHandler.bind(this));
}
function onMouseUp() {
$(document).unbind('mouseup', mouseUpHandler);
mouseUpHandler = null;
selection.active = false;
onMouseUp() {
$(document).unbind('mouseup', this.mouseUpHandler.bind(this));
this.mouseUpHandler = null;
this.selection.active = false;
let selectionRange = Math.abs(selection.x2 - selection.x1);
if (selection.x2 >= 0 && selectionRange > MIN_SELECTION_WIDTH) {
let timeFrom = xScale.invert(Math.min(selection.x1, selection.x2) - yAxisWidth);
let timeTo = xScale.invert(Math.max(selection.x1, selection.x2) - yAxisWidth);
let selectionRange = Math.abs(this.selection.x2 - this.selection.x1);
if (this.selection.x2 >= 0 && selectionRange > MIN_SELECTION_WIDTH) {
let timeFrom = this.xScale.invert(Math.min(this.selection.x1, this.selection.x2) - this.yAxisWidth);
let timeTo = this.xScale.invert(Math.max(this.selection.x1, this.selection.x2) - this.yAxisWidth);
ctrl.timeSrv.setTime({
this.ctrl.timeSrv.setTime({
from: moment.utc(timeFrom),
to: moment.utc(timeTo),
});
}
clearSelection();
this.clearSelection();
}
function onMouseLeave() {
onMouseLeave() {
appEvents.emit('graph-hover-clear');
clearCrosshair();
this.clearCrosshair();
}
function onMouseMove(event) {
if (!heatmap) {
onMouseMove(event) {
if (!this.heatmap) {
return;
}
if (selection.active) {
if (this.selection.active) {
// Clear crosshair and tooltip
clearCrosshair();
tooltip.destroy();
this.clearCrosshair();
this.tooltip.destroy();
selection.x2 = limitSelection(event.offsetX);
drawSelection(selection.x1, selection.x2);
this.selection.x2 = this.limitSelection(event.offsetX);
this.drawSelection(this.selection.x1, this.selection.x2);
} else {
emitGraphHoverEvent(event);
drawCrosshair(event.offsetX);
tooltip.show(event, data);
this.emitGraphHoverEvent(event);
this.drawCrosshair(event.offsetX);
this.tooltip.show(event, this.data);
}
}
function emitGraphHoverEvent(event) {
let x = xScale.invert(event.offsetX - yAxisWidth).valueOf();
let y = yScale.invert(event.offsetY);
emitGraphHoverEvent(event) {
let x = this.xScale.invert(event.offsetX - this.yAxisWidth).valueOf();
let y = this.yScale.invert(event.offsetY);
let pos = {
pageX: event.pageX,
pageY: event.pageY,
......@@ -730,105 +746,100 @@ export default function link(scope, elem, attrs, ctrl) {
};
// Set minimum offset to prevent showing legend from another panel
pos.panelRelY = Math.max(event.offsetY / height, 0.001);
pos.panelRelY = Math.max(event.offsetY / this.height, 0.001);
// broadcast to other graph panels that we are hovering
appEvents.emit('graph-hover', { pos: pos, panel: panel });
appEvents.emit('graph-hover', { pos: pos, panel: this.panel });
}
function limitSelection(x2) {
x2 = Math.max(x2, yAxisWidth);
x2 = Math.min(x2, chartWidth + yAxisWidth);
limitSelection(x2) {
x2 = Math.max(x2, this.yAxisWidth);
x2 = Math.min(x2, this.chartWidth + this.yAxisWidth);
return x2;
}
function drawSelection(posX1, posX2) {
if (heatmap) {
heatmap.selectAll('.heatmap-selection').remove();
drawSelection(posX1, posX2) {
if (this.heatmap) {
this.heatmap.selectAll('.heatmap-selection').remove();
let selectionX = Math.min(posX1, posX2);
let selectionWidth = Math.abs(posX1 - posX2);
if (selectionWidth > MIN_SELECTION_WIDTH) {
heatmap
this.heatmap
.append('rect')
.attr('class', 'heatmap-selection')
.attr('x', selectionX)
.attr('width', selectionWidth)
.attr('y', chartTop)
.attr('height', chartHeight);
.attr('y', this.chartTop)
.attr('height', this.chartHeight);
}
}
}
function clearSelection() {
selection.x1 = -1;
selection.x2 = -1;
clearSelection() {
this.selection.x1 = -1;
this.selection.x2 = -1;
if (heatmap) {
heatmap.selectAll('.heatmap-selection').remove();
if (this.heatmap) {
this.heatmap.selectAll('.heatmap-selection').remove();
}
}
function drawCrosshair(position) {
if (heatmap) {
heatmap.selectAll('.heatmap-crosshair').remove();
drawCrosshair(position) {
if (this.heatmap) {
this.heatmap.selectAll('.heatmap-crosshair').remove();
let posX = position;
posX = Math.max(posX, yAxisWidth);
posX = Math.min(posX, chartWidth + yAxisWidth);
posX = Math.max(posX, this.yAxisWidth);
posX = Math.min(posX, this.chartWidth + this.yAxisWidth);
heatmap
this.heatmap
.append('g')
.attr('class', 'heatmap-crosshair')
.attr('transform', 'translate(' + posX + ',0)')
.append('line')
.attr('x1', 1)
.attr('y1', chartTop)
.attr('y1', this.chartTop)
.attr('x2', 1)
.attr('y2', chartBottom)
.attr('y2', this.chartBottom)
.attr('stroke-width', 1);
}
}
function drawSharedCrosshair(pos) {
if (heatmap && ctrl.dashboard.graphTooltip !== 0) {
let posX = xScale(pos.x) + yAxisWidth;
drawCrosshair(posX);
drawSharedCrosshair(pos) {
if (this.heatmap && this.ctrl.dashboard.graphTooltip !== 0) {
let posX = this.xScale(pos.x) + this.yAxisWidth;
this.drawCrosshair(posX);
}
}
function clearCrosshair() {
if (heatmap) {
heatmap.selectAll('.heatmap-crosshair').remove();
clearCrosshair() {
if (this.heatmap) {
this.heatmap.selectAll('.heatmap-crosshair').remove();
}
}
function render() {
data = ctrl.data;
panel = ctrl.panel;
timeRange = ctrl.range;
render() {
this.data = this.ctrl.data;
this.panel = this.ctrl.panel;
this.timeRange = this.ctrl.range;
if (!setElementHeight() || !data) {
if (!this.setElementHeight() || !this.data) {
return;
}
// Draw default axes and return if no data
if (_.isEmpty(data.buckets)) {
addHeatmapCanvas();
addAxes();
if (_.isEmpty(this.data.buckets)) {
this.addHeatmapCanvas();
this.addAxes();
return;
}
addHeatmap();
scope.yAxisWidth = yAxisWidth;
scope.xAxisHeight = xAxisHeight;
scope.chartHeight = chartHeight;
scope.chartWidth = chartWidth;
scope.chartTop = chartTop;
this.addHeatmap();
this.scope.yAxisWidth = this.yAxisWidth;
this.scope.xAxisHeight = this.xAxisHeight;
this.scope.chartHeight = this.chartHeight;
this.scope.chartWidth = this.chartWidth;
this.scope.chartTop = this.chartTop;
}
// Register selection listeners
$heatmap.on('mousedown', onMouseDown);
$heatmap.on('mousemove', onMouseMove);
$heatmap.on('mouseleave', onMouseLeave);
}
import { describe, beforeEach, it, sinon, expect, angularMocks } from '../../../../../test/lib/common';
import '../module';
import angular from 'angular';
import $ from 'jquery';
import helpers from 'test/specs/helpers';
import TimeSeries from 'app/core/time_series2';
import moment from 'moment';
import { Emitter } from 'app/core/core';
import rendering from '../rendering';
import { convertToHeatMap, convertToCards, histogramToHeatmap, calculateBucketSize } from '../heatmap_data_converter';
describe('grafanaHeatmap', function() {
beforeEach(angularMocks.module('grafana.core'));
function heatmapScenario(desc, func, elementWidth = 500) {
describe(desc, function() {
var ctx: any = {};
ctx.setup = function(setupFunc) {
beforeEach(
angularMocks.module(function($provide) {
$provide.value('timeSrv', new helpers.TimeSrvStub());
})
);
beforeEach(
angularMocks.inject(function($rootScope, $compile) {
var ctrl: any = {
colorSchemes: [
{
name: 'Oranges',
value: 'interpolateOranges',
invert: 'dark',
},
{ name: 'Reds', value: 'interpolateReds', invert: 'dark' },
],
events: new Emitter(),
height: 200,
panel: {
heatmap: {},
cards: {
cardPadding: null,
cardRound: null,
},
color: {
mode: 'spectrum',
cardColor: '#b4ff00',
colorScale: 'linear',
exponent: 0.5,
colorScheme: 'interpolateOranges',
fillBackground: false,
},
legend: {
show: false,
},
xBucketSize: 1000,
xBucketNumber: null,
yBucketSize: 1,
yBucketNumber: null,
xAxis: {
show: true,
},
yAxis: {
show: true,
format: 'short',
decimals: null,
logBase: 1,
splitFactor: null,
min: null,
max: null,
removeZeroValues: false,
},
tooltip: {
show: true,
seriesStat: false,
showHistogram: false,
},
highlightCards: true,
},
renderingCompleted: sinon.spy(),
hiddenSeries: {},
dashboard: {
getTimezone: sinon.stub().returns('utc'),
},
range: {
from: moment.utc('01 Mar 2017 10:00:00', 'DD MMM YYYY HH:mm:ss'),
to: moment.utc('01 Mar 2017 11:00:00', 'DD MMM YYYY HH:mm:ss'),
},
};
var scope = $rootScope.$new();
scope.ctrl = ctrl;
ctx.series = [];
ctx.series.push(
new TimeSeries({
datapoints: [[1, 1422774000000], [2, 1422774060000]],
alias: 'series1',
})
);
ctx.series.push(
new TimeSeries({
datapoints: [[2, 1422774000000], [3, 1422774060000]],
alias: 'series2',
})
);
ctx.data = {
heatmapStats: {
min: 1,
max: 3,
minLog: 1,
},
xBucketSize: ctrl.panel.xBucketSize,
yBucketSize: ctrl.panel.yBucketSize,
};
setupFunc(ctrl, ctx);
let logBase = ctrl.panel.yAxis.logBase;
let bucketsData;
if (ctrl.panel.dataFormat === 'tsbuckets') {
bucketsData = histogramToHeatmap(ctx.series);
} else {
bucketsData = convertToHeatMap(ctx.series, ctx.data.yBucketSize, ctx.data.xBucketSize, logBase);
}
ctx.data.buckets = bucketsData;
let { cards, cardStats } = convertToCards(bucketsData);
ctx.data.cards = cards;
ctx.data.cardStats = cardStats;
let elemHtml = `
<div class="heatmap-wrapper">
<div class="heatmap-canvas-wrapper">
<div class="heatmap-panel" style='width:${elementWidth}px'></div>
</div>
</div>`;
var element = angular.element(elemHtml);
$compile(element)(scope);
scope.$digest();
ctrl.data = ctx.data;
ctx.element = element;
rendering(scope, $(element), [], ctrl);
ctrl.events.emit('render');
})
);
};
func(ctx);
});
}
heatmapScenario('default options', function(ctx) {
ctx.setup(function(ctrl) {
ctrl.panel.yAxis.logBase = 1;
});
it('should draw correct Y axis', function() {
var yTicks = getTicks(ctx.element, '.axis-y');
expect(yTicks).to.eql(['1', '2', '3']);
});
it('should draw correct X axis', function() {
var xTicks = getTicks(ctx.element, '.axis-x');
let expectedTicks = [
formatTime('01 Mar 2017 10:00:00'),
formatTime('01 Mar 2017 10:15:00'),
formatTime('01 Mar 2017 10:30:00'),
formatTime('01 Mar 2017 10:45:00'),
formatTime('01 Mar 2017 11:00:00'),
];
expect(xTicks).to.eql(expectedTicks);
});
});
heatmapScenario('when logBase is 2', function(ctx) {
ctx.setup(function(ctrl) {
ctrl.panel.yAxis.logBase = 2;
});
it('should draw correct Y axis', function() {
var yTicks = getTicks(ctx.element, '.axis-y');
expect(yTicks).to.eql(['1', '2', '4']);
});
});
heatmapScenario('when logBase is 10', function(ctx) {
ctx.setup(function(ctrl, ctx) {
ctrl.panel.yAxis.logBase = 10;
ctx.series.push(
new TimeSeries({
datapoints: [[10, 1422774000000], [20, 1422774060000]],
alias: 'series3',
})
);
ctx.data.heatmapStats.max = 20;
});
it('should draw correct Y axis', function() {
var yTicks = getTicks(ctx.element, '.axis-y');
expect(yTicks).to.eql(['1', '10', '100']);
});
});
heatmapScenario('when logBase is 32', function(ctx) {
ctx.setup(function(ctrl) {
ctrl.panel.yAxis.logBase = 32;
ctx.series.push(
new TimeSeries({
datapoints: [[10, 1422774000000], [100, 1422774060000]],
alias: 'series3',
})
);
ctx.data.heatmapStats.max = 100;
});
it('should draw correct Y axis', function() {
var yTicks = getTicks(ctx.element, '.axis-y');
expect(yTicks).to.eql(['1', '32', '1.0 K']);
});
});
heatmapScenario('when logBase is 1024', function(ctx) {
ctx.setup(function(ctrl) {
ctrl.panel.yAxis.logBase = 1024;
ctx.series.push(
new TimeSeries({
datapoints: [[2000, 1422774000000], [300000, 1422774060000]],
alias: 'series3',
})
);
ctx.data.heatmapStats.max = 300000;
});
it('should draw correct Y axis', function() {
var yTicks = getTicks(ctx.element, '.axis-y');
expect(yTicks).to.eql(['1', '1 K', '1.0 Mil']);
});
});
heatmapScenario('when Y axis format set to "none"', function(ctx) {
ctx.setup(function(ctrl) {
ctrl.panel.yAxis.logBase = 1;
ctrl.panel.yAxis.format = 'none';
ctx.data.heatmapStats.max = 10000;
});
it('should draw correct Y axis', function() {
var yTicks = getTicks(ctx.element, '.axis-y');
expect(yTicks).to.eql(['0', '2000', '4000', '6000', '8000', '10000', '12000']);
});
});
heatmapScenario('when Y axis format set to "second"', function(ctx) {
ctx.setup(function(ctrl) {
ctrl.panel.yAxis.logBase = 1;
ctrl.panel.yAxis.format = 's';
ctx.data.heatmapStats.max = 3600;
});
it('should draw correct Y axis', function() {
var yTicks = getTicks(ctx.element, '.axis-y');
expect(yTicks).to.eql(['0 ns', '17 min', '33 min', '50 min', '1.11 hour']);
});
});
heatmapScenario('when data format is Time series buckets', function(ctx) {
ctx.setup(function(ctrl, ctx) {
ctrl.panel.dataFormat = 'tsbuckets';
const series = [
{
alias: '1',
datapoints: [[1000, 1422774000000], [200000, 1422774060000]],
},
{
alias: '2',
datapoints: [[3000, 1422774000000], [400000, 1422774060000]],
},
{
alias: '3',
datapoints: [[2000, 1422774000000], [300000, 1422774060000]],
},
];
ctx.series = series.map(s => new TimeSeries(s));
ctx.data.tsBuckets = series.map(s => s.alias).concat('');
ctx.data.yBucketSize = 1;
let xBucketBoundSet = series[0].datapoints.map(dp => dp[1]);
ctx.data.xBucketSize = calculateBucketSize(xBucketBoundSet);
});
it('should draw correct Y axis', function() {
var yTicks = getTicks(ctx.element, '.axis-y');
expect(yTicks).to.eql(['1', '2', '3', '']);
});
});
});
function getTicks(element, axisSelector) {
return element
.find(axisSelector)
.find('text')
.map(function() {
return this.textContent;
})
.get();
}
function formatTime(timeStr) {
let format = 'HH:mm';
return moment.utc(timeStr, 'DD MMM YYYY HH:mm:ss').format(format);
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment