| <!-- The <cluster-sk> custom element declaration. |
| |
| Displays digests in a force layout, i.e. as svg circles as if they were |
| attached via springs that were proportional to the distance between the |
| digest images. |
| |
| Attributes: |
| disabled: Boolean, if set to true the keyboard shortcuts will be disabled. |
| |
| Events: |
| digest-select - Event generated when a digest is selected. The event |
| detail is an array containing the selected digests. |
| |
| Methods: |
| setData(data) - Data describing digests in a d3 compatible format. i.e.: |
| |
| { |
| "nodes": [ |
| {"name":"a3801be6d11aa0c9316daaa0b22ca3ac"}, |
| {"name":"0ae9d1ee00a9f6149a61dd1c2c372fc3"} |
| ], |
| "links":[ |
| {"source":0, "target":0, "value":0}, |
| {"source":0, "target":1, "value":5.0775}, |
| {"source":0, "target":2, "value":5.0725}, |
| ] |
| } |
| |
| startUse - to be called before the element is atively being used. |
| It registers the necessary keyboard shortcuts. |
| |
| endUse - to be called once the element is not used any more. |
| |
| setTextNodes(text, additive) - Takes a map from digests to the text to display |
| next to the digest node. |
| |
| If additive is true then just add the text to nodes in addition to |
| existing text, else remove all existing text first. |
| |
| newTriageStatus(digests, status) - Set the new triage status for the array |
| of given digests. |
| --> |
| <link rel="import" href="bower_components/iron-icons/iron-icons.html"> |
| |
| <link rel="import" href="help-dialog-sk.html"> |
| <link rel="import" href="shared-styles.html"> |
| |
| <dom-module id="cluster-sk"> |
| <template> |
| <style type="text/css" media="screen" include="shared-styles"> |
| ::content .link { |
| stroke: #ccc; |
| } |
| |
| ::content .node { |
| cursor: pointer; |
| fill: #ccc; |
| stroke: #000; |
| stroke-width: 1.5px; |
| } |
| |
| ::content .node.positive circle { |
| fill: #1B9E77; |
| } |
| |
| ::content .node.negative circle { |
| fill: #E7298A; |
| } |
| |
| ::content .node.untriaged circle { |
| fill: #A6761D; |
| } |
| |
| ::content .node.selected circle { |
| stroke-width: 5px; |
| } |
| |
| :host { |
| display: block; |
| width: 100%; |
| } |
| |
| ::content svg { |
| width: 100%; |
| } |
| |
| ::content .selectionRect { |
| stroke-width: 1px; |
| stroke-dasharray: 5px; |
| stroke: black; |
| stroke-opacity: 0.7; |
| fill: transparent; |
| } |
| </style> |
| |
| <div hidden$="{{!_data}}"><a on-tap="_openHelpDialog"><iron-icon icon="help"></iron-icon></a></div> |
| <!-- help dialog --> |
| <help-dialog-sk id="helpDialog"> |
| <table> |
| <tr><th>A</th><td>Increase spring size</td></tr> |
| <tr><th>Z</th><td>Descrease spring size</td></tr> |
| <tr><th>S</th><td>Descrease node overlap</td></tr> |
| <tr><th>X</th><td>Increase node overlap</td></tr> |
| <tr><th>?</th><td>Help</td></tr> |
| <tr><th>Ctrl+Click</th><td>Add to selection, works on both nodes and |
| parameters. |
| </td></tr> |
| </table> |
| </help-dialog-sk> |
| <content> |
| <svg id="cluster"></svg> |
| </content> |
| </template> |
| <script> |
| Polymer({ |
| is: 'cluster-sk', |
| |
| properties: { |
| _data: { |
| type: Object, |
| value: null |
| }, |
| disabled: { |
| type: Boolean, |
| value: false, |
| notify: true |
| } |
| }, |
| |
| ready: function () { |
| // The list of selected digests. |
| this.selected = []; |
| // Controls the link distance multiplier between nodes. |
| this.scale = 10; |
| // Controls the electrostatic charge between nodes, used in the |
| // Barnes-Hut layout. |
| this.charge = -100; |
| this.width = 960; |
| this.height = 500; |
| |
| // d3 force layout object. |
| this.force = null; |
| this.dragging = false; |
| |
| // Wire up mouse events. |
| this.svg = d3.select(this.$.cluster) |
| .attr('width', this.width) |
| .attr('height', this.height) |
| .on('mousedown', this._mouseDown.bind(this)) |
| .on('mousemove', this._mouseMove.bind(this)) |
| .on('mouseup', this._mouseUp.bind(this)); |
| }, |
| |
| setData: function(data) { |
| this._data = data; |
| this._reloadSVG(); |
| }, |
| |
| startUse: function() { |
| this._active = true; |
| this.width = this.height = -1; |
| this._resizeWatcher(); |
| this.listen(document, 'keypress', '_handleKeyDown'); |
| }, |
| |
| endUse: function() { |
| this._active = false; |
| this.cancelAsync(this._resizeHandle); |
| this.unlisten(document, 'keypress', '_handleKeyDown'); |
| }, |
| |
| newTriageStatus: function (digests, status) { |
| digests.forEach(function (digest) { |
| var ele = $$$('#x' + digest, this); |
| var selected = Polymer.dom(ele).classList.contains('selected'); |
| Polymer.dom(ele).setAttribute('class', 'node ' + status); |
| Polymer.dom(ele).classList.toggle('selected', selected); |
| }.bind(this)); |
| this._data.nodes.forEach(function (e) { |
| if (digests.indexOf(e.name) != -1) { |
| e.status = status; |
| } |
| }.bind(this)); |
| }, |
| |
| // Takes a map from digests to the text to display next to the digest node. |
| // |
| // additive - If true then just add the text to nodes in addition to |
| // existing text, else remove all existing text first. |
| setTextNodes: function (text, additive) { |
| if (!additive) { |
| this._clearTextNodes(); |
| } |
| // Use a data attribute 'data-count' to store the number of non-empty |
| // text strings attached to a node. |
| node = this.svg.selectAll('.node').attr('data-count', function (d) { |
| if (!additive) { |
| return 1; |
| } |
| var count = +d3.select(this).attr('data-count') || 0; |
| if (text[d.name]) { |
| return count + 1; |
| } else { |
| return count; |
| } |
| }).append('text').attr('dx', 14).attr('dy', function (d) { |
| return d3.select(this.parentElement).attr('data-count') * 20; |
| }).text(function (d) { |
| return text[d.name]; |
| }); |
| |
| }, |
| |
| _openHelpDialog: function() { |
| this.$.helpDialog.open(); |
| }, |
| |
| _mouseDown: function () { |
| if (this.dragging) { |
| return; |
| } |
| var p = d3.mouse(this); |
| this.svg.append('rect').attr({ |
| rx: 6, |
| ry: 6, |
| class: 'selectionRect', |
| x: p[0], |
| y: p[1], |
| width: 0, |
| height: 0 |
| }); |
| }, |
| |
| _mouseMove: function () { |
| if (this.dragging) { |
| return; |
| } |
| var s = this.svg.select('.selectionRect'); |
| if (s.empty()) { |
| return; |
| } |
| var p = d3.mouse(this); |
| var d = { |
| x: parseInt(s.attr('x'), 10), |
| y: parseInt(s.attr('y'), 10), |
| width: parseInt(s.attr('width'), 10), |
| height: parseInt(s.attr('height'), 10) |
| }; |
| var dx = p[0] - d.x; |
| var dy = p[1] - d.y; |
| if (dx < 1) { |
| d.x = p[0]; |
| d.width -= dx; |
| } else { |
| d.width = dx; |
| } |
| if (dy < 1) { |
| d.y = p[1]; |
| d.height -= dy; |
| } else { |
| d.height = dy; |
| } |
| s.attr(d); |
| }, |
| |
| _mouseUp: function () { |
| if (this.dragging) { |
| return; |
| } |
| // Clear all old selections. |
| $$('.node.selected').forEach(function (ele) { |
| Polymer.dom(ele).classList.toggle('selected', false); |
| }); |
| // Build the list of selected nodes. |
| this.selected = []; |
| var r = $$$('.selectionRect', this); |
| var hits = $$$('svg', this).getIntersectionList(r.getBBox(), null); |
| for (var i = 0; i < hits.length; i++) { |
| var ele = hits.item(i); |
| if (ele.nodeName == 'circle') { |
| var p = ele.parentElement; |
| Polymer.dom(p).classList.toggle('selected', true); |
| // Note that we use getAttribute because SVG doesn't support |
| // ele.dataset.* type access. |
| this.selected.push(p.getAttribute('data-name')); |
| } |
| } |
| this.fire('digest-select', this.selected.slice()); |
| |
| // Remove the selectionRect. |
| $$('.selectionRect', this.$.svg).forEach(function (ele) { |
| Polymer.dom(ele.parentElement).removeChild(ele); |
| }); |
| }, |
| |
| _handleKeyDown: function (e) { |
| if (this.disabled) { |
| return; |
| } |
| |
| var c = String.fromCharCode(e.keyCode).toUpperCase(); |
| switch (c) { |
| case 'A': |
| this.scale = this.scale * 2; |
| this.force.start(); |
| break; |
| case 'Z': |
| this.scale = this.scale / 2; |
| this.force.start(); |
| break; |
| case 'S': |
| this.charge = this.charge * 2; |
| this.force.start(); |
| break; |
| case 'X': |
| this.charge = this.charge / 2; |
| this.force.start(); |
| break; |
| case '?': |
| this._openHelpDialog(); |
| break; |
| } |
| }, |
| |
| _dragstart: function (d) { |
| this.dragging = true; |
| var e = d3.event.sourceEvent; |
| if (e.ctrlKey || e.shiftKey) { |
| // Ctrl or Shift means to toggle the selected state of a digest. |
| var index = this.selected.indexOf(d.name); |
| Polymer.dom($$$('#x' + d.name, this)).classList.toggle('selected', index == -1); |
| if (index == -1) { |
| this.selected.push(d.name); |
| } else { |
| this.selected.splice(index, 1); |
| } |
| } else { |
| this.selected = [d.name]; |
| $$('.node.selected').forEach(function (ele) { |
| Polymer.dom(ele).classList.toggle('selected', false); |
| }); |
| Polymer.dom($$$('#x' + d.name, this)).classList.toggle('selected', true); |
| } |
| |
| this.fire('digest-select', this.selected.slice()); |
| }, |
| |
| _dragend: function (d) { |
| this.dragging = false; |
| }, |
| |
| _reloadSVG: function () { |
| this.svg.selectAll('.link,.node').remove(); |
| |
| this.force = d3.layout.force() |
| .gravity(0.05) |
| .linkDistance(function (d) { return d.value * this.scale; }.bind(this)) |
| .charge(function () { return this.charge; }.bind(this)) |
| .size([this.width, this.height]) |
| .nodes(this._data.nodes) |
| .links(this._data.links) |
| .start(); |
| |
| this.force.drag() |
| .on('dragstart', this._dragstart.bind(this)) |
| .on('dragend', this._dragend.bind(this)); |
| |
| var link = this.svg.selectAll('.link') |
| .data(this._data.links) |
| .enter() |
| .append('line') |
| .attr('class', 'link'); |
| |
| var node = this.svg.selectAll('.node') |
| .data(this._data.nodes) |
| .enter() |
| .append('g') |
| .attr('class', function (d) { return 'node ' + d.status; }) |
| .attr('data-name', function (d) { return d.name; }) |
| .attr('id', function (d) { return 'x' + d.name; }) |
| .call(this.force.drag); |
| |
| node.append('circle') |
| .attr('r', 12); |
| |
| this.force.on('tick', function () { |
| link |
| .attr('x1', function (d) { return d.source.x; }) |
| .attr('y1', function (d) { return d.source.y; }) |
| .attr('x2', function (d) { return d.target.x; }) |
| .attr('y2', function (d) { return d.target.y; }); |
| |
| node.attr('transform', function (d) { return 'translate(' + d.x + ',' + d.y + ')'; }); |
| }); |
| }, |
| |
| // Watch the size of the svg parents element and when it changes then |
| // then resize the svg element to match. |
| _resizeWatcher: function () { |
| if (this._active) { |
| this._resizeHandle = this.async(this._resizeWatcher.bind(this), 300); |
| } |
| if (!this.force) { |
| return; |
| } |
| |
| var w = Math.round(window.getComputedStyle(this, null).getPropertyValue('width').slice(0, -2)); |
| var h = Math.round(window.getComputedStyle(this, null).getPropertyValue('height').slice(0, -2)); |
| if (w != this.width || h != this.height) { |
| this.svg.attr('width', w).attr('height', h); |
| this.force.size([ |
| w, |
| h |
| ]).start(); |
| |
| // Measure again since the resize of svg might have caused a tiny change in the size of |
| // the parent window. |
| w = Math.round(window.getComputedStyle(this, null).getPropertyValue('width').slice(0, -2)); |
| h = Math.round(window.getComputedStyle(this, null).getPropertyValue('height').slice(0, -2)); |
| this.width = w; |
| this.height = h; |
| } |
| }, |
| |
| // Removes all the text on the digest nodes. |
| _clearTextNodes: function () { |
| var node = this.svg.selectAll('.node text').remove(); |
| } |
| }); |
| </script> |
| </dom-module> |