|
|
@@ -0,0 +1,857 @@
|
|
|
+///<reference path="../../../headers/common.d.ts" />
|
|
|
+
|
|
|
+import _ from 'lodash';
|
|
|
+import $ from 'jquery';
|
|
|
+import moment from 'moment';
|
|
|
+import kbn from 'app/core/utils/kbn';
|
|
|
+import {appEvents, contextSrv} from 'app/core/core';
|
|
|
+import {tickStep} from 'app/core/utils/ticks';
|
|
|
+import d3 from 'd3';
|
|
|
+import {HeatmapTooltip} from './heatmap_tooltip';
|
|
|
+import {convertToCards, mergeZeroBuckets, removeZeroBuckets} from './heatmap_data_converter';
|
|
|
+
|
|
|
+let MIN_CARD_SIZE = 1,
|
|
|
+ CARD_PADDING = 1,
|
|
|
+ CARD_ROUND = 0,
|
|
|
+ DATA_RANGE_WIDING_FACTOR = 1.2,
|
|
|
+ DEFAULT_X_TICK_SIZE_PX = 100,
|
|
|
+ DEFAULT_Y_TICK_SIZE_PX = 50,
|
|
|
+ X_AXIS_TICK_PADDING = 10,
|
|
|
+ Y_AXIS_TICK_PADDING = 5,
|
|
|
+ MIN_SELECTION_WIDTH = 2;
|
|
|
+
|
|
|
+export default function link(scope, elem, attrs, ctrl) {
|
|
|
+ let data, timeRange, panel, heatmap;
|
|
|
+
|
|
|
+ // $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 = {
|
|
|
+ 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;
|
|
|
+
|
|
|
+ ctrl.events.on('render', () => {
|
|
|
+ render();
|
|
|
+ ctrl.renderingCompleted();
|
|
|
+ });
|
|
|
+
|
|
|
+ function setElementHeight() {
|
|
|
+ try {
|
|
|
+ var height = ctrl.height || panel.height || ctrl.row.height;
|
|
|
+ if (_.isString(height)) {
|
|
|
+ height = parseInt(height.replace('px', ''), 10);
|
|
|
+ }
|
|
|
+
|
|
|
+ height -= 5; // padding
|
|
|
+ height -= panel.title ? 24 : 9; // subtract panel title bar
|
|
|
+
|
|
|
+ $heatmap.css('height', height + 'px');
|
|
|
+
|
|
|
+ return true;
|
|
|
+ } catch (e) { // IE throws errors sometimes
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function getYAxisWidth(elem) {
|
|
|
+ let axis_text = elem.selectAll(".axis-y text").nodes();
|
|
|
+ let max_text_width = _.max(_.map(axis_text, text => {
|
|
|
+ let el = $(text);
|
|
|
+ // Use JQuery outerWidth() to compute full element width
|
|
|
+ return el.outerWidth();
|
|
|
+ }));
|
|
|
+
|
|
|
+ return max_text_width;
|
|
|
+ }
|
|
|
+
|
|
|
+ function 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"));
|
|
|
+ let canvas_width = parseFloat(elem.attr("height"));
|
|
|
+ return canvas_width - axis_line_position;
|
|
|
+ } else {
|
|
|
+ // Default height
|
|
|
+ return 30;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function addXAxis() {
|
|
|
+ xScale = d3.scaleTime()
|
|
|
+ .domain([timeRange.from, timeRange.to])
|
|
|
+ .range([0, chartWidth]);
|
|
|
+
|
|
|
+ let ticks = chartWidth / DEFAULT_X_TICK_SIZE_PX;
|
|
|
+ let grafanaTimeFormatter = grafanaTimeFormat(ticks, timeRange.from, timeRange.to);
|
|
|
+
|
|
|
+ let xAxis = d3.axisBottom(xScale)
|
|
|
+ .ticks(ticks)
|
|
|
+ .tickFormat(d3.timeFormat(grafanaTimeFormatter))
|
|
|
+ .tickPadding(X_AXIS_TICK_PADDING)
|
|
|
+ .tickSize(chartHeight);
|
|
|
+
|
|
|
+ let posY = margin.top;
|
|
|
+ let posX = yAxisWidth;
|
|
|
+ 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.select(".axis-x").select(".domain").remove();
|
|
|
+ }
|
|
|
+
|
|
|
+ function addYAxis() {
|
|
|
+ let ticks = Math.ceil(chartHeight / DEFAULT_Y_TICK_SIZE_PX);
|
|
|
+ let tick_interval = tickStep(data.heatmapStats.min, data.heatmapStats.max, ticks);
|
|
|
+ let {y_min, y_max} = wideYAxisRange(data.heatmapStats.min, 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;
|
|
|
+
|
|
|
+ // Adjust ticks after Y range widening
|
|
|
+ tick_interval = tickStep(y_min, y_max, ticks);
|
|
|
+ ticks = Math.ceil((y_max - y_min) / tick_interval);
|
|
|
+
|
|
|
+ let decimals = panel.yAxis.decimals === null ? getPrecision(tick_interval) : panel.yAxis.decimals;
|
|
|
+
|
|
|
+ // Set default Y min and max if no data
|
|
|
+ if (_.isEmpty(data.buckets)) {
|
|
|
+ y_max = 1;
|
|
|
+ y_min = -1;
|
|
|
+ ticks = 3;
|
|
|
+ decimals = 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ data.yAxis = {
|
|
|
+ min: y_min,
|
|
|
+ max: y_max,
|
|
|
+ ticks: ticks
|
|
|
+ };
|
|
|
+
|
|
|
+ yScale = d3.scaleLinear()
|
|
|
+ .domain([y_min, y_max])
|
|
|
+ .range([chartHeight, 0]);
|
|
|
+
|
|
|
+ let yAxis = d3.axisLeft(yScale)
|
|
|
+ .ticks(ticks)
|
|
|
+ .tickFormat(tickValueFormatter(decimals))
|
|
|
+ .tickSizeInner(0 - width)
|
|
|
+ .tickSizeOuter(0)
|
|
|
+ .tickPadding(Y_AXIS_TICK_PADDING);
|
|
|
+
|
|
|
+ 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 + ")");
|
|
|
+
|
|
|
+ // Remove vertical line in the right of axis labels (called domain in d3)
|
|
|
+ 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;
|
|
|
+ let y_min, y_max;
|
|
|
+
|
|
|
+ if (tickInterval === 0) {
|
|
|
+ y_max = max * dataRangeWidingFactor;
|
|
|
+ y_min = min - min * (dataRangeWidingFactor - 1);
|
|
|
+ tickInterval = (y_max - y_min) / 2;
|
|
|
+ } else {
|
|
|
+ y_max = Math.ceil((max + y_widing) / tickInterval) * tickInterval;
|
|
|
+ y_min = Math.floor((min - y_widing) / tickInterval) * tickInterval;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Don't wide axis below 0 if all values are positive
|
|
|
+ if (min >= 0 && y_min < 0) {
|
|
|
+ y_min = 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ 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);
|
|
|
+
|
|
|
+ y_min = panel.yAxis.min !== null ? adjustLogMin(panel.yAxis.min, log_base) : y_min;
|
|
|
+ y_max = panel.yAxis.max !== null ? adjustLogMax(panel.yAxis.max, log_base) : y_max;
|
|
|
+
|
|
|
+ // Set default Y min and max if no data
|
|
|
+ if (_.isEmpty(data.buckets)) {
|
|
|
+ y_max = Math.pow(log_base, 2);
|
|
|
+ y_min = 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ yScale = d3.scaleLog()
|
|
|
+ .base(panel.yAxis.logBase)
|
|
|
+ .domain([y_min, y_max])
|
|
|
+ .range([chartHeight, 0]);
|
|
|
+
|
|
|
+ let domain = yScale.domain();
|
|
|
+ let tick_values = logScaleTickValues(domain, log_base);
|
|
|
+ let decimals = panel.yAxis.decimals;
|
|
|
+
|
|
|
+ data.yAxis = {
|
|
|
+ min: y_min,
|
|
|
+ max: y_max,
|
|
|
+ ticks: tick_values.length
|
|
|
+ };
|
|
|
+
|
|
|
+ let yAxis = d3.axisLeft(yScale)
|
|
|
+ .tickValues(tick_values)
|
|
|
+ .tickFormat(tickValueFormatter(decimals))
|
|
|
+ .tickSizeInner(0 - width)
|
|
|
+ .tickSizeOuter(0)
|
|
|
+ .tickPadding(Y_AXIS_TICK_PADDING);
|
|
|
+
|
|
|
+ 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 + ")");
|
|
|
+
|
|
|
+ // Set first tick as pseudo 0
|
|
|
+ if (y_min < 1) {
|
|
|
+ heatmap.select(".axis-y").select(".tick text").text("0");
|
|
|
+ }
|
|
|
+
|
|
|
+ // Remove vertical line in the right of axis labels (called domain in d3)
|
|
|
+ heatmap.select(".axis-y").select(".domain").remove();
|
|
|
+ }
|
|
|
+
|
|
|
+ // Adjust data range to log base
|
|
|
+ function adjustLogRange(min, max, logBase) {
|
|
|
+ let y_min, y_max;
|
|
|
+
|
|
|
+ y_min = data.heatmapStats.minLog;
|
|
|
+ if (data.heatmapStats.minLog > 1 || !data.heatmapStats.minLog) {
|
|
|
+ y_min = 1;
|
|
|
+ } else {
|
|
|
+ y_min = adjustLogMin(data.heatmapStats.minLog, logBase);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Adjust max Y value to log base
|
|
|
+ y_max = adjustLogMax(data.heatmapStats.max, logBase);
|
|
|
+
|
|
|
+ return {y_min, y_max};
|
|
|
+ }
|
|
|
+
|
|
|
+ function adjustLogMax(max, base) {
|
|
|
+ return Math.pow(base, Math.ceil(logp(max, base)));
|
|
|
+ }
|
|
|
+
|
|
|
+ function adjustLogMin(min, base) {
|
|
|
+ return Math.pow(base, Math.floor(logp(min, base)));
|
|
|
+ }
|
|
|
+
|
|
|
+ function logScaleTickValues(domain, base) {
|
|
|
+ let domainMin = domain[0];
|
|
|
+ let domainMax = domain[1];
|
|
|
+ let tickValues = [];
|
|
|
+
|
|
|
+ if (domainMin < 1) {
|
|
|
+ let under_one_ticks = Math.floor(logp(domainMin, base));
|
|
|
+ for (let i = under_one_ticks; i < 0; i++) {
|
|
|
+ let tick_value = Math.pow(base, i);
|
|
|
+ tickValues.push(tick_value);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ let ticks = Math.ceil(logp(domainMax, base));
|
|
|
+ for (let i = 0; i <= ticks; i++) {
|
|
|
+ let tick_value = Math.pow(base, i);
|
|
|
+ tickValues.push(tick_value);
|
|
|
+ }
|
|
|
+
|
|
|
+ return tickValues;
|
|
|
+ }
|
|
|
+
|
|
|
+ function tickValueFormatter(decimals) {
|
|
|
+ let format = panel.yAxis.format;
|
|
|
+ return function(value) {
|
|
|
+ return kbn.valueFormats[format](value, decimals);
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ function fixYAxisTickSize() {
|
|
|
+ heatmap.select(".axis-y")
|
|
|
+ .selectAll(".tick line")
|
|
|
+ .attr("x2", chartWidth);
|
|
|
+ }
|
|
|
+
|
|
|
+ function addAxes() {
|
|
|
+ chartHeight = height - margin.top - margin.bottom;
|
|
|
+ chartTop = margin.top;
|
|
|
+ chartBottom = chartTop + chartHeight;
|
|
|
+
|
|
|
+ if (panel.yAxis.logBase === 1) {
|
|
|
+ addYAxis();
|
|
|
+ } else {
|
|
|
+ addLogYAxis();
|
|
|
+ }
|
|
|
+
|
|
|
+ yAxisWidth = getYAxisWidth(heatmap) + Y_AXIS_TICK_PADDING;
|
|
|
+ chartWidth = width - yAxisWidth - margin.right;
|
|
|
+ fixYAxisTickSize();
|
|
|
+
|
|
|
+ addXAxis();
|
|
|
+ xAxisHeight = getXAxisHeight(heatmap);
|
|
|
+
|
|
|
+ if (!panel.yAxis.show) {
|
|
|
+ heatmap.select(".axis-y").selectAll("line").style("opacity", 0);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!panel.xAxis.show) {
|
|
|
+ heatmap.select(".axis-x").selectAll("line").style("opacity", 0);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function addHeatmapCanvas() {
|
|
|
+ let heatmap_elem = $heatmap[0];
|
|
|
+
|
|
|
+ width = Math.floor($heatmap.width()) - padding.right;
|
|
|
+ height = Math.floor($heatmap.height()) - padding.bottom;
|
|
|
+
|
|
|
+ cardPadding = panel.cards.cardPadding !== null ? panel.cards.cardPadding : CARD_PADDING;
|
|
|
+ cardRound = panel.cards.cardRound !== null ? panel.cards.cardRound : CARD_ROUND;
|
|
|
+
|
|
|
+ if (heatmap) {
|
|
|
+ heatmap.remove();
|
|
|
+ }
|
|
|
+
|
|
|
+ heatmap = d3.select(heatmap_elem)
|
|
|
+ .append("svg")
|
|
|
+ .attr("width", width)
|
|
|
+ .attr("height", height);
|
|
|
+ }
|
|
|
+
|
|
|
+ function addHeatmap() {
|
|
|
+ addHeatmapCanvas();
|
|
|
+ addAxes();
|
|
|
+
|
|
|
+ if (panel.yAxis.logBase !== 1) {
|
|
|
+ if (panel.yAxis.removeZeroValues) {
|
|
|
+ data.buckets = removeZeroBuckets(data.buckets);
|
|
|
+ } else {
|
|
|
+ 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));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ let cardsData = convertToCards(data.buckets);
|
|
|
+
|
|
|
+ let max_value = d3.max(cardsData, card => {
|
|
|
+ return card.values.length;
|
|
|
+ });
|
|
|
+
|
|
|
+ colorScale = getColorScale(max_value);
|
|
|
+ setOpacityScale(max_value);
|
|
|
+ setCardSize();
|
|
|
+
|
|
|
+ if (panel.color.fillBackground && panel.color.mode === 'spectrum') {
|
|
|
+ fillBackground(heatmap, colorScale(0));
|
|
|
+ }
|
|
|
+
|
|
|
+ let cards = 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("class", "bordered heatmap-card")
|
|
|
+ .style("fill", getCardColor)
|
|
|
+ .style("stroke", getCardColor)
|
|
|
+ .style("stroke-width", 0)
|
|
|
+ .style("opacity", getCardOpacity);
|
|
|
+
|
|
|
+ let $cards = $heatmap.find(".heatmap-card");
|
|
|
+ $cards.on("mouseenter", (event) => {
|
|
|
+ tooltip.mouseOverBucket = true;
|
|
|
+ highlightCard(event);
|
|
|
+ })
|
|
|
+ .on("mouseleave", (event) => {
|
|
|
+ tooltip.mouseOverBucket = false;
|
|
|
+ resetCardHighLight(event);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function highlightCard(event) {
|
|
|
+ if (panel.highlightCards) {
|
|
|
+ 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;
|
|
|
+ current_card.style("fill", highlightColor)
|
|
|
+ .style("stroke", strokeColor)
|
|
|
+ .style("stroke-width", 1);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function resetCardHighLight(event) {
|
|
|
+ if (panel.highlightCards) {
|
|
|
+ d3.select(event.target).style("fill", tooltip.originalFillColor)
|
|
|
+ .style("stroke", tooltip.originalFillColor)
|
|
|
+ .style("stroke-width", 0);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function getColorScale(maxValue) {
|
|
|
+ let colorScheme = _.find(ctrl.colorSchemes, {value: panel.color.colorScheme});
|
|
|
+ let colorInterpolator = d3[colorScheme.value];
|
|
|
+ let colorScaleInverted = colorScheme.invert === 'always' ||
|
|
|
+ (colorScheme.invert === 'dark' && !contextSrv.user.lightTheme);
|
|
|
+
|
|
|
+ let start = colorScaleInverted ? maxValue : 0;
|
|
|
+ let end = colorScaleInverted ? 0 : maxValue;
|
|
|
+
|
|
|
+ return d3.scaleSequential(colorInterpolator).domain([start, end]);
|
|
|
+ }
|
|
|
+
|
|
|
+ function setOpacityScale(max_value) {
|
|
|
+ if (panel.color.colorScale === 'linear') {
|
|
|
+ opacityScale = d3.scaleLinear()
|
|
|
+ .domain([0, max_value])
|
|
|
+ .range([0, 1]);
|
|
|
+ } else if (panel.color.colorScale === 'sqrt') {
|
|
|
+ opacityScale = d3.scalePow().exponent(panel.color.exponent)
|
|
|
+ .domain([0, max_value])
|
|
|
+ .range([0, 1]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function setCardSize() {
|
|
|
+ let xGridSize = Math.floor(xScale(data.xBucketSize) - xScale(0));
|
|
|
+ let yGridSize = Math.floor(yScale(yScale.invert(0) - 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);
|
|
|
+ }
|
|
|
+
|
|
|
+ cardWidth = xGridSize - cardPadding * 2;
|
|
|
+ cardHeight = yGridSize ? yGridSize - cardPadding * 2 : 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ function getCardX(d) {
|
|
|
+ let x;
|
|
|
+ if (xScale(d.x) < 0) {
|
|
|
+ // Cut card left to prevent overlay
|
|
|
+ x = yAxisWidth + cardPadding;
|
|
|
+ } else {
|
|
|
+ x = xScale(d.x) + yAxisWidth + cardPadding;
|
|
|
+ }
|
|
|
+
|
|
|
+ return x;
|
|
|
+ }
|
|
|
+
|
|
|
+ function getCardWidth(d) {
|
|
|
+ let w;
|
|
|
+ if (xScale(d.x) < 0) {
|
|
|
+ // Cut card left to prevent overlay
|
|
|
+ let cutted_width = xScale(d.x) + cardWidth;
|
|
|
+ w = cutted_width > 0 ? cutted_width : 0;
|
|
|
+ } else if (xScale(d.x) + cardWidth > chartWidth) {
|
|
|
+ // Cut card right to prevent overlay
|
|
|
+ w = chartWidth - xScale(d.x) - cardPadding;
|
|
|
+ } else {
|
|
|
+ w = cardWidth;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Card width should be MIN_CARD_SIZE at least
|
|
|
+ w = Math.max(w, MIN_CARD_SIZE);
|
|
|
+ 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;
|
|
|
+ } else {
|
|
|
+ if (y < chartTop) {
|
|
|
+ y = chartTop;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return y;
|
|
|
+ }
|
|
|
+
|
|
|
+ function getCardHeight(d) {
|
|
|
+ let y = yScale(d.y) + chartTop - cardHeight - cardPadding;
|
|
|
+ let h = cardHeight;
|
|
|
+
|
|
|
+ if (panel.yAxis.logBase !== 1 && d.y === 0) {
|
|
|
+ return 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;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Height can't be more than chart height
|
|
|
+ h = Math.min(h, 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;
|
|
|
+ } else {
|
|
|
+ return colorScale(d.values.length);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function getCardOpacity(d) {
|
|
|
+ if (panel.color.mode === 'opacity') {
|
|
|
+ return opacityScale(d.values.length);
|
|
|
+ } else {
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function fillBackground(heatmap, color) {
|
|
|
+ heatmap.insert("rect", "g")
|
|
|
+ .attr("x", yAxisWidth)
|
|
|
+ .attr("y", margin.top)
|
|
|
+ .attr("width", chartWidth)
|
|
|
+ .attr("height", chartHeight)
|
|
|
+ .attr("fill", color);
|
|
|
+ }
|
|
|
+
|
|
|
+ /////////////////////////////
|
|
|
+ // Selection and crosshair //
|
|
|
+ /////////////////////////////
|
|
|
+
|
|
|
+ // Shared crosshair and tooltip
|
|
|
+ appEvents.on('graph-hover', event => {
|
|
|
+ drawSharedCrosshair(event.pos);
|
|
|
+
|
|
|
+ // Show shared tooltip
|
|
|
+ if (ctrl.dashboard.graphTooltip === 2) {
|
|
|
+ tooltip.show(event.pos, data);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ appEvents.on('graph-hover-clear', () => {
|
|
|
+ clearCrosshair();
|
|
|
+ tooltip.destroy();
|
|
|
+ });
|
|
|
+
|
|
|
+ function onMouseDown(event) {
|
|
|
+ selection.active = true;
|
|
|
+ selection.x1 = event.offsetX;
|
|
|
+
|
|
|
+ mouseUpHandler = function() {
|
|
|
+ onMouseUp();
|
|
|
+ };
|
|
|
+ $(document).one("mouseup", mouseUpHandler);
|
|
|
+ }
|
|
|
+
|
|
|
+ function onMouseUp() {
|
|
|
+ $(document).unbind("mouseup", mouseUpHandler);
|
|
|
+ mouseUpHandler = null;
|
|
|
+ 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);
|
|
|
+
|
|
|
+ ctrl.timeSrv.setTime({
|
|
|
+ from: moment.utc(timeFrom),
|
|
|
+ to: moment.utc(timeTo)
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ clearSelection();
|
|
|
+ }
|
|
|
+
|
|
|
+ function onMouseLeave() {
|
|
|
+ appEvents.emit('graph-hover-clear');
|
|
|
+ clearCrosshair();
|
|
|
+ }
|
|
|
+
|
|
|
+ function onMouseMove(event) {
|
|
|
+ if (!heatmap) { return; }
|
|
|
+
|
|
|
+ if (selection.active) {
|
|
|
+ // Clear crosshair and tooltip
|
|
|
+ clearCrosshair();
|
|
|
+ tooltip.destroy();
|
|
|
+
|
|
|
+ selection.x2 = limitSelection(event.offsetX);
|
|
|
+ drawSelection(selection.x1, selection.x2);
|
|
|
+ } else {
|
|
|
+ emitGraphHoverEvet(event);
|
|
|
+ drawCrosshair(event.offsetX);
|
|
|
+ tooltip.show(event, data);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function emitGraphHoverEvet(event) {
|
|
|
+ let x = xScale.invert(event.offsetX - yAxisWidth).valueOf();
|
|
|
+ let y = yScale.invert(event.offsetY);
|
|
|
+ let pos = {
|
|
|
+ pageX: event.pageX,
|
|
|
+ pageY: event.pageY,
|
|
|
+ x: x, x1: x,
|
|
|
+ y: y, y1: y,
|
|
|
+ panelRelY: null
|
|
|
+ };
|
|
|
+
|
|
|
+ // Set minimum offset to prevent showing legend from another panel
|
|
|
+ pos.panelRelY = Math.max(event.offsetY / height, 0.001);
|
|
|
+
|
|
|
+ // broadcast to other graph panels that we are hovering
|
|
|
+ appEvents.emit('graph-hover', {pos: pos, panel: panel});
|
|
|
+ }
|
|
|
+
|
|
|
+ function limitSelection(x2) {
|
|
|
+ x2 = Math.max(x2, yAxisWidth);
|
|
|
+ x2 = Math.min(x2, chartWidth + yAxisWidth);
|
|
|
+ return x2;
|
|
|
+ }
|
|
|
+
|
|
|
+ function drawSelection(posX1, posX2) {
|
|
|
+ if (heatmap) {
|
|
|
+ heatmap.selectAll(".heatmap-selection").remove();
|
|
|
+ let selectionX = Math.min(posX1, posX2);
|
|
|
+ let selectionWidth = Math.abs(posX1 - posX2);
|
|
|
+
|
|
|
+ if (selectionWidth > MIN_SELECTION_WIDTH) {
|
|
|
+ heatmap.append("rect")
|
|
|
+ .attr("class", "heatmap-selection")
|
|
|
+ .attr("x", selectionX)
|
|
|
+ .attr("width", selectionWidth)
|
|
|
+ .attr("y", chartTop)
|
|
|
+ .attr("height", chartHeight);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function clearSelection() {
|
|
|
+ selection.x1 = -1;
|
|
|
+ selection.x2 = -1;
|
|
|
+
|
|
|
+ if (heatmap) {
|
|
|
+ heatmap.selectAll(".heatmap-selection").remove();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function drawCrosshair(position) {
|
|
|
+ if (heatmap) {
|
|
|
+ heatmap.selectAll(".heatmap-crosshair").remove();
|
|
|
+
|
|
|
+ let posX = position;
|
|
|
+ posX = Math.max(posX, yAxisWidth);
|
|
|
+ posX = Math.min(posX, chartWidth + yAxisWidth);
|
|
|
+
|
|
|
+ heatmap.append("g")
|
|
|
+ .attr("class", "heatmap-crosshair")
|
|
|
+ .attr("transform", "translate(" + posX + ",0)")
|
|
|
+ .append("line")
|
|
|
+ .attr("x1", 1)
|
|
|
+ .attr("y1", chartTop)
|
|
|
+ .attr("x2", 1)
|
|
|
+ .attr("y2", chartBottom)
|
|
|
+ .attr("stroke-width", 1);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function drawSharedCrosshair(pos) {
|
|
|
+ if (heatmap && ctrl.dashboard.graphTooltip !== 0) {
|
|
|
+ let posX = xScale(pos.x) + yAxisWidth;
|
|
|
+ drawCrosshair(posX);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function clearCrosshair() {
|
|
|
+ if (heatmap) {
|
|
|
+ heatmap.selectAll(".heatmap-crosshair").remove();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function drawColorLegend() {
|
|
|
+ d3.select("#heatmap-color-legend").selectAll("rect").remove();
|
|
|
+
|
|
|
+ let legend = d3.select("#heatmap-color-legend");
|
|
|
+ let legendWidth = Math.floor($(d3.select("#heatmap-color-legend").node()).outerWidth());
|
|
|
+ let legendHeight = d3.select("#heatmap-color-legend").attr("height");
|
|
|
+
|
|
|
+ let legendColorScale = getColorScale(legendWidth);
|
|
|
+
|
|
|
+ let rangeStep = 2;
|
|
|
+ let valuesRange = d3.range(0, legendWidth, rangeStep);
|
|
|
+ var legendRects = legend.selectAll(".heatmap-color-legend-rect").data(valuesRange);
|
|
|
+
|
|
|
+ legendRects.enter().append("rect")
|
|
|
+ .attr("x", d => d)
|
|
|
+ .attr("y", 0)
|
|
|
+ .attr("width", rangeStep + 1) // Overlap rectangles to prevent gaps
|
|
|
+ .attr("height", legendHeight)
|
|
|
+ .attr("stroke-width", 0)
|
|
|
+ .attr("fill", d => {
|
|
|
+ return legendColorScale(d);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function drawOpacityLegend() {
|
|
|
+ d3.select("#heatmap-opacity-legend").selectAll("rect").remove();
|
|
|
+
|
|
|
+ let legend = d3.select("#heatmap-opacity-legend");
|
|
|
+ let legendWidth = Math.floor($(d3.select("#heatmap-opacity-legend").node()).outerWidth());
|
|
|
+ let legendHeight = d3.select("#heatmap-opacity-legend").attr("height");
|
|
|
+
|
|
|
+ let legendOpacityScale;
|
|
|
+ if (panel.color.colorScale === 'linear') {
|
|
|
+ legendOpacityScale = d3.scaleLinear()
|
|
|
+ .domain([0, legendWidth])
|
|
|
+ .range([0, 1]);
|
|
|
+ } else if (panel.color.colorScale === 'sqrt') {
|
|
|
+ legendOpacityScale = d3.scalePow().exponent(panel.color.exponent)
|
|
|
+ .domain([0, legendWidth])
|
|
|
+ .range([0, 1]);
|
|
|
+ }
|
|
|
+
|
|
|
+ let rangeStep = 1;
|
|
|
+ let valuesRange = d3.range(0, legendWidth, rangeStep);
|
|
|
+ var legendRects = legend.selectAll(".heatmap-opacity-legend-rect").data(valuesRange);
|
|
|
+
|
|
|
+ legendRects.enter().append("rect")
|
|
|
+ .attr("x", d => d)
|
|
|
+ .attr("y", 0)
|
|
|
+ .attr("width", rangeStep)
|
|
|
+ .attr("height", legendHeight)
|
|
|
+ .attr("stroke-width", 0)
|
|
|
+ .attr("fill", panel.color.cardColor)
|
|
|
+ .style("opacity", d => {
|
|
|
+ return legendOpacityScale(d);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function render() {
|
|
|
+ data = ctrl.data;
|
|
|
+ panel = ctrl.panel;
|
|
|
+ timeRange = ctrl.range;
|
|
|
+
|
|
|
+ if (setElementHeight()) {
|
|
|
+
|
|
|
+ if (data) {
|
|
|
+ // Draw default axes and return if no data
|
|
|
+ if (_.isEmpty(data.buckets)) {
|
|
|
+ addHeatmapCanvas();
|
|
|
+ addAxes();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ addHeatmap();
|
|
|
+ scope.yScale = yScale;
|
|
|
+ scope.xScale = xScale;
|
|
|
+ scope.yAxisWidth = yAxisWidth;
|
|
|
+ scope.xAxisHeight = xAxisHeight;
|
|
|
+ scope.chartHeight = chartHeight;
|
|
|
+ scope.chartWidth = chartWidth;
|
|
|
+ scope.chartTop = chartTop;
|
|
|
+
|
|
|
+ // Register selection listeners
|
|
|
+ $heatmap.on("mousedown", onMouseDown);
|
|
|
+ $heatmap.on("mousemove", onMouseMove);
|
|
|
+ $heatmap.on("mouseleave", onMouseLeave);
|
|
|
+ } else {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Draw only if color editor is opened
|
|
|
+ if (!d3.select("#heatmap-color-legend").empty()) {
|
|
|
+ drawColorLegend();
|
|
|
+ }
|
|
|
+ if (!d3.select("#heatmap-opacity-legend").empty()) {
|
|
|
+ drawOpacityLegend();
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function grafanaTimeFormat(ticks, min, max) {
|
|
|
+ if (min && max && ticks) {
|
|
|
+ let range = max - min;
|
|
|
+ let secPerTick = (range/ticks) / 1000;
|
|
|
+ let oneDay = 86400000;
|
|
|
+ let oneYear = 31536000000;
|
|
|
+
|
|
|
+ if (secPerTick <= 45) {
|
|
|
+ return "%H:%M:%S";
|
|
|
+ }
|
|
|
+ if (secPerTick <= 7200 || range <= oneDay) {
|
|
|
+ return "%H:%M";
|
|
|
+ }
|
|
|
+ if (secPerTick <= 80000) {
|
|
|
+ return "%m/%d %H:%M";
|
|
|
+ }
|
|
|
+ if (secPerTick <= 2419200 || range <= oneYear) {
|
|
|
+ return "%m/%d";
|
|
|
+ }
|
|
|
+ return "%Y-%m";
|
|
|
+ }
|
|
|
+
|
|
|
+ return "%H:%M";
|
|
|
+}
|
|
|
+
|
|
|
+function logp(value, base) {
|
|
|
+ return Math.log(value) / Math.log(base);
|
|
|
+}
|
|
|
+
|
|
|
+function getPrecision(num) {
|
|
|
+ let str = num.toString();
|
|
|
+ let dot_index = str.indexOf(".");
|
|
|
+ if (dot_index === -1) {
|
|
|
+ return 0;
|
|
|
+ } else {
|
|
|
+ return str.length - dot_index - 1;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function getTicksPrecision(values) {
|
|
|
+ let precisions = _.map(values, getPrecision);
|
|
|
+ return _.max(precisions);
|
|
|
+}
|