Browse Source

Merge branch 'master' into sql-proxy

Torkel Ödegaard 8 years ago
parent
commit
26804d630f
48 changed files with 3414 additions and 32 deletions
  1. 4 0
      CHANGELOG.md
  2. 3 3
      conf/defaults.ini
  3. 1 1
      conf/ldap.toml
  4. 1 1
      docs/sources/http_api/dashboard.md
  5. 1 1
      docs/sources/installation/ldap.md
  6. 1 1
      docs/sources/plugins/developing/development.md
  7. 2 2
      latest.json
  8. 3 0
      pkg/services/sqlstore/sqlstore.go
  9. 9 4
      pkg/tsdb/influxdb/query.go
  10. 1 0
      public/app/core/services/context_srv.ts
  11. 27 0
      public/app/core/utils/ticks.ts
  12. 1 1
      public/app/features/alerting/partials/alert_tab.html
  13. 14 2
      public/app/features/templating/adhoc_variable.ts
  14. 7 2
      public/app/features/templating/specs/adhoc_variable_specs.ts
  15. 5 0
      public/app/headers/common.d.ts
  16. 11 5
      public/app/plugins/panel/graph/axes_editor.html
  17. 1 0
      public/app/plugins/panel/graph/axes_editor.ts
  18. 13 0
      public/app/plugins/panel/graph/data_processor.ts
  19. 58 1
      public/app/plugins/panel/graph/graph.ts
  20. 48 0
      public/app/plugins/panel/graph/histogram.ts
  21. 1 0
      public/app/plugins/panel/graph/module.ts
  22. 65 0
      public/app/plugins/panel/graph/specs/histogram_specs.ts
  23. 0 0
      public/app/plugins/panel/heatmap/README.md
  24. 49 0
      public/app/plugins/panel/heatmap/axes_editor.ts
  25. 26 0
      public/app/plugins/panel/heatmap/display_editor.ts
  26. 282 0
      public/app/plugins/panel/heatmap/heatmap_ctrl.ts
  27. 526 0
      public/app/plugins/panel/heatmap/heatmap_data_converter.ts
  28. 250 0
      public/app/plugins/panel/heatmap/heatmap_tooltip.ts
  29. 195 0
      public/app/plugins/panel/heatmap/img/icn-heatmap-panel.svg
  30. 12 0
      public/app/plugins/panel/heatmap/module.html
  31. 7 0
      public/app/plugins/panel/heatmap/module.ts
  32. 95 0
      public/app/plugins/panel/heatmap/partials/axes_editor.html
  33. 87 0
      public/app/plugins/panel/heatmap/partials/display_editor.html
  34. 16 0
      public/app/plugins/panel/heatmap/plugin.json
  35. 857 0
      public/app/plugins/panel/heatmap/rendering.ts
  36. 76 0
      public/app/plugins/panel/heatmap/specs/heatmap_ctrl_specs.ts
  37. 253 0
      public/app/plugins/panel/heatmap/specs/heatmap_data_converter_specs.ts
  38. 267 0
      public/app/plugins/panel/heatmap/specs/renderer_specs.ts
  39. 6 6
      public/app/plugins/panel/table/module.html
  40. 2 1
      public/app/system.conf.js
  41. 1 0
      public/sass/_grafana.scss
  42. 39 0
      public/sass/components/_panel_heatmap.scss
  43. 2 1
      public/test/test-main.js
  44. 27 0
      public/vendor/d3/LICENSE
  45. 57 0
      public/vendor/d3/README.md
  46. 1 0
      public/vendor/d3/d3-scale-chromatic.min.js
  47. 3 0
      public/vendor/d3/d3.js
  48. 1 0
      public/vendor/d3/d3.v4.min.js

+ 4 - 0
CHANGELOG.md

