فهرست منبع

Move heatmap panel into core grafana.

Alexander Zobnin 8 سال پیش
والد
کامیت
68f5e75eba

+ 5 - 0
public/app/headers/common.d.ts

@@ -67,3 +67,8 @@ declare module 'remarkable' {
   var config: any;
   var config: any;
   export default config;
   export default config;
 }
 }
+
+declare module 'd3' {
+  var d3: any;
+  export default d3;
+}

+ 0 - 0
public/app/plugins/panel/heatmap/README.md


+ 43 - 0
public/app/plugins/panel/heatmap/axes_editor.ts

@@ -0,0 +1,43 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import kbn from 'app/core/utils/kbn';
+
+export class AxesEditorCtrl {
+  panel: any;
+  panelCtrl: any;
+  unitFormats: any;
+  logScales: any;
+
+  /** @ngInject */
+  constructor($scope) {
+    $scope.editor = this;
+    this.panelCtrl = $scope.ctrl;
+    this.panel = this.panelCtrl.panel;
+
+    this.unitFormats = kbn.getUnitFormats();
+
+    this.logScales = {
+      'linear': 1,
+      'log (base 2)': 2,
+      'log (base 10)': 10,
+      'log (base 32)': 32,
+      'log (base 1024)': 1024
+    };
+  }
+
+  setUnitFormat(subItem) {
+    this.panel.yAxis.format = subItem.value;
+    this.panelCtrl.render();
+  }
+}
+
+/** @ngInject */
+export function axesEditor() {
+  'use strict';
+  return {
+    restrict: 'E',
+    scope: true,
+    templateUrl: 'public/app/plugins/panel/heatmap/partials/axes_editor.html',
+    controller: AxesEditorCtrl,
+  };
+}

+ 70 - 0
public/app/plugins/panel/heatmap/css/heatmap.dark.css

@@ -0,0 +1,70 @@
+.axis text {
+}
+
+.axis {
+	font-family: "Open Sans", Helvetica, Arial, sans-serif;
+	font-size: smaller;
+	fill: #D8D9DA;
+}
+
+.axis path,
+.axis line {
+  fill: none;
+  stroke: #7B7B7B;
+  /*shape-rendering: crispEdges;*/
+}
+
+.axis .domain {
+	/*opacity: 0;*/
+}
+
+.tick line {
+	opacity: 0.4;
+	stroke: #7B7B7B;
+}
+
+.tick text {
+	fill: #D8D9DA;
+}
+
+.heatmap-panel {
+  cursor: crosshair;
+  padding: 0;
+}
+
+div.heatmap-tooltip {
+  position: absolute;
+  text-align: left;
+  min-width: 160px;
+  padding: 12px;
+  font-family: "Open Sans", Helvetica, Arial, sans-serif;
+  font-size: 13px;
+  background: #141414;
+  border: 0px;
+  border-radius: 8px;
+  pointer-events: none;
+}
+
+.card-highlighted:hover {
+	stroke: #D8D9DA;
+}
+
+rect.heatmap-card {
+  pointer-events: all;
+}
+
+.heatmap-histogram rect {
+  fill: #828282;
+}
+
+.heatmap-crosshair line {
+  stroke: #9a1010;
+  stroke-width: 1;
+}
+
+.heatmap-selection {
+  stroke-width: 1;
+  opacity: 0.3;
+  fill: #828282;
+  stroke: #D8D9DA;
+}

+ 58 - 0
public/app/plugins/panel/heatmap/css/heatmap.light.css

@@ -0,0 +1,58 @@
+.axis {
+	font-family: "Open Sans", Helvetica, Arial, sans-serif;
+	font-size: smaller;
+	fill: #555555;
+}
+
+.axis path,
+.axis line {
+  fill: none;
+  stroke: #D8D9DA;
+  /*shape-rendering: crispEdges;*/
+}
+
+.tick line {
+	opacity: 0.4;
+	stroke: #D8D9DA;
+}
+
+.tick text {
+  fill: #555555;
+}
+
+.heatmap-panel {
+  cursor: crosshair;
+}
+
+div.heatmap-tooltip {
+  position: absolute;
+  text-align: left;
+  min-width: 160px;
+  padding: 12px;
+  font-family: "Open Sans", Helvetica, Arial, sans-serif;
+  font-size: 13px;
+  background: #ECECEC;
+  border: 0px;
+  border-radius: 8px;
+  pointer-events: none;
+}
+
+.card-highlighted:hover {
+	stroke: #D8D9DA;
+}
+
+.heatmap-histogram rect {
+  fill: #555555;
+}
+
+.heatmap-crosshair line {
+  stroke: #a25959;
+  stroke-width: 1;
+}
+
+.heatmap-selection {
+  stroke-width: 1;
+  opacity: 0.3;
+  fill: #555555;
+  stroke: #000;
+}

+ 26 - 0
public/app/plugins/panel/heatmap/display_editor.ts

@@ -0,0 +1,26 @@
+///<reference path="../../../headers/common.d.ts" />
+
+export class HeatmapDisplayEditorCtrl {
+  panel: any;
+  panelCtrl: any;
+
+  /** @ngInject */
+  constructor($scope) {
+    $scope.editor = this;
+    this.panelCtrl = $scope.ctrl;
+    this.panel = this.panelCtrl.panel;
+
+    this.panelCtrl.render();
+  }
+}
+
+/** @ngInject */
+export function heatmapDisplayEditor() {
+  'use strict';
+  return {
+    restrict: 'E',
+    scope: true,
+    templateUrl: 'public/app/plugins/panel/heatmap/partials/display_editor.html',
+    controller: HeatmapDisplayEditorCtrl,
+  };
+}

+ 220 - 0
public/app/plugins/panel/heatmap/heatmap_ctrl.ts

