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.vis_ = undefined;
44 this.nodes_ = undefined;
50 var chartAreaSel = d3.select(this.chartAreaElement);
51 this.legendSel_ = chartAreaSel.append('g');
53 var pieGroupSel = chartAreaSel.append('g')
54 .attr('class', 'pie-group');
55 this.pieGroup_ = pieGroupSel.node();
57 this.backSel_ = pieGroupSel.append('g');
60 this.pathsGroup_ = pieGroupSel.append('g')
61 .attr('class', 'paths')
71 * @param {Data} Data for the chart, where data must be of the
72 * form {category: str, name: str, (size: number or children: [])} .
76 this.updateContents_();
80 var margin = {top: 0, right: 0, bottom: 0, left: 0};
86 set selectedNodeID(id) {
90 get selectedNodeID() {
91 if (this.selectedNode_ != null)
92 return this.selectedNode_.id;
97 if (this.selectedNode_ != null)
98 return this.selectedNode_;
102 getMinSize: function() {
103 if (!tvcm.ui.isElementAttachedToDocument(this))
104 throw new Error('Cannot measure when unattached');
105 this.updateContents_();
107 var titleWidth = this.querySelector(
108 '#title').getBoundingClientRect().width;
109 var margin = this.margin;
110 var marginWidth = margin.left + margin.right;
111 var marginHeight = margin.top + margin.bottom;
113 // TODO(vmiura): Calc this when we're done with layout.
120 getLegendKeys_: function() {
121 // This class creates its own legend, instead of using ChartBase.
125 updateScales_: function(width, height) {
126 if (this.data_ === undefined)
130 // Interpolate the scales!
131 arcTween_: function(minX, maxX, minY) {
136 xd = d3.interpolate(that.xDomainScale_.domain(), [minX, maxX]);
138 that.yDomainScale_.domain(), [minY, that.yDomainMax_]);
139 yr = d3.interpolate(that.yDomainScale_.range(), [50, that.radius_]);
142 xd = d3.interpolate(that.xDomainScale_.domain(), [minX, maxX]);
143 yd = d3.interpolate(that.yDomainScale_.domain(),
144 [that.yDomainMin_, that.yDomainMax_]);
145 yr = d3.interpolate(that.yDomainScale_.range(), [50, that.radius_]);
148 return function(d, i) {
149 return i ? function(t) { return that.arc_(d); }
151 that.xDomainScale_.domain(xd(t));
152 that.yDomainScale_.domain(yd(t)).range(yr(t));
158 getNodeById_: function(id) {
162 if (id < 0 || id > this.nodes_.length)
165 return this.nodes_[id];
168 zoomOut_: function() {
169 window.history.back();
172 // This function assumes that, till the given depth,
173 // the tree is linear. (i.e, a single string with no branches.)
174 zoomToDepth: function(depth) {
175 var node = this.data_.nodes;
176 while (node.depth !== depth) {
177 if (node.children.length !== 1)
178 throw new Error("zoomToDepth requires the tree to be linear " +
179 "till the specified depth.");
180 node = node.children[0];
182 return this.zoomToID_(node.id);
185 zoomToID_: function(id) {
186 var d = this.getNodeById_(id);
189 this.clickedY_ = d.y;
191 this.maxX_ = d.x + d.dx;
201 this.selectedNode_ = d;
202 this.redrawSegments_(this.minX_, this.maxX_, this.minY_);
203 var path = this.vis_.selectAll('path');
207 .attrTween('d', this.arcTween_(this.minX_, this.maxX_, this.minY_));
209 this.showBreadcrumbs_(d);
211 var e = new Event('node-selected');
213 this.dispatchEvent(e);
216 click_: function(d) {
217 if (d3.event.shiftKey) {
218 // Zoom partially onto the selected range
219 var diff_x = (this.maxX_ - this.minX_) * 0.5;
220 this.minX_ = d.x + d.dx * 0.5 - diff_x * 0.5;
221 this.minX_ = this.minX_ < 0.0 ? 0.0 : this.minX_;
222 this.maxX_ = this.minX_ + diff_x;
223 this.maxX_ = this.maxX_ > 1.0 ? 1.0 : this.maxX_;
224 this.minX_ = this.maxX_ - diff_x;
226 this.selectedNode_ = d;
227 this.redrawSegments_(this.minX_, this.maxX_, this.minY_);
229 var path = this.vis_.selectAll('path');
232 .attrTween('d', this.arcTween_(this.minX_, this.maxX_, this.minY_));
237 this.selectedNodeID = d.id;
239 var e = new Event('node-clicked');
241 this.dispatchEvent(e);
244 // Given a node in a partition layout, return an array of all of its
245 // ancestor nodes, highest first, but excluding the root.
246 getAncestors_: function(node) {
249 while (current.parent) {
250 path.unshift(current);
251 current = current.parent;
256 showBreadcrumbs_: function(d) {
257 var sequenceArray = this.getAncestors_(d);
259 // Fade all the segments.
260 this.vis_.selectAll('path')
261 .style('opacity', function(d) {
262 return sequenceArray.indexOf(d) >= 0 ? 0.7 : 1.0;
265 var e = new Event('node-highlighted');
267 this.dispatchEvent(e);
269 //if (this.data_.onNodeHighlighted != undefined)
270 // this.data_.onNodeHighlighted(this, d);
273 mouseOver_: function(d) {
274 this.showBreadcrumbs_(d);
277 // Restore everything to full opacity when moving off the
279 mouseLeave_: function(d) {
281 // Hide the breadcrumb trail
282 if (that.selectedNode_ != null)
283 that.showBreadcrumbs_(that.selectedNode_);
285 // Deactivate all segments during transition.
286 that.vis_.selectAll('path')
287 .on('mouseover', null);
289 // Transition each segment to full opacity and then reactivate it.
290 that.vis_.selectAll('path')
294 .each('end', function() {
295 d3.select(that).on('mouseover', function(d) {
302 // Update visible segments between new min/max ranges.
303 redrawSegments_: function(minX, maxX, minY) {
305 var scale = maxX - minX;
306 var visible_nodes = that.nodes_.filter(function(d) {
310 (d.x + d.dx > minX) &&
311 (d.dx / scale > 0.001);
313 var path = that.vis_.data([that.data_.nodes]).selectAll('path')
314 .data(visible_nodes, function(d) { return d.id; });
316 path.enter().insert('svg:path')
317 .attr('d', that.arc_)
318 .attr('fill-rule', 'evenodd')
319 .style('fill', function(dd) { return getColorOfKey(dd.category); })
320 .style('opacity', 1.0)
321 .on('mouseover', function(d) { that.mouseOver_(d); })
322 .on('click', function(d) { that.click_(d); });
324 path.exit().remove();
328 updateContents_: function() {
329 ChartBase.prototype.updateContents_.call(this);
335 // Partition data into d3 nodes.
336 var partition = d3.layout.partition()
338 .value(function(d) { return d.size; });
339 that.nodes_ = partition.nodes(that.data_.nodes);
341 // Allocate an id to each node. Gather all categories.
342 var categoryDict = {};
343 that.nodes_.forEach(function f(d, i) {
345 categoryDict[d.category] = null;
350 w: 85, h: 20, s: 3, r: 3
353 var legend = that.legendSel_.append('svg:svg')
355 .attr('height', d3.keys(categoryDict).length * (li.h + li.s));
357 var g = legend.selectAll('g')
358 .data(d3.keys(categoryDict))
359 .enter().append('svg:g')
360 .attr('transform', function(d, i) {
361 return 'translate(0,' + i * (li.h + li.s) + ')';
368 .attr('height', li.h)
369 .style('fill', function(d) { return getColorOfKey(d); });
374 .attr('dy', '0.35em')
375 .attr('text-anchor', 'middle')
376 .attr('fill', '#fff')
377 .attr('font-size', '12px')
378 .text(function(d) { return d; });
380 // Create sunburst visualization.
381 var width = that.chartAreaSize.width;
382 var height = that.chartAreaSize.height;
383 that.radius_ = Math.max(MIN_RADIUS, Math.min(width, height) / 2);
385 d3.select(that.pieGroup_).attr(
387 'translate(' + width / 2 + ',' + height / 2 + ')');
389 that.selectedNode_ = null;
391 var depth = 1.0 + d3.max(that.nodes_, function(d) { return d.depth; });
392 that.yDomainMin_ = 1.0 / depth;
393 that.yDomainMax_ = Math.min(Math.max(depth, 20), 50) / depth;
395 that.xDomainScale_ = d3.scale.linear()
396 .range([0, 2 * Math.PI]);
398 that.yDomainScale_ = d3.scale.sqrt()
399 .domain([that.yDomainMin_, that.yDomainMax_])
400 .range([50, that.radius_]);
402 that.arc_ = d3.svg.arc()
403 .startAngle(function(d) {
404 return Math.max(0, Math.min(2 * Math.PI, that.xDomainScale_(d.x)));
406 .endAngle(function(d) {
408 Math.min(2 * Math.PI, that.xDomainScale_(d.x + d.dx)));
410 .innerRadius(function(d) {
411 return Math.max(0, that.yDomainScale_((d.y)));
413 .outerRadius(function(d) {
414 return Math.max(0, that.yDomainScale_((d.y + d.dy)));
418 // Bounding circle underneath the sunburst, to make it easier to detect
419 // when the mouse leaves the parent g.
420 that.backSel_.append('svg:circle')
421 .attr('r', that.radius_)
422 .style('opacity', 0.0)
423 .on('click', function() { that.zoomOut_(); });
426 that.vis_ = d3.select(that.pathsGroup_);
427 that.selectedNodeID = 0;
428 that.vis_.on('mouseleave', function(d) { that.mouseLeave_(d); });
431 updateHighlight_: function() {
432 ChartBase.prototype.updateHighlight_.call(this);
433 // Update color of pie segments.
434 var pathsGroupSel = d3.select(this.pathsGroup_);
436 pathsGroupSel.selectAll('.arc').each(function(d, i) {
437 var origData = that.data_[i];
438 var highlighted = origData.label == that.currentHighlightedLegendKey;
439 var color = getColorOfKey(origData.label, highlighted);
440 this.style.fill = getColorOfKey(origData.label, highlighted);
446 SunburstChart: SunburstChart