blob: a5dafc7e198166d073fa63f0216207b3d90d2c91 [file] [log] [blame]
<!-- 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>