@@ -0,0 +1,220 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import {MetricsPanelCtrl} from 'app/plugins/sdk';
+import _ from 'lodash';
+import kbn from 'app/core/utils/kbn';
+import TimeSeries from 'app/core/time_series';
+import {axesEditor} from './axes_editor';
+import {heatmapDisplayEditor} from './display_editor';
+import rendering from './rendering';
+import {convertToHeatMap, getMinLog} from './heatmap_data_converter';
+
+let X_BUCKET_NUMBER_DEFAULT = 30;
+let Y_BUCKET_NUMBER_DEFAULT = 10;
+
+let panelDefaults = {
+  heatmap: {
+  },
+  cards: {
+    cardPadding: null,
+    cardRound: null
+  },
+  color: {
+    mode: 'color',
+    cardColor: '#b4ff00',
+    colorScale: 'linear',
+    exponent: 0.5,
+    colorScheme: 'interpolateSpectral',
+    fillBackground: false
+  },
+  xBucketSize: null,
+  xBucketNumber: null,
+  yBucketSize: null,
+  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
+};
+
+let colorModes = ['opacity', 'color'];
+let opacityScales = ['linear', 'sqrt'];
+
+// Schemes from d3-scale-chromatic
+// https://github.com/d3/d3-scale-chromatic
+let colorSchemes = [
+  // Diverging
+  {name: 'Spectral', value: 'interpolateSpectral'},
+  {name: 'BrBG', value: 'interpolateBrBG'},
+  {name: 'PRGn', value: 'interpolatePRGn'},
+  {name: 'PiYG', value: 'interpolatePiYG'},
+  {name: 'PuOr', value: 'interpolatePuOr'},
+  {name: 'RdBu', value: 'interpolateRdBu'},
+  {name: 'RdGy', value: 'interpolateRdGy'},
+  {name: 'RdYlBu', value: 'interpolateRdYlBu'},
+  {name: 'RdYlGn', value: 'interpolateRdYlGn'},
+
+  // Sequential (Single Hue)
+  {name: 'Blues', value: 'interpolateBlues'},
+  {name: 'Greens', value: 'interpolateGreens'},
+  {name: 'Greys', value: 'interpolateGreys'},
+  {name: 'Oranges', value: 'interpolateOranges'},
+  {name: 'Purples', value: 'interpolatePurples'},
+  {name: 'Reds', value: 'interpolateReds'},
+
+  // Sequential (Multi-Hue)
+  {name: 'BuGn', value: 'interpolateBuGn'},
+  {name: 'BuPu', value: 'interpolateBuPu'},
+  {name: 'GnBu', value: 'interpolateGnBu'},
+  {name: 'OrRd', value: 'interpolateOrRd'},
+  {name: 'PuBuGn', value: 'interpolatePuBuGn'},
+  {name: 'PuBu', value: 'interpolatePuBu'},
+  {name: 'PuRd', value: 'interpolatePuRd'},
+  {name: 'RdPu', value: 'interpolateRdPu'},
+  {name: 'YlGnBu', value: 'interpolateYlGnBu'},
+  {name: 'YlGn', value: 'interpolateYlGn'},
+  {name: 'YlOrBr', value: 'interpolateYlOrBr'},
+  {name: 'YlOrRd', value: 'interpolateYlOrRd'}
+];
+
+export class HeatmapCtrl extends MetricsPanelCtrl {
+  static templateUrl = 'module.html';
+
+  opacityScales: any = [];
+  colorModes: any =  [];
+  colorSchemes: any = [];
+  selectionActivated: boolean;
+  unitFormats: any;
+  data: any;
+  series: any;
+  timeSrv: any;
+
+  constructor($scope, $injector, private $rootScope, timeSrv) {
+    super($scope, $injector);
+    this.$rootScope = $rootScope;
+    this.timeSrv = timeSrv;
+    this.selectionActivated = false;
+
+    _.defaultsDeep(this.panel, panelDefaults);
+    this.opacityScales = opacityScales;
+    this.colorModes = colorModes;
+    this.colorSchemes = colorSchemes;
+
+    // Bind grafana panel events
+    this.events.on('render', this.onRender.bind(this));
+    this.events.on('data-received', this.onDataReceived.bind(this));
+    this.events.on('data-error', this.onDataError.bind(this));
+    this.events.on('data-snapshot-load', this.onDataReceived.bind(this));
+    this.events.on('init-edit-mode', this.onInitEditMode.bind(this));
+  }
+
+  onInitEditMode() {
+    this.addEditorTab('Axes', axesEditor, 2);
+    this.addEditorTab('Display', heatmapDisplayEditor, 3);
+    this.unitFormats = kbn.getUnitFormats();
+  }
+
+  zoomOut(evt) {
+    this.publishAppEvent('zoom-out', 2);
+  }
+
+  onRender() {
+    if (!this.range) { return; }
+
+    let xBucketSize, yBucketSize;
+    let logBase = this.panel.yAxis.logBase;
+    let xBucketNumber = this.panel.xBucketNumber || X_BUCKET_NUMBER_DEFAULT;
+    let xBucketSizeByNumber = Math.floor((this.range.to - this.range.from) / xBucketNumber);
+
+    // Parse X bucket size (number or interval)
+    let isIntervalString = kbn.interval_regex.test(this.panel.xBucketSize);
+    if (isIntervalString) {
+      xBucketSize = kbn.interval_to_ms(this.panel.xBucketSize);
+    } else if (isNaN(Number(this.panel.xBucketSize)) || this.panel.xBucketSize === '' || this.panel.xBucketSize === null) {
+      xBucketSize = xBucketSizeByNumber;
+    } else {
+      xBucketSize = Number(this.panel.xBucketSize);
+    }
+
+    // Calculate Y bucket size
+    let heatmapStats = this.parseSeries(this.series);
+    let yBucketNumber = this.panel.yBucketNumber || Y_BUCKET_NUMBER_DEFAULT;
+    if (logBase !== 1) {
+      yBucketSize = this.panel.yAxis.splitFactor;
+    } else {
+      if (heatmapStats.max === heatmapStats.min) {
+        yBucketSize = heatmapStats.max / Y_BUCKET_NUMBER_DEFAULT;
+      } else {
+        yBucketSize = (heatmapStats.max - heatmapStats.min) / yBucketNumber;
+      }
+      yBucketSize = this.panel.yBucketSize || yBucketSize;
+    }
+
+    let bucketsData = convertToHeatMap(this.series, yBucketSize, xBucketSize, logBase);
+
+    // Set default Y range if no data
+    if (!heatmapStats.min && !heatmapStats.max) {
+      heatmapStats = {min: -1, max: 1, minLog: 1};
+      yBucketSize = 1;
+    }
+
+    this.data = {
+      buckets: bucketsData,
+      heatmapStats: heatmapStats,
+      xBucketSize: xBucketSize,
+      yBucketSize: yBucketSize
+    };
+  }
+
+  onDataReceived(dataList) {
+    this.series = dataList.map(this.seriesHandler.bind(this));
+    this.render();
+  }
+
+  onDataError() {
+    this.series = [];
+    this.render();
+  }
+
+  seriesHandler(seriesData) {
+    var series = new TimeSeries({
+      datapoints: seriesData.datapoints,
+      alias: seriesData.target
+    });
+
+    series.flotpairs = series.getFlotPairs(this.panel.nullPointMode);
+    series.minLog = getMinLog(series);
+    return series;
+  }
+
+  parseSeries(series) {
+    let min = _.min(_.map(series, s => s.stats.min));
+    let minLog = _.min(_.map(series, s => s.minLog));
+    let max = _.max(_.map(series, s => s.stats.max));
+
+    return {
+      max: max,
+      min: min,
+      minLog: minLog
+    };
+  }
+
+  link(scope, elem, attrs, ctrl) {
+    rendering(scope, elem, attrs, ctrl);
+  }
+}

+ 357 - 0
public/app/plugins/panel/heatmap/heatmap_data_converter.ts