@@ -15,6 +15,10 @@
 * **Cloudwatch**: Correctly obtain IAM roles within ECS container tasks [#7892](https://github.com/grafana/grafana/issues/7892) thx [@gomlgs](https://github.com/gomlgs)
 * **Cloudwatch**: Correctly obtain IAM roles within ECS container tasks [#7892](https://github.com/grafana/grafana/issues/7892) thx [@gomlgs](https://github.com/gomlgs)
 * **Units**: New number format: Scientific notation [#7781](https://github.com/grafana/grafana/issues/7781) thx [@cadnce](https://github.com/cadnce)
 * **Units**: New number format: Scientific notation [#7781](https://github.com/grafana/grafana/issues/7781) thx [@cadnce](https://github.com/cadnce)
 * **Oauth**: Add common type for oauth authorization errors [#6428](https://github.com/grafana/grafana/issues/6428) thx [@amenzhinsky](https://github.com/amenzhinsky)
 * **Oauth**: Add common type for oauth authorization errors [#6428](https://github.com/grafana/grafana/issues/6428) thx [@amenzhinsky](https://github.com/amenzhinsky)
+* **Templating**: Data source variable now supports multi value and panel repeats [#7030](https://github.com/grafana/grafana/issues/7030) thx [@mtanda](https://github.com/mtanda)
+
+## Fixes
+* **Table Panel**: Fixed annotation display in table panel, [#8023](https://github.com/grafana/grafana/issues/8023)
 
 
 # 4.2.0 (2017-03-22)
 # 4.2.0 (2017-03-22)
 ## Minor Enhancements
 ## Minor Enhancements

+ 3 - 3
conf/defaults.ini

@@ -60,14 +60,14 @@ cert_key =
 #################################### Database ############################
 #################################### Database ############################
 [database]
 [database]
 # You can configure the database connection by specifying type, host, name, user and password
 # You can configure the database connection by specifying type, host, name, user and password
-# as seperate properties or as on string using the url propertie.
+# as separate properties or as on string using the url property.
 
 
 # Either "mysql", "postgres" or "sqlite3", it's your choice
 # Either "mysql", "postgres" or "sqlite3", it's your choice
 type = sqlite3
 type = sqlite3
 host = 127.0.0.1:3306
 host = 127.0.0.1:3306
 name = grafana
 name = grafana
 user = root
 user = root
-# If the password contains # or ; you have to wrap it with trippel quotes. Ex """#password;"""
+# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;"""
 password =
 password =
 # Use either URL or the previous fields to configure the database
 # Use either URL or the previous fields to configure the database
 # Example: mysql://user:secret@host:port/database
 # Example: mysql://user:secret@host:port/database
@@ -132,7 +132,7 @@ logging = false
 reporting_enabled = true
 reporting_enabled = true
 
 
 # Set to false to disable all checks to https://grafana.com
 # Set to false to disable all checks to https://grafana.com
-# for new vesions (grafana itself and plugins), check is used
+# for new versions (grafana itself and plugins), check is used
 # in some UI views to notify that grafana or plugin update exists
 # in some UI views to notify that grafana or plugin update exists
 # This option does not cause any auto updates, nor send any information
 # This option does not cause any auto updates, nor send any information
 # only a GET request to https://grafana.com to get latest versions
 # only a GET request to https://grafana.com to get latest versions

+ 1 - 1
conf/ldap.toml

@@ -14,7 +14,7 @@ start_tls = false
 # set to true if you want to skip ssl cert validation
 # set to true if you want to skip ssl cert validation
 ssl_skip_verify = false
 ssl_skip_verify = false
 # set to the path to your root CA certificate or leave unset to use system defaults
 # set to the path to your root CA certificate or leave unset to use system defaults
-# root_ca_cert = /path/to/certificate.crt
+# root_ca_cert = "/path/to/certificate.crt"
 
 
 # Search user bind dn
 # Search user bind dn
 bind_dn = "cn=admin,dc=grafana,dc=org"
 bind_dn = "cn=admin,dc=grafana,dc=org"

+ 1 - 1
docs/sources/http_api/dashboard.md

@@ -232,7 +232,7 @@ Get all tags of dashboards
 Status Codes:
 Status Codes:
 
 
 - **query** – Search Query
 - **query** – Search Query
-- **tags** – Tags to use
+- **tag** – Tag to use
 - **starred** – Flag indicating if only starred Dashboards should be returned
 - **starred** – Flag indicating if only starred Dashboards should be returned
 - **tagcloud** - Flag indicating if a tagcloud should be returned
 - **tagcloud** - Flag indicating if a tagcloud should be returned
 
 

+ 1 - 1
docs/sources/installation/ldap.md

@@ -38,7 +38,7 @@ start_tls = false
 # set to true if you want to skip ssl cert validation
 # set to true if you want to skip ssl cert validation
 ssl_skip_verify = false
 ssl_skip_verify = false
 # set to the path to your root CA certificate or leave unset to use system defaults
 # set to the path to your root CA certificate or leave unset to use system defaults
-# root_ca_cert = /path/to/certificate.crt
+# root_ca_cert = "/path/to/certificate.crt"
 
 
 # Search user bind dn
 # Search user bind dn
 bind_dn = "cn=admin,dc=grafana,dc=org"
 bind_dn = "cn=admin,dc=grafana,dc=org"

+ 1 - 1
docs/sources/plugins/developing/development.md

@@ -17,7 +17,7 @@ There are two blog posts about authoring a plugin that might also be of interest
 ## Short version
 ## Short version
 
 
 1. [Setup grafana](http://docs.grafana.org/project/building_from_source/)
 1. [Setup grafana](http://docs.grafana.org/project/building_from_source/)
-2. Clone an example plugin into ```/var/lib/grafana/plugins```  or `data/plugins` (relative to grafana git repo if your running development version from source dir)
+2. Clone an example plugin into ```/var/lib/grafana/plugins```  or `data/plugins` (relative to grafana git repo if you're running development version from source dir)
 3. Code away!
 3. Code away!
 
 
 ## What languages?
 ## What languages?

+ 2 - 2
latest.json

@@ -1,4 +1,4 @@
 {
 {
-  "stable": "4.1.1",
-	"testing": "4.1.1"
+  "stable": "4.2.0",
+	"testing": "4.2.0"
 }
 }

+ 3 - 0
pkg/services/sqlstore/sqlstore.go

@@ -198,6 +198,9 @@ func LoadConfig() {
 
 
 	if DbCfg.Type == "sqlite3" {
 	if DbCfg.Type == "sqlite3" {
 		UseSQLite3 = true
 		UseSQLite3 = true
+		// only allow one connection as sqlite3 has multi threading issues that casue table locks
+		// DbCfg.MaxIdleConn = 1
+		// DbCfg.MaxOpenConn = 1
 	}
 	}
 	DbCfg.SslMode = sec.Key("ssl_mode").String()
 	DbCfg.SslMode = sec.Key("ssl_mode").String()
 	DbCfg.CaCertPath = sec.Key("ca_cert_path").String()
 	DbCfg.CaCertPath = sec.Key("ca_cert_path").String()

+ 9 - 4
pkg/tsdb/influxdb/query.go

@@ -34,13 +34,18 @@ func (query *Query) Build(queryContext *tsdb.QueryContext) (string, error) {
 		return "", err
 		return "", err
 	}
 	}
 
 
-	res = strings.Replace(res, "$timeFilter", query.renderTimeFilter(queryContext), 1)
-	res = strings.Replace(res, "$interval", interval.Text, 1)
-	res = strings.Replace(res, "$__interval_ms", strconv.FormatInt(interval.Value.Nanoseconds()/int64(time.Millisecond), 10), 1)
-	res = strings.Replace(res, "$__interval", interval.Text, 1)
+	res = replaceVariable(res, "$timeFilter", query.renderTimeFilter(queryContext))
+	res = replaceVariable(res, "$interval", interval.Text)
+	res = replaceVariable(res, "$__interval_ms", strconv.FormatInt(interval.Value.Nanoseconds()/int64(time.Millisecond), 10))
+	res = replaceVariable(res, "$__interval", interval.Text)
 	return res, nil
 	return res, nil
 }
 }
 
 
+func replaceVariable(str string, variable string, value string) string {
+	count := strings.Count(str, variable)
+	return strings.Replace(str, variable, value, count)
+}
+
 func getDefinedInterval(query *Query, queryContext *tsdb.QueryContext) (*tsdb.Interval, error) {
 func getDefinedInterval(query *Query, queryContext *tsdb.QueryContext) (*tsdb.Interval, error) {
 	defaultInterval := tsdb.CalculateInterval(queryContext.TimeRange)
 	defaultInterval := tsdb.CalculateInterval(queryContext.TimeRange)
 
 

+ 1 - 0
public/app/core/services/context_srv.ts

@@ -11,6 +11,7 @@ export class User {
   orgRole: any;
   orgRole: any;
   timezone: string;
   timezone: string;
   helpFlags1: number;
   helpFlags1: number;
+  lightTheme: boolean;
 
 
   constructor() {
   constructor() {
     if (config.bootData.user) {
     if (config.bootData.user) {

+ 27 - 0
public/app/core/utils/ticks.ts

@@ -0,0 +1,27 @@
+/**
+ * Calculate tick step.
+ * Implementation from d3-array (ticks.js)
+ * https://github.com/d3/d3-array/blob/master/src/ticks.js
+ * @param start Start value
+ * @param stop End value
+ * @param count Ticks count
+ */
+export function tickStep(start: number, stop: number, count: number): number {
+  let e10 = Math.sqrt(50),
+    e5 = Math.sqrt(10),
+    e2 = Math.sqrt(2);
+
+  let 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;
+}

+ 1 - 1
public/app/features/alerting/partials/alert_tab.html

@@ -129,7 +129,7 @@
 			</div>
 			</div>
 			<div class="gf-form gf-form--v-stretch">
 			<div class="gf-form gf-form--v-stretch">
 				<span class="gf-form-label width-8">Message</span>
 				<span class="gf-form-label width-8">Message</span>
-				<textarea class="gf-form-input width-20" rows="10" ng-model="ctrl.alert.message"  placeholder="Notification message details..."></textarea>
+				<textarea class="gf-form-input" rows="10" ng-model="ctrl.alert.message"  placeholder="Notification message details..."></textarea>
 			</div>
 			</div>
 		</div>
 		</div>
 
 

+ 14 - 2
public/app/features/templating/adhoc_variable.ts

@@ -45,7 +45,9 @@ export class AdhocVariable implements Variable {
     }
     }
 
 
     this.filters = urlValue.map(item => {
     this.filters = urlValue.map(item => {
-      var values = item.split('|');
+      var values = item.split('|').map(value => {
+        return this.unescapeDelimiter(value);
+      });
       return {
       return {
         key: values[0],
         key: values[0],
         operator: values[1],
         operator: values[1],
@@ -58,10 +60,20 @@ export class AdhocVariable implements Variable {
 
 
   getValueForUrl() {
   getValueForUrl() {
     return this.filters.map(filter => {
     return this.filters.map(filter => {
-      return filter.key + '|' + filter.operator + '|' + filter.value;
+      return [filter.key, filter.operator, filter.value].map(value => {
+        return this.escapeDelimiter(value);
+      }).join('|');
     });
     });
   }
   }
 
 
+  escapeDelimiter(value) {
+    return value.replace('|', '__gfp__');
+  }
+
+  unescapeDelimiter(value) {
+    return value.replace('__gfp__', '|');
+  }
+
   setFilters(filters: any[]) {
   setFilters(filters: any[]) {
     this.filters = filters;
     this.filters = filters;
   }
   }

+ 7 - 2
public/app/features/templating/specs/adhoc_variable_specs.ts

@@ -11,10 +11,11 @@ describe('AdhocVariable', function() {
         filters: [
         filters: [
           {key: 'key1', operator: '=', value: 'value1'},
           {key: 'key1', operator: '=', value: 'value1'},
           {key: 'key2', operator: '!=', value: 'value2'},
           {key: 'key2', operator: '!=', value: 'value2'},
+          {key: 'key3', operator: '=', value: 'value3a|value3b'},
         ]
         ]
       });
       });
       var urlValue = variable.getValueForUrl();
       var urlValue = variable.getValueForUrl();
-      expect(urlValue).to.eql(["key1|=|value1", "key2|!=|value2"]);
+      expect(urlValue).to.eql(["key1|=|value1", "key2|!=|value2", "key3|=|value3a__gfp__value3b"]);
     });
     });
 
 
   });
   });
@@ -23,7 +24,7 @@ describe('AdhocVariable', function() {
 
 
     it('should restore filters', function() {
     it('should restore filters', function() {
       var variable = new AdhocVariable({});
       var variable = new AdhocVariable({});
-      variable.setValueFromUrl(["key1|=|value1", "key2|!=|value2"]);
+      variable.setValueFromUrl(["key1|=|value1", "key2|!=|value2", "key3|=|value3a__gfp__value3b"]);
 
 
       expect(variable.filters[0].key).to.be('key1');
       expect(variable.filters[0].key).to.be('key1');
       expect(variable.filters[0].operator).to.be('=');
       expect(variable.filters[0].operator).to.be('=');
@@ -32,6 +33,10 @@ describe('AdhocVariable', function() {
       expect(variable.filters[1].key).to.be('key2');
       expect(variable.filters[1].key).to.be('key2');
       expect(variable.filters[1].operator).to.be('!=');
       expect(variable.filters[1].operator).to.be('!=');
       expect(variable.filters[1].value).to.be('value2');
       expect(variable.filters[1].value).to.be('value2');
+
+      expect(variable.filters[2].key).to.be('key3');
+      expect(variable.filters[2].operator).to.be('=');
+      expect(variable.filters[2].value).to.be('value3a|value3b');
     });
     });
 
 
   });
   });

+ 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;
+}

+ 11 - 5
public/app/plugins/panel/graph/axes_editor.html

@@ -39,10 +39,10 @@
 
 
 	<div class="section gf-form-group">
 	<div class="section gf-form-group">
 		<h5 class="section-heading">X-Axis</h5>
 		<h5 class="section-heading">X-Axis</h5>
-		<gf-form-switch class="gf-form" label="Show" label-class="width-5" checked="ctrl.panel.xaxis.show" on-change="ctrl.render()"></gf-form-switch>
+		<gf-form-switch class="gf-form" label="Show" label-class="width-6" checked="ctrl.panel.xaxis.show" on-change="ctrl.render()"></gf-form-switch>
 
 
 		<div class="gf-form">
 		<div class="gf-form">
-			<label class="gf-form-label width-5">Mode</label>
+			<label class="gf-form-label width-6">Mode</label>
 			<div class="gf-form-select-wrapper max-width-15">
 			<div class="gf-form-select-wrapper max-width-15">
 				<select class="gf-form-input" ng-model="ctrl.panel.xaxis.mode" ng-options="v as k for (k, v) in ctrl.xAxisModes" ng-change="ctrl.xAxisOptionChanged()"> </select>
 				<select class="gf-form-input" ng-model="ctrl.panel.xaxis.mode" ng-options="v as k for (k, v) in ctrl.xAxisModes" ng-change="ctrl.xAxisOptionChanged()"> </select>
 			</div>
 			</div>
@@ -50,22 +50,28 @@
 
 
     <!-- Table mode -->
     <!-- Table mode -->
 		<div class="gf-form" ng-if="ctrl.panel.xaxis.mode === 'field'">
 		<div class="gf-form" ng-if="ctrl.panel.xaxis.mode === 'field'">
-			<label class="gf-form-label width-5">Name</label>
+			<label class="gf-form-label width-6">Name</label>
       <metric-segment-model property="ctrl.panel.xaxis.name" get-options="ctrl.getDataFieldNames(false)" on-change="ctrl.xAxisOptionChanged()" custom="false" css-class="width-10" select-mode="true"></metric-segment-model>
       <metric-segment-model property="ctrl.panel.xaxis.name" get-options="ctrl.getDataFieldNames(false)" on-change="ctrl.xAxisOptionChanged()" custom="false" css-class="width-10" select-mode="true"></metric-segment-model>
 		</div>
 		</div>
 
 
     <!-- Series mode -->
     <!-- Series mode -->
 		<div class="gf-form" ng-if="ctrl.panel.xaxis.mode === 'field'">
 		<div class="gf-form" ng-if="ctrl.panel.xaxis.mode === 'field'">
-			<label class="gf-form-label width-5">Value</label>
+			<label class="gf-form-label width-6">Value</label>
       <metric-segment-model property="ctrl.panel.xaxis.values[0]" get-options="ctrl.getDataFieldNames(true)" on-change="ctrl.xAxisOptionChanged()" custom="false" css-class="width-10" select-mode="true"></metric-segment-model>
       <metric-segment-model property="ctrl.panel.xaxis.values[0]" get-options="ctrl.getDataFieldNames(true)" on-change="ctrl.xAxisOptionChanged()" custom="false" css-class="width-10" select-mode="true"></metric-segment-model>
 		</div>
 		</div>
 
 
 		<!-- Series mode -->
 		<!-- Series mode -->
 		<div class="gf-form" ng-if="ctrl.panel.xaxis.mode === 'series'">
 		<div class="gf-form" ng-if="ctrl.panel.xaxis.mode === 'series'">
-			<label class="gf-form-label width-5">Value</label>
+			<label class="gf-form-label width-6">Value</label>
       <metric-segment-model property="ctrl.panel.xaxis.values[0]" options="ctrl.xAxisStatOptions" on-change="ctrl.xAxisOptionChanged()" custom="false" css-class="width-10" select-mode="true"></metric-segment-model>
       <metric-segment-model property="ctrl.panel.xaxis.values[0]" options="ctrl.xAxisStatOptions" on-change="ctrl.xAxisOptionChanged()" custom="false" css-class="width-10" select-mode="true"></metric-segment-model>
 		</div>
 		</div>
 
 
+		<!-- Histogram mode -->
+		<div class="gf-form" ng-if="ctrl.panel.xaxis.mode === 'histogram'">
+			<label class="gf-form-label width-6">Buckets</label>
+			<input type="number" class="gf-form-input max-width-8" ng-model="ctrl.panel.xaxis.buckets" placeholder="auto" ng-change="ctrl.render()" ng-model-onblur bs-tooltip="'Number of buckets'" data-placement="right">
+		</div>
+
 	</div>
 	</div>
 
 
 </div>
 </div>

+ 1 - 0
public/app/plugins/panel/graph/axes_editor.ts

@@ -30,6 +30,7 @@ export class AxesEditorCtrl {
     this.xAxisModes = {
     this.xAxisModes = {
       'Time': 'time',
       'Time': 'time',
       'Series': 'series',
       'Series': 'series',
+      'Histogram': 'histogram'
       // 'Data field': 'field',
       // 'Data field': 'field',
     };
     };
 
 

+ 13 - 0
public/app/plugins/panel/graph/data_processor.ts

@@ -29,6 +29,7 @@ export class DataProcessor {
 
 
     switch (this.panel.xaxis.mode) {
     switch (this.panel.xaxis.mode) {
       case 'series':
       case 'series':
+      case 'histogram':
       case 'time': {
       case 'time': {
         return options.dataList.map((item, index) => {
         return options.dataList.map((item, index) => {
           return this.timeSeriesHandler(item, index, options);
           return this.timeSeriesHandler(item, index, options);
@@ -48,6 +49,9 @@ export class DataProcessor {
         if (this.panel.xaxis.mode === 'series') {
         if (this.panel.xaxis.mode === 'series') {
           return 'series';
           return 'series';
         }
         }
+        if (this.panel.xaxis.mode === 'histogram') {
+          return 'histogram';
+        }
         return 'time';
         return 'time';
       }
       }
     }
     }
@@ -74,6 +78,15 @@ export class DataProcessor {
         this.panel.xaxis.values = ['total'];
         this.panel.xaxis.values = ['total'];
         break;
         break;
       }
       }
+      case 'histogram': {
+        this.panel.bars = true;
+        this.panel.lines = false;
+        this.panel.points = false;
+        this.panel.stack = false;
+        this.panel.legend.show = false;
+        this.panel.tooltip.shared = false;
+        break;
+      }
     }
     }
   }
   }
 
 

+ 58 - 1
public/app/plugins/panel/graph/graph.ts

@@ -12,10 +12,12 @@ import './jquery.flot.events';
 import $ from 'jquery';
 import $ from 'jquery';
 import _ from 'lodash';
 import _ from 'lodash';
 import moment from 'moment';
 import moment from 'moment';
-import kbn from   'app/core/utils/kbn';
+import kbn from 'app/core/utils/kbn';
+import {tickStep} from 'app/core/utils/ticks';
 import {appEvents, coreModule} from 'app/core/core';
 import {appEvents, coreModule} from 'app/core/core';
 import GraphTooltip from './graph_tooltip';
 import GraphTooltip from './graph_tooltip';
 import {ThresholdManager} from './threshold_manager';
 import {ThresholdManager} from './threshold_manager';
+import {convertValuesToHistogram, getSeriesValues} from './histogram';
 
 
 coreModule.directive('grafanaGraph', function($rootScope, timeSrv) {
 coreModule.directive('grafanaGraph', function($rootScope, timeSrv) {
   return {
   return {
@@ -290,6 +292,29 @@ coreModule.directive('grafanaGraph', function($rootScope, timeSrv) {
             addXSeriesAxis(options);
             addXSeriesAxis(options);
             break;
             break;
           }
           }
+          case 'histogram': {
+            let bucketSize: number;
+            let values = getSeriesValues(data);
+
+            if (data.length && values.length) {
+              let histMin = _.min(_.map(data, s => s.stats.min));
+              let histMax = _.max(_.map(data, s => s.stats.max));
+              let ticks = panel.xaxis.buckets || panelWidth / 50;
+              bucketSize = tickStep(histMin, histMax, ticks);
+              let histogram = convertValuesToHistogram(values, bucketSize);
+
+              data[0].data = histogram;
+              data[0].alias = data[0].label = data[0].id = "count";
+              data = [data[0]];
+
+              options.series.bars.barWidth = bucketSize * 0.8;
+            } else {
+              bucketSize = 0;
+            }
+
+            addXHistogramAxis(options, bucketSize);
+            break;
+          }
           case 'table': {
           case 'table': {
             options.series.bars.barWidth = 0.7;
             options.series.bars.barWidth = 0.7;
             options.series.bars.align = 'center';
             options.series.bars.align = 'center';
@@ -384,6 +409,38 @@ coreModule.directive('grafanaGraph', function($rootScope, timeSrv) {
         };
         };
       }
       }
 
 
+      function addXHistogramAxis(options, bucketSize) {
+        let ticks, min, max;
+
+        if (data.length) {
+          ticks = _.map(data[0].data, point => point[0]);
+
+          // Expand ticks for pretty view
+          min = Math.max(0, _.min(ticks) - bucketSize);
+          max = _.max(ticks) + bucketSize;
+
+          ticks = [];
+          for (let i = min; i <= max; i += bucketSize) {
+            ticks.push(i);
+          }
+        } else {
+          // Set defaults if no data
+          ticks = panelWidth / 100;
+          min = 0;
+          max = 1;
+        }
+
+        options.xaxis = {
+          timezone: dashboard.getTimezone(),
+          show: panel.xaxis.show,
+          mode: null,
+          min: min,
+          max: max,
+          label: "Histogram",
+          ticks: ticks
+        };
+      }
+
       function addXTableAxis(options) {
       function addXTableAxis(options) {
         var ticks = _.map(data, function(series, seriesIndex) {
         var ticks = _.map(data, function(series, seriesIndex) {
           return _.map(series.datapoints, function(point, pointIndex) {
           return _.map(series.datapoints, function(point, pointIndex) {

+ 48 - 0
public/app/plugins/panel/graph/histogram.ts

@@ -0,0 +1,48 @@
+import _ from 'lodash';
+
+/**
+ * Convert series into array of series values.
+ * @param data Array of series
+ */
+export function getSeriesValues(data: any): number[] {
+  let values = [];
+
+  // Count histogam stats
+  for (let i = 0; i < data.length; i++) {
+    let series = data[i];
+    for (let j = 0; j < series.data.length; j++) {
+      if (series.data[j][1] !== null) {
+        values.push(series.data[j][1]);
+      }
+    }
+  }
+
+  return values;
+}
+
+/**
+ * Convert array of values into timeseries-like histogram:
+ * [[val_1, count_1], [val_2, count_2], ..., [val_n, count_n]]
+ * @param values
+ * @param bucketSize
+ */
+export function convertValuesToHistogram(values: number[], bucketSize: number): any[] {
+  let histogram = {};
+
+  for (let i = 0; i < values.length; i++) {
+    let bound = getBucketBound(values[i], bucketSize);
+    if (histogram[bound]) {
+      histogram[bound] = histogram[bound] + 1;
+    } else {
+      histogram[bound] = 1;
+    }
+  }
+
+  return _.map(histogram, (count, bound) => {
+    return [Number(bound), count];
+  });
+}
+
+function getBucketBound(value: number, bucketSize: number): number {
+  return Math.floor(value / bucketSize) * bucketSize;
+}

+ 1 - 0
public/app/plugins/panel/graph/module.ts

@@ -59,6 +59,7 @@ class GraphCtrl extends MetricsPanelCtrl {
       mode: 'time',
       mode: 'time',
       name: null,
       name: null,
       values: [],
       values: [],
+      buckets: null
     },
     },
     // show/hide lines
     // show/hide lines
     lines         : true,
     lines         : true,

+ 65 - 0
public/app/plugins/panel/graph/specs/histogram_specs.ts

@@ -0,0 +1,65 @@
+///<reference path="../../../../headers/common.d.ts" />
+
+import { describe, beforeEach, it, expect } from '../../../../../test/lib/common';
+
+import { convertValuesToHistogram, getSeriesValues } from '../histogram';
+
+describe('Graph Histogam Converter', function () {
+
+  describe('Values to histogram converter', () => {
+    let values;
+    let bucketSize = 10;
+
+    beforeEach(() => {
+      values = [1, 2, 10, 11, 17, 20, 29];
+    });
+
+    it('Should convert to series-like array', () => {
+      bucketSize = 10;
+      let expected = [
+        [0, 2], [10, 3], [20, 2]
+      ];
+
+      let histogram = convertValuesToHistogram(values, bucketSize);
+      expect(histogram).to.eql(expected);
+    });
+
+    it('Should not add empty buckets', () => {
+      bucketSize = 5;
+      let expected = [
+        [0, 2], [10, 2], [15, 1], [20, 1], [25, 1]
+      ];
+
+      let histogram = convertValuesToHistogram(values, bucketSize);
+      expect(histogram).to.eql(expected);
+    });
+  });
+
+  describe('Series to values converter', () => {
+    let data;
+
+    beforeEach(() => {
+      data = [
+        {
+          data: [[0, 1], [0, 2], [0, 10], [0, 11], [0, 17], [0, 20], [0, 29]]
+        }
+      ];
+    });
+
+    it('Should convert to values array', () => {
+      let expected = [1, 2, 10, 11, 17, 20, 29];
+
+      let values = getSeriesValues(data);
+      expect(values).to.eql(expected);
+    });
+
+    it('Should skip null values', () => {
+      data[0].data.push([0, null]);
+
+      let expected = [1, 2, 10, 11, 17, 20, 29];
+
+      let values = getSeriesValues(data);
+      expect(values).to.eql(expected);
+    });
+  });
+});

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


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

@@ -0,0 +1,49 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import kbn from 'app/core/utils/kbn';
+
+export class AxesEditorCtrl {
+  panel: any;
+  panelCtrl: any;
+  unitFormats: any;
+  logScales: any;
+  dataFormats: 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
+    };
+
+    this.dataFormats = {
+      'Timeseries': 'timeseries',
+      'ES histogram': 'es_histogram'
+    };
+  }
+
+  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,
+  };
+}

+ 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,
+  };
+}

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

@@ -0,0 +1,282 @@
+///<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, elasticHistogramToHeatmap, calculateBucketSize, 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: 'spectrum',
+    cardColor: '#b4ff00',
+    colorScale: 'sqrt',
+    exponent: 0.5,
+    colorScheme: 'interpolateOranges',
+    fillBackground: false
+  },
+  dataFormat: 'timeseries',
+  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', 'spectrum'];
+let opacityScales = ['linear', 'sqrt'];
+
+// Schemes from d3-scale-chromatic
+// https://github.com/d3/d3-scale-chromatic
+let colorSchemes = [
+  // Diverging
+  {name: 'Spectral',  value: 'interpolateSpectral', invert: 'always'},
+  {name: 'RdYlGn',    value: 'interpolateRdYlGn',   invert: 'always'},
+
+  // Sequential (Single Hue)
+  {name: 'Blues',     value: 'interpolateBlues',    invert: 'dark'},
+  {name: 'Greens',    value: 'interpolateGreens',   invert: 'dark'},
+  {name: 'Greys',     value: 'interpolateGreys',    invert: 'dark'},
+  {name: 'Oranges',   value: 'interpolateOranges',  invert: 'dark'},
+  {name: 'Purples',   value: 'interpolatePurples',  invert: 'dark'},
+  {name: 'Reds',      value: 'interpolateReds',     invert: 'dark'},
+
+  // Sequential (Multi-Hue)
+  {name: 'BuGn',    value: 'interpolateBuGn',       invert: 'dark'},
+  {name: 'BuPu',    value: 'interpolateBuPu',       invert: 'dark'},
+  {name: 'GnBu',    value: 'interpolateGnBu',       invert: 'dark'},
+  {name: 'OrRd',    value: 'interpolateOrRd',       invert: 'dark'},
+  {name: 'PuBuGn',  value: 'interpolatePuBuGn',     invert: 'dark'},
+  {name: 'PuBu',    value: 'interpolatePuBu',       invert: 'dark'},
+  {name: 'PuRd',    value: 'interpolatePuRd',       invert: 'dark'},
+  {name: 'RdPu',    value: 'interpolateRdPu',       invert: 'dark'},
+  {name: 'YlGnBu',  value: 'interpolateYlGnBu',     invert: 'dark'},
+  {name: 'YlGn',    value: 'interpolateYlGn',       invert: 'dark'},
+  {name: 'YlOrBr',  value: 'interpolateYlOrBr',     invert: 'dark'},
+  {name: 'YlOrRd',  value: 'interpolateYlOrRd',     invert: 'darm'}
+];
+
+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;
+  dataWarning: any;
+
+  /** @ngInject */
+  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, heatmapStats, bucketsData;
+    let logBase = this.panel.yAxis.logBase;
+
+    if (this.panel.dataFormat === 'es_histogram') {
+      heatmapStats = this.parseHistogramSeries(this.series);
+      bucketsData = elasticHistogramToHeatmap(this.series);
+
+      // Calculate bucket size based on ES heatmap data
+      let xBucketBoundSet = _.map(_.keys(bucketsData), key => Number(key));
+      let yBucketBoundSet = _.map(this.series, series => Number(series.alias));
+      xBucketSize = calculateBucketSize(xBucketBoundSet);
+      yBucketSize = calculateBucketSize(yBucketBoundSet, logBase);
+      if (logBase !== 1) {
+        // Use yBucketSize in meaning of "Split factor" for log scales
+        yBucketSize = 1 / yBucketSize;
+      }
+    } else {
+      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
+      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) {
+          if (heatmapStats.max) {
+            yBucketSize = heatmapStats.max / Y_BUCKET_NUMBER_DEFAULT;
+          } else {
+            yBucketSize = 1;
+          }
+        } else {
+          yBucketSize = (heatmapStats.max - heatmapStats.min) / yBucketNumber;
+        }
+        yBucketSize = this.panel.yBucketSize || yBucketSize;
+      }
+
+      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.dataWarning = null;
+    const datapointsCount = _.reduce(this.series, (sum, series) => {
+      return sum + series.datapoints.length;
+    }, 0);
+
+    if (datapointsCount === 0) {
+      this.dataWarning = {
+        title: 'No data points',
+        tip: 'No datapoints returned from data query'
+      };
+    } else {
+      for (let series of this.series) {
+        if (series.isOutsideRange) {
+          this.dataWarning = {
+            title: 'Data points outside time range',
+            tip: 'Can be caused by timezone mismatch or missing time filter in query',
+          };
+          break;
+        }
+      }
+    }
+
+    this.render();
+  }
+
+  onDataError() {
+    this.series = [];
+    this.render();
+  }
+
+  seriesHandler(seriesData) {
+    let series = new TimeSeries({
+      datapoints: seriesData.datapoints,
+      alias: seriesData.target
+    });
+
+    series.flotpairs = series.getFlotPairs(this.panel.nullPointMode);
+    series.minLog = getMinLog(series);
+
+    let datapoints = seriesData.datapoints || [];
+    if (datapoints && datapoints.length > 0) {
+      let last = datapoints[datapoints.length - 1][1];
+      let from = this.range.from;
+      if (last - from < -10000) {
+        series.isOutsideRange = true;
+      }
+    }
+
+    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
+    };
+  }
+
+  parseHistogramSeries(series) {
+    let bounds = _.map(series, s => Number(s.alias));
+    let min = _.min(bounds);
+    let minLog = _.min(bounds);
+    let max = _.max(bounds);
+
+    return {
+      max: max,
+      min: min,
+      minLog: minLog
+    };
+  }
+
+  link(scope, elem, attrs, ctrl) {
+    rendering(scope, elem, attrs, ctrl);
+  }
+}

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

@@ -0,0 +1,526 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import _ from 'lodash';
+import TimeSeries from 'app/core/time_series2';
+
+let VALUE_INDEX = 0;
+let TIME_INDEX = 1;
+
+interface XBucket {
+  x: number;
+  buckets: any;
+}
+
+interface YBucket {
+  y: number;
+  values: number[];
+}
+
+function elasticHistogramToHeatmap(series) {
+  let seriesBuckets = _.map(series, (s: TimeSeries) => {
+    return convertEsSeriesToHeatmap(s);
+  });
+  let buckets = mergeBuckets(seriesBuckets);
+  return buckets;
+}
+
+function convertEsSeriesToHeatmap(series: TimeSeries, saveZeroCounts = false) {
+  let xBuckets: XBucket[] = [];
+
+  _.forEach(series.datapoints, point => {
+    let bound = series.alias;
+    let count = point[VALUE_INDEX];
+
+    if (!count) {
+      return;
+    }
+
+    let values = new Array(Math.round(count));
+    values.fill(Number(bound));
+
+    let valueBuckets = {};
+    valueBuckets[bound] = {
+      y: Number(bound),
+      values: values
+    };
+
+    let xBucket: XBucket = {
+      x: point[TIME_INDEX],
+      buckets: valueBuckets
+    };
+
+    // Don't push buckets with 0 count until saveZeroCounts flag is set
+    if (count !== 0 || (count === 0 && saveZeroCounts)) {
+      xBuckets.push(xBucket);
+    }
+  });
+
+  let heatmap: any = {};
+  _.forEach(xBuckets, (bucket: XBucket) => {
+    heatmap[bucket.x] = bucket;
+  });
+
+  return heatmap;
+}
+
+/**
+ * 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: [],
+      points: []
+    };
+
+    if (nullBucket.values) {
+      newBucket.values = nullBucket.values.concat(minBucket.values);
+    }
+    if (nullBucket.points) {
+      newBucket.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: any = {};
+  _.forEach(seriesBuckets, (seriesBucket, index) => {
+    if (index === 0) {
+      mergedBuckets = seriesBucket;
+    } else {
+      _.forEach(seriesBucket, (xBucket, xBound) => {
+        if (mergedBuckets[xBound]) {
+          if (xBucket.points) {
+            mergedBuckets[xBound].points = xBucket.points.concat(mergedBuckets[xBound].points);
+          }
+          if (xBucket.values) {
+            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);
+
+              if (bucket.points) {
+                mergedBuckets[xBound].buckets[yBound].points = bucket.points.concat(yBucket.points);
+              }
+            } else {
+              mergedBuckets[xBound].buckets[yBound] = yBucket;
+            }
+
+            let points = mergedBuckets[xBound].buckets[yBound].points;
+            if (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
+ * @param value
+ * @param base logarithm base
+ */
+function logp(value, base) {
+  return Math.log(value) / Math.log(base);
+}
+
+/**
+ * Calculate size of Y bucket from given buckets bounds.
+ * @param bounds Array of Y buckets bounds
+ * @param logBase Logarithm base
+ */
+function calculateBucketSize(bounds: number[], logBase = 1): number {
+  let bucketSize = Infinity;
+
+  if (bounds.length === 0) {
+    return 0;
+  } else if (bounds.length === 1) {
+    return bounds[0];
+  } else {
+    bounds = _.sortBy(bounds);
+    for (let i = 1; i < bounds.length; i++) {
+      let distance = getDistance(bounds[i], bounds[i - 1], logBase);
+      bucketSize = distance < bucketSize ? distance : bucketSize;
+    }
+  }
+
+  return bucketSize;
+}
+
+/**
+ * Calculate distance between two numbers in given scale (linear or logarithmic).
+ * @param a
+ * @param b
+ * @param logBase
+ */
+function getDistance(a: number, b: number, logBase = 1): number {
+  if (logBase === 1) {
+    // Linear distance
+    return Math.abs(b - a);
+  } else {
+    // logarithmic distance
+    let ratio = Math.max(a, b) / Math.min(a, b);
+    return logp(ratio, logBase);
+  }
+}
+
+/**
+ * Compare two heatmap data objects
+ * @param objA
+ * @param objB
+ */
+function isHeatmapDataEqual(objA: any, objB: any): boolean {
+  let is_eql = !emptyXOR(objA, objB);
+
+  _.forEach(objA, (xBucket: XBucket, x) => {
+    if (objB[x]) {
+      if (emptyXOR(xBucket.buckets, objB[x].buckets)) {
+        is_eql = false;
+        return false;
+      }
+
+      _.forEach(xBucket.buckets, (yBucket: YBucket, y) => {
+        if (objB[x].buckets && objB[x].buckets[y]) {
+          if (objB[x].buckets[y].values) {
+            is_eql = _.isEqual(_.sortBy(yBucket.values), _.sortBy(objB[x].buckets[y].values));
+            if (!is_eql) {
+              return false;
+            }
+          } else {
+            is_eql = false;
+            return false;
+          }
+        } else {
+          is_eql = false;
+          return false;
+        }
+      });
+
+      if (!is_eql) {
+        return false;
+      }
+    } else {
+      is_eql = false;
+      return false;
+    }
+  });
+
+  return is_eql;
+}
+
+function emptyXOR(foo: any, bar: any): boolean {
+  return (_.isEmpty(foo) || _.isEmpty(bar)) && !(_.isEmpty(foo) && _.isEmpty(bar));
+}
+
+export {
+  convertToHeatMap,
+  elasticHistogramToHeatmap,
+  convertToCards,
+  removeZeroBuckets,
+  mergeZeroBuckets,
+  getMinLog,
+  getValueBucketBound,
+  isHeatmapDataEqual,
+  calculateBucketSize
+};

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

@@ -0,0 +1,250 @@
+///<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.panel.tooltip.show || _.isEmpty(this.scope.ctrl.data.buckets)) { return; }
+
+    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 graph-tooltip grafana-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 class="graph-tooltip-time">${time}</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>
+        count: <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>

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

@@ -0,0 +1,12 @@
+<div class="heatmap-wrapper">
+	<div class="heatmap-canvas-wrapper">
+
+		<div class="datapoints-warning" ng-if="ctrl.dataWarning">
+			<span class="small" bs-tooltip="ctrl.dataWarning.tip">{{ctrl.dataWarning.title}}</span>
+		</div>
+
+		<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>

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

@@ -0,0 +1,7 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import {HeatmapCtrl} from './heatmap_ctrl';
+
+export {
+  HeatmapCtrl as PanelCtrl
+};

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

@@ -0,0 +1,95 @@
+<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 class="section gf-form-group">
+  <h5 class="section-heading">Data format</h5>
+  <div class="gf-form">
+    <label class="gf-form-label width-5">Format</label>
+    <div class="gf-form-select-wrapper max-width-15">
+      <select class="gf-form-input" ng-model="ctrl.panel.dataFormat" ng-options="v as k for (k, v) in editor.dataFormats" ng-change="ctrl.render()"></select>
+    </div>
+  </div>
+</div>
+</div>

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

@@ -0,0 +1,87 @@
+<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-7">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-7">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-7">Scale</label>
+        <div class="gf-form-select-wrapper width-8">
+          <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-7">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 === 'spectrum'">
+      <div class="gf-form">
+        <label class="gf-form-label width-7">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-9" 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"
+    }
+  }
+}

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

@@ -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);
+}

+ 76 - 0
public/app/plugins/panel/heatmap/specs/heatmap_ctrl_specs.ts

@@ -0,0 +1,76 @@
+///<reference path="../../../../headers/common.d.ts" />
+
+import {describe, beforeEach, it, sinon, expect, angularMocks} from '../../../../../test/lib/common';
+
+import angular from 'angular';
+import moment from 'moment';
+import {HeatmapCtrl} from '../heatmap_ctrl';
+import helpers from '../../../../../test/specs/helpers';
+
+describe('HeatmapCtrl', function() {
+  var ctx = new helpers.ControllerTestContext();
+
+  beforeEach(angularMocks.module('grafana.services'));
+  beforeEach(angularMocks.module('grafana.controllers'));
+  beforeEach(angularMocks.module(function($compileProvider) {
+    $compileProvider.preAssignBindingsEnabled(true);
+  }));
+
+  beforeEach(ctx.providePhase());
+  beforeEach(ctx.createPanelController(HeatmapCtrl));
+  beforeEach(() => {
+    ctx.ctrl.annotationsPromise = Promise.resolve({});
+    ctx.ctrl.updateTimeRange();
+  });
+
+  describe('when time series are outside range', function() {
+
+    beforeEach(function() {
+      var data = [
+        {target: 'test.cpu1', datapoints: [[45, 1234567890], [60, 1234567899]]},
+      ];
+
+      ctx.ctrl.range = {from: moment().valueOf(), to: moment().valueOf()};
+      ctx.ctrl.onDataReceived(data);
+    });
+
+    it('should set datapointsOutside', function() {
+      expect(ctx.ctrl.dataWarning.title).to.be('Data points outside time range');
+    });
+  });
+
+  describe('when time series are inside range', function() {
+    beforeEach(function() {
+      var range = {
+        from: moment().subtract(1, 'days').valueOf(),
+        to: moment().valueOf()
+      };
+
+      var data = [
+        {target: 'test.cpu1', datapoints: [[45, range.from + 1000], [60, range.from + 10000]]},
+      ];
+
+      ctx.ctrl.range = range;
+      ctx.ctrl.onDataReceived(data);
+    });
+
+    it('should set datapointsOutside', function() {
+      expect(ctx.ctrl.dataWarning).to.be(null);
+    });
+  });
+
+  describe('datapointsCount given 2 series', function() {
+    beforeEach(function() {
+      var data = [
+        {target: 'test.cpu1', datapoints: []},
+        {target: 'test.cpu2', datapoints: []},
+      ];
+      ctx.ctrl.onDataReceived(data);
+    });
+
+    it('should set datapointsCount warning', function() {
+      expect(ctx.ctrl.dataWarning.title).to.be('No data points');
+    });
+  });
+
+});

+ 253 - 0
public/app/plugins/panel/heatmap/specs/heatmap_data_converter_specs.ts

@@ -0,0 +1,253 @@
+///<reference path="../../../../headers/common.d.ts" />
+
+import _ from 'lodash';
+import { describe, beforeEach, it, sinon, expect, angularMocks } from '../../../../../test/lib/common';
+import TimeSeries from 'app/core/time_series2';
+import { convertToHeatMap, elasticHistogramToHeatmap, calculateBucketSize, isHeatmapDataEqual } from '../heatmap_data_converter';
+
+describe('isHeatmapDataEqual', () => {
+  let ctx: any = {};
+
+  beforeEach(() => {
+    ctx.heatmapA = {
+      '1422774000000': {
+        x: 1422774000000,
+        buckets: {
+          '1': { y: 1, values: [1, 1.5] },
+          '2': { y: 2, values: [1] }
+        }
+      }
+    };
+
+    ctx.heatmapB = {
+      '1422774000000': {
+        x: 1422774000000,
+        buckets: {
+          '1': { y: 1, values: [1.5, 1] },
+          '2': { y: 2, values: [1] }
+        }
+      }
+    };
+  });
+
+  it('should proper compare objects', () => {
+    let heatmapC = _.cloneDeep(ctx.heatmapA);
+    heatmapC['1422774000000'].buckets['1'].values = [1, 1.5];
+
+    let heatmapD = _.cloneDeep(ctx.heatmapA);
+    heatmapD['1422774000000'].buckets['1'].values = [1.5, 1, 1.6];
+
+    let heatmapE = _.cloneDeep(ctx.heatmapA);
+    heatmapE['1422774000000'].buckets['1'].values = [1, 1.6];
+
+    let empty = {};
+    let emptyValues = _.cloneDeep(ctx.heatmapA);
+    emptyValues['1422774000000'].buckets['1'].values = [];
+
+    expect(isHeatmapDataEqual(ctx.heatmapA, ctx.heatmapB)).to.be(true);
+    expect(isHeatmapDataEqual(ctx.heatmapB, ctx.heatmapA)).to.be(true);
+
+    expect(isHeatmapDataEqual(ctx.heatmapA, heatmapC)).to.be(true);
+    expect(isHeatmapDataEqual(heatmapC, ctx.heatmapA)).to.be(true);
+
+    expect(isHeatmapDataEqual(ctx.heatmapA, heatmapD)).to.be(false);
+    expect(isHeatmapDataEqual(heatmapD, ctx.heatmapA)).to.be(false);
+
+    expect(isHeatmapDataEqual(ctx.heatmapA, heatmapE)).to.be(false);
+    expect(isHeatmapDataEqual(heatmapE, ctx.heatmapA)).to.be(false);
+
+    expect(isHeatmapDataEqual(empty, ctx.heatmapA)).to.be(false);
+    expect(isHeatmapDataEqual(ctx.heatmapA, empty)).to.be(false);
+
+    expect(isHeatmapDataEqual(emptyValues, ctx.heatmapA)).to.be(false);
+    expect(isHeatmapDataEqual(ctx.heatmapA, emptyValues)).to.be(false);
+  });
+});
+
+describe('calculateBucketSize', () => {
+  let ctx: any = {};
+
+  describe('when logBase is 1 (linear scale)', () => {
+
+    beforeEach(() => {
+      ctx.logBase = 1;
+      ctx.bounds_set = [
+        { bounds: [], size: 0 },
+        { bounds: [0], size: 0 },
+        { bounds: [4], size: 4 },
+        { bounds: [0, 1, 2, 3, 4], size: 1 },
+        { bounds: [0, 1, 3, 5, 7], size: 1 },
+        { bounds: [0, 3, 7, 9, 15], size: 2 },
+        { bounds: [0, 7, 3, 15, 9], size: 2 },
+        { bounds: [0, 5, 10, 15, 50], size: 5 }
+      ];
+    });
+
+    it('should properly calculate bucket size', () => {
+      _.each(ctx.bounds_set, (b) => {
+        let bucketSize = calculateBucketSize(b.bounds, ctx.logBase);
+        expect(bucketSize).to.be(b.size);
+      });
+    });
+  });
+
+  describe('when logBase is 2', () => {
+
+    beforeEach(() => {
+      ctx.logBase = 2;
+      ctx.bounds_set = [
+        { bounds: [], size: 0 },
+        { bounds: [0], size: 0 },
+        { bounds: [4], size: 4 },
+        { bounds: [1, 2, 4, 8], size: 1 },
+        { bounds: [1, Math.SQRT2, 2, 8, 16], size: 0.5 }
+      ];
+    });
+
+    it('should properly calculate bucket size', () => {
+      _.each(ctx.bounds_set, (b) => {
+        let bucketSize = calculateBucketSize(b.bounds, ctx.logBase);
+        expect(isEqual(bucketSize, b.size)).to.be(true);
+      });
+    });
+  });
+});
+
+describe('HeatmapDataConverter', () => {
+  let ctx: any = {};
+
+  beforeEach(() => {
+    ctx.series = [];
+    ctx.series.push(new TimeSeries({
+      datapoints: [[1, 1422774000000], [2, 1422774060000]],
+      alias: 'series1'
+    }));
+    ctx.series.push(new TimeSeries({
+      datapoints: [[2, 1422774000000], [3, 1422774060000]],
+      alias: 'series2'
+    }));
+
+    ctx.xBucketSize = 60000; // 60s
+    ctx.yBucketSize = 1;
+    ctx.logBase = 1;
+  });
+
+  describe('when logBase is 1 (linear scale)', () => {
+
+    beforeEach(() => {
+      ctx.logBase = 1;
+    });
+
+    it('should build proper heatmap data', () => {
+      let expectedHeatmap = {
+        '1422774000000': {
+          x: 1422774000000,
+          buckets: {
+            '1': { y: 1, values: [1] },
+            '2': { y: 2, values: [2] }
+          }
+        },
+        '1422774060000': {
+          x: 1422774060000,
+          buckets: {
+            '2': { y: 2, values: [2] },
+            '3': { y: 3, values: [3] }
+          }
+        },
+      };
+
+      let heatmap = convertToHeatMap(ctx.series, ctx.yBucketSize, ctx.xBucketSize, ctx.logBase);
+      expect(isHeatmapDataEqual(heatmap, expectedHeatmap)).to.be(true);
+    });
+  });
+
+  describe('when logBase is 2', () => {
+
+    beforeEach(() => {
+      ctx.logBase = 2;
+    });
+
+    it('should build proper heatmap data', () => {
+      let expectedHeatmap = {
+        '1422774000000': {
+          x: 1422774000000,
+          buckets: {
+            '1': { y: 1, values: [1] },
+            '2': { y: 2, values: [2] }
+          }
+        },
+        '1422774060000': {
+          x: 1422774060000,
+          buckets: {
+            '2': { y: 2, values: [2, 3] }
+          }
+        },
+      };
+
+      let heatmap = convertToHeatMap(ctx.series, ctx.yBucketSize, ctx.xBucketSize, ctx.logBase);
+      expect(isHeatmapDataEqual(heatmap, expectedHeatmap)).to.be(true);
+    });
+  });
+});
+
+describe('ES Histogram converter', () => {
+  let ctx: any = {};
+
+  beforeEach(() => {
+    ctx.series = [];
+    ctx.series.push(new TimeSeries({
+      datapoints: [[1, 1422774000000], [0, 1422774060000]],
+      alias: '1', label: '1'
+    }));
+    ctx.series.push(new TimeSeries({
+      datapoints: [[1, 1422774000000], [3, 1422774060000]],
+      alias: '2', label: '2'
+    }));
+    ctx.series.push(new TimeSeries({
+      datapoints: [[0, 1422774000000], [1, 1422774060000]],
+      alias: '3', label: '3'
+    }));
+  });
+
+  describe('when converting ES histogram', () => {
+
+    beforeEach(() => {
+    });
+
+    it('should build proper heatmap data', () => {
+      let expectedHeatmap = {
+        '1422774000000': {
+          x: 1422774000000,
+          buckets: {
+            '1': { y: 1, values: [1] },
+            '2': { y: 2, values: [2] }
+          }
+        },
+        '1422774060000': {
+          x: 1422774060000,
+          buckets: {
+            '2': { y: 2, values: [2, 2, 2] },
+            '3': { y: 3, values: [3] }
+          }
+        },
+      };
+
+      let heatmap = elasticHistogramToHeatmap(ctx.series);
+      expect(isHeatmapDataEqual(heatmap, expectedHeatmap)).to.be(true);
+    });
+  });
+});
+
+/**
+ * Compare two numbers with given precision. Suitable for compare float numbers after conversions with precision loss.
+ * @param a
+ * @param b
+ * @param precision
+ */
+function isEqual(a: number, b: number, precision = 0.000001): boolean {
+  if (a === b) {
+    return true;
+  } else {
+    return Math.abs(1 - a / b) <= precision;
+  }
+}

+ 267 - 0
public/app/plugins/panel/heatmap/specs/renderer_specs.ts

@@ -0,0 +1,267 @@
+///<reference path="../../../../headers/common.d.ts" />
+
+import { describe, beforeEach, it, sinon, expect, angularMocks } from '../../../../../test/lib/common';
+
+import '../module';
+import angular from 'angular';
+import $ from 'jquery';
+import _ from 'lodash';
+import helpers from 'test/specs/helpers';
+import TimeSeries from 'app/core/time_series2';
+import moment from 'moment';
+import { Emitter } from 'app/core/core';
+import rendering from '../rendering';
+import { convertToHeatMap } from '../heatmap_data_converter';
+// import d3 from 'd3';
+
+describe('grafanaHeatmap', function () {
+
+  beforeEach(angularMocks.module('grafana.core'));
+
+  function heatmapScenario(desc, func, elementWidth = 500) {
+    describe(desc, function () {
+      var ctx: any = {};
+
+      ctx.setup = function (setupFunc) {
+
+        beforeEach(angularMocks.module(function ($provide) {
+          $provide.value("timeSrv", new helpers.TimeSrvStub());
+        }));
+
+        beforeEach(angularMocks.inject(function ($rootScope, $compile) {
+          var ctrl: any = {
+            colorSchemes: [
+              {name: 'Oranges',   value: 'interpolateOranges',  invert: 'dark'},
+              {name: 'Reds',      value: 'interpolateReds',     invert: 'dark'},
+            ],
+            events: new Emitter(),
+            height: 200,
+            panel: {
+              heatmap: {
+              },
+              cards: {
+                cardPadding: null,
+                cardRound: null
+              },
+              color: {
+                mode: 'spectrum',
+                cardColor: '#b4ff00',
+                colorScale: 'linear',
+                exponent: 0.5,
+                colorScheme: 'interpolateOranges',
+                fillBackground: false
+              },
+              xBucketSize: 1000,
+              xBucketNumber: null,
+              yBucketSize: 1,
+              yBucketNumber: null,
+              xAxis: {
+                show: true
+              },
+              yAxis: {
+                show: true,
+                format: 'short',
+                decimals: null,
+                logBase: 1,
+                splitFactor: null,
+                min: null,
+                max: null,
+                removeZeroValues: false
+              },
+              tooltip: {
+                show: true,
+                seriesStat: false,
+                showHistogram: false
+              },
+              highlightCards: true
+            },
+            renderingCompleted: sinon.spy(),
+            hiddenSeries: {},
+            dashboard: {
+              getTimezone: sinon.stub().returns('utc')
+            },
+            range: {
+              from: moment.utc("01 Mar 2017 10:00:00"),
+              to: moment.utc("01 Mar 2017 11:00:00"),
+            },
+          };
+
+          var scope = $rootScope.$new();
+          scope.ctrl = ctrl;
+
+          ctx.series = [];
+          ctx.series.push(new TimeSeries({
+            datapoints: [[1, 1422774000000], [2, 1422774060000]],
+            alias: 'series1'
+          }));
+          ctx.series.push(new TimeSeries({
+            datapoints: [[2, 1422774000000], [3, 1422774060000]],
+            alias: 'series2'
+          }));
+
+          ctx.data = {
+            heatmapStats: {
+              min: 1,
+              max: 3,
+              minLog: 1
+            },
+            xBucketSize: ctrl.panel.xBucketSize,
+            yBucketSize: ctrl.panel.yBucketSize
+          };
+
+          setupFunc(ctrl, ctx);
+
+          let logBase = ctrl.panel.yAxis.logBase;
+          let bucketsData = convertToHeatMap(ctx.series, ctx.data.yBucketSize, ctx.data.xBucketSize, logBase);
+          ctx.data.buckets = bucketsData;
+
+          // console.log("bucketsData", bucketsData);
+          // console.log("series", ctrl.panel.yAxis.logBase, ctx.series.length);
+
+          let elemHtml = `
+          <div class="heatmap-wrapper">
+            <div class="heatmap-canvas-wrapper">
+              <div class="heatmap-panel" style='width:${elementWidth}px'></div>
+            </div>
+          </div>`;
+
+          var element = angular.element(elemHtml);
+          $compile(element)(scope);
+          scope.$digest();
+
+          ctrl.data = ctx.data;
+          ctx.element = element;
+          let render = rendering(scope, $(element), [], ctrl);
+          ctrl.events.emit('render');
+        }));
+      };
+
+      func(ctx);
+    });
+  }
+
+  heatmapScenario('default options', function (ctx) {
+    ctx.setup(function (ctrl) {
+      ctrl.panel.yAxis.logBase = 1;
+    });
+
+    it('should draw correct Y axis', function () {
+      var yTicks = getTicks(ctx.element, ".axis-y");
+      expect(yTicks).to.eql(['1', '2', '3']);
+    });
+
+    it('should draw correct X axis', function () {
+      var xTicks = getTicks(ctx.element, ".axis-x");
+      let expectedTicks = [
+        formatLocalTime("01 Mar 2017 10:00:00"),
+        formatLocalTime("01 Mar 2017 10:15:00"),
+        formatLocalTime("01 Mar 2017 10:30:00"),
+        formatLocalTime("01 Mar 2017 10:45:00"),
+        formatLocalTime("01 Mar 2017 11:00:00")
+      ];
+      expect(xTicks).to.eql(expectedTicks);
+    });
+  });
+
+  heatmapScenario('when logBase is 2', function (ctx) {
+    ctx.setup(function (ctrl) {
+      ctrl.panel.yAxis.logBase = 2;
+    });
+
+    it('should draw correct Y axis', function () {
+      var yTicks = getTicks(ctx.element, ".axis-y");
+      expect(yTicks).to.eql(['1', '2', '4']);
+    });
+  });
+
+  heatmapScenario('when logBase is 10', function (ctx) {
+    ctx.setup(function (ctrl, ctx) {
+      ctrl.panel.yAxis.logBase = 10;
+
+      ctx.series.push(new TimeSeries({
+        datapoints: [[10, 1422774000000], [20, 1422774060000]],
+        alias: 'series3'
+      }));
+      ctx.data.heatmapStats.max = 20;
+    });
+
+    it('should draw correct Y axis', function () {
+      var yTicks = getTicks(ctx.element, ".axis-y");
+      expect(yTicks).to.eql(['1', '10', '100']);
+    });
+  });
+
+  heatmapScenario('when logBase is 32', function (ctx) {
+    ctx.setup(function (ctrl) {
+      ctrl.panel.yAxis.logBase = 32;
+
+      ctx.series.push(new TimeSeries({
+        datapoints: [[10, 1422774000000], [100, 1422774060000]],
+        alias: 'series3'
+      }));
+      ctx.data.heatmapStats.max = 100;
+    });
+
+    it('should draw correct Y axis', function () {
+      var yTicks = getTicks(ctx.element, ".axis-y");
+      expect(yTicks).to.eql(['1', '32', '1 K']);
+    });
+  });
+
+  heatmapScenario('when logBase is 1024', function (ctx) {
+    ctx.setup(function (ctrl) {
+      ctrl.panel.yAxis.logBase = 1024;
+
+      ctx.series.push(new TimeSeries({
+        datapoints: [[2000, 1422774000000], [300000, 1422774060000]],
+        alias: 'series3'
+      }));
+      ctx.data.heatmapStats.max = 300000;
+    });
+
+    it('should draw correct Y axis', function () {
+      var yTicks = getTicks(ctx.element, ".axis-y");
+      expect(yTicks).to.eql(['1', '1 K', '1 Mil']);
+    });
+  });
+
+  heatmapScenario('when Y axis format set to "none"', function (ctx) {
+    ctx.setup(function (ctrl) {
+      ctrl.panel.yAxis.logBase = 1;
+      ctrl.panel.yAxis.format = "none";
+      ctx.data.heatmapStats.max = 10000;
+    });
+
+    it('should draw correct Y axis', function () {
+      var yTicks = getTicks(ctx.element, ".axis-y");
+      expect(yTicks).to.eql(['0', '2000', '4000', '6000', '8000', '10000', '12000']);
+    });
+  });
+
+  heatmapScenario('when Y axis format set to "second"', function (ctx) {
+    ctx.setup(function (ctrl) {
+      ctrl.panel.yAxis.logBase = 1;
+      ctrl.panel.yAxis.format = "s";
+      ctx.data.heatmapStats.max = 3600;
+    });
+
+    it('should draw correct Y axis', function () {
+      var yTicks = getTicks(ctx.element, ".axis-y");
+      expect(yTicks).to.eql(['0 ns', '17 min', '33 min', '50 min', '1 hour']);
+    });
+  });
+
+});
+
+
+function getTicks(element, axisSelector) {
+  return element.find(axisSelector).find("text")
+    .map(function () {
+      return this.textContent;
+    }).get();
+}
+
+function formatLocalTime(timeStr) {
+  let format = "HH:mm";
+  return moment.utc(timeStr).local().format(format);
+}

+ 6 - 6
public/app/plugins/panel/table/module.html

@@ -1,7 +1,7 @@
 
 
 <div class="table-panel-container">
 <div class="table-panel-container">
-        <div class="table-panel-header-bg" ng-show="ctrl.dataRaw.length>0"></div>
-	<div class="table-panel-scroll" ng-show="ctrl.dataRaw.length>0">
+	<div class="table-panel-header-bg" ng-show="ctrl.table.rows.length"></div>
+	<div class="table-panel-scroll" ng-show="ctrl.table.rows.length">
 		<table class="table-panel-table">
 		<table class="table-panel-table">
 			<thead>
 			<thead>
 				<tr>
 				<tr>
@@ -21,10 +21,10 @@
 		</table>
 		</table>
 	</div>
 	</div>
 </div>
 </div>
-<div class="datapoints-warning" ng-show="ctrl.dataRaw.length===0">
-          <span class="small" >
-              No datapoints <tip>No datapoints returned from metric query</tip>
-          </span>
+<div class="datapoints-warning" ng-show="ctrl.table.rows.length===0">
+	<span class="small" >
+		No data to show <tip>Nothing returned by data query</tip>
+	</span>
 </div>
 </div>
 <div class="table-panel-footer">
 <div class="table-panel-footer">
 </div>
 </div>

+ 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: {

+ 1 - 0
public/sass/_grafana.scss

@@ -48,6 +48,7 @@
 @import "components/panel_singlestat";
 @import "components/panel_singlestat";
 @import "components/panel_table";
 @import "components/panel_table";
 @import "components/panel_text";
 @import "components/panel_text";
+@import "components/panel_heatmap";
 @import "components/tagsinput";
 @import "components/tagsinput";
 @import "components/tables_lists";
 @import "components/tables_lists";
 @import "components/search";
 @import "components/search";

+ 39 - 0
public/sass/components/_panel_heatmap.scss

@@ -0,0 +1,39 @@
+.heatmap-canvas-wrapper {
+  // position: relative;
+  cursor: crosshair;
+}
+
+.heatmap-panel {
+  position: relative;
+
+  .axis .tick {
+    text {
+      fill: $text-color;
+      color: $text-color;
+      font-size: $font-size-sm;
+    }
+
+    line {
+      opacity: 0.4;
+      stroke: $text-color-weak;
+    }
+  }
+}
+
+.heatmap-tooltip {
+  white-space: nowrap;
+  font-size: $font-size-sm;
+  background-color: $graph-tooltip-bg;
+  color: $text-color;
+}
+
+.heatmap-histogram rect {
+  fill: $text-color-weak;
+}
+
+.heatmap-crosshair {
+  line {
+    stroke: darken($red,15%);
+    stroke-width: 1;
+  }
+}

+ 2 - 1
public/test/test-main.js

@@ -38,7 +38,8 @@
       "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"));
+```

File diff suppressed because it is too large
+ 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;

File diff suppressed because it is too large
+ 1 - 0
public/vendor/d3/d3.v4.min.js


Some files were not shown because too many files changed in this diff