| <!DOCTYPE html> |
| <!-- |
| Copyright (c) 2014 The Chromium Authors. All rights reserved. |
| Use of this source code is governed by a BSD-style license that can be |
| found in the LICENSE file. |
| --> |
| <link rel="import" href="/base/range.html"> |
| <link rel="import" href="/base/ui/d3.html"> |
| <link rel="import" href="/base/ui/dom_helpers.html"> |
| <link rel="import" href="/base/ui/chart_base.html"> |
| <link rel="stylesheet" href="/base/ui/sunburst_chart.css"> |
| <script> |
| 'use strict'; |
| |
| tr.exportTo('tr.b.ui', function() { |
| var ChartBase = tr.b.ui.ChartBase; |
| var getColorOfKey = tr.b.ui.getColorOfKey; |
| |
| var MIN_RADIUS = 100; |
| |
| /** |
| * @constructor |
| */ |
| var SunburstChart = tr.b.ui.define('sunburst-chart', ChartBase); |
| |
| SunburstChart.prototype = { |
| __proto__: ChartBase.prototype, |
| |
| decorate: function() { |
| ChartBase.prototype.decorate.call(this); |
| this.classList.add('sunburst-chart'); |
| |
| this.data_ = undefined; |
| this.seriesKeys_ = undefined; |
| |
| this.yDomainMin_ = 0.0; |
| this.yDomainMax_ = 0.0; |
| this.xDomainScale_ = undefined; |
| this.yDomainScale_ = undefined; |
| this.radius_ = undefined; |
| this.arc_ = undefined; |
| this.selectedNode_ = null; |
| this.vis_ = undefined; |
| this.nodes_ = undefined; |
| this.minX_ = 0.0; |
| this.maxX_ = 1.0; |
| this.minY_ = 0.0; |
| this.clickedY_ = 0; |
| |
| var chartAreaSel = d3.select(this.chartAreaElement); |
| this.legendSel_ = chartAreaSel.append('g'); |
| |
| var pieGroupSel = chartAreaSel.append('g') |
| .attr('class', 'pie-group'); |
| this.pieGroup_ = pieGroupSel.node(); |
| |
| this.backSel_ = pieGroupSel.append('g'); |
| |
| |
| this.pathsGroup_ = pieGroupSel.append('g') |
| .attr('class', 'paths') |
| .node(); |
| }, |
| |
| get data() { |
| return this.data_; |
| }, |
| |
| |
| /** |
| * @param {Data} Data for the chart, where data must be of the |
| * form {category: str, name: str, (size: number or children: [])} . |
| */ |
| set data(data) { |
| this.data_ = data; |
| this.updateContents_(); |
| }, |
| |
| get margin() { |
| var margin = {top: 0, right: 0, bottom: 0, left: 0}; |
| if (this.chartTitle_) |
| margin.top += 40; |
| return margin; |
| }, |
| |
| set selectedNodeID(id) { |
| this.zoomToID_(id); |
| }, |
| |
| get selectedNodeID() { |
| if (this.selectedNode_ != null) |
| return this.selectedNode_.id; |
| return null; |
| }, |
| |
| get selectedNode() { |
| if (this.selectedNode_ != null) |
| return this.selectedNode_; |
| return null; |
| }, |
| |
| getMinSize: function() { |
| if (!tr.b.ui.isElementAttachedToDocument(this)) |
| throw new Error('Cannot measure when unattached'); |
| this.updateContents_(); |
| |
| var titleWidth = this.querySelector( |
| '#title').getBoundingClientRect().width; |
| var margin = this.margin; |
| var marginWidth = margin.left + margin.right; |
| var marginHeight = margin.top + margin.bottom; |
| |
| // TODO(vmiura): Calc this when we're done with layout. |
| return { |
| width: 600, |
| height: 600 |
| }; |
| }, |
| |
| getLegendKeys_: function() { |
| // This class creates its own legend, instead of using ChartBase. |
| return undefined; |
| }, |
| |
| updateScales_: function(width, height) { |
| if (this.data_ === undefined) |
| return; |
| }, |
| |
| // Interpolate the scales! |
| arcTween_: function(minX, maxX, minY) { |
| var that = this; |
| var xd, yd, yr; |
| |
| if (minY > 0) { |
| xd = d3.interpolate(that.xDomainScale_.domain(), [minX, maxX]); |
| yd = d3.interpolate( |
| that.yDomainScale_.domain(), [minY, that.yDomainMax_]); |
| yr = d3.interpolate(that.yDomainScale_.range(), [50, that.radius_]); |
| } |
| else { |
| xd = d3.interpolate(that.xDomainScale_.domain(), [minX, maxX]); |
| yd = d3.interpolate(that.yDomainScale_.domain(), |
| [that.yDomainMin_, that.yDomainMax_]); |
| yr = d3.interpolate(that.yDomainScale_.range(), [50, that.radius_]); |
| } |
| |
| return function(d, i) { |
| return i ? function(t) { return that.arc_(d); } |
| : function(t) { |
| that.xDomainScale_.domain(xd(t)); |
| that.yDomainScale_.domain(yd(t)).range(yr(t)); |
| return that.arc_(d); |
| }; |
| }; |
| }, |
| |
| getNodeById_: function(id) { |
| if (!this.nodes_) |
| return null; |
| |
| if (id < 0 || id > this.nodes_.length) |
| return null; |
| |
| return this.nodes_[id]; |
| }, |
| |
| zoomOut_: function() { |
| window.history.back(); |
| }, |
| |
| // This function assumes that, till the given depth, |
| // the tree is linear. (i.e, a single string with no branches.) |
| zoomToDepth: function(depth) { |
| var node = this.data_.nodes; |
| while (node.depth !== depth) { |
| if (node.children.length !== 1) |
| throw new Error('zoomToDepth requires the tree to be linear ' + |
| 'till the specified depth.'); |
| node = node.children[0]; |
| } |
| return this.zoomToID_(node.id); |
| }, |
| |
| zoomToID_: function(id) { |
| var d = this.getNodeById_(id); |
| |
| if (d) { |
| this.clickedY_ = d.y; |
| this.minX_ = d.x; |
| this.maxX_ = d.x + d.dx; |
| this.minY_ = d.y; |
| } |
| else { |
| this.clickedY_ = -1; |
| this.minX_ = 0.0; |
| this.maxX_ = 1.0; |
| this.minY_ = 0.0; |
| } |
| |
| this.selectedNode_ = d; |
| this.redrawSegments_(this.minX_, this.maxX_, this.minY_); |
| var path = this.vis_.selectAll('path'); |
| |
| path.transition() |
| .duration(750) |
| .attrTween('d', this.arcTween_(this.minX_, this.maxX_, this.minY_)); |
| |
| this.showBreadcrumbs_(d); |
| |
| var e = new Event('node-selected'); |
| e.node = d; |
| this.dispatchEvent(e); |
| }, |
| |
| click_: function(d) { |
| if (d3.event.shiftKey) { |
| // Zoom partially onto the selected range |
| var diff_x = (this.maxX_ - this.minX_) * 0.5; |
| this.minX_ = d.x + d.dx * 0.5 - diff_x * 0.5; |
| this.minX_ = this.minX_ < 0.0 ? 0.0 : this.minX_; |
| this.maxX_ = this.minX_ + diff_x; |
| this.maxX_ = this.maxX_ > 1.0 ? 1.0 : this.maxX_; |
| this.minX_ = this.maxX_ - diff_x; |
| |
| this.selectedNode_ = d; |
| this.redrawSegments_(this.minX_, this.maxX_, this.minY_); |
| |
| var path = this.vis_.selectAll('path'); |
| path.transition() |
| .duration(750) |
| .attrTween('d', this.arcTween_(this.minX_, this.maxX_, this.minY_)); |
| |
| return; |
| } |
| |
| this.selectedNodeID = d.id; |
| |
| var e = new Event('node-clicked'); |
| e.node = d; |
| this.dispatchEvent(e); |
| }, |
| |
| // Given a node in a partition layout, return an array of all of its |
| // ancestor nodes, highest first, but excluding the root. |
| getAncestors_: function(node) { |
| var path = []; |
| var current = node; |
| while (current.parent) { |
| path.unshift(current); |
| current = current.parent; |
| } |
| return path; |
| }, |
| |
| showBreadcrumbs_: function(d) { |
| var sequenceArray = this.getAncestors_(d); |
| |
| // Fade all the segments. |
| this.vis_.selectAll('path') |
| .style('opacity', function(d) { |
| return sequenceArray.indexOf(d) >= 0 ? 0.7 : 1.0; |
| }); |
| |
| var e = new Event('node-highlighted'); |
| e.node = d; |
| this.dispatchEvent(e); |
| |
| //if (this.data_.onNodeHighlighted != undefined) |
| // this.data_.onNodeHighlighted(this, d); |
| }, |
| |
| mouseOver_: function(d) { |
| this.showBreadcrumbs_(d); |
| }, |
| |
| // Restore everything to full opacity when moving off the |
| // visualization. |
| mouseLeave_: function(d) { |
| var that = this; |
| // Hide the breadcrumb trail |
| if (that.selectedNode_ != null) |
| that.showBreadcrumbs_(that.selectedNode_); |
| else { |
| // Deactivate all segments during transition. |
| that.vis_.selectAll('path') |
| .on('mouseover', null); |
| |
| // Transition each segment to full opacity and then reactivate it. |
| that.vis_.selectAll('path') |
| .transition() |
| .duration(300) |
| .style('opacity', 1) |
| .each('end', function() { |
| d3.select(that).on('mouseover', function(d) { |
| that.mouseOver_(d); |
| }); |
| }); |
| } |
| }, |
| |
| // Update visible segments between new min/max ranges. |
| redrawSegments_: function(minX, maxX, minY) { |
| var that = this; |
| var scale = maxX - minX; |
| var visible_nodes = that.nodes_.filter(function(d) { |
| return d.depth && |
| (d.y >= minY) && |
| (d.x < maxX) && |
| (d.x + d.dx > minX) && |
| (d.dx / scale > 0.001); |
| }); |
| var path = that.vis_.data([that.data_.nodes]).selectAll('path') |
| .data(visible_nodes, function(d) { return d.id; }); |
| |
| path.enter().insert('svg:path') |
| .attr('d', that.arc_) |
| .attr('fill-rule', 'evenodd') |
| .style('fill', function(dd) { return getColorOfKey(dd.category); }) |
| .style('opacity', 1.0) |
| .on('mouseover', function(d) { that.mouseOver_(d); }) |
| .on('click', function(d) { that.click_(d); }); |
| |
| path.exit().remove(); |
| return path; |
| }, |
| |
| updateContents_: function() { |
| ChartBase.prototype.updateContents_.call(this); |
| if (!this.data_) |
| return; |
| |
| var that = this; |
| |
| // Partition data into d3 nodes. |
| var partition = d3.layout.partition() |
| .size([1, 1]) |
| .value(function(d) { return d.size; }); |
| that.nodes_ = partition.nodes(that.data_.nodes); |
| |
| // Allocate an id to each node. Gather all categories. |
| var categoryDict = {}; |
| that.nodes_.forEach(function f(d, i) { |
| d.id = i; |
| categoryDict[d.category] = null; |
| }); |
| |
| // Create legend. |
| var li = { |
| w: 85, h: 20, s: 3, r: 3 |
| }; |
| |
| var legend = that.legendSel_.append('svg:svg') |
| .attr('width', li.w) |
| .attr('height', d3.keys(categoryDict).length * (li.h + li.s)); |
| |
| var g = legend.selectAll('g') |
| .data(d3.keys(categoryDict)) |
| .enter().append('svg:g') |
| .attr('transform', function(d, i) { |
| return 'translate(0,' + i * (li.h + li.s) + ')'; |
| }); |
| |
| g.append('svg:rect') |
| .attr('rx', li.r) |
| .attr('ry', li.r) |
| .attr('width', li.w) |
| .attr('height', li.h) |
| .style('fill', function(d) { return getColorOfKey(d); }); |
| |
| g.append('svg:text') |
| .attr('x', li.w / 2) |
| .attr('y', li.h / 2) |
| .attr('dy', '0.35em') |
| .attr('text-anchor', 'middle') |
| .attr('fill', '#fff') |
| .attr('font-size', '12px') |
| .text(function(d) { return d; }); |
| |
| // Create sunburst visualization. |
| var width = that.chartAreaSize.width; |
| var height = that.chartAreaSize.height; |
| that.radius_ = Math.max(MIN_RADIUS, Math.min(width, height) / 2); |
| |
| d3.select(that.pieGroup_).attr( |
| 'transform', |
| 'translate(' + width / 2 + ',' + height / 2 + ')'); |
| |
| that.selectedNode_ = null; |
| |
| var depth = 1.0 + d3.max(that.nodes_, function(d) { return d.depth; }); |
| that.yDomainMin_ = 1.0 / depth; |
| that.yDomainMax_ = Math.min(Math.max(depth, 20), 50) / depth; |
| |
| that.xDomainScale_ = d3.scale.linear() |
| .range([0, 2 * Math.PI]); |
| |
| that.yDomainScale_ = d3.scale.sqrt() |
| .domain([that.yDomainMin_, that.yDomainMax_]) |
| .range([50, that.radius_]); |
| |
| that.arc_ = d3.svg.arc() |
| .startAngle(function(d) { |
| return Math.max(0, Math.min(2 * Math.PI, that.xDomainScale_(d.x))); |
| }) |
| .endAngle(function(d) { |
| return Math.max(0, |
| Math.min(2 * Math.PI, that.xDomainScale_(d.x + d.dx))); |
| }) |
| .innerRadius(function(d) { |
| return Math.max(0, that.yDomainScale_((d.y))); |
| }) |
| .outerRadius(function(d) { |
| return Math.max(0, that.yDomainScale_((d.y + d.dy))); |
| }); |
| |
| |
| // Bounding circle underneath the sunburst, to make it easier to detect |
| // when the mouse leaves the parent g. |
| that.backSel_.append('svg:circle') |
| .attr('r', that.radius_) |
| .style('opacity', 0.0) |
| .on('click', function() { that.zoomOut_(); }); |
| |
| |
| that.vis_ = d3.select(that.pathsGroup_); |
| that.selectedNodeID = 0; |
| that.vis_.on('mouseleave', function(d) { that.mouseLeave_(d); }); |
| }, |
| |
| updateHighlight_: function() { |
| ChartBase.prototype.updateHighlight_.call(this); |
| // Update color of pie segments. |
| var pathsGroupSel = d3.select(this.pathsGroup_); |
| var that = this; |
| pathsGroupSel.selectAll('.arc').each(function(d, i) { |
| var origData = that.data_[i]; |
| var highlighted = origData.label == that.currentHighlightedLegendKey; |
| var color = getColorOfKey(origData.label, highlighted); |
| this.style.fill = getColorOfKey(origData.label, highlighted); |
| }); |
| } |
| }; |
| |
| return { |
| SunburstChart: SunburstChart |
| }; |
| }); |
| </script> |