@@ -0,0 +1,357 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import _ from 'lodash';
+
+let VALUE_INDEX = 0;
+let TIME_INDEX = 1;
+
+/**
+ * Convert set of time series into heatmap buckets
+ * @return {Object}    Heatmap object:
+ * {
+ *   xBucketBound_1: {
+ *     x: xBucketBound_1,
+ *     buckets: {
+ *       yBucketBound_1: {
+ *         y: yBucketBound_1,
+ *         bounds: {bottom, top}
+ *         values: [val_1, val_2, ..., val_K],
+ *         points: [[val_Y, val_X, series_name], ..., [...]],
+ *         seriesStat: {seriesName_1: val_1, seriesName_2: val_2}
+ *       },
+ *       ...
+ *       yBucketBound_M: {}
+ *     },
+ *     values: [val_1, val_2, ..., val_K],
+ *     points: [
+ *       [val_Y, val_X, series_name], (point_1)
+ *       ...
+ *       [...] (point_K)
+ *     ]
+ *   },
+ *   xBucketBound_2: {},
+ *   ...
+ *   xBucketBound_N: {}
+ * }
+ */
+function convertToHeatMap(series, yBucketSize, xBucketSize, logBase) {
+  let seriesBuckets = _.map(series, s => {
+    return seriesToHeatMap(s, yBucketSize, xBucketSize, logBase);
+  });
+
+  let buckets = mergeBuckets(seriesBuckets);
+  return buckets;
+}
+
+/**
+ * Convert buckets into linear array of "cards" - objects, represented heatmap elements.
+ * @param  {Object} buckets
+ * @return {Array}          Array of "card" objects
+ */
+function convertToCards(buckets) {
+  let cards = [];
+  _.forEach(buckets, xBucket => {
+    _.forEach(xBucket.buckets, (yBucket, key) => {
+      if (yBucket.values.length) {
+        let card = {
+          x: Number(xBucket.x),
+          y: Number(key),
+          yBounds: yBucket.bounds,
+          values: yBucket.values,
+          seriesStat: getSeriesStat(yBucket.points)
+        };
+
+        cards.push(card);
+      }
+    });
+  });
+
+  return cards;
+}
+
+/**
+ * Special method for log scales. When series converted into buckets with log scale,
+ * for simplification, 0 values are converted into 0, not into -Infinity. On the other hand, we mean
+ * that all values less than series minimum, is 0 values, and we create special "minimum" bucket for
+ * that values (actually, there're no values less than minimum, so this bucket is empty).
+ *  8-16|    | ** |    |  * |  **|
+ *   4-8|  * |*  *|*   |** *| *  |
+ *   2-4| * *|    | ***|    |*   |
+ *   1-2|*   |    |    |    |    | This bucket contains minimum series value
+ * 0.5-1|____|____|____|____|____| This bucket should be displayed as 0 on graph
+ *     0|____|____|____|____|____| This bucket is for 0 values (should actually be -Infinity)
+ * So we should merge two bottom buckets into one (0-value bucket).
+ *
+ * @param  {Object} buckets  Heatmap buckets
+ * @param  {Number} minValue Minimum series value
+ * @return {Object}          Transformed buckets
+ */
+function mergeZeroBuckets(buckets, minValue) {
+  _.forEach(buckets, xBucket => {
+    let yBuckets = xBucket.buckets;
+
+    let emptyBucket = {
+      bounds: {bottom: 0, top: 0},
+      values: [],
+      points: []
+    };
+
+    let nullBucket = yBuckets[0] || emptyBucket;
+    let minBucket = yBuckets[minValue] || emptyBucket;
+
+    let newBucket = {
+      y: 0,
+      bounds: {bottom: minValue, top: minBucket.bounds.top || minValue},
+      values: nullBucket.values.concat(minBucket.values),
+      points: nullBucket.points.concat(minBucket.points)
+    };
+
+    let newYBuckets = {};
+    _.forEach(yBuckets, (bucket, bound) => {
+      bound = Number(bound);
+      if (bound !== 0 && bound !== minValue) {
+        newYBuckets[bound] = bucket;
+      }
+    });
+    newYBuckets[0] = newBucket;
+    xBucket.buckets = newYBuckets;
+  });
+
+  return buckets;
+}
+
+/**
+ * Remove 0 values from heatmap buckets.
+ */
+function removeZeroBuckets(buckets) {
+  _.forEach(buckets, xBucket => {
+    let yBuckets = xBucket.buckets;
+    let newYBuckets = {};
+    _.forEach(yBuckets, (bucket, bound) => {
+      if (bucket.y !== 0) {
+        newYBuckets[bound] = bucket;
+      }
+    });
+    xBucket.buckets = newYBuckets;
+  });
+
+  return buckets;
+}
+
+/**
+ * Count values number for each timeseries in given bucket
+ * @param  {Array}  points Bucket's datapoints with series name ([val, ts, series_name])
+ * @return {Object}        seriesStat: {seriesName_1: val_1, seriesName_2: val_2}
+ */
+function getSeriesStat(points) {
+  return _.countBy(points, p => p[2]);
+}
+
+/**
+ * Convert individual series to heatmap buckets
+ */
+function seriesToHeatMap(series, yBucketSize, xBucketSize, logBase = 1) {
+  let datapoints = series.datapoints;
+  let seriesName = series.label;
+  let xBuckets = {};
+
+  // Slice series into X axis buckets
+  // |    | ** |    |  * |  **|
+  // |  * |*  *|*   |** *| *  |
+  // |** *|    | ***|    |*   |
+  // |____|____|____|____|____|_
+  //
+  _.forEach(datapoints, point => {
+    let bucketBound = getBucketBound(point[TIME_INDEX], xBucketSize);
+    pushToXBuckets(xBuckets, point, bucketBound, seriesName);
+  });
+
+  // Slice X axis buckets into Y (value) buckets
+  // |  **|     |2|,
+  // | *  | --\ |1|,
+  // |*   | --/ |1|,
+  // |____|     |0|
+  //
+  _.forEach(xBuckets, xBucket => {
+    if (logBase !== 1) {
+      xBucket.buckets = convertToLogScaleValueBuckets(xBucket, yBucketSize, logBase);
+    } else {
+      xBucket.buckets = convertToValueBuckets(xBucket, yBucketSize);
+    }
+  });
+  return xBuckets;
+}
+
+function pushToXBuckets(buckets, point, bucketNum, seriesName) {
+  let value = point[VALUE_INDEX];
+  if (value === null || value === undefined || isNaN(value)) { return; }
+
+  // Add series name to point for future identification
+  point.push(seriesName);
+
+  if (buckets[bucketNum] && buckets[bucketNum].values) {
+    buckets[bucketNum].values.push(value);
+    buckets[bucketNum].points.push(point);
+  } else {
+    buckets[bucketNum] = {
+      x: bucketNum,
+      values: [value],
+      points: [point]
+    };
+  }
+}
+
+function pushToYBuckets(buckets, bucketNum, value, point, bounds) {
+  if (buckets[bucketNum]) {
+    buckets[bucketNum].values.push(value);
+    buckets[bucketNum].points.push(point);
+  } else {
+    buckets[bucketNum] = {
+      y: bucketNum,
+      bounds: bounds,
+      values: [value],
+      points: [point]
+    };
+  }
+}
+
+function getValueBucketBound(value, yBucketSize, logBase) {
+  if (logBase === 1) {
+    return getBucketBound(value, yBucketSize);
+  } else {
+    return getLogScaleBucketBound(value, yBucketSize, logBase);
+  }
+}
+
+/**
+ * Find bucket for given value (for linear scale)
+ */
+function getBucketBounds(value, bucketSize) {
+  let bottom, top;
+  bottom = Math.floor(value / bucketSize) * bucketSize;
+  top = (Math.floor(value / bucketSize) + 1) * bucketSize;
+
+  return {bottom, top};
+}
+
+function getBucketBound(value, bucketSize) {
+  let bounds = getBucketBounds(value, bucketSize);
+  return bounds.bottom;
+}
+
+function convertToValueBuckets(xBucket, bucketSize) {
+  let values = xBucket.values;
+  let points = xBucket.points;
+  let buckets = {};
+  _.forEach(values, (val, index) => {
+    let bounds = getBucketBounds(val, bucketSize);
+    let bucketNum = bounds.bottom;
+    pushToYBuckets(buckets, bucketNum, val, points[index], bounds);
+  });
+
+  return buckets;
+}
+
+/**
+ * Find bucket for given value (for log scales)
+ */
+function getLogScaleBucketBounds(value, yBucketSplitFactor, logBase) {
+  let top, bottom;
+  if (value === 0) {
+    return {bottom: 0, top: 0};
+  }
+
+  let value_log = logp(value, logBase);
+  let pow, powTop;
+  if (yBucketSplitFactor === 1 || !yBucketSplitFactor) {
+    pow = Math.floor(value_log);
+    powTop = pow + 1;
+  } else {
+    let additional_bucket_size = 1 / yBucketSplitFactor;
+    let additional_log = value_log - Math.floor(value_log);
+    additional_log = Math.floor(additional_log / additional_bucket_size) * additional_bucket_size;
+    pow = Math.floor(value_log) + additional_log;
+    powTop = pow + additional_bucket_size;
+  }
+  bottom = Math.pow(logBase, pow);
+  top = Math.pow(logBase, powTop);
+
+  return {bottom, top};
+}
+
+function getLogScaleBucketBound(value, yBucketSplitFactor, logBase) {
+  let bounds = getLogScaleBucketBounds(value, yBucketSplitFactor, logBase);
+  return bounds.bottom;
+}
+
+function convertToLogScaleValueBuckets(xBucket, yBucketSplitFactor, logBase) {
+  let values = xBucket.values;
+  let points = xBucket.points;
+
+  let buckets = {};
+  _.forEach(values, (val, index) => {
+    let bounds = getLogScaleBucketBounds(val, yBucketSplitFactor, logBase);
+    let bucketNum = bounds.bottom;
+    pushToYBuckets(buckets, bucketNum, val, points[index], bounds);
+  });
+
+  return buckets;
+}
+
+/**
+ * Merge individual buckets for all series into one
+ * @param  {Array}  seriesBuckets Array of series buckets
+ * @return {Object}               Merged buckets.
+ */
+function mergeBuckets(seriesBuckets) {
+  let mergedBuckets = {};
+  _.forEach(seriesBuckets, (seriesBucket, index) => {
+    if (index === 0) {
+      mergedBuckets = seriesBucket;
+    } else {
+      _.forEach(seriesBucket, (xBucket, xBound) => {
+        if (mergedBuckets[xBound]) {
+          mergedBuckets[xBound].points = xBucket.points.concat(mergedBuckets[xBound].points);
+          mergedBuckets[xBound].values = xBucket.values.concat(mergedBuckets[xBound].values);
+
+          _.forEach(xBucket.buckets, (yBucket, yBound) => {
+            let bucket = mergedBuckets[xBound].buckets[yBound];
+            if (bucket && bucket.values) {
+              mergedBuckets[xBound].buckets[yBound].values = bucket.values.concat(yBucket.values);
+              mergedBuckets[xBound].buckets[yBound].points = bucket.points.concat(yBucket.points);
+            } else {
+              mergedBuckets[xBound].buckets[yBound] = yBucket;
+            }
+
+            let points = mergedBuckets[xBound].buckets[yBound].points;
+            mergedBuckets[xBound].buckets[yBound].seriesStat = getSeriesStat(points);
+          });
+        } else {
+          mergedBuckets[xBound] = xBucket;
+        }
+      });
+    }
+  });
+
+  return mergedBuckets;
+}
+
+// Get minimum non zero value.
+function getMinLog(series) {
+  let values = _.compact(_.map(series.datapoints, p => p[0]));
+  return _.min(values);
+}
+
+// Logarithm for custom base
+function logp(value, base) {
+  return Math.log(value) / Math.log(base);
+}
+
+export {
+  convertToHeatMap,
+  convertToCards,
+  removeZeroBuckets,
+  mergeZeroBuckets,
+  getMinLog,
+  getValueBucketBound
+};

