Skip to content

Instantly share code, notes, and snippets.

@FrissAnalytics
Last active October 5, 2018 07:37
Show Gist options
  • Save FrissAnalytics/e3af3e8047573585add80becdc32036c to your computer and use it in GitHub Desktop.
Save FrissAnalytics/e3af3e8047573585add80becdc32036c to your computer and use it in GitHub Desktop.
Interactive tool for creating directed graphs using d3.js.
license: mit

directed-graph-creator

Interactive tool for creating directed graphs, created using d3.js.

Demo: http://bl.ocks.org/cjrd/6863459

Operation:

  • drag/scroll to translate/zoom the graph
  • shift-click on graph to create a node
  • shift-click on a node and then drag to another node to connect them with a directed edge
  • shift-click on a node to change its title
  • click on node or edge and press backspace/delete to delete

Run:

Github repo is at https://github.com/metacademy/directed-graph-creator

License: MIT/X

forked from cjrd's block: Interactive tool for creating directed graphs using d3.js.

�PNG

IHDR���>a�6IDATx���M� a��>n\�( d#��Qle͊R�J֔�p�|lX���XX�b)KE�,(�H)�r����i��{;͜33��������I�d�3ϯs;�g"#�Mt}�m�L�얙'sO�k��EV,��/������.���/���8"s���ծ/����$st��e�c]_X��C� |0\��Pp�C� |0\��Pp�C� |0\��Pp�C� |0\��Pp�C��T ��G�G����M�|�Y,+e޷���<�9$�:�<)���!�SfA �b�̇Г���e��nZ
ev�x�� =ɸ�/�e~5����K�7��-�ӯOd�����e��xL����O�< �*�<)l����>�ƻ���.�_c��>G8O�w˲���Kݛ��c�%`I��a��з�_
� ��@@H|�@��� ]H\� � � >�t Ap����.�6e�\>�yx�T6�l��\}�y@:(s-�������K��t��n����a���;���b�;'s���bP����ʬ��� ��,-8�ALE��>ɬ��� ]�9^�X��-_� s"�s�4)sKfo��u�0l�wd��������� @[��0�Q�g務[�v�����0P M��| �"h���k()A]]-_���"����kQUt�| ���
�>,_@��m�=%��E0
���ߕٗ��| 5��`�>-_@ͪ ��*�7�O��РQ�ɬ�pl�����a�T���k(A���XS}X���EЗ�k�TU}Z����Bз�k�\�>._@��u�5� $��o�4 �t�u����x0���x0���x0���x0���x0���x0���x0�X�(�=٭��"����� ���`</
���p���G>IEND�B`�
body{
margin: 0;
padding: 0;
overflow:hidden;
}
p{
text-align: center;
overflow: overlay;
position: relative;
}
body{
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
background-color: rgb(248, 248, 248)
}
#toolbox{
position: absolute;
bottom: 0;
left: 0;
margin-bottom: 0.5em;
margin-left: 1em;
border: 2px solid #EEEEEE;
border-radius: 5px;
padding: 1em;
z-index: 5;
}
#toolbox input{
width: 30px;
opacity: 0.4;
}
#toolbox input:hover{
opacity: 1;
cursor: pointer;
}
#hidden-file-upload{
display: none;
}
#download-input{
margin: 0 0.5em;
}
.conceptG text{
pointer-events: none;
}
marker{
fill: #333;
}
g.conceptG circle{
fill: #F6FBFF;
stroke: #333;
stroke-width: 2px;
}
g.conceptG:hover circle{
fill: rgb(200, 238, 241);
}
g.selected circle{
fill: rgb(250, 232, 255);
}
g.selected:hover circle{
fill: rgb(250, 232, 255);
}
path.link {
fill: none;
stroke: #333;
stroke-width: 6px;
cursor: default;
}
path.link:hover{
stroke: rgb(94, 196, 204);
}
g.connect-node circle{
fill: #BEFFFF;
}
path.link.hidden{
stroke-width: 0;
}
path.link.selected {
stroke: rgb(229, 172, 247);
}
document.onload = (function(d3, saveAs, Blob, undefined){
"use strict";
// define graphcreator object
var GraphCreator = function(svg, nodes, edges){
var thisGraph = this;
thisGraph.idct = 0;
thisGraph.nodes = nodes || [];
thisGraph.edges = edges || [];
thisGraph.state = {
selectedNode: null,
selectedEdge: null,
mouseDownNode: null,
mouseDownLink: null,
justDragged: false,
justScaleTransGraph: false,
lastKeyDown: -1,
shiftNodeDrag: false,
selectedText: null
};
// define arrow markers for graph links
var defs = svg.append('svg:defs');
defs.append('svg:marker')
.attr('id', 'end-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', "32")
.attr('markerWidth', 3.5)
.attr('markerHeight', 3.5)
.attr('orient', 'auto')
.append('svg:path')
.attr('d', 'M0,-5L10,0L0,5');
// define arrow markers for leading arrow
defs.append('svg:marker')
.attr('id', 'mark-end-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 7)
.attr('markerWidth', 3.5)
.attr('markerHeight', 3.5)
.attr('orient', 'auto')
.append('svg:path')
.attr('d', 'M0,-5L10,0L0,5');
thisGraph.svg = svg;
thisGraph.svgG = svg.append("g")
.classed(thisGraph.consts.graphClass, true);
var svgG = thisGraph.svgG;
// displayed when dragging between nodes
thisGraph.dragLine = svgG.append('svg:path')
.attr('class', 'link dragline hidden')
.attr('d', 'M0,0L0,0')
.style('marker-end', 'url(#mark-end-arrow)');
// svg nodes and edges
thisGraph.paths = svgG.append("g").selectAll("g");
thisGraph.circles = svgG.append("g").selectAll("g");
thisGraph.drag = d3.behavior.drag()
.origin(function(d){
return {x: d.x, y: d.y};
})
.on("drag", function(args){
thisGraph.state.justDragged = true;
thisGraph.dragmove.call(thisGraph, args);
})
.on("dragend", function() {
// todo check if edge-mode is selected
});
// listen for key events
d3.select(window).on("keydown", function(){
thisGraph.svgKeyDown.call(thisGraph);
})
.on("keyup", function(){
thisGraph.svgKeyUp.call(thisGraph);
});
svg.on("mousedown", function(d){thisGraph.svgMouseDown.call(thisGraph, d);});
svg.on("mouseup", function(d){thisGraph.svgMouseUp.call(thisGraph, d);});
// listen for dragging
var dragSvg = d3.behavior.zoom()
.on("zoom", function(){
if (d3.event.sourceEvent.shiftKey){
// TODO the internal d3 state is still changing
return false;
} else{
thisGraph.zoomed.call(thisGraph);
}
return true;
})
.on("zoomstart", function(){
var ael = d3.select("#" + thisGraph.consts.activeEditId).node();
if (ael){
ael.blur();
}
if (!d3.event.sourceEvent.shiftKey) d3.select('body').style("cursor", "move");
})
.on("zoomend", function(){
d3.select('body').style("cursor", "auto");
});
svg.call(dragSvg).on("dblclick.zoom", null);
// listen for resize
window.onresize = function(){thisGraph.updateWindow(svg);};
// handle download data
d3.select("#download-input").on("click", function(){
var saveEdges = [];
thisGraph.edges.forEach(function(val, i){
saveEdges.push({source: val.source.id, target: val.target.id});
});
var blob = new Blob([window.JSON.stringify({"nodes": thisGraph.nodes, "edges": saveEdges})], {type: "text/plain;charset=utf-8"});
saveAs(blob, "mydag.json");
});
// handle uploaded data
d3.select("#upload-input").on("click", function(){
document.getElementById("hidden-file-upload").click();
});
d3.select("#hidden-file-upload").on("change", function(){
if (window.File && window.FileReader && window.FileList && window.Blob) {
var uploadFile = this.files[0];
var filereader = new window.FileReader();
filereader.onload = function(){
var txtRes = filereader.result;
// TODO better error handling
try{
var jsonObj = JSON.parse(txtRes);
thisGraph.deleteGraph(true);
thisGraph.nodes = jsonObj.nodes;
thisGraph.setIdCt(jsonObj.nodes.length + 1);
var newEdges = jsonObj.edges;
newEdges.forEach(function(e, i){
newEdges[i] = {source: thisGraph.nodes.filter(function(n){return n.id == e.source;})[0],
target: thisGraph.nodes.filter(function(n){return n.id == e.target;})[0]};
});
thisGraph.edges = newEdges;
thisGraph.updateGraph();
}catch(err){
window.alert("Error parsing uploaded file\nerror message: " + err.message);
return;
}
};
filereader.readAsText(uploadFile);
} else {
alert("Your browser won't let you save this graph -- try upgrading your browser to IE 10+ or Chrome or Firefox.");
}
});
// handle delete graph
d3.select("#delete-graph").on("click", function(){
thisGraph.deleteGraph(false);
});
};
GraphCreator.prototype.setIdCt = function(idct){
this.idct = idct;
};
GraphCreator.prototype.consts = {
selectedClass: "selected",
connectClass: "connect-node",
circleGClass: "conceptG",
graphClass: "graph",
activeEditId: "active-editing",
BACKSPACE_KEY: 8,
DELETE_KEY: 46,
ENTER_KEY: 13,
nodeRadius: 50
};
/* PROTOTYPE FUNCTIONS */
GraphCreator.prototype.dragmove = function(d) {
var thisGraph = this;
if (thisGraph.state.shiftNodeDrag){
thisGraph.dragLine.attr('d', 'M' + d.x + ',' + d.y + 'L' + d3.mouse(thisGraph.svgG.node())[0] + ',' + d3.mouse(this.svgG.node())[1]);
} else{
d.x += d3.event.dx;
d.y += d3.event.dy;
thisGraph.updateGraph();
}
};
GraphCreator.prototype.deleteGraph = function(skipPrompt){
var thisGraph = this,
doDelete = true;
if (!skipPrompt){
doDelete = window.confirm("Press OK to delete this graph");
}
if(doDelete){
thisGraph.nodes = [];
thisGraph.edges = [];
thisGraph.updateGraph();
}
};
/* select all text in element: taken from http://stackoverflow.com/questions/6139107/programatically-select-text-in-a-contenteditable-html-element */
GraphCreator.prototype.selectElementContents = function(el) {
var range = document.createRange();
range.selectNodeContents(el);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
};
/* insert svg line breaks: taken from http://stackoverflow.com/questions/13241475/how-do-i-include-newlines-in-labels-in-d3-charts */
GraphCreator.prototype.insertTitleLinebreaks = function (gEl, title) {
var words = title.split(/\s+/g),
nwords = words.length;
var el = gEl.append("text")
.attr("text-anchor","middle")
.attr("dy", "-" + (nwords-1)*7.5);
for (var i = 0; i < words.length; i++) {
var tspan = el.append('tspan').text(words[i]);
if (i > 0)
tspan.attr('x', 0).attr('dy', '15');
}
};
// remove edges associated with a node
GraphCreator.prototype.spliceLinksForNode = function(node) {
var thisGraph = this,
toSplice = thisGraph.edges.filter(function(l) {
return (l.source === node || l.target === node);
});
toSplice.map(function(l) {
thisGraph.edges.splice(thisGraph.edges.indexOf(l), 1);
});
};
GraphCreator.prototype.replaceSelectEdge = function(d3Path, edgeData){
var thisGraph = this;
d3Path.classed(thisGraph.consts.selectedClass, true);
if (thisGraph.state.selectedEdge){
thisGraph.removeSelectFromEdge();
}
thisGraph.state.selectedEdge = edgeData;
};
GraphCreator.prototype.replaceSelectNode = function(d3Node, nodeData){
var thisGraph = this;
d3Node.classed(this.consts.selectedClass, true);
if (thisGraph.state.selectedNode){
thisGraph.removeSelectFromNode();
}
thisGraph.state.selectedNode = nodeData;
};
GraphCreator.prototype.removeSelectFromNode = function(){
var thisGraph = this;
thisGraph.circles.filter(function(cd){
return cd.id === thisGraph.state.selectedNode.id;
}).classed(thisGraph.consts.selectedClass, false);
thisGraph.state.selectedNode = null;
};
GraphCreator.prototype.removeSelectFromEdge = function(){
var thisGraph = this;
thisGraph.paths.filter(function(cd){
return cd === thisGraph.state.selectedEdge;
}).classed(thisGraph.consts.selectedClass, false);
thisGraph.state.selectedEdge = null;
};
GraphCreator.prototype.pathMouseDown = function(d3path, d){
var thisGraph = this,
state = thisGraph.state;
d3.event.stopPropagation();
state.mouseDownLink = d;
if (state.selectedNode){
thisGraph.removeSelectFromNode();
}
var prevEdge = state.selectedEdge;
if (!prevEdge || prevEdge !== d){
thisGraph.replaceSelectEdge(d3path, d);
} else{
thisGraph.removeSelectFromEdge();
}
};
// mousedown on node
GraphCreator.prototype.circleMouseDown = function(d3node, d){
var thisGraph = this,
state = thisGraph.state;
d3.event.stopPropagation();
state.mouseDownNode = d;
if (d3.event.shiftKey){
state.shiftNodeDrag = d3.event.shiftKey;
// reposition dragged directed edge
thisGraph.dragLine.classed('hidden', false)
.attr('d', 'M' + d.x + ',' + d.y + 'L' + d.x + ',' + d.y);
return;
}
};
/* place editable text on node in place of svg text */
GraphCreator.prototype.changeTextOfNode = function(d3node, d){
var thisGraph= this,
consts = thisGraph.consts,
htmlEl = d3node.node();
d3node.selectAll("text").remove();
var nodeBCR = htmlEl.getBoundingClientRect(),
curScale = nodeBCR.width/consts.nodeRadius,
placePad = 5*curScale,
useHW = curScale > 1 ? nodeBCR.width*0.71 : consts.nodeRadius*1.42;
// replace with editableconent text
var d3txt = thisGraph.svg.selectAll("foreignObject")
.data([d])
.enter()
.append("foreignObject")
.attr("x", nodeBCR.left + placePad )
.attr("y", nodeBCR.top + placePad)
.attr("height", 2*useHW)
.attr("width", useHW)
.append("xhtml:p")
.attr("id", consts.activeEditId)
.attr("contentEditable", "true")
.text(d.title)
.on("mousedown", function(d){
d3.event.stopPropagation();
})
.on("keydown", function(d){
d3.event.stopPropagation();
if (d3.event.keyCode == consts.ENTER_KEY && !d3.event.shiftKey){
this.blur();
}
})
.on("blur", function(d){
d.title = this.textContent;
thisGraph.insertTitleLinebreaks(d3node, d.title);
d3.select(this.parentElement).remove();
});
return d3txt;
};
// mouseup on nodes
GraphCreator.prototype.circleMouseUp = function(d3node, d){
var thisGraph = this,
state = thisGraph.state,
consts = thisGraph.consts;
// reset the states
state.shiftNodeDrag = false;
d3node.classed(consts.connectClass, false);
var mouseDownNode = state.mouseDownNode;
if (!mouseDownNode) return;
thisGraph.dragLine.classed("hidden", true);
if (mouseDownNode !== d){
// we're in a different node: create new edge for mousedown edge and add to graph
var newEdge = {source: mouseDownNode, target: d};
var filtRes = thisGraph.paths.filter(function(d){
if (d.source === newEdge.target && d.target === newEdge.source){
thisGraph.edges.splice(thisGraph.edges.indexOf(d), 1);
}
return d.source === newEdge.source && d.target === newEdge.target;
});
if (!filtRes[0].length){
thisGraph.edges.push(newEdge);
thisGraph.updateGraph();
}
} else{
// we're in the same node
if (state.justDragged) {
// dragged, not clicked
state.justDragged = false;
} else{
// clicked, not dragged
if (d3.event.shiftKey){
// shift-clicked node: edit text content
var d3txt = thisGraph.changeTextOfNode(d3node, d);
var txtNode = d3txt.node();
thisGraph.selectElementContents(txtNode);
txtNode.focus();
} else{
if (state.selectedEdge){
thisGraph.removeSelectFromEdge();
}
var prevNode = state.selectedNode;
if (!prevNode || prevNode.id !== d.id){
thisGraph.replaceSelectNode(d3node, d);
} else{
thisGraph.removeSelectFromNode();
}
}
}
}
state.mouseDownNode = null;
return;
}; // end of circles mouseup
// mousedown on main svg
GraphCreator.prototype.svgMouseDown = function(){
this.state.graphMouseDown = true;
};
// mouseup on main svg
GraphCreator.prototype.svgMouseUp = function(){
var thisGraph = this,
state = thisGraph.state;
if (state.justScaleTransGraph) {
// dragged not clicked
state.justScaleTransGraph = false;
} else if (state.graphMouseDown && d3.event.shiftKey){
// clicked not dragged from svg
var xycoords = d3.mouse(thisGraph.svgG.node()),
d = {id: thisGraph.idct++, title: "new concept", x: xycoords[0], y: xycoords[1]};
thisGraph.nodes.push(d);
thisGraph.updateGraph();
// make title of text immediently editable
var d3txt = thisGraph.changeTextOfNode(thisGraph.circles.filter(function(dval){
return dval.id === d.id;
}), d),
txtNode = d3txt.node();
thisGraph.selectElementContents(txtNode);
txtNode.focus();
} else if (state.shiftNodeDrag){
// dragged from node
state.shiftNodeDrag = false;
thisGraph.dragLine.classed("hidden", true);
}
state.graphMouseDown = false;
};
// keydown on main svg
GraphCreator.prototype.svgKeyDown = function() {
var thisGraph = this,
state = thisGraph.state,
consts = thisGraph.consts;
// make sure repeated key presses don't register for each keydown
if(state.lastKeyDown !== -1) return;
state.lastKeyDown = d3.event.keyCode;
var selectedNode = state.selectedNode,
selectedEdge = state.selectedEdge;
switch(d3.event.keyCode) {
case consts.BACKSPACE_KEY:
case consts.DELETE_KEY:
d3.event.preventDefault();
if (selectedNode){
thisGraph.nodes.splice(thisGraph.nodes.indexOf(selectedNode), 1);
thisGraph.spliceLinksForNode(selectedNode);
state.selectedNode = null;
thisGraph.updateGraph();
} else if (selectedEdge){
thisGraph.edges.splice(thisGraph.edges.indexOf(selectedEdge), 1);
state.selectedEdge = null;
thisGraph.updateGraph();
}
break;
}
};
GraphCreator.prototype.svgKeyUp = function() {
this.state.lastKeyDown = -1;
};
// call to propagate changes to graph
GraphCreator.prototype.updateGraph = function(){
var thisGraph = this,
consts = thisGraph.consts,
state = thisGraph.state;
thisGraph.paths = thisGraph.paths.data(thisGraph.edges, function(d){
return String(d.source.id) + "+" + String(d.target.id);
});
var paths = thisGraph.paths;
// update existing paths
paths.style('marker-end', 'url(#end-arrow)')
.classed(consts.selectedClass, function(d){
return d === state.selectedEdge;
})
.attr("d", function(d){
return "M" + d.source.x + "," + d.source.y + "L" + d.target.x + "," + d.target.y;
});
// add new paths
paths.enter()
.append("path")
.style('marker-end','url(#end-arrow)')
.classed("link", true)
.attr("d", function(d){
return "M" + d.source.x + "," + d.source.y + "L" + d.target.x + "," + d.target.y;
})
.on("mousedown", function(d){
thisGraph.pathMouseDown.call(thisGraph, d3.select(this), d);
}
)
.on("mouseup", function(d){
state.mouseDownLink = null;
});
// remove old links
paths.exit().remove();
// update existing nodes
thisGraph.circles = thisGraph.circles.data(thisGraph.nodes, function(d){ return d.id;});
thisGraph.circles.attr("transform", function(d){return "translate(" + d.x + "," + d.y + ")";});
// add new nodes
var newGs= thisGraph.circles.enter()
.append("g");
newGs.classed(consts.circleGClass, true)
.attr("transform", function(d){return "translate(" + d.x + "," + d.y + ")";})
.on("mouseover", function(d){
if (state.shiftNodeDrag){
d3.select(this).classed(consts.connectClass, true);
}
})
.on("mouseout", function(d){
d3.select(this).classed(consts.connectClass, false);
})
.on("mousedown", function(d){
thisGraph.circleMouseDown.call(thisGraph, d3.select(this), d);
})
.on("mouseup", function(d){
thisGraph.circleMouseUp.call(thisGraph, d3.select(this), d);
})
.call(thisGraph.drag);
newGs.append("circle")
.attr("r", String(consts.nodeRadius));
newGs.each(function(d){
thisGraph.insertTitleLinebreaks(d3.select(this), d.title);
});
// remove old nodes
thisGraph.circles.exit().remove();
};
GraphCreator.prototype.zoomed = function(){
this.state.justScaleTransGraph = true;
d3.select("." + this.consts.graphClass)
.attr("transform", "translate(" + d3.event.translate + ") scale(" + d3.event.scale + ")");
};
GraphCreator.prototype.updateWindow = function(svg){
var docEl = document.documentElement,
bodyEl = document.getElementsByTagName('body')[0];
var x = window.innerWidth || docEl.clientWidth || bodyEl.clientWidth;
var y = window.innerHeight|| docEl.clientHeight|| bodyEl.clientHeight;
svg.attr("width", x).attr("height", y);
};
/**** MAIN ****/
// warn the user when leaving
window.onbeforeunload = function(){
return "Make sure to save your graph locally before leaving :-)";
};
var docEl = document.documentElement,
bodyEl = document.getElementsByTagName('body')[0];
var width = window.innerWidth || docEl.clientWidth || bodyEl.clientWidth,
height = window.innerHeight|| docEl.clientHeight|| bodyEl.clientHeight;
var xLoc = width/2 - 25,
yLoc = 100;
// initial node data
var nodes = [{title: "new concept", id: 0, x: xLoc, y: yLoc},
{title: "new concept", id: 1, x: xLoc, y: yLoc + 200}];
var edges = [{source: nodes[1], target: nodes[0]}];
/** MAIN SVG **/
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height);
var graph = new GraphCreator(svg, nodes, edges);
graph.setIdCt(2);
graph.updateGraph();
})(window.d3, window.saveAs, window.Blob);
<!DOCTYPE HTML>
<html>
<head>
<link rel="stylesheet" href="graph-creator.css" />
</head>
<body>
<div id="toolbox">
<input type="file" id="hidden-file-upload"><input id="upload-input" type="image" title="upload graph" src="upload-icon.png" alt="upload graph"> <input type="image" id="download-input" title="download graph" src="download-icon.png" alt="download graph"> <input type="image" id="delete-graph" title="delete graph" src="trash-icon.png" alt="delete graph">
</div>
<script src="//d3js.org/d3.v3.js" charset="utf-8"></script>
<script src="//cdn.jsdelivr.net/filesaver.js/0.1/FileSaver.min.js"></script>
<script src="graph-creator.js"></script>
</body>
</html>
�PNG

IHDR\r�f �IDATx�� r�6���r���l�'����۰d��vW�*�(E�{�(��Q����!?�9��o�@�=x� ���&f^�  ����<A@ �=�Կ����O� ������9p��y�?s$���_���I����#���z z�&݇N;�Wy � ��|�o����a������;p�x��^�)����ϑw���S���6�`ϝ���Ȼ���+z�&۞;�R��9��ɶ��b8G������)��4�ɶ�����}z�&��CO�dۃt�IC�l{�=i�mС' a��A:��!L�=@��4�ɶ�Г�0�� z�&��CO}����A,�q�s��ʰc\���;�Ŝ#�8Gߗ�8F@D��r�`�(�. *jͅ
F@P�Pk.T0�o˅�<F��;���?T���� `�-T&Ԛ ����*��5o0AP�s[����� �c ��)?a6�^Y_��r�M���ȼ0���u) n��/�1-� |L���,�;�#i���;8F��}w�,�I�Mp��&s����/�Z��4m����]�譴� -�.�譴� -�.�譴� -6j�����] ��xV �9c�I󺴅S������G��������ϱ#��s2��-� �G5-���wZ��x�=�{ ����.�I�͚+Қ�c!^i���;���ɺ��S����� �p����~1\���}q���c��$�J���������.c�|�-�l=�mR/d+��<7�@PR/d+��T�(�~��� �~m�t��9&���\�@�A�����x�dֳ%��{�W�������l��#�'{�
�t�{2[?���^A��!���O?�{�W�i��x�d�XL��5�^�?&}B��@'K}"��c�' d*��R����� ���t��'B�?&}B���@'K}�Q�mSޓ��� 6�A�d�P�1ޓ��Lǁ��gO�:<�(�y�g���gOf�% {�0؃t�!U0ɲy�t����)���t@�8�Q" !��[��ٓ�Γ,�DB�\@'Cm��\F�$�,�C:j��2J$!d���t2���O* �Z��\�-Y6����A:k�FW��
�t�z2K�,؃t�!��� �$�,�ʉt2��o�ZB%L2l� � (�n�$"dx��t�z2�9���ID�p� �D��g��)�����@'z]<c\N�D� ED:����r�$"Tj���#��r�=@�[]\�,�y�G!��GOf�- {�0؃t�!���80Љ.�c9<�&�I���]��L�d�1 o|�(���@Љ\���Q*!z1�N�xŷ�R�ы�t"��+�m�JF��l3`C�}�m {�N����]ǰ��t�{2�ّmt��κ�@Ǻ'���V�660 �hcG�1
�T�$�Љ,�r�\Bx��v�%$D>؁t���#��KH�\T���m�\BB�"�����m;�"���D��9��&�����p3~�����A��v��eO�><A��E:�=�OL@ ������� �*�I���U%�Jɤ��'.3J&%D=��t"��:.3J&%D-.ЉX ��(����@'b-��2�dRB���@'b-�Q���7݌�%�F��~�N�Z�<1��*0б�ɨ�a
xb^�b�� AO��@���!�0�x��t"
��:)���^@�c2�lbB��@Ǫ'��1�lbBē^@'Z,c2�lbB�"#�hu��ɜ�� ��t���E�O� s�͸�>"n��^��x�-` ����,
�t,z2b_��^��!���!��y�R]���t� ��o�2��&�6{�N4�^#���=�E:=�1�� �!�H5��Ǎ�� ъ�t"��*7J''D+6ЉT�x�(�����f�`�6��@o��|� ��c�3_���'#� q�e^���@gwOF�W�[�u욇pt@��|@'��Q��&�6}�N$�_���;�"�@:Q��"w�'(D*:Љ2���S>A!R��N����ŝ�
pƋ�i?�� ��@�@�9��g�@ggOF;�xϼng���Ξ��!@��!�zB��3�C�ŏO�`e��D@���"I �(q��E�B� @'���# -���D���q��E�B��#�s�;�0�HR��6�a��a�(w: [�X�Z�l�|�1�=F:�z�c��>f^���ή��R�P ����!���!�c�u�gl`H'L"l!�h�.�$* ��c#C�$*Dx�tv�d�s �h���$����1��M�B�&@:��3�p�IT��1�x��9��t@�F�q"�uD���j��|r ��yݎF@:;z2B�C�>g^���F� �ϙ�!���!��WB���Z��V�
ޛA@�*Y��y0��ѓ���U����0��9��K�d�f@:��V�
�̀t�!���4���{�7,@�R3"��Ãt�u���I�z���������3�C�c#C:
��80���1��*$0��B:�h��%, ��c#C�%,x A:^�c��KX@q�F��KX@q�F��KX�А3>�s8����JC"ۜS���k� #��=�}�#<�k�u+��IN~��y��U� �������&^ �x ��Zh���b�� i���u@�x���q��2i��2i��2i��m1@��\����5%��7 ��X����I�_\c^��A�Ϊ����5�u�fl`�Ƽ،� �,���bD:h��&. �xc#C�&.xA:��r�t�M\@��F��M\@��F��M\�8|u���{+Q���\�������|��]�7�v��ʢ8Ǟqe��ȿٚ
�S�է�s�Y�W��θv|5�Ws}ʟg?r�
�w_��V��;�f<;�ﳜg<+��!�� �F���x��z�s�~!�_��y���uM�Q*B]/�]���)@�� � ���^_����� ��5%���,���$����]��fè"l�^��FA��94R �y@�T�z��0ՠ�7@�� 3a�b�S&�6w����� �I��o?�<�������Ӿ��O�p�j����'������c�~ JH� w�!/�`�� |
��1����[� ��cp���{���9����1H��`�|�ux >�Й�������c�ۆʁ�����w p�ǰ�IJ��f�[f��IEND�B`�
�PNG

IHDR���>a�[IDATx�흍��4��N�2a�� &�LPw.&�2�\&���2f�ූIn�>Y�e˒���{���m=�>����y�������
P8*@����
P8*@����y٥]:�tMk��@��ϻ��K�.�2i
�.5W��'4�R2�B��!i��Х �P��w�>�_l�{ z��>q�R2����m���k� qK |���oqǏѠ���2����kL �XwD�Q �:�9.#�O[ .N]�!�"�
�K���k���xf5S�t��|�>�'�2�(����[�OI�Ȁ���������3�Z 9 �b�Z9>s6�Y+�o�!�9 ����<��ط�E��$J��`��\+��^�8cJ(�QJ�pe~�:v�Ǜ���k�=6�X���}ŏ�#鸏بu��l�7�׫�^ę0.�ޫ��n��HY��a�OY����pU,�V�:�%�Гv4����e���n��f�������1~A�ٷ���*� C�w���E`�̢r���4E����6<�Pׅ�s��w��]�[ b@�6����OQ���|B��Fx�ٵ`���i%s`��Re��/RˠƊ���h�9a'�k `U��^J���k �j��2~�0��H)�C
n��*��!�ϝ��]C��:�����w�vq" �|�5~0��`���q ��t�H��+ ��U���I���Rjl�c�!<���:���B�XH$sCu�܎�7�������r2V�2��@�����|i~+nj�����;��ݿJ�UR,|| ��i��c��%�Z^;!p�'�,�?nq��a��V#p�B��瀴�fcc%l��U� !�mL��Ja [��!�U)����%��nɗ1R�`��k����
�؁�ːb�h�'��}��-'�K�J�#�X H��+�z��>ô���$���5̓I�����;��@x���[Θ�̾�c���m��mPe��ܛ��U"�p�^!-z9��*m��ͭp���Ǹ�$�X�?�2e6_��H�^��t0�&�ԷO�N�P����\�7�k�����z��
�Z��⪛�a~�a|ͅ8��VW�A�4�� E8��հ�$^ ���S!�b����˹���<Y:w-����u`�:��y�"K����G%� �t��ګgk�T(C�G�� ��+R"�j�V�G���� @)s�|��+�#�,��0K(�� "2e�j�|$����t�@wΩ�7g�r�<$�n�O�=�}Nߒe��@چ�@r��b��
�K ��dBBnPQ!} ���A- ��e+���3.-�kZ�H�E����u�G�;�?��<�{<���]����־����|�llqO�~Ս��THS��I��'.H�XgI�v�S2�� @r�@���N+`l�O;�|���,��̀H�`ξ�����Tnk���ȋ�Y�#��V��M_�"�$H��l�Q�@�?S:��6���ũ�[ R*%�sv��Hk ���Ɔ��@�m�]��#��x7ց�a>�m[�� � U�R���NH'șBi�7� @���#R����K�gK�)���.LIئ�����k\�� yU�0zS߮ r��_!��O% M��zl�C�DZ�
yH�����G�6\O�")>�떜��_���
��k<�aO�*�خ"<�G���B���Ѧ{�R�,�Mް���+X��=�|&V��g��wr����w�!t����Sna�И��ym�3�R���
�B|�#p��'PzfOl�._ )��fZ���(j궣!�9��PLy>��\*xT�s��eip����[��Ԍ %���2-��{ǒ��)Be�!�IϩB ����^cF']�����Ġ��~]�G�S���ێe-Z\n�f�[_k><���p�dVN�V�0i ����~|�^I��
*��*@1��B�T�
p��
P *�A��@(��\h��
`P.4P�A0��ŠT� T�bP �
`��FZ{"�Tlk���"�U)U2<��s 9�2�-�R�
T��Q
G(�pT��Q
G(�pT��Q
G(�pT��Q
�?�5(�1�IEND�B`�
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment