3 Copyright (c) 2014 The Chromium Authors. All rights reserved.
4 Use of this source code is governed by a BSD-style license that can be
5 found in the LICENSE file.
7 <link rel="import" href="/tvcm/range.html">
8 <link rel="import" href="/tvcm/ui/d3.html">
9 <link rel="import" href="/tvcm/ui/dom_helpers.html">
10 <link rel="import" href="/tvcm/ui/chart_base.html">
11 <link rel="stylesheet" href="/tvcm/ui/sunburst_chart.css">
15 tvcm.exportTo('tvcm.ui', function() {
16 var ChartBase = tvcm.ui.ChartBase;
17 var getColorOfKey = tvcm.ui.getColorOfKey;
24 var SunburstChart = tvcm.ui.define('sunburst-chart', ChartBase);
26 SunburstChart.prototype = {
27 __proto__: ChartBase.prototype,
29 decorate: function() {
30 ChartBase.prototype.decorate.call(this);
31 this.classList.add('sunburst-chart');
33 this.data_ = undefined;
34 this.seriesKeys_ = undefined;
36 this.yDomainMin_ = 0.0;
37 this.yDomainMax_ = 0.0;
38 this.xDomainScale_ = undefined;
39 this.yDomainScale_ = undefined;
40 this.radius_ = undefined;
41 this.arc_ = undefined;
42 this.selectedNode_ = null;
43 this.clickStack_ = undefined;
44 this.vis_ = undefined;
45 this.nodes_ = undefined;
51 var chartAreaSel = d3.select(this.chartAreaElement);
52 this.legendSel_ = chartAreaSel.append('g');
54 var pieGroupSel = chartAreaSel.append('g')
55 .attr('class', 'pie-group');
56 this.pieGroup_ = pieGroupSel.node();
58 this.backSel_ = pieGroupSel.append('g');
61 this.pathsGroup_ = pieGroupSel.append('g')
62 .attr('class', 'paths')
72 * @param {Data} Data for the chart, where data must be of the
73 * form {category: str, name: str, (size: number or children: [])} .
77 this.updateContents_();
81 var margin = {top: 0, right: 0, bottom: 0, left: 0};
87 set selectedNodeID(id) {
91 get selectedNodeID() {
92 if (this.selectedNode_ != null)
93 return this.selectedNode_.id;
98 if (this.selectedNode_ != null)
99 return this.selectedNode_;
103 getMinSize: function() {
104 if (!tvcm.ui.isElementAttachedToDocument(this))
105 throw new Error('Cannot measure when unattached');
106 this.updateContents_();
108 var titleWidth = this.querySelector(
109 '#title').getBoundingClientRect().width;
110 var margin = this.margin;
111 var marginWidth = margin.left + margin.right;
112 var marginHeight = margin.top + margin.bottom;
114 // TODO(vmiura): Calc this when we're done with layout.
121 getLegendKeys_: function() {
122 // This class creates its own legend, instead of using ChartBase.
126 updateScales_: function(width, height) {
127 if (this.data_ === undefined)
131 // Interpolate the scales!
132 arcTween_: function(minX, maxX, minY) {
137 xd = d3.interpolate(that.xDomainScale_.domain(), [minX, maxX]);
139 that.yDomainScale_.domain(), [minY, that.yDomainMax_]);
140 yr = d3.interpolate(that.yDomainScale_.range(), [50, that.radius_]);
143 xd = d3.interpolate(that.xDomainScale_.domain(), [minX, maxX]);
144 yd = d3.interpolate(that.yDomainScale_.domain(),
145 [that.yDomainMin_, that.yDomainMax_]);
146 yr = d3.interpolate(that.yDomainScale_.range(), [50, that.radius_]);
149 return function(d, i) {
150 return i ? function(t) { return that.arc_(d); }
152 that.xDomainScale_.domain(xd(t));
153 that.yDomainScale_.domain(yd(t)).range(yr(t));
159 getNodeById_: function(id) {
163 if (id < 0 || id > this.nodes_.length)
166 return this.nodes_[id];
169 zoomOut_: function() {
170 if (this.clickStack_.length > 1) {
171 this.clickStack_.pop();
172 this.selectedNodeID = this.clickStack_[this.clickStack_.length - 1];
176 zoomToID_: function(id) {
177 var d = this.getNodeById_(id);
180 this.clickedY_ = d.y;
182 this.maxX_ = d.x + d.dx;
192 this.selectedNode_ = d;
193 this.redrawSegments_(this.minX_, this.maxX_, this.minY_);
194 var path = this.vis_.selectAll('path');
198 .attrTween('d', this.arcTween_(this.minX_, this.maxX_, this.minY_));
200 this.showBreadcrumbs_(d);
202 var e = new Event('node-selected');
204 this.dispatchEvent(e);
207 click_: function(d) {
208 if (d3.event.shiftKey) {
209 // Zoom partially onto the selected range
210 var diff_x = (this.maxX_ - this.minX_) * 0.5;
211 this.minX_ = d.x + d.dx * 0.5 - diff_x * 0.5;
212 this.minX_ = this.minX_ < 0.0 ? 0.0 : this.minX_;
213 this.maxX_ = this.minX_ + diff_x;
214 this.maxX_ = this.maxX_ > 1.0 ? 1.0 : this.maxX_;
215 this.minX_ = this.maxX_ - diff_x;
217 this.selectedNode_ = d;
218 this.redrawSegments_(this.minX_, this.maxX_, this.minY_);
220 var path = this.vis_.selectAll('path');
223 .attrTween('d', this.arcTween_(this.minX_, this.maxX_, this.minY_));
228 if (this.clickStack_[this.clickStack_.length - 1] != d.id) {
229 this.clickStack_.push(d.id);
230 this.selectedNodeID = d.id;
234 // Given a node in a partition layout, return an array of all of its
235 // ancestor nodes, highest first, but excluding the root.
236 getAncestors_: function(node) {
239 while (current.parent) {
240 path.unshift(current);
241 current = current.parent;
246 showBreadcrumbs_: function(d) {
247 var sequenceArray = this.getAncestors_(d);
249 // Fade all the segments.
250 this.vis_.selectAll('path')
251 .style('opacity', function(d) {
252 return sequenceArray.indexOf(d) >= 0 ? 0.7 : 1.0;
255 var e = new Event('node-highlighted');
257 this.dispatchEvent(e);
259 //if (this.data_.onNodeHighlighted != undefined)
260 // this.data_.onNodeHighlighted(this, d);
263 mouseOver_: function(d) {
264 this.showBreadcrumbs_(d);
267 // Restore everything to full opacity when moving off the
269 mouseLeave_: function(d) {
271 // Hide the breadcrumb trail
272 if (that.selectedNode_ != null)
273 that.showBreadcrumbs_(that.selectedNode_);
275 // Deactivate all segments during transition.
276 that.vis_.selectAll('path')
277 .on('mouseover', null);
279 // Transition each segment to full opacity and then reactivate it.
280 that.vis_.selectAll('path')
284 .each('end', function() {
285 d3.select(that).on('mouseover', function(d) {
292 // Update visible segments between new min/max ranges.
293 redrawSegments_: function(minX, maxX, minY) {
295 var scale = maxX - minX;
296 var visible_nodes = that.nodes_.filter(function(d) {
300 (d.x + d.dx > minX) &&
301 (d.dx / scale > 0.001);
303 var path = that.vis_.data([that.data_.nodes]).selectAll('path')
304 .data(visible_nodes, function(d) { return d.id; });
306 path.enter().insert('svg:path')
307 .attr('d', that.arc_)
308 .attr('fill-rule', 'evenodd')
309 .style('fill', function(dd) { return getColorOfKey(dd.category); })
310 .style('opacity', 1.0)
311 .on('mouseover', function(d) { that.mouseOver_(d); })
312 .on('click', function(d) { that.click_(d); });
314 path.exit().remove();
318 updateContents_: function() {
319 ChartBase.prototype.updateContents_.call(this);
325 // Partition data into d3 nodes.
326 var partition = d3.layout.partition()
328 .value(function(d) { return d.size; });
329 that.nodes_ = partition.nodes(that.data_.nodes);
331 // Allocate an id to each node. Gather all categories.
332 var categoryDict = {};
333 that.nodes_.forEach(function f(d, i) {
335 categoryDict[d.category] = null;
340 w: 85, h: 20, s: 3, r: 3
343 var legend = that.legendSel_.append('svg:svg')
345 .attr('height', d3.keys(categoryDict).length * (li.h + li.s));
347 var g = legend.selectAll('g')
348 .data(d3.keys(categoryDict))
349 .enter().append('svg:g')
350 .attr('transform', function(d, i) {
351 return 'translate(0,' + i * (li.h + li.s) + ')';
358 .attr('height', li.h)
359 .style('fill', function(d) { return getColorOfKey(d); });
364 .attr('dy', '0.35em')
365 .attr('text-anchor', 'middle')
366 .attr('fill', '#fff')
367 .attr('font-size', '12px')
368 .text(function(d) { return d; });
370 // Create sunburst visualization.
371 var width = that.chartAreaSize.width;
372 var height = that.chartAreaSize.height;
373 that.radius_ = Math.max(MIN_RADIUS, Math.min(width, height) / 2);
375 d3.select(that.pieGroup_).attr(
377 'translate(' + width / 2 + ',' + height / 2 + ')');
379 that.selectedNode_ = null;
380 that.clickStack_ = new Array();
381 that.clickStack_.push(0);
383 var depth = 1.0 + d3.max(that.nodes_, function(d) { return d.depth; });
384 that.yDomainMin_ = 1.0 / depth;
385 that.yDomainMax_ = Math.min(Math.max(depth, 20), 50) / depth;
387 that.xDomainScale_ = d3.scale.linear()
388 .range([0, 2 * Math.PI]);
390 that.yDomainScale_ = d3.scale.sqrt()
391 .domain([that.yDomainMin_, that.yDomainMax_])
392 .range([50, that.radius_]);
394 that.arc_ = d3.svg.arc()
395 .startAngle(function(d) {
396 return Math.max(0, Math.min(2 * Math.PI, that.xDomainScale_(d.x)));
398 .endAngle(function(d) {
400 Math.min(2 * Math.PI, that.xDomainScale_(d.x + d.dx)));
402 .innerRadius(function(d) {
403 return Math.max(0, that.yDomainScale_((d.y)));
405 .outerRadius(function(d) {
406 return Math.max(0, that.yDomainScale_((d.y + d.dy)));
410 // Bounding circle underneath the sunburst, to make it easier to detect
411 // when the mouse leaves the parent g.
412 that.backSel_.append('svg:circle')
413 .attr('r', that.radius_)
414 .style('opacity', 0.0)
415 .on('click', function() { that.zoomOut_(); });
418 that.vis_ = d3.select(that.pathsGroup_);
419 that.selectedNodeID = 0;
420 that.vis_.on('mouseleave', function(d) { that.mouseLeave_(d); });
423 updateHighlight_: function() {
424 ChartBase.prototype.updateHighlight_.call(this);
425 // Update color of pie segments.
426 var pathsGroupSel = d3.select(this.pathsGroup_);
428 pathsGroupSel.selectAll('.arc').each(function(d, i) {
429 var origData = that.data_[i];
430 var highlighted = origData.label == that.currentHighlightedLegendKey;
431 var color = getColorOfKey(origData.label, highlighted);
432 this.style.fill = getColorOfKey(origData.label, highlighted);
438 SunburstChart: SunburstChart