+ 248 - 0
public/app/plugins/panel/heatmap/heatmap_tooltip.ts

@@ -0,0 +1,248 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import d3 from 'd3';
+import $ from 'jquery';
+import _ from 'lodash';
+import kbn from 'app/core/utils/kbn';
+import {getValueBucketBound} from './heatmap_data_converter';
+
+let TOOLTIP_PADDING_X = 30;
+let TOOLTIP_PADDING_Y = 5;
+let HISTOGRAM_WIDTH = 160;
+let HISTOGRAM_HEIGHT = 40;
+
+export class HeatmapTooltip {
+  tooltip: any;
+  scope: any;
+  dashboard: any;
+  panel: any;
+  heatmapPanel: any;
+  mouseOverBucket: boolean;
+  originalFillColor: any;
+
+  constructor(elem, scope) {
+    this.scope = scope;
+    this.dashboard = scope.ctrl.dashboard;
+    this.panel = scope.ctrl.panel;
+    this.heatmapPanel = elem;
+    this.mouseOverBucket = false;
+    this.originalFillColor = null;
+
+    elem.on("mouseover", this.onMouseOver.bind(this));
+    elem.on("mouseleave", this.onMouseLeave.bind(this));
+  }
+
+  onMouseOver(e) {
+    if (!this.tooltip) {
+      this.add();
+      this.move(e);
+    }
+  }
+
+  onMouseLeave() {
+    this.destroy();
+  }
+
+  onMouseMove(e) {
+    if (!this.panel.tooltip.show) { return; }
+
+    this.move(e);
+  }
+
+  add() {
+    this.tooltip = d3.select("body")
+      .append("div")
+      .attr("class", "heatmap-tooltip");
+  }
+
+  destroy() {
+    if (this.tooltip) {
+      this.tooltip.remove();
+    }
+
+    this.tooltip = null;
+  }
+
+  show(pos, data) {
+    if (!this.panel.tooltip.show || !data) { return; }
+
+    let {xBucketIndex, yBucketIndex} = this.getBucketIndexes(pos, data);
+
+    if (!data.buckets[xBucketIndex] || !this.tooltip) {
+      this.destroy();
+      return;
+    }
+
+    let boundBottom, boundTop, valuesNumber;
+    let xData = data.buckets[xBucketIndex];
+    let yData = xData.buckets[yBucketIndex];
+
+    let tooltipTimeFormat = 'YYYY-MM-DD HH:mm:ss';
+    let time = this.dashboard.formatDate(xData.x, tooltipTimeFormat);
+    let decimals = this.panel.tooltipDecimals || 5;
+    let valueFormatter = this.valueFormatter(decimals);
+
+    let tooltipHtml = `<div><b>${time}</b></div>
+      <div class="heatmap-histogram"></div>`;
+
+    if (yData) {
+      boundBottom = valueFormatter(yData.bounds.bottom);
+      boundTop = valueFormatter(yData.bounds.top);
+      valuesNumber = yData.values.length;
+      tooltipHtml += `<div>
+        bucket: <b>${boundBottom} - ${boundTop}</b> <br>
+        values: <b>${valuesNumber}</b> <br>
+      </div>`;
+
+      if (this.panel.tooltip.seriesStat && yData.seriesStat) {
+        tooltipHtml = this.addSeriesStat(tooltipHtml, yData.seriesStat);
+      }
+    } else {
+      if (!this.panel.tooltip.showHistogram) {
+        this.destroy();
+        return;
+      }
+      boundBottom = yBucketIndex;
+      boundTop = '';
+      valuesNumber = 0;
+    }
+
+    this.tooltip.html(tooltipHtml);
+
+    if (this.panel.tooltip.showHistogram) {
+      this.addHistogram(xData);
+    }
+
+    this.move(pos);
+  }
+
+  getBucketIndexes(pos, data) {
+    let xBucketIndex, yBucketIndex;
+
+    // if panelRelY is defined another panel wants us to show a tooltip
+    if (pos.panelRelY) {
+      xBucketIndex = getValueBucketBound(pos.x, data.xBucketSize, 1);
+      let y = this.scope.yScale.invert(pos.panelRelY * this.scope.chartHeight);
+      yBucketIndex = getValueBucketBound(y, data.yBucketSize, this.panel.yAxis.logBase);
+      pos = this.getSharedTooltipPos(pos);
+
+      if (!this.tooltip) {
+        // Add shared tooltip for panel
+        this.add();
+      }
+    } else {
+      xBucketIndex = this.getXBucketIndex(pos.offsetX, data);
+      yBucketIndex = this.getYBucketIndex(pos.offsetY, data);
+    }
+
+    return {xBucketIndex, yBucketIndex};
+  }
+
+  getXBucketIndex(offsetX, data) {
+    let x = this.scope.xScale.invert(offsetX - this.scope.yAxisWidth).valueOf();
+    let xBucketIndex = getValueBucketBound(x, data.xBucketSize, 1);
+    return xBucketIndex;
+  }
+
+  getYBucketIndex(offsetY, data) {
+    let y = this.scope.yScale.invert(offsetY - this.scope.chartTop);
+    let yBucketIndex = getValueBucketBound(y, data.yBucketSize, this.panel.yAxis.logBase);
+    return yBucketIndex;
+  }
+
+  getSharedTooltipPos(pos) {
+    // get pageX from position on x axis and pageY from relative position in original panel
+    pos.pageX = this.heatmapPanel.offset().left + this.scope.xScale(pos.x);
+    pos.pageY = this.heatmapPanel.offset().top + this.scope.chartHeight * pos.panelRelY;
+    return pos;
+  }
+
+  addSeriesStat(tooltipHtml, seriesStat) {
+    tooltipHtml += "series: <br>";
+    _.forEach(seriesStat, (values, series) => {
+      tooltipHtml += `&nbsp;&nbsp;-&nbsp;&nbsp;${series}: <b>${values}</b><br>`;
+    });
+
+    return tooltipHtml;
+  }
+
+  addHistogram(data) {
+    let xBucket = this.scope.ctrl.data.buckets[data.x];
+    let yBucketSize = this.scope.ctrl.data.yBucketSize;
+    let {min, max, ticks} = this.scope.ctrl.data.yAxis;
+    let histogramData = _.map(xBucket.buckets, bucket => {
+      return [bucket.y, bucket.values.length];
+    });
+    histogramData = _.filter(histogramData, d => {
+      return d[0] >= min && d[0] <= max;
+    });
+
+    let scale = this.scope.yScale.copy();
+    let histXScale = scale
+      .domain([min, max])
+      .range([0, HISTOGRAM_WIDTH]);
+
+    let barWidth;
+    if (this.panel.yAxis.logBase === 1) {
+      barWidth = Math.floor(HISTOGRAM_WIDTH / (max - min) * yBucketSize * 0.9);
+    } else {
+      barWidth = Math.floor(HISTOGRAM_WIDTH / ticks / yBucketSize * 0.9);
+    }
+    barWidth = Math.max(barWidth, 1);
+
+    let histYScale = d3.scaleLinear()
+      .domain([0, _.max(_.map(histogramData, d => d[1]))])
+      .range([0, HISTOGRAM_HEIGHT]);
+
+    let histogram = this.tooltip.select(".heatmap-histogram")
+      .append("svg")
+      .attr("width", HISTOGRAM_WIDTH)
+      .attr("height", HISTOGRAM_HEIGHT);
+
+    histogram.selectAll(".bar").data(histogramData)
+      .enter().append("rect")
+      .attr("x", d => {
+        return histXScale(d[0]);
+      })
+      .attr("width", barWidth)
+      .attr("y", d => {
+        return HISTOGRAM_HEIGHT - histYScale(d[1]);
+      })
+      .attr("height", d => {
+        return histYScale(d[1]);
+      });
+  }
+
+  move(pos) {
+    if (!this.tooltip) { return; }
+
+    let elem = $(this.tooltip.node())[0];
+    let tooltipWidth = elem.clientWidth;
+    let tooltipHeight = elem.clientHeight;
+
+    let left = pos.pageX + TOOLTIP_PADDING_X;
+    let top = pos.pageY + TOOLTIP_PADDING_Y;
+
+    if (pos.pageX + tooltipWidth + 40 > window.innerWidth) {
+      left = pos.pageX - tooltipWidth - TOOLTIP_PADDING_X;
+    }
+
+    if (pos.pageY - window.pageYOffset + tooltipHeight + 20 > window.innerHeight) {
+      top = pos.pageY - tooltipHeight - TOOLTIP_PADDING_Y;
+    }
+
+    return this.tooltip
+      .style("left", left + "px")
+      .style("top", top + "px");
+  }
+
+  valueFormatter(decimals) {
+    let format = this.panel.yAxis.format;
+    return function(value) {
+      if (_.isInteger(value)) {
+        decimals = 0;
+      }
+      return kbn.valueFormats[format](value, decimals);
+    };
+  }
+}

