/** * The 'd3-heatmap' component is used for visualizing matrices * where the individual values are represented as colors. * The component makes use of two {@link Ext.d3.axis.Data Data} axes (one for each * dimension of the matrix) and a single {@link Ext.d3.axis.Color Color} axis * to encode the values. * * @example * Ext.create('Ext.panel.Panel', { * renderTo: Ext.getBody(), * title: 'Heatmap Chart', * height: 750, * width: 750, * layout: 'fit', * items: [ * { * xtype: 'd3-heatmap', * padding: { * top: 20, * right: 30, * bottom: 20, * left: 80 * }, * * xAxis: { * axis: { * ticks: 'd3.time.days', * tickFormat: "d3.time.format('%b %d')", * orient: 'bottom' * }, * scale: { * type: 'time' * }, * title: { * text: 'Date' * }, * field: 'date', * step: 24 * 60 * 60 * 1000 * }, * * yAxis: { * axis: { * orient: 'left' * }, * scale: { * type: 'linear' * }, * title: { * text: 'Total' * }, * field: 'bucket', * step: 100 * }, * * colorAxis: { * scale: { * type: 'linear', * range: ['white', 'orange'] * }, * field: 'count', * minimum: 0 * }, * * tiles: { * attr: { * 'stroke': 'black', * 'stroke-width': 1 * } * }, * * store: { * fields: [ * {name: 'date', type: 'date', dateFormat: 'Y-m-d'}, * 'bucket', * 'count' * ], * data: [ * { "date": "2012-07-20", "bucket": 800, "count": 119 }, * { "date": "2012-07-20", "bucket": 900, "count": 123 }, * { "date": "2012-07-20", "bucket": 1000, "count": 173 }, * { "date": "2012-07-20", "bucket": 1100, "count": 226 }, * { "date": "2012-07-20", "bucket": 1200, "count": 284 }, * { "date": "2012-07-21", "bucket": 800, "count": 123 }, * { "date": "2012-07-21", "bucket": 900, "count": 165 }, * { "date": "2012-07-21", "bucket": 1000, "count": 237 }, * { "date": "2012-07-21", "bucket": 1100, "count": 278 }, * { "date": "2012-07-21", "bucket": 1200, "count": 338 }, * { "date": "2012-07-22", "bucket": 900, "count": 154 }, * { "date": "2012-07-22", "bucket": 1000, "count": 241 }, * { "date": "2012-07-22", "bucket": 1100, "count": 246 }, * { "date": "2012-07-22", "bucket": 1200, "count": 300 }, * { "date": "2012-07-22", "bucket": 1300, "count": 305 }, * { "date": "2012-07-23", "bucket": 800, "count": 120 }, * { "date": "2012-07-23", "bucket": 900, "count": 156 }, * { "date": "2012-07-23", "bucket": 1000, "count": 209 }, * { "date": "2012-07-23", "bucket": 1100, "count": 267 }, * { "date": "2012-07-23", "bucket": 1200, "count": 299 }, * { "date": "2012-07-23", "bucket": 1300, "count": 316 }, * { "date": "2012-07-24", "bucket": 800, "count": 105 }, * { "date": "2012-07-24", "bucket": 900, "count": 156 }, * { "date": "2012-07-24", "bucket": 1000, "count": 220 }, * { "date": "2012-07-24", "bucket": 1100, "count": 255 }, * { "date": "2012-07-24", "bucket": 1200, "count": 308 }, * { "date": "2012-07-25", "bucket": 800, "count": 104 }, * { "date": "2012-07-25", "bucket": 900, "count": 191 }, * { "date": "2012-07-25", "bucket": 1000, "count": 201 }, * { "date": "2012-07-25", "bucket": 1100, "count": 238 }, * { "date": "2012-07-25", "bucket": 1200, "count": 223 }, * { "date": "2012-07-26", "bucket": 1300, "count": 132 }, * { "date": "2012-07-26", "bucket": 1400, "count": 117 }, * { "date": "2012-07-26", "bucket": 1500, "count": 124 }, * { "date": "2012-07-26", "bucket": 1600, "count": 154 }, * { "date": "2012-07-26", "bucket": 1700, "count": 167 } * ] * } * } * ] * }); */Ext.define('Ext.d3.HeatMap', { extend: 'Ext.d3.svg.Svg', xtype: 'd3-heatmap', requires: [ 'Ext.d3.axis.Data', 'Ext.d3.axis.Color', 'Ext.d3.legend.Color', 'Ext.d3.Helpers' ], mixins: [ 'Ext.d3.mixin.ToolTip' ], config: { componentCls: 'heatmap', /** * @cfg {Ext.d3.axis.Data} xAxis * The axis that corresponds to the columns of the data matrix. */ xAxis: { axis: { orient: 'bottom' }, scale: { type: 'linear' } }, /** * @cfg {Ext.d3.axis.Data} yAxis * The axis that corresponds to the rows of the data matrix. */ yAxis: { axis: { orient: 'left' }, scale: { type: 'linear' } }, /** * @cfg {Ext.d3.axis.Color} colorAxis * The axis that corresponds to the values of the data matrix. */ colorAxis: {}, /** * @cfg {Ext.d3.legend.Color} legend * The legend for tiles' colors. * See the {@link Ext.d3.legend.Color} documentation for configuration options. */ legend: false, /** * @cfg {Object} tiles * This config controls the appearance of the heatmap tiles. * @cfg {String} tiles.cls The CSS class name to use for each tile. * @cfg {Object} tiles.attr The attributes to apply to each tile ('rect') element. */ tiles: null, /** * @cfg {Object/Boolean} [labels=true] * This config controls the appearance of the heatmap labels. * @cfg {String} labels.cls The CSS class name to use for each label. * @cfg {Object} labels.attr The attributes to apply to each label ('text') element. */ labels: true }, data: null, // store data items tiles: null, tilesGroup: null, tilesRect: null, legendRect: null, defaultCls: { tiles: Ext.baseCSSPrefix + 'd3-tiles', tile: Ext.baseCSSPrefix + 'd3-tile', label: Ext.baseCSSPrefix + 'd3-tile-label' }, constructor: function (config) { this.callParent([config]); this.mixins.d3tooltip.constructor.call(this, config); }, applyTooltip: function (tooltip, oldTooltip) { if (tooltip) { tooltip.delegate = 'g.' + this.defaultCls.tile; } return this.mixins.d3tooltip.applyTooltip.call(this, tooltip, oldTooltip); }, updateTooltip: null, // Override the updater in Modern component. applyAxis: function (axis, oldAxis) { if (axis) { axis = new Ext.d3.axis.Data(Ext.merge({ parent: this.getScene(), component: this }, axis)); } return axis || oldAxis; }, updateAxis: function (axis, oldAxis) { var me = this; if (!me.isConfiguring) { me.processData(); me.renderScene(); } }, applyXAxis: function (xAxis, oldXAxis) { return this.applyAxis(xAxis, oldXAxis); }, updateXAxis: function () { this.updateAxis(); }, applyYAxis: function (yAxis, oldYAxis) { return this.applyAxis(yAxis, oldYAxis); }, updateYAxis: function () { this.updateAxis(); }, applyLegend: function (legend, oldLegend) { var me = this; if (legend) { legend.axis = me.getColorAxis(); legend = new Ext.d3.legend.Color(Ext.merge({component: me}, legend)); } return legend || oldLegend; }, updateLegend: function (legend, oldLegend) { var me = this, events = { show: 'onLegendVisibility', hide: 'onLegendVisibility', scope: me }; if (oldLegend) { oldLegend.un(events); } if (legend) { legend.on(events); } if (!me.isConfiguring) { me.performLayout(); } }, onLegendVisibility: function () { this.performLayout(); }, applyColorAxis: function (colorAxis, oldColorAxis) { if (colorAxis) { colorAxis = new Ext.d3.axis.Color(colorAxis); } return colorAxis || oldColorAxis; }, updateColorAxis: function () { var me = this; if (!me.isConfiguring) { me.processData(); me.renderScene(); } }, getStoreData: function(store){ return store ? store.getData().items : []; }, processData: function (store) { var me = this, items = me.data = me.getStoreData(store || me.getStore()), xAxis = me.getXAxis(), yAxis = me.getYAxis(), colorAxis = me.getColorAxis(), xScale = xAxis.getScale(), yScale = yAxis.getScale(), xCategories, yCategories, xField = xAxis.getField(), yField = yAxis.getField(), xStep = xAxis.getStep(), yStep = yAxis.getStep(), xDomain, yDomain; // If an axis is using a time scale, the date format parser // should be specified in the store, e.g.: // fields: [ // {name: 'xField', type: 'date', dateFormat: 'Y-m-d'}, // ... // And Ext.Date.parse function will be used to parse date strings. // In pure D3, one typically creates a d3.time.format('%Y-%m-%d').parse // parser and calls it on every item's date string in a loop. // Here, date fields should already be Date objects. // Same goes for other field types, in pure D3 it's common to coerce // strings to numbers, e.g. `data.count = +data.count`. // Here, we assume all of this has been taken care of by the store. xDomain = d3.extent(items, function (item) { return item.data[xField]; }); yDomain = d3.extent(items, function (item) { return item.data[yField]; }); if (Ext.d3.Helpers.isOrdinalScale(xScale)) { // When an ordinal scale is used, it is assumed that the order // of data in the store is linear. // E.g. the store for the sales by employee by day heatmap // has all records listed for the first employee, // then all records for the second employee, and so on. // The days in the employee records are expected to be // ordered as well. // For example: // { employee: 'John', day: 1, sales: 5 }, // { employee: 'John', day: 2, sales: 7 }, // { employee: 'Jane', day: 1, sales: 4 }, // { employee: 'Jane', day: 2, sales: 8 } xCategories = items.map(function (item) { return item.data[xField]; }).filter(function (element, index, array) { // keep first or not equal previous // return !index || element != array[index - 1]; // Quadratic time, but preserves order of items in both cases: // Case 1: 5 5 5 4 4 4 3 3 3 // Case 2: 5 4 3 5 4 3 5 4 3 // Both will result in the following sequence: 5 4 3. // Quadratic time should be acceptable as ordinal scales are not // expected to be used with large datasets. return array.indexOf(element) === index; }); xScale.domain(xCategories); } else { // Coerce domain values to a number (they may be Date objects). // The assumption in HeatMap component is that the data values starts at // startValue and ends at endValue - step. So, for example, if one wants // to map hours along the xAxis, the data values would range from 0 to 23, // and one would set step to 1. If one wants to map every other hour. // TODO: finish comment xScale.domain([+xDomain[0], +xDomain[1] + xStep]); } if (Ext.d3.Helpers.isOrdinalScale(yScale)) { yCategories = items.map(function (item) { return item.data[yField]; }).filter(function (element, index, array) { return array.indexOf(element) === index; }); yScale.domain(yCategories); } else { yScale.domain([+yDomain[0], +yDomain[1] + yStep]); } colorAxis.setDomainFromData(items); }, isDataProcessed: false, processDataChange: function (store) { var me = this; me.processData(store); me.isDataProcessed = true; if (!me.isConfiguring) { me.performLayout(); } }, onSceneResize: function (scene, rect) { var me = this; me.callParent([scene, rect]); if (!me.isDataProcessed) { me.processData(); } me.performLayout(rect); }, performLayout: function (rect) { var me = this; rect = rect || me.getSceneRect(); if (!rect) { return; } me.showScene(); var legend = me.getLegend(), xAxis = me.getXAxis(), yAxis = me.getYAxis(), xAxisGroup = xAxis.getGroup(), yAxisGroup = yAxis.getGroup(), xD3Axis = xAxis.getAxis(), yD3Axis = yAxis.getAxis(), xScale = xAxis.getScale(), yScale = yAxis.getScale(), isOrdinalX = Ext.d3.Helpers.isOrdinalScale(xScale), isOrdinalY = Ext.d3.Helpers.isOrdinalScale(yScale), isRtl = me.getInherited().rtl, legendRect, legendBox, legendDocked, tilesRect, shrinkRect, xRange; shrinkRect = { x: 0, y: 0, width: rect.width, height: rect.height }; me.tilesRect = tilesRect = Ext.Object.chain(shrinkRect); if (legend) { legendBox = legend.getBox(); legendDocked = legend.getDocked(); me.legendRect = legendRect = Ext.Object.chain(shrinkRect); switch (legendDocked) { case 'right': tilesRect.width -= legendBox.width; legendRect.width = legendBox.width; legendRect.x = rect.width - legendBox.width; break; case 'left': tilesRect.width -= legendBox.width; legendRect.width = legendBox.width; tilesRect.x += legendBox.width; break; case 'bottom': tilesRect.height -= legendBox.height; legendRect.height = legendBox.height; legendRect.y = rect.height - legendBox.height; break; case 'top': tilesRect.height -= legendBox.height; legendRect.height = legendBox.height; tilesRect.y += legendBox.height; break; } Ext.d3.Helpers.alignRect('center', 'center', legendBox, legendRect, legend.getGroup()); } xRange = [tilesRect.x, tilesRect.x + tilesRect.width]; if (isRtl) { xRange.reverse(); } xScale[isOrdinalX ? 'rangeBands' : 'range'](xRange); yScale[isOrdinalY ? 'rangeBands' : 'range']([tilesRect.y + tilesRect.height, tilesRect.y]); xAxisGroup.attr('transform', 'translate(0,' + (xD3Axis.orient() === 'top' ? tilesRect.y : (tilesRect.y + tilesRect.height)) + ')'); yAxisGroup.attr('transform', 'translate(' + (yD3Axis.orient() === 'left' ? tilesRect.x : (tilesRect.x + tilesRect.width)) + ',0)'); me.renderScene(); }, setupScene: function (scene) { var me = this; me.callParent([scene]); me.tilesGroup = scene.append('g').classed(me.defaultCls.tiles, true); // To avoid seeing heatmap components immidiately, // the scene is hidden until the first layout. me.hideScene(); }, getRenderedTiles: function () { return this.tilesGroup.selectAll('.' + this.defaultCls.tile); }, renderScene: function (data) { var me = this, xAxis = me.getXAxis(), yAxis = me.getYAxis(), tiles; data = data || me.data || me.getStoreData(me.getStore()); tiles = me.getRenderedTiles().data(data); me.onAddTiles(tiles.enter()); me.onUpdateTiles(tiles); me.onRemoveTiles(tiles.exit()); xAxis.render(); yAxis.render(); }, onAddTiles: function (selection) { var me = this, tiles = me.getTiles(), labels = me.getLabels(), groups, rects, texts; if (selection.empty()) { return; } groups = selection.append('g') .classed(me.defaultCls.tile, true); rects = groups.append('rect'); if (tiles) { rects.attr(tiles.attr); groups.classed(tiles.cls, !!tiles.cls); if (labels) { texts = groups.append('text'); texts.attr(labels.attr); texts.classed(labels.cls, !!labels.cls); if (Ext.d3.Helpers.noDominantBaseline()) { texts.each(function () { Ext.d3.Helpers.fakeDominantBaseline(this, 'central', true); }); } } } }, onUpdateTiles: function (selection) { var me = this, isRtl = me.getInherited().rtl, xAxis = me.getXAxis(), yAxis = me.getYAxis(), colorAxis = me.getColorAxis(), xScale = xAxis.getScale(), yScale = yAxis.getScale(), colorScale = colorAxis.getScale(), isOrdinalX = Ext.d3.Helpers.isOrdinalScale(xScale), isOrdinalY = Ext.d3.Helpers.isOrdinalScale(yScale), xBand = isOrdinalX ? xScale.rangeBand() : 0, yBand = isOrdinalY ? yScale.rangeBand() : 0, xField = xAxis.getField(), yField = yAxis.getField(), colorField = colorAxis.getField(), xStep = xAxis.getStep(), yStep = yAxis.getStep(), tileWidth = xBand || Math.abs(xScale(xStep) - xScale(0)); selection.select('rect') .attr('x', function (item) { var x = xScale(item.data[xField]); if (isRtl) { x -= isOrdinalX ? 0 : tileWidth; } return x; }) .attr('y', function (item) { var value = item.data[yField]; if (!isOrdinalY) { value += yStep; } return yScale(value); }) .attr('width', tileWidth) .attr('height', yBand || yScale(0) - yScale(yStep)) .style('fill', function (item) { return colorScale(item.data[colorField]); }); selection.select('text') .attr('x', function (item) { var value = item.data[xField]; if (!isOrdinalX) { // `value` may be a Date object, so coerce it to number value = +value + xStep / 2; } value = xScale(value); if (isOrdinalX) { value += xBand / 2; } return value; }) .attr('y', function (item) { var value = item.data[yField]; if (!isOrdinalY) { value = +value + yStep / 2; } value = yScale(value); if (isOrdinalY) { value += yBand / 2; } return value; }) .text(function (item) { return item.data[colorField]; }); }, onRemoveTiles: function (selection) { selection.remove(); }, destroy: function () { var me = this, xAxis = me.getXAxis(), yAxis = me.getYAxis(), colorAxis = me.getColorAxis(), legend = me.getLegend(); Ext.destroy(xAxis, yAxis, colorAxis, legend); me.callParent(); } });