+ 195 - 0
public/app/plugins/panel/heatmap/img/icn-heatmap-panel.svg

@@ -0,0 +1,195 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   version="1.1"
+   id="Layer_1"
+   x="0px"
+   y="0px"
+   width="100px"
+   height="100px"
+   viewBox="0 0 100 100"
+   style="enable-background:new 0 0 100 100;"
+   xml:space="preserve"
+   sodipodi:docname="icn-heatmap-panel.svg"
+   inkscape:version="0.92.1 unknown"><metadata
+     id="metadata108"><rdf:RDF><cc:Work
+         rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
+     id="defs106" /><sodipodi:namedview
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1"
+     objecttolerance="10"
+     gridtolerance="10"
+     guidetolerance="10"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:window-width="2491"
+     inkscape:window-height="1410"
+     id="namedview104"
+     showgrid="false"
+     inkscape:zoom="9.44"
+     inkscape:cx="37.431994"
+     inkscape:cy="46.396264"
+     inkscape:window-x="69"
+     inkscape:window-y="30"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="Layer_1" /><rect
+     x="-0.017525015"
+     y="33.438038"
+     style="opacity:0.35714285;fill:#decd87;fill-opacity:1;stroke-width:0.70710677"
+     width="15.8115"
+     height="15.049"
+     id="rect69" /><path
+     style="opacity:0.42857145;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 16.874036,24.263391 v -7.46822 h 7.891949 7.891949 v 7.46822 7.46822 h -7.891949 -7.891949 z"
+     id="path4883"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.79365079;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 33.69883,24.337252 v -7.46822 h 7.891949 7.891949 v 7.46822 7.46822 H 41.590779 33.69883 Z"
+     id="path4885"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.80952382;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 50.523624,24.337251 v -7.46822 h 7.891949 7.89195 v 7.46822 7.46822 h -7.89195 -7.891949 z"
+     id="path4887"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.43650794;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 67.348418,24.167764 v -7.46822 h 7.891949 7.891949 v 7.46822 7.46822 h -7.891949 -7.891949 z"
+     id="path4889"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.24603176;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 84.173218,24.279957 v -7.46822 h 7.891947 7.891956 v 7.46822 7.46822 h -7.891956 -7.891947 z"
+     id="path4891"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.38158725;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 84.226177,40.968612 v -7.46822 h 7.891949 7.891954 v 7.46822 7.468221 h -7.891954 -7.891949 z"
+     id="path4893"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.75396824;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 67.377433,40.884464 v -7.46822 h 7.891949 7.891949 v 7.46822 7.468221 h -7.891949 -7.891949 z"
+     id="path4895"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.94444442;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 50.528693,41.011582 v -7.46822 h 7.891949 7.89195 v 7.46822 7.468221 h -7.89195 -7.891949 z"
+     id="path4897"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.53174606;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 33.679956,41.011587 v -7.46822 h 7.891949 7.891949 v 7.46822 7.468221 h -7.891949 -7.891949 z"
+     id="path4899"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.64285715;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 16.831216,40.956187 v -7.46822 h 7.891949 7.891949 v 7.46822 7.468221 h -7.891949 -7.891949 z"
+     id="path4901"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.58730158;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 0.04924114,57.615687 v -7.46822 H 7.8882241 15.727207 v 7.46822 7.468221 H 7.8882241 0.04924114 Z"
+     id="path4905"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.43650794;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 16.884627,57.648974 v -7.46822 h 7.838984 7.838983 v 7.46822 7.468221 h -7.838983 -7.838984 z"
+     id="path4907"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:1;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 67.390785,57.601163 v -7.46822 h 7.891949 7.891949 v 7.46822 7.468221 h -7.891949 -7.891949 z"
+     id="path4913"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.29365079;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 84.226177,57.657262 v -7.46822 h 7.891947 7.891946 v 7.46822 7.468221 h -7.891946 -7.891947 z"
+     id="path4915"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.73015873;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 84.226177,74.345913 v -7.46822 h 7.891948 7.891955 v 7.46822 7.468221 h -7.891955 -7.891948 z"
+     id="path4917"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.58730158;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 67.380199,74.317863 v -7.46822 h 7.891949 7.891949 v 7.46822 7.468221 h -7.891949 -7.891949 z"
+     id="path4919"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.66666667;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 50.534214,74.360232 v -7.46822 h 7.891949 7.89195 v 7.46822 7.468221 h -7.89195 -7.891949 z"
+     id="path4921"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.84920636;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 33.688232,74.360242 v -7.46822 h 7.891949 7.891949 v 7.46822 7.468221 h -7.891949 -7.891949 z"
+     id="path4923"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.70634921;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 16.842256,74.341769 v -7.46822 h 7.891949 7.891949 v 7.46822 7.468221 h -7.891949 -7.891949 z"
+     id="path4925"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.43650794;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="M -0.00372516,74.325127 V 66.856906 H 7.8882239 15.780174 v 7.468221 7.46822 H 7.8882239 -0.00372516 Z"
+     id="path4927"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.13492061;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="M 0.04924124,91.034564 V 83.566343 H 7.8882241 15.727207 v 7.468221 7.468221 H 7.8882241 0.04924114 Z"
+     id="path4929"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.26190479;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="M 16.88187,91.034561 V 83.56634 h 7.838983 7.838984 v 7.468221 7.468224 h -7.838983 -7.838983 z"
+     id="path4931"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.58730158;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 33.714496,91.034569 v -7.468221 h 7.891949 7.891949 v 7.468221 7.468216 h -7.891949 -7.891949 z"
+     id="path4933"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.30158727;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="M 50.547126,91.034561 V 83.56634 h 7.891949 7.89195 v 7.468221 7.468224 h -7.89195 -7.891949 z"
+     id="path4935"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.15873018;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 67.379756,91.034564 v -7.468221 h 7.891949 7.891949 v 7.468221 7.468221 h -7.891949 -7.891949 z"
+     id="path4937"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.11904764;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 84.212376,91.034568 v -7.468221 h 7.891952 7.89195 v 7.468221 7.468217 h -7.89195 -7.891952 z"
+     id="path4939"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.89682539;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 50.555398,57.68591 v -7.46822 h 7.838983 7.838983 v 7.46822 7.468221 h -7.838983 -7.838983 z"
+     id="path4941"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:1;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 33.720011,57.685908 v -7.46822 h 7.838983 7.838983 v 7.46822 7.468221 h -7.838983 -7.838983 z"
+     id="path4943"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.16666667;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="M 0.04924152,24.249783 V 16.728597 H 7.8882245 15.727207 v 7.521186 7.521186 H 7.8882245 0.04924152 Z"
+     id="path4976"
+     inkscape:connector-curvature="0" /><rect
+     x="16.900255"
+     y="0.10238234"
+     style="opacity:0.43650794;fill:#decd87;fill-opacity:1;stroke-width:0.70710677"
+     width="15.8115"
+     height="15.049"
+     id="rect69-5-7-3" /><rect
+     x="84.304306"
+     y="0.12308588"
+     style="opacity:0.11904764;fill:#decd87;fill-opacity:1;stroke-width:0.70710677"
+     width="15.8115"
+     height="15.049"
+     id="rect69-5-2-2-6" /><path
+     style="opacity:0.3174603;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="M 33.751268,7.6629239 V 0.19470386 h 7.891949 7.891949 V 7.6629239 15.131142 h -7.891949 -7.891949 z"
+     id="path4885-1"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.43650794;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="M 50.602281,7.6629315 V 0.19471149 h 7.891949 7.891951 V 7.6629315 15.13115 H 58.49423 50.602281 Z"
+     id="path4887-2"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.73015873;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="m 67.453295,7.4510673 v -7.4682202 h 7.89195 7.89195 v 7.4682202 7.4682177 h -7.89195 -7.89195 z"
+     id="path4889-9"
+     inkscape:connector-curvature="0" /><path
+     style="opacity:0.15873018;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
+     d="M -0.02566414,7.5403525 V 0.0191665 H 7.8133188 15.652302 v 7.521186 7.5211835 H 7.8133188 -0.02566414 Z"
+     id="path4976-3"
+     inkscape:connector-curvature="0" /></svg>

+ 7 - 0
public/app/plugins/panel/heatmap/module.html

@@ -0,0 +1,7 @@
+<div class="heatmap-wrapper">
+	<div class="heatmap-canvas-wrapper">
+		<div class="heatmap-panel" ng-dblclick="ctrl.zoomOut()"></div>
+	</div>
+	<!-- <div class="graph-legend-wrapper" ng-if="ctrl.panel.legend.show" heatmap-legend></div> -->
+</div>
+<div class="clearfix"></div>

+ 13 - 0
public/app/plugins/panel/heatmap/module.ts

@@ -0,0 +1,13 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import {loadPluginCss} from 'app/plugins/sdk';
+import {HeatmapCtrl} from './heatmap_ctrl';
+
+// loadPluginCss({
+//   dark: 'public/app/plugins/panel/heatmap/css/heatmap.dark.css',
+//   light: 'public/app/plugins/panel/heatmap/css/heatmap.light.css'
+// });
+
+export {
+  HeatmapCtrl as PanelCtrl
+};

+ 85 - 0
public/app/plugins/panel/heatmap/partials/axes_editor.html

@@ -0,0 +1,85 @@
+<div class="editor-row">
+  <div class="section gf-form-group">
+    <h5 class="section-heading">Y Axis</h5>
+    <gf-form-switch class="gf-form" label-class="width-5"
+      label="Show"
+      checked="ctrl.panel.yAxis.show" on-change="ctrl.render()">
+    </gf-form-switch>
+    <div class="gf-form">
+      <label class="gf-form-label width-5">Unit</label>
+      <div class="gf-form-dropdown-typeahead max-width-15"
+        ng-model="ctrl.panel.yAxis.format"
+        dropdown-typeahead2="editor.unitFormats"
+        dropdown-typeahead-on-select="editor.setUnitFormat($subItem)">
+      </div>
+    </div>
+    <div class="gf-form">
+      <label class="gf-form-label width-5">Scale</label>
+      <div class="gf-form-select-wrapper max-width-15">
+        <select class="gf-form-input" ng-model="ctrl.panel.yAxis.logBase" ng-options="v as k for (k, v) in editor.logScales" ng-change="ctrl.refresh()"></select>
+      </div>
+    </div>
+    <div class="gf-form-inline">
+      <div class="gf-form max-width-10">
+        <label class="gf-form-label width-5">Y-Min</label>
+        <input type="text" class="gf-form-input" placeholder="auto" empty-to-null ng-model="ctrl.panel.yAxis.min" ng-change="ctrl.render()" ng-model-onblur>
+      </div>
+      <div class="gf-form max-width-10">
+        <label class="gf-form-label width-5">Y-Max</label>
+        <input type="text" class="gf-form-input" placeholder="auto" empty-to-null ng-model="ctrl.panel.yAxis.max" ng-change="ctrl.render()" ng-model-onblur>
+      </div>
+    </div>
+    <div class="gf-form">
+      <label class="gf-form-label width-10">Decimals</label>
+      <input type="number" class="gf-form-input width-10" placeholder="auto" data-placement="right"
+        bs-tooltip="'Override automatic decimal precision for axis.'"
+        ng-model="ctrl.panel.yAxis.decimals" ng-change="ctrl.render()" ng-model-onblur>
+    </div>
+    <div ng-show="ctrl.panel.yAxis.logBase === 1">
+      <div class="gf-form">
+        <label class="gf-form-label width-10">Buckets</label>
+        <input type="number" class="gf-form-input width-10" placeholder="auto" data-placement="right"
+          bs-tooltip="'Number of buckets for Y axis.'"
+          ng-model="ctrl.panel.yBucketNumber" ng-change="ctrl.refresh()" ng-model-onblur>
+      </div>
+      <div class="gf-form">
+        <label class="gf-form-label width-10">Bucket Size</label>
+        <input type="number" class="gf-form-input width-10" placeholder="auto" data-placement="right"
+          bs-tooltip="'Size of bucket. Has priority over Buckets option.'"
+          ng-model="ctrl.panel.yBucketSize" ng-change="ctrl.refresh()" ng-model-onblur>
+      </div>
+    </div>
+    <div ng-show="ctrl.panel.yAxis.logBase !== 1">
+      <div class="gf-form">
+        <label class="gf-form-label width-10">Split Buckets</label>
+        <input type="number" class="gf-form-input width-10" placeholder="1" data-placement="right"
+          bs-tooltip="'For log scales only. By default Y values is splitted by integer powers of log base (1, 2, 4, 8, 16, ... for log2). This option allows to split each default bucket into specified number of buckets.'"
+          ng-model="ctrl.panel.yAxis.splitFactor" ng-change="ctrl.refresh()" ng-model-onblur>
+      </div>
+      <gf-form-switch class="gf-form" label-class="width-10"
+        label="Remove zero values"
+        checked="ctrl.panel.yAxis.removeZeroValues" on-change="ctrl.render()">
+      </gf-form-switch>
+    </div>
+  </div>
+
+  <div class="section gf-form-group">
+    <h5 class="section-heading">X Axis</h5>
+    <gf-form-switch class="gf-form" label-class="width-8"
+      label="Show"
+      checked="ctrl.panel.xAxis.show" on-change="ctrl.render()">
+    </gf-form-switch>
+    <div class="gf-form">
+      <label class="gf-form-label width-8">Buckets</label>
+      <input type="number" class="gf-form-input width-8" placeholder="auto" data-placement="right"
+        bs-tooltip="'Number of buckets for X axis.'"
+        ng-model="ctrl.panel.xBucketNumber" ng-change="ctrl.refresh()" ng-model-onblur>
+    </div>
+    <div class="gf-form">
+      <label class="gf-form-label width-8">Bucket Size</label>
+      <input type="text" class="gf-form-input width-8" placeholder="auto" data-placement="right"
+        bs-tooltip="'Size of bucket. Number or interval (10s, 5m, 1h, etc). Supported intervals: ms, s, m, h, d, w, M, y. Has priority over Buckets option.'"
+        ng-model="ctrl.panel.xBucketSize" ng-change="ctrl.refresh()" ng-model-onblur>
+    </div>
+  </div>
+</div>

+ 93 - 0
public/app/plugins/panel/heatmap/partials/display_editor.html

@@ -0,0 +1,93 @@
+<div class="editor-row">
+  <div class="section gf-form-group">
+    <h5 class="section-heading">Colors</h5>
+    <div class="gf-form">
+      <label class="gf-form-label width-8">Color mode</label>
+      <div class="gf-form-select-wrapper width-12">
+        <select class="input-small gf-form-input" ng-model="ctrl.panel.color.mode" ng-options="s for s in ctrl.colorModes" ng-change="ctrl.render()"></select>
+      </div>
+    </div>
+
+    <div ng-show="ctrl.panel.color.mode === 'opacity'">
+      <div class="gf-form">
+        <label class="gf-form-label width-8">Card Color</label>
+        <span class="gf-form-label">
+          <spectrum-picker ng-model="ctrl.panel.color.cardColor" ng-change="ctrl.render()" ></spectrum-picker>
+        </span>
+      </div>
+      <div class="gf-form">
+        <label class="gf-form-label width-8">Opacity scale</label>
+        <div class="gf-form-select-wrapper width-12">
+          <select class="input-small gf-form-input" ng-model="ctrl.panel.color.colorScale" ng-options="s for s in ctrl.opacityScales" ng-change="ctrl.render()"></select>
+        </div>
+      </div>
+      <div class="gf-form" ng-if="ctrl.panel.color.colorScale === 'sqrt'">
+        <label class="gf-form-label width-8">Exponent</label>
+        <input type="number" class="gf-form-input width-8" placeholder="auto" data-placement="right" bs-tooltip="''" ng-model="ctrl.panel.color.exponent" ng-change="ctrl.refresh()" ng-model-onblur>
+      </div>
+      <div class="gf-form">
+        <svg id="heatmap-opacity-legend"
+          width="22.7em" height="2em">
+        </svg>
+      </div>
+    </div>
+
+    <div ng-show="ctrl.panel.color.mode === 'color'">
+      <div class="gf-form">
+        <label class="gf-form-label width-8">Color scheme</label>
+        <div class="gf-form-select-wrapper width-12">
+          <select class="input-small gf-form-input" ng-model="ctrl.panel.color.colorScheme" ng-options="s.value as s.name for s in ctrl.colorSchemes" ng-change="ctrl.render()"></select>
+        </div>
+      </div>
+      <div class="gf-form">
+        <svg id="heatmap-color-legend"
+          width="22.7em" height="2em">
+        </svg>
+      </div>
+      <gf-form-switch class="gf-form" label-class="width-10"
+        label="Fill background"
+        checked="ctrl.panel.color.fillBackground" on-change="ctrl.render()">
+      </gf-form-switch>
+    </div>
+  </div>
+
+  <div class="section gf-form-group">
+    <h5 class="section-heading">Cards</h5>
+    <div class="gf-form">
+      <label class="gf-form-label width-8">Space</label>
+      <input type="number" class="gf-form-input width-5" placeholder="auto" data-placement="right" bs-tooltip="''" ng-model="ctrl.panel.cards.cardPadding" ng-change="ctrl.refresh()" ng-model-onblur>
+    </div>
+    <div class="gf-form">
+      <label class="gf-form-label width-8">Round</label>
+      <input type="number" class="gf-form-input width-5" placeholder="auto" data-placement="right" bs-tooltip="''" ng-model="ctrl.panel.cards.cardRound" ng-change="ctrl.refresh()" ng-model-onblur>
+    </div>
+  </div>
+
+  <div class="section gf-form-group">
+    <h5 class="section-heading">Tooltip</h5>
+    <gf-form-switch class="gf-form" label-class="width-8"
+      label="Show tooltip"
+      checked="ctrl.panel.tooltip.show" on-change="ctrl.render()">
+    </gf-form-switch>
+    <div ng-if="ctrl.panel.tooltip.show">
+      <gf-form-switch class="gf-form" label-class="width-8"
+        label="Highlight cards"
+        checked="ctrl.panel.highlightCards" on-change="ctrl.render()">
+      </gf-form-switch>
+      <gf-form-switch class="gf-form" label-class="width-8"
+        label="Series stats"
+        checked="ctrl.panel.tooltip.seriesStat" on-change="ctrl.render()">
+      </gf-form-switch>
+      <gf-form-switch class="gf-form" label-class="width-8"
+        label="Histogram"
+        checked="ctrl.panel.tooltip.showHistogram" on-change="ctrl.render()">
+      </gf-form-switch>
+      <div class="gf-form">
+        <label class="gf-form-label width-8">Decimals</label>
+        <input type="number" class="gf-form-input width-5" placeholder="auto" data-placement="right"
+          bs-tooltip="'Max decimal precision for tooltip.'"
+          ng-model="ctrl.panel.tooltipDecimals" ng-change="ctrl.render()" ng-model-onblur>
+      </div>
+    </div>
+  </div>
+</div>

+ 16 - 0
public/app/plugins/panel/heatmap/plugin.json

@@ -0,0 +1,16 @@
+{
+  "type": "panel",
+  "name": "Heatmap",
+  "id": "heatmap",
+
+  "info": {
+    "author": {
+      "name": "Grafana Project",
+      "url": "https://grafana.com"
+    },
+    "logos": {
+      "small": "img/icn-heatmap-panel.svg",
+      "large": "img/icn-heatmap-panel.svg"
+    }
+  }
+}

+ 861 - 0
public/app/plugins/panel/heatmap/rendering.ts

@@ -0,0 +1,861 @@
+///<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} from 'app/core/core';
+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 = heatmap_elem.clientWidth - padding.right;
+    height = heatmap_elem.clientHeight - 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;
+    });
+
+    setColorScale(max_value);
+    setOpacityScale(max_value);
+    setCardSize();
+
+    if (panel.color.fillBackground && panel.color.mode === 'color') {
+      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 setColorScale(max_value) {
+    let colorInterpolator = d3[panel.color.colorScheme];
+    colorScale = d3.scaleSequential(colorInterpolator).domain([0, max_value]);
+  }
+
+  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 = panel.yAxis.splitFactor || 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 colorInterpolator = d3[panel.color.colorScheme];
+    let legendColorScale = d3.scaleSequential(colorInterpolator).domain([0, 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() {
+    if (!ctrl.data || _.isEmpty(ctrl.data.buckets)) { return; }
+
+    data = ctrl.data;
+    panel = ctrl.panel;
+    timeRange = ctrl.range;
+
+    if (setElementHeight()) {
+      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);
+    }
+
+    // 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";
+}
+
+// Calculate tick step.
+// Implementation from d3-array (ticks.js)
+// https://github.com/d3/d3-array/blob/master/src/ticks.js
+function tickStep(start, stop, count) {
+  var e10 = Math.sqrt(50),
+      e5 = Math.sqrt(10),
+      e2 = Math.sqrt(2);
+
+  var step0 = Math.abs(stop - start) / Math.max(0, count),
+      step1 = Math.pow(10, Math.floor(Math.log(step0) / Math.LN10)),
+      error = step0 / step1;
+
+  if (error >= e10) {
+    step1 *= 10;
+  } else if (error >= e5) {
+    step1 *= 5;
+  } else if (error >= e2) {
+    step1 *= 2;
+  }
+
+  return stop < start ? -step1 : step1;
+}
+
+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);
+}

+ 2 - 1
public/app/system.conf.js

@@ -30,7 +30,8 @@ System.config({
     "jquery.flot.time": "vendor/flot/jquery.flot.time",
     "jquery.flot.time": "vendor/flot/jquery.flot.time",
     "jquery.flot.crosshair": "vendor/flot/jquery.flot.crosshair",
     "jquery.flot.crosshair": "vendor/flot/jquery.flot.crosshair",
     "jquery.flot.fillbelow": "vendor/flot/jquery.flot.fillbelow",
     "jquery.flot.fillbelow": "vendor/flot/jquery.flot.fillbelow",
-    "jquery.flot.gauge": "vendor/flot/jquery.flot.gauge"
+    "jquery.flot.gauge": "vendor/flot/jquery.flot.gauge",
+    "d3": "vendor/d3/d3.js"
   },
   },
 
 
   packages: {
   packages: {

+ 27 - 0
public/vendor/d3/LICENSE

@@ -0,0 +1,27 @@
+Copyright 2010-2016 Mike Bostock
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+* Neither the name of the author nor the names of contributors may be used to
+  endorse or promote products derived from this software without specific prior
+  written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

+ 57 - 0
public/vendor/d3/README.md

@@ -0,0 +1,57 @@
+# D3: Data-Driven Documents
+
+<a href="https://d3js.org"><img src="https://d3js.org/logo.svg" align="left" hspace="10" vspace="6"></a>
+
+**D3** (or **D3.js**) is a JavaScript library for visualizing data using web standards. D3 helps you bring data to life using SVG, Canvas and HTML. D3 combines powerful visualization and interaction techniques with a data-driven approach to DOM manipulation, giving you the full capabilities of modern browsers and the freedom to design the right visual interface for your data.
+
+## Resources
+
+* [API Reference](https://github.com/d3/d3/blob/master/API.md)
+* [Release Notes](https://github.com/d3/d3/releases)
+* [Gallery](https://github.com/d3/d3/wiki/Gallery)
+* [Examples](http://bl.ocks.org/mbostock)
+* [Wiki](https://github.com/d3/d3/wiki)
+
+## Installing
+
+If you use npm, `npm install d3`. Otherwise, download the [latest release](https://github.com/d3/d3/releases/latest). The released bundle supports anonymous AMD, CommonJS, and vanilla environments. You can load directly from [d3js.org](https://d3js.org), [CDNJS](https://cdnjs.com/libraries/d3), or [unpkg](https://unpkg.com/d3/). For example:
+
+```html
+<script src="https://d3js.org/d3.v4.js"></script>
+```
+
+For the minified version:
+
+```html
+<script src="https://d3js.org/d3.v4.min.js"></script>
+```
+
+You can also use the standalone D3 microlibraries. For example, [d3-selection](https://github.com/d3/d3-selection):
+
+```html
+<script src="https://d3js.org/d3-selection.v1.js"></script>
+```
+
+D3 is written using [ES2015 modules](http://www.2ality.com/2014/09/es6-modules-final.html). Create a [custom bundle using Rollup](http://bl.ocks.org/mbostock/bb09af4c39c79cffcde4), Webpack, or your preferred bundler. To import D3 into an ES2015 application, either import specific symbols from specific D3 modules:
+
+```js
+import {scaleLinear} from "d3-scale";
+```
+
+Or import everything into a namespace (here, `d3`):
+
+```js
+import * as d3 from "d3";
+```
+
+In Node:
+
+```js
+var d3 = require("d3");
+```
+
+You can also require individual modules and combine them into a `d3` object using [Object.assign](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign):
+
+```js
+var d3 = Object.assign({}, require("d3-format"), require("d3-geo"), require("d3-geo-projection"));
+```

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 0
public/vendor/d3/d3-scale-chromatic.min.js


+ 3 - 0
public/vendor/d3/d3.js

@@ -0,0 +1,3 @@
+// Import main D3.js module and combine it with another
+var d3 = Object.assign({}, require('./d3.v4.min.js'), require('./d3-scale-chromatic.min.js'));
+module.exports = d3;

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 0
public/vendor/d3/d3.v4.min.js


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است