Inspired by Dan Lopuch's Fireflies. Written in Angular & D3 to show vote forking to consensus on Spokenvote.
Created
August 19, 2014 19:53
-
-
Save kimardenmiller/99871f99132616c7b669 to your computer and use it in GitHub Desktop.
Vote Forking in Spokenvote
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Generated by CoffeeScript 1.6.3 | |
(function() { | |
var FireCtrl; | |
FireCtrl = function($sce, $scope, $interval) { | |
var timer; | |
$scope.options = { | |
initialID: 1, | |
width: 960, | |
height: 440, | |
exitAnimateStyle: { | |
opacity: 0 | |
}, | |
enterAnimateStyle: { | |
opacity: 1 | |
}, | |
animateExit: { | |
msToFade: 750 | |
}, | |
enterAtParent: true, | |
enterCenterJitter: 10, | |
min: 0, | |
currentNode: 0, | |
headLabel: 'fa-play', | |
currentTs: void 0, | |
sliderReset: true, | |
radiusMeasure: 7, | |
force: { | |
charge: function(n) { | |
return n.forceViewCharge || -200; | |
}, | |
linkDistance: function(l) { | |
return l.linkDistance || 200; | |
} | |
} | |
}; | |
$scope.message = $sce.trustAsHtml('Press <b><em>Play</em></b> and <b><em>Pause</em></b> controls on left to start and stop,\ | |
or you may scrub the visualization using the <b><em>Slider</em></b>.'); | |
$scope.hovered = function(d) { | |
if (d === 'leave') { | |
$scope.hover = 'Hover, click or Drag any Node to see more.'; | |
} else { | |
$scope.hover = d.type.charAt(0).toUpperCase() + d.type.substr(1).toLowerCase() + ': ' + (function() { | |
switch (false) { | |
case d.type !== 'voter': | |
return d.name; | |
case d.type !== 'hub': | |
return 'This would be your group'; | |
default: | |
return d.text; | |
} | |
})(); | |
} | |
return $scope.$apply(); | |
}; | |
timer = void 0; | |
$scope.play = function() { | |
var curVal, dir, forward, tick; | |
$scope.message = null; | |
$scope.hover = 'Hover, click or Drag any Node to see more.'; | |
if (angular.isDefined(timer)) { | |
return $scope.pauseSlider(); | |
} else { | |
$scope.options.headLabel = 'fa-pause'; | |
forward = true; | |
curVal = void 0; | |
dir = 1; | |
tick = ($scope.options.max - $scope.options.min) / 200; | |
return timer = $interval(function() { | |
curVal = Number($scope.options.currentNode) || $scope.options.min; | |
if (curVal - tick < $scope.options.min) { | |
dir = 1; | |
$scope.options.sliderReset = true; | |
} | |
$scope.options.currentNode = curVal + dir * tick; | |
if (curVal + tick > $scope.options.max) { | |
return $scope.pauseSlider(); | |
} | |
}, 400); | |
} | |
}; | |
$scope.pauseSlider = function() { | |
if (angular.isDefined(timer)) { | |
$interval.cancel(timer); | |
$scope.options.headLabel = 'fa-play'; | |
return timer = undefined; | |
} | |
}; | |
return $scope.$on("$destroy", function() { | |
return $scope.pauseSlider(); | |
}); | |
}; | |
window.App = angular.module('spokenvote', ['spokenvote.services', 'spokenvote.directives', 'ui.bootstrap']); | |
App.Directives = angular.module('spokenvote.directives', []); | |
App.Services = angular.module('spokenvote.services', ['ngResource']); | |
App.controller('FireCtrl', FireCtrl); | |
}).call(this); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Generated by CoffeeScript 1.6.3 | |
(function() { | |
var forceChart; | |
forceChart = function($compile, NodeEmitter, CollapsibleTreeLoader, FlattenVotingTree, AnimatingExperiments) { | |
return { | |
restrict: "EA", | |
replace: true, | |
scope: { | |
options: '=', | |
hovered: '&hovered' | |
}, | |
link: function(scope, element, attrs) { | |
var forceHeight, forceWidth, fvID, links, nodes, root, update; | |
root = []; | |
nodes = []; | |
links = []; | |
fvID = "_fv" + scope.options.initialID++; | |
forceWidth = scope.options.width || angular.element(window)[0].innerWidth; | |
forceHeight = scope.options.height || angular.element(window)[0].innerHeight * .7; | |
scope._tick = function() { | |
scope.link.attr({ | |
x1: function(d) { | |
return d.source.x; | |
}, | |
y1: function(d) { | |
return d.source.y; | |
}, | |
x2: function(d) { | |
return d.target.x; | |
}, | |
y2: function(d) { | |
return d.target.y; | |
} | |
}); | |
return scope.node.attr({ | |
transform: function(d) { | |
return "translate(" + d.x + "," + d.y + ")"; | |
} | |
}).exit().attr({ | |
cx: function(d) { | |
return d.x; | |
}, | |
cy: function(d) { | |
return d.y; | |
} | |
}); | |
}; | |
scope._setSelectionRadius = function(selection) { | |
return selection.attr("r", function(d) { | |
return scope.options.radiusMeasure || 7; | |
}); | |
}; | |
scope._colorNode = function(d) { | |
if (d.isDemo) { | |
return "#82b446"; | |
} else { | |
return "steelblue"; | |
} | |
}; | |
scope.collapseClick = function(d) { | |
if (!d3.event.defaultPrevented) { | |
d.hidden = !d.hidden; | |
FlattenVotingTree(scope, root); | |
return update(); | |
} | |
}; | |
window.onresize = function() { | |
if (!scope.options.width) { | |
forceWidth = angular.element(window)[0].innerWidth; | |
} | |
if (!scope.options.height) { | |
forceHeight = angular.element(window)[0].innerHeight; | |
} | |
scope.force.size([forceWidth, forceHeight]); | |
scope.visSvg.selectAll('.node').remove(); | |
if (nodes.length > 0) { | |
return scope.render(); | |
} | |
}; | |
scope.visSvg = d3.select(element[0]).append("svg").attr({ | |
width: forceWidth, | |
height: forceHeight | |
}); | |
scope.visSvg.append("clipPath").attr("id", "clip").append("circle").attr({ | |
cx: 0, | |
cy: 0, | |
r: 15 | |
}); | |
scope.force = d3.layout.force().size([forceWidth, forceHeight]).linkDistance(scope.options.force.linkDistance).charge(scope.options.force.charge).linkStrength(.35).friction(.85).theta(.9).gravity(.06).on('tick', scope._tick); | |
CollapsibleTreeLoader().then(function(json) { | |
root = json; | |
FlattenVotingTree(scope, root); | |
return scope.$watch('options.currentNode', function() { | |
return update(); | |
}); | |
}); | |
update = function() { | |
NodeEmitter(scope); | |
if (scope.nodesAndLinks) { | |
if (scope.nodesAndLinks.nodes) { | |
nodes = scope.nodesAndLinks.nodes; | |
} | |
if (scope.nodesAndLinks.links) { | |
links = scope.nodesAndLinks.links; | |
} | |
if (scope.nodesAndLinks.nodes.length > 0) { | |
return scope.render(); | |
} | |
} | |
}; | |
scope.link = scope.visSvg.selectAll("line.link"); | |
return scope.render = function() { | |
var enterLinks, enterNodes; | |
if (scope.options.sliderReset) { | |
scope.visSvg.selectAll('.node').remove(); | |
scope.options.sliderReset = false; | |
} | |
if (!nodes || nodes.length < 1) { | |
console.log("No nodes present."); | |
return; | |
} | |
scope.force.nodes(nodes).links(links).start(); | |
scope.node = scope.visSvg.selectAll("g.node").data(nodes, function(d) { | |
return d.id; | |
}).attr({ | |
"transform": function(d) { | |
return "translate(" + d.x + "," + d.y + ")"; | |
} | |
}); | |
if (scope.options.animateExit) { | |
scope.node.each(function(d) { | |
return delete d[fvID].isExitting; | |
}).interrupt().transition().duration(scope.options.animateExit.msToFade / 2).style(scope.options.enterAnimateStyle); | |
} | |
enterNodes = scope.node.enter().append('svg:g').each(function(d) { | |
if (!d[fvID]) { | |
d[fvID] = {}; | |
} | |
if (scope.options.enterAtParent && d.parent && d.parent.x) { | |
d.x = d.px = d.parent.x; | |
d.y = d.py = d.parent.y; | |
} else if (scope.options.enterCenterJitter && d.type !== 'hub' && scope.node[0][0].__data__.x) { | |
d.x = d.px = scope.node[0][0].__data__.x + 2 * scope.options.enterCenterJitter * Math.random() - scope.options.enterCenterJitter; | |
d.y = d.py = scope.node[0][0].__data__.y + 2 * scope.options.enterCenterJitter * Math.random() - scope.options.enterCenterJitter; | |
} else if (d.type === 'hub') { | |
d.x = d.px = 50; | |
d.y = d.py = 50; | |
} else { | |
d.x = d.px = forceWidth / 2; | |
d.y = d.py = forceHeight / 2; | |
} | |
return delete d[fvID].isExitting; | |
}).attr({ | |
id: function(d) { | |
return d.id; | |
}, | |
"class": 'node' | |
}).call(scope._setSelectionRadius).call(scope.force.drag).filter(function(d) { | |
return !d.isDemo; | |
}).on({ | |
mouseover: function(d) { | |
return scope.hovered({ | |
args: d | |
}); | |
}, | |
mouseleave: function() { | |
return scope.hovered({ | |
args: 'leave' | |
}); | |
} | |
}); | |
enterNodes.filter(function(d) { | |
return d.type === 'hub'; | |
}).append('circle').attr({ | |
"class": 'hub' | |
}).style({ | |
fill: 'DarkGray' | |
}).on({ | |
click: scope.collapseClick | |
}); | |
scope.node.filter(function(d) { | |
return d.type === 'hub'; | |
}).selectAll('circle').attr({ | |
r: function(d) { | |
d.size = nodes.length; | |
return d.size * .8 + 15; | |
} | |
}); | |
enterNodes.filter(function(d) { | |
return d.type === 'hub'; | |
}).append("text").attr({ | |
"class": 'hub label', | |
dy: .5 + 'em' | |
}).text(function(d) { | |
return d.name; | |
}).on({ | |
click: scope.collapseClick | |
}); | |
scope.node.filter(function(d) { | |
return d.type === 'hub'; | |
}).selectAll('text').style({ | |
'font-size': function(d) { | |
return d.size * .4 + 5 + 'px'; | |
}, | |
color: 'black' | |
}); | |
enterNodes.filter(function(d) { | |
return d.type === 'topic'; | |
}).append('circle').attr({ | |
"class": 'topic' | |
}).style({ | |
fill: 'Chocolate' | |
}).on({ | |
click: scope.collapseClick | |
}); | |
scope.node.filter(function(d) { | |
return d.type === 'topic'; | |
}).selectAll('circle').attr({ | |
r: function(d) { | |
var pLks, vLks; | |
pLks = links.filter(function(l) { | |
return l.source.id === d.id; | |
}); | |
d.size = pLks.length; | |
vLks = []; | |
pLks.forEach(function(pl) { | |
return vLks = links.filter(function(l) { | |
return l.source.id === pl.target.id; | |
}); | |
}); | |
d.size = (d.size + vLks.length) / 2 + 15; | |
return d.size; | |
} | |
}); | |
enterNodes.filter(function(d) { | |
return d.type === 'topic'; | |
}).append("text").attr({ | |
"class": 'topic label', | |
dy: .35 + 'em' | |
}).text(function(d) { | |
return d.name; | |
}).on({ | |
click: scope.collapseClick | |
}); | |
scope.node.filter(function(d) { | |
return d.type === 'topic'; | |
}).selectAll('text').style({ | |
'font-size': function(d) { | |
return Math.sqrt(d.size) * 2 + 'px'; | |
} | |
}); | |
enterNodes.filter(function(d) { | |
return d.type === 'proposal'; | |
}).append('circle').attr({ | |
"class": 'proposal' | |
}).style({ | |
fill: scope._colorNode | |
}).on({ | |
click: scope.collapseClick | |
}); | |
scope.node.filter(function(d) { | |
return d.type === 'proposal'; | |
}).selectAll('circle').attr({ | |
r: function(d) { | |
var pLks; | |
pLks = links.filter(function(l) { | |
return l.source.id === d.id; | |
}); | |
d.size = pLks.length * 3 + 10; | |
return d.size; | |
} | |
}); | |
enterNodes.filter(function(d) { | |
return d.type === 'proposal'; | |
}).append("text").text(function(d) { | |
return d.name; | |
}).attr({ | |
"class": 'proposal label', | |
dy: .35 + 'em' | |
}).on({ | |
click: scope.collapseClick | |
}); | |
scope.node.filter(function(d) { | |
return d.type === 'proposal'; | |
}).selectAll('text').style({ | |
'font-size': function(d) { | |
return Math.sqrt(d.size) * 2 + 'px'; | |
} | |
}); | |
enterNodes.filter(function(d) { | |
return d.type === 'voter'; | |
}).append('image').attr({ | |
'xlink:href': function(d) { | |
return 'http://graph.facebook.com/' + d.name + '/picture?'; | |
}, | |
x: -20, | |
y: -20, | |
width: 40, | |
height: 40 | |
}).style({ | |
'clip-path': 'url(#clip)' | |
}); | |
enterNodes.filter(function(d) { | |
return d.type === 'voter'; | |
}).append('circle').attr({ | |
"class": 'voter-ring', | |
r: 15 | |
}).style({ | |
stroke: 'DarkOliveGreen ', | |
'stroke-width': '.75', | |
fill: 'none' | |
}); | |
scope.link = scope.link.data(links, function(d) { | |
return d.target.id; | |
}); | |
enterLinks = scope.link.enter().insert("svg:line", ".node").attr({ | |
"class": 'link', | |
x1: function(d) { | |
return d.source.x; | |
}, | |
y1: function(d) { | |
return d.source.y; | |
}, | |
x2: function(d) { | |
return d.source.x; | |
}, | |
y2: function(d) { | |
return d.source.x; | |
} | |
}); | |
AnimatingExperiments(scope, enterNodes, scope.node, enterLinks, scope.link); | |
if (!scope.options.animateExit) { | |
console.log('no animate exit: '); | |
scope.node.exit().remove(); | |
scope.node.exit().remove(); | |
} else { | |
} | |
scope.node.exit().filter(function(d) { | |
return !d[fvID].isExitting; | |
}).each(function(d) { | |
d[fvID].isExitting = true; | |
return nodes.push(d); | |
}).interrupt().transition().duration(scope.options.animateExit.msToFade).style(scope.options.exitAnimateStyle).each('end', function(d) { | |
return delete d[fvID].isExitting; | |
}).remove(); | |
scope.link.exit().remove(); | |
return scope.force.nodes(nodes).links(links).start(); | |
}; | |
} | |
}; | |
}; | |
App.Directives.directive('forceChart', forceChart); | |
}).call(this); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Generated by CoffeeScript 1.6.3 | |
(function() { | |
var AnimatingExperiments, CollapsibleTree, CollapsibleTreeLoader, FlattenVotingTree, NodeEmitter, | |
__indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; | |
CollapsibleTree = function($resource) { | |
return $resource('z_collapsible.json'); | |
}; | |
CollapsibleTreeLoader = function(CollapsibleTree, $q) { | |
return function() { | |
var delay; | |
delay = $q.defer(); | |
CollapsibleTree.get({}, function(root) { | |
return delay.resolve(root); | |
}, function() { | |
return delay.reject('Unable to locate CollapsibleTree '); | |
}); | |
return delay.promise; | |
}; | |
}; | |
FlattenVotingTree = function() { | |
return function(scope, json) { | |
var i, l, links, nodes, recurse; | |
recurse = function(h) { | |
if (h.type === 'hub') { | |
if (!h.id) { | |
h.id = i++; | |
} | |
if (!h.hidden) { | |
h.hidden = false; | |
} | |
h.forceViewCharge = -900; | |
nodes.push(h); | |
if (h.children) { | |
return h.children.forEach(function(t) { | |
var link; | |
if (!t.id) { | |
t.id = i++; | |
} | |
if (!t.hidden) { | |
t.hidden = false; | |
} | |
t.hiddenParent = h.hidden; | |
t.forceViewCharge = -400; | |
nodes.push(t); | |
link = { | |
id: ++l, | |
source: h, | |
target: t, | |
linkDistance: 133 | |
}; | |
links.push(link); | |
if (t.children) { | |
return t.children.forEach(function(p) { | |
if (!p.id) { | |
p.id = i++; | |
} | |
if (!p.hidden) { | |
p.hidden = false; | |
} | |
if (t.hidden === true || t.hiddenParent === true) { | |
p.hiddenParent = true; | |
} else { | |
p.hiddenParent = false; | |
} | |
p.forceViewCharge = -200; | |
nodes.push(p); | |
link = { | |
id: ++l, | |
source: t, | |
target: p, | |
linkDistance: 100 | |
}; | |
links.push(link); | |
if (p.children) { | |
return p.children.forEach(function(v) { | |
v.type = 'voter'; | |
v.topicVoterId = t.id + '-' + v.name; | |
v.parent = {}; | |
if (!v.id) { | |
v.id = i++; | |
} | |
if (!v.hidden) { | |
v.hidden = false; | |
} | |
if (p.hidden === true || p.hiddenParent === true) { | |
v.hiddenParent = true; | |
} else { | |
v.hiddenParent = false; | |
} | |
v.forceViewCharge = -150; | |
nodes.push(v); | |
link = { | |
id: ++l, | |
source: p, | |
target: v, | |
topicVoterId: t.id + '-' + v.name, | |
linkDistance: 40 | |
}; | |
return links.push(link); | |
}); | |
} | |
}); | |
} | |
}); | |
} | |
} | |
}; | |
nodes = []; | |
links = []; | |
i = 0; | |
l = 0; | |
recurse(json); | |
scope.options.max = links.length + 1; | |
scope.flattenedNodesAndLinks = { | |
nodes: nodes, | |
links: links | |
}; | |
return console.log("Initialized nodes And Links: ", scope.flattenedNodesAndLinks); | |
}; | |
}; | |
NodeEmitter = function() { | |
return function(scope) { | |
var i, l, links, linksBytopicVoterId, nodes, nodesAndLinks, targets, ts, uniqLinksByTopic; | |
ts = scope.options.currentNode; | |
nodesAndLinks = scope.flattenedNodesAndLinks; | |
links = nodesAndLinks.links.filter(function(l) { | |
return l.id < ts; | |
}); | |
linksBytopicVoterId = _.chain(links).sortBy(['topicVoterId', 'id']).value(); | |
uniqLinksByTopic = []; | |
i = 0; | |
l = links.length; | |
while (i < (l - 1)) { | |
if (linksBytopicVoterId[i + 1].topicVoterId && linksBytopicVoterId[i + 1].topicVoterId === linksBytopicVoterId[i].topicVoterId) { | |
if (linksBytopicVoterId[i + 1].hidden === void 0 && linksBytopicVoterId[i].hidden === void 0 && nodesAndLinks.nodes[linksBytopicVoterId[i + 1].target.id].parent) { | |
nodesAndLinks.nodes[linksBytopicVoterId[i + 1].target.id].parent.x = nodesAndLinks.nodes[linksBytopicVoterId[i].target.id].x; | |
nodesAndLinks.nodes[linksBytopicVoterId[i + 1].target.id].parent.y = nodesAndLinks.nodes[linksBytopicVoterId[i].target.id].y; | |
} | |
} else { | |
if (!(nodesAndLinks.nodes[linksBytopicVoterId[i].source.id].hidden === true || nodesAndLinks.nodes[linksBytopicVoterId[i].source.id].hiddenParent === true)) { | |
uniqLinksByTopic.push(linksBytopicVoterId[i]); | |
} | |
} | |
i++; | |
} | |
if (!(i === 0 || nodesAndLinks.nodes[linksBytopicVoterId[i].source.id].hidden === true || nodesAndLinks.nodes[linksBytopicVoterId[i].source.id].hiddenParent === true)) { | |
uniqLinksByTopic.push(linksBytopicVoterId[i]); | |
} | |
targets = _.chain(uniqLinksByTopic).pluck('target').pluck('id').value(); | |
nodes = nodesAndLinks.nodes.filter(function(d) { | |
var _ref; | |
if (targets.length > 0) { | |
return (_ref = d.id, __indexOf.call(targets, _ref) >= 0) || d.id === 0; | |
} else if (ts > 0) { | |
return d.type === 'hub'; | |
} | |
}); | |
return scope.nodesAndLinks = { | |
nodes: nodes, | |
links: uniqLinksByTopic | |
}; | |
}; | |
}; | |
AnimatingExperiments = function() { | |
return function(scope, enterNodes, nodes, enterLinks, links) { | |
var i; | |
i = 0; | |
return enterLinks.style({ | |
stroke: "red", | |
opacity: 0 | |
}).transition().delay(function(d) { | |
if (d) { | |
return (i++) * 50; | |
} | |
}).duration(0).each("end", function(d) { | |
d3.select("svg").selectAll('.voter-ring').data([d.target], function(d) { | |
return d.id; | |
}).style({ | |
stroke: '#DC143C', | |
'stroke-width': '2', | |
opacity: .9 | |
}).transition().duration(3500).style({ | |
stroke: '#556B2F', | |
'stroke-width': '1' | |
}).transition().duration(250).style({ | |
opacity: 1 | |
}); | |
return d3.select(this).style({ | |
opacity: 1 | |
}).transition().duration(750).style({ | |
stroke: "#ddd", | |
opacity: 1 | |
}); | |
}); | |
}; | |
}; | |
App.Services.factory('CollapsibleTree', CollapsibleTree); | |
App.Services.factory('CollapsibleTreeLoader', CollapsibleTreeLoader); | |
App.Services.factory('FlattenVotingTree', FlattenVotingTree); | |
App.Services.factory('NodeEmitter', NodeEmitter); | |
App.Services.factory('AnimatingExperiments', AnimatingExperiments); | |
}).call(this); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
FireCtrl = ( $sce, $scope, $interval ) -> | |
$scope.options = | |
initialID: 1 | |
width: 960 # 960 Bl.ocks Default | |
height: 440 # 600 Bl.ocks Default | |
exitAnimateStyle: #When nodes animate out of the force view, we animate them to these css params | |
opacity: 0 | |
enterAnimateStyle: #Opposite state of the exitAnimateStyle | |
opacity: 1 | |
animateExit: | |
msToFade: 750 | |
enterAtParent: true | |
enterCenterJitter: 10 | |
min: 0 | |
currentNode: 0 | |
headLabel: 'fa-play' | |
currentTs: undefined | |
sliderReset: true | |
radiusMeasure: 7 | |
force: | |
charge: (n) -> | |
n.forceViewCharge or -200 | |
linkDistance: (l) -> | |
l.linkDistance or 200 | |
$scope.message = $sce.trustAsHtml('Press <b><em>Play</em></b> and <b><em>Pause</em></b> controls on left to start and stop, | |
or you may scrub the visualization using the <b><em>Slider</em></b>.') | |
$scope.hovered = (d) -> | |
if d is 'leave' | |
$scope.hover = 'Hover, click or Drag any Node to see more.' | |
else | |
$scope.hover = d.type.charAt(0).toUpperCase() + d.type.substr(1).toLowerCase() + ': ' + | |
switch | |
when d.type is 'voter' then d.name | |
when d.type is 'hub' then 'This would be your group' | |
else d.text | |
$scope.$apply() | |
timer = undefined | |
$scope.play = -> | |
$scope.message = null | |
$scope.hover = 'Hover, click or Drag any Node to see more.' | |
if angular.isDefined(timer) | |
$scope.pauseSlider() | |
else | |
$scope.options.headLabel = 'fa-pause' | |
forward = true | |
curVal = undefined | |
dir = 1 | |
tick = ($scope.options.max - $scope.options.min) / 200 | |
timer = $interval(-> | |
curVal = Number($scope.options.currentNode) or $scope.options.min | |
#dir = -6 if curVal + tick > $scope.options.max # Logic to run the slider in reverse if you want it. | |
if curVal - tick < $scope.options.min | |
dir = 1 | |
$scope.options.sliderReset = true | |
$scope.options.currentNode = curVal + dir * tick | |
$scope.pauseSlider() if curVal + tick > $scope.options.max | |
, 400) # must be larger than debounce! | |
$scope.pauseSlider = -> | |
if angular.isDefined(timer) | |
$interval.cancel timer | |
$scope.options.headLabel = 'fa-play' | |
timer = `undefined` | |
$scope.$on "$destroy", -> | |
$scope.pauseSlider() | |
window.App = angular.module('spokenvote', [ 'spokenvote.services', 'spokenvote.directives', 'ui.bootstrap' ]) | |
App.Directives = angular.module('spokenvote.directives', []) | |
App.Services = angular.module('spokenvote.services', ['ngResource']) | |
App.controller 'FireCtrl', FireCtrl |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
forceChart = ( $compile, NodeEmitter, CollapsibleTreeLoader, FlattenVotingTree, AnimatingExperiments ) -> | |
restrict: "EA" | |
replace: true | |
scope: | |
options: '=' | |
hovered: '&hovered' | |
link: (scope, element, attrs) -> | |
root = [] | |
nodes = [] | |
links = [] | |
# Every force view gets its own ID. This is used to scope ForceView instance-specific state onto the node Objects | |
fvID = "_fv" + scope.options.initialID++ | |
# Set default size | |
forceWidth = scope.options.width or angular.element(window)[0].innerWidth | |
forceHeight = scope.options.height or angular.element(window)[0].innerHeight * .7 | |
scope._tick = -> | |
scope.link | |
.attr | |
x1: (d) -> | |
d.source.x | |
y1: (d) -> | |
d.source.y | |
x2: (d) -> | |
d.target.x | |
y2: (d) -> | |
d.target.y | |
scope.node | |
.attr | |
transform: (d) -> | |
"translate(" + d.x + "," + d.y + ")" | |
.exit() | |
.attr | |
cx: (d) -> | |
d.x | |
cy: (d) -> | |
d.y | |
scope._setSelectionRadius = (selection) -> | |
selection.attr "r", (d) -> | |
return scope.options.radiusMeasure or 7 | |
scope._colorNode = (d) -> | |
if d.isDemo then "#82b446" else "steelblue" | |
# Toggle children on click. | |
scope.collapseClick = (d) -> | |
unless d3.event.defaultPrevented | |
d.hidden = !d.hidden | |
FlattenVotingTree scope, root | |
update() | |
window.onresize = -> | |
forceWidth = angular.element(window)[0].innerWidth unless scope.options.width | |
forceHeight = angular.element(window)[0].innerHeight unless scope.options.height | |
scope.force.size([ forceWidth, forceHeight ]) | |
scope.visSvg | |
.selectAll('.node') | |
.remove() | |
scope.render() if nodes.length > 0 | |
scope.visSvg = d3.select(element[0]) | |
.append("svg") | |
.attr | |
width: forceWidth | |
height: forceHeight | |
scope.visSvg | |
.append("clipPath") | |
.attr("id", "clip") | |
.append("circle") | |
.attr | |
cx: 0 | |
cy: 0 | |
r: 15 | |
scope.force = d3 | |
.layout.force() | |
.size([ forceWidth, forceHeight ]) | |
.linkDistance(scope.options.force.linkDistance) | |
.charge(scope.options.force.charge) | |
.linkStrength(.35) | |
.friction(.85) | |
.theta(.9) | |
.gravity(.06) | |
.on 'tick', scope._tick | |
CollapsibleTreeLoader().then (json) -> | |
root = json | |
FlattenVotingTree scope, root | |
scope.$watch 'options.currentNode', -> | |
update() | |
update = -> | |
NodeEmitter scope | |
if scope.nodesAndLinks | |
nodes = scope.nodesAndLinks.nodes if scope.nodesAndLinks.nodes | |
links = scope.nodesAndLinks.links if scope.nodesAndLinks.links | |
scope.render() if scope.nodesAndLinks.nodes.length > 0 | |
scope.link = scope.visSvg | |
.selectAll("line.link") | |
scope.render = -> | |
if scope.options.sliderReset | |
scope.visSvg | |
.selectAll('.node') | |
.remove() | |
scope.options.sliderReset = false | |
if !nodes or nodes.length < 1 | |
console.log "No nodes present." | |
return | |
scope.force | |
.nodes(nodes) | |
.links(links) | |
.start() | |
# ------------------- | |
# Update the nodes... | |
scope.node = scope.visSvg | |
.selectAll("g.node") | |
.data nodes, (d) -> | |
d.id | |
.attr | |
"transform": (d) -> | |
"translate(" + d.x + "," + d.y + ")" | |
# If we're animating things out, they could be in the middle of their outbound animations. Animate them back in. | |
if scope.options.animateExit | |
scope.node.each (d) -> | |
delete d[fvID].isExitting | |
.interrupt() | |
.transition() | |
.duration( scope.options.animateExit.msToFade / 2 ) | |
.style scope.options.enterAnimateStyle | |
# Some may have already exitted but now they're back in the game. Adjust their states. | |
enterNodes = scope.node | |
.enter() | |
.append('svg:g') | |
.each (d) -> | |
d[fvID] = {} unless d[fvID] | |
if scope.options.enterAtParent and d.parent and d.parent.x | |
d.x = d.px = d.parent.x | |
d.y = d.py = d.parent.y | |
else if scope.options.enterCenterJitter and d.type isnt 'hub' and scope.node[0][0].__data__.x | |
d.x = d.px = scope.node[0][0].__data__.x + 2 * scope.options.enterCenterJitter * Math.random() - scope.options.enterCenterJitter | |
d.y = d.py = scope.node[0][0].__data__.y + 2 * scope.options.enterCenterJitter * Math.random() - scope.options.enterCenterJitter | |
else if d.type is 'hub' | |
d.x = d.px = 50 | |
d.y = d.py = 50 | |
else | |
d.x = d.px = forceWidth / 2 | |
d.y = d.py = forceHeight / 2 | |
delete d[fvID].isExitting | |
.attr | |
id: (d) -> | |
d.id | |
class: 'node' | |
#'tooltip-append-to-body': true | |
#tooltip: (d) -> | |
#d.name | |
#.call -> | |
#$compile(this[0].parentNode)(scope) | |
#console.log 'compile: ' | |
.call(scope._setSelectionRadius) | |
.call(scope.force.drag).filter (d) -> | |
not d.isDemo | |
.on | |
mouseover: (d) -> | |
scope.hovered args: d | |
mouseleave: -> | |
scope.hovered args: 'leave' | |
# append newly entering hub circles | |
enterNodes | |
.filter (d) -> | |
d.type is 'hub' | |
.append('circle') | |
.attr | |
class: 'hub' | |
.style | |
fill: 'DarkGray' | |
.on | |
click: scope.collapseClick | |
# adjust the all hub circles | |
scope.node | |
.filter (d) -> | |
d.type is 'hub' | |
.selectAll('circle') | |
.attr | |
r: (d) -> | |
d.size = nodes.length | |
d.size * .8 + 15 | |
# newly entering hub labels | |
enterNodes | |
.filter (d) -> | |
d.type is 'hub' | |
.append("text") | |
.attr | |
class: 'hub label' | |
dy: .5 + 'em' | |
.text (d) -> | |
d.name | |
.on | |
click: scope.collapseClick | |
# adjust the all hub labels | |
scope.node | |
.filter (d) -> | |
d.type is 'hub' | |
.selectAll('text') | |
.style | |
'font-size': (d) -> | |
d.size * .4 + 5 + 'px' | |
color: 'black' | |
# append newly entering topic circles | |
enterNodes | |
.filter (d) -> | |
d.type is 'topic' | |
.append('circle') | |
.attr | |
class: 'topic' | |
.style | |
fill: 'Chocolate' | |
.on | |
click: scope.collapseClick | |
# adjust the all topic circles | |
scope.node | |
.filter (d) -> | |
d.type is 'topic' | |
.selectAll('circle') | |
.attr | |
r: (d) -> | |
pLks = links.filter (l) -> | |
l.source.id is d.id | |
d.size = pLks.length | |
vLks = [] | |
pLks.forEach (pl) -> | |
vLks = links.filter (l) -> | |
l.source.id is pl.target.id | |
d.size = (d.size + vLks.length) / 2 + 15 | |
d.size | |
# newly entering topic labels | |
enterNodes | |
.filter (d) -> | |
d.type is 'topic' | |
.append("text") | |
.attr | |
class: 'topic label' | |
dy: .35 + 'em' | |
.text (d) -> | |
d.name | |
.on | |
click: scope.collapseClick | |
# adjust the all topic labels | |
scope.node | |
.filter (d) -> | |
d.type is 'topic' | |
.selectAll('text') | |
.style | |
'font-size': (d) -> | |
Math.sqrt(d.size) * 2 + 'px' | |
# append newly entering proposal circles | |
enterNodes | |
.filter (d) -> | |
d.type is 'proposal' | |
.append('circle') | |
.attr | |
class: 'proposal' | |
.style | |
fill: scope._colorNode | |
.on | |
click: scope.collapseClick | |
# adjust the all proposal circles | |
scope.node | |
.filter (d) -> | |
d.type is 'proposal' | |
.selectAll('circle') | |
.attr | |
r: (d) -> | |
pLks = links.filter (l) -> | |
l.source.id is d.id | |
d.size = pLks.length * 3 + 10 | |
d.size | |
# append newly entering proposal labels | |
enterNodes | |
.filter (d) -> | |
d.type is 'proposal' | |
.append("text") | |
.text (d) -> | |
d.name | |
.attr | |
class: 'proposal label' | |
dy: .35 + 'em' | |
.on | |
click: scope.collapseClick | |
# adjust the all proposal labels | |
scope.node | |
.filter (d) -> | |
d.type is 'proposal' | |
.selectAll('text') | |
.style | |
'font-size': (d) -> | |
Math.sqrt(d.size) * 2 + 'px' | |
#voter | |
enterNodes | |
.filter (d) -> | |
d.type is 'voter' | |
.append('image') | |
.attr | |
'xlink:href': (d) -> | |
'http://graph.facebook.com/' + d.name + '/picture?' | |
x: -20 | |
y: -20 | |
width: 40 | |
height: 40 | |
.style | |
'clip-path': 'url(#clip)' | |
#voter ring | |
enterNodes | |
.filter (d) -> | |
d.type is 'voter' | |
.append('circle') | |
.attr | |
class: 'voter-ring' | |
r: 15 | |
.style | |
stroke: 'DarkOliveGreen ' | |
'stroke-width': '.75' | |
fill: 'none' | |
# ------------------- | |
# Update the links... | |
scope.link = scope.link | |
.data links, (d) -> | |
d.target.id | |
# Enter any new links. | |
enterLinks = scope.link | |
.enter() | |
.insert( "svg:line", ".node" ) | |
.attr | |
class: 'link' | |
x1: (d) -> | |
d.source.x | |
y1: (d) -> | |
d.source.y | |
x2: (d) -> | |
d.source.x | |
y2: (d) -> | |
d.source.x | |
# ------------------ | |
AnimatingExperiments scope, enterNodes, scope.node, enterLinks, scope.link | |
# ------------------ | |
# Exits | |
# Exit any old nodes. | |
unless scope.options.animateExit | |
console.log 'no animate exit: ' | |
scope.node.exit().remove() | |
scope.node.exit().remove() | |
else | |
# Some exit nodes may have already started the exit animation. Let them be, they'll be removed. | |
# They're in the exit selection because they weren't in the list of nodes. | |
# However, we still want them as part of the force view until they leave for sure. | |
scope.node | |
.exit() | |
.filter (d) -> # remove the els when transition is done | |
not d[fvID].isExitting | |
.each (d) -> | |
d[fvID].isExitting = true | |
nodes.push d | |
.interrupt() | |
.transition() | |
.duration(scope.options.animateExit.msToFade) | |
.style(scope.options.exitAnimateStyle) | |
.each 'end', (d) -> | |
delete d[fvID].isExitting | |
.remove() | |
# Exit any old links. | |
scope.link.exit().remove() | |
scope.force | |
.nodes(nodes) | |
.links(links) | |
.start() | |
App.Directives.directive 'forceChart', forceChart |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
.hub, .topic, .proposal { | |
cursor: pointer; | |
} | |
line.link { | |
fill: none; | |
stroke: #ddd; /*#9ecae1;*/ | |
stroke-width: 1.5px; | |
} | |
body { | |
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; | |
} | |
body { | |
font: 14px sans-serif; | |
} | |
.axis path, .axis line { | |
fill: none; | |
stroke: black; | |
shape-rendering: crispEdges; | |
} | |
.axis path{ | |
fill: none; | |
stroke: none; | |
} | |
.bar { | |
fill: steelblue; | |
} | |
.label { | |
text-anchor: middle; | |
font-size: .6em; | |
} | |
.box { | |
width: 945px; | |
margin-left: 10px; | |
/*background-color: #F8F8FF;*/ | |
border-radius: 6px; | |
height: 40px; | |
line-height: 40px; | |
text-align: center; | |
} | |
.text { | |
display: inline-block; | |
vertical-align: middle; | |
line-height: normal; | |
margin: 0; | |
} | |
.play { | |
font-weight: bold; | |
vertical-align: middle; | |
} | |
.block .play { | |
display: inline-block; | |
width: 42px; | |
text-align: right; | |
padding-right: 7px; | |
} | |
.block input { | |
display: inline-block; | |
width: 895px; | |
vertical-align: middle; | |
} | |
.fa-pause { | |
color: darkred; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html ng-app="spokenvote"> | |
<head> | |
<meta charset="utf-8" /> | |
<title>Vote Forking in Spokenvote</title> | |
<link href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css" rel="stylesheet"> | |
<link href="//cdnjs.cloudflare.com/ajax/libs/font-awesome/4.1.0/css/font-awesome.css" rel="stylesheet"> | |
<link rel="stylesheet" href="fire.css"/> | |
<script data-require="jquery@*" data-semver="2.0.3" src="http://code.jquery.com/jquery-2.0.3.min.js"></script> | |
<script data-require="d3@*" data-semver="3.4.6" src="//cdnjs.cloudflare.com/ajax/libs/d3/3.4.6/d3.min.js"></script> | |
<script data-require="angular.js@*" data-semver="1.2.9" src="http://code.angularjs.org/1.2.9/angular.js"></script> | |
<script data-require="angular-resource@*" data-semver="1.2.9" src="http://code.angularjs.org/1.2.9/angular-resource.js"></script> | |
<script data-require="lodash.js@*" data-semver="2.4.1" src="http://cdnjs.cloudflare.com/ajax/libs/lodash.js/2.4.1/lodash.js"></script> | |
<script data-require="ui-bootstrap@*" data-semver="0.10.0" src="http://angular-ui.github.io/bootstrap/ui-bootstrap-tpls-0.10.0.js"></script> | |
<script src="README.controller.js"></script> <!-- README prefix hides files on Bl.ocks.org --> | |
<script src="README.directive.js"></script> | |
<script src="README.services.js"></script> | |
</head> | |
<body> | |
<!--<body class="well">--> | |
<div ng-controller="FireCtrl"> | |
<div style="width: 85%;"> | |
<div style="width: 960px;"> | |
<force-chart options='options' hovered='hovered(args)' ></force-chart> | |
<div class="form block" > | |
<a href='#' class="play" ng-click='play()' tooltip-placement='right' tooltip='Click to start and stop the conversation, or use the slider to scrub the timeline.'> | |
<i class="fa {{ options.headLabel }} fa-lg"></i> | |
</a> | |
<input type="range" ng-model="options.currentNode" min='0' max='78' > | |
</div> | |
<div class="box"> | |
<h4 class='text' ng-show='hover'> {{ hover }}</h4> | |
<h4 class='text' ng-show='message' ng-bind-html='message' ></h4> | |
</div> | |
</div> | |
</div> | |
</div> | |
</body> | |
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
CollapsibleTree = ($resource) -> | |
$resource 'z_collapsible.json' | |
CollapsibleTreeLoader = (CollapsibleTree, $q) -> | |
-> | |
delay = $q.defer() | |
CollapsibleTree.get {} | |
, (root) -> | |
# Returns a list of all nodes under the root. | |
delay.resolve root | |
, -> | |
delay.reject 'Unable to locate CollapsibleTree ' | |
delay.promise | |
FlattenVotingTree = -> | |
( scope, json ) -> | |
recurse = (h) -> | |
if h.type is 'hub' | |
h.id = i++ unless h.id | |
h.hidden = false unless h.hidden | |
h.forceViewCharge = -900 | |
nodes.push h | |
if h.children | |
h.children.forEach (t) -> | |
t.id = i++ unless t.id | |
t.hidden = false unless t.hidden | |
t.hiddenParent = h.hidden | |
t.forceViewCharge = -400 | |
nodes.push t | |
link = | |
id: ++l | |
source: h | |
target: t | |
linkDistance: 133 | |
links.push link | |
if t.children | |
t.children.forEach (p) -> | |
p.id = i++ unless p.id | |
p.hidden = false unless p.hidden | |
if t.hidden is true or t.hiddenParent is true | |
p.hiddenParent = true | |
else | |
p.hiddenParent = false | |
p.forceViewCharge = -200 | |
nodes.push p | |
link = | |
id: ++l | |
source: t | |
target: p | |
linkDistance: 100 | |
links.push link | |
if p.children | |
p.children.forEach (v) -> | |
v.type = 'voter' | |
v.topicVoterId = t.id + '-' + v.name | |
v.parent = {} | |
v.id = i++ unless v.id | |
v.hidden = false unless v.hidden | |
if p.hidden is true or p.hiddenParent is true | |
v.hiddenParent = true | |
else | |
v.hiddenParent = false | |
v.forceViewCharge = -150 | |
nodes.push v | |
link = | |
id: ++l | |
source: p | |
target: v | |
topicVoterId: t.id + '-' + v.name | |
linkDistance: 40 | |
links.push link | |
nodes = [] | |
links = [] | |
i = 0 | |
l = 0 | |
recurse json | |
scope.options.max = links.length + 1 | |
scope.flattenedNodesAndLinks = | |
nodes: nodes | |
links: links | |
console.log "Initialized nodes And Links: ", scope.flattenedNodesAndLinks | |
NodeEmitter = -> | |
( scope ) -> | |
ts = scope.options.currentNode | |
nodesAndLinks = scope.flattenedNodesAndLinks | |
links = nodesAndLinks.links.filter (l) -> | |
l.id < ts | |
linksBytopicVoterId = _.chain(links) | |
.sortBy(['topicVoterId', 'id']) | |
.value() | |
uniqLinksByTopic = [] | |
i = 0 | |
l = links.length | |
while i < (l - 1) | |
if linksBytopicVoterId[i + 1].topicVoterId and linksBytopicVoterId[i + 1].topicVoterId is linksBytopicVoterId[i].topicVoterId | |
if linksBytopicVoterId[i + 1].hidden is undefined and linksBytopicVoterId[i].hidden is undefined and nodesAndLinks.nodes[linksBytopicVoterId[i + 1].target.id].parent | |
nodesAndLinks.nodes[linksBytopicVoterId[i + 1].target.id].parent.x = nodesAndLinks.nodes[linksBytopicVoterId[i].target.id].x | |
nodesAndLinks.nodes[linksBytopicVoterId[i + 1].target.id].parent.y = nodesAndLinks.nodes[linksBytopicVoterId[i].target.id].y | |
else | |
unless nodesAndLinks.nodes[linksBytopicVoterId[i].source.id].hidden is true or nodesAndLinks.nodes[linksBytopicVoterId[i].source.id].hiddenParent is true | |
uniqLinksByTopic.push linksBytopicVoterId[i] | |
i++ | |
unless i is 0 or nodesAndLinks.nodes[linksBytopicVoterId[i].source.id].hidden is true or nodesAndLinks.nodes[linksBytopicVoterId[i].source.id].hiddenParent is true | |
uniqLinksByTopic.push linksBytopicVoterId[i] | |
targets = _.chain(uniqLinksByTopic).pluck('target').pluck('id').value() | |
nodes = | |
nodesAndLinks.nodes.filter (d) -> | |
if targets.length > 0 | |
d.id in targets or d.id is 0 | |
else if ts > 0 | |
d.type is 'hub' | |
scope.nodesAndLinks = | |
nodes: nodes | |
links: uniqLinksByTopic | |
# experiments in animating | |
AnimatingExperiments = -> | |
(scope, enterNodes, nodes, enterLinks, links) -> | |
# ANIMATE NEW LINKS: | |
# All new links: three step animation | |
# 1) Initialize new links to red/transparent | |
# 2) Transition, each with its staggered delay (but 0 transition length... just want the delay) | |
# 3) When these end, suddenly make transparent, then create a new transition that fades in | |
# (Note that transition.transition() doesn't work when the first transition is delayed... overrides it) | |
i = 0 | |
enterLinks | |
.style | |
stroke: "red" | |
opacity: 0 | |
.transition().delay (d) -> | |
# In the enter selection, some elements are undefined. Don't want to use argument[1] as i b/c it still | |
# counts the undefineds. Make our own i counter to get accurate "this is the i-th entering item" counts | |
(i++) * 50 if d | |
.duration( 0 ) | |
.each "end", (d) -> | |
d3.select( "svg" ) | |
.selectAll( '.voter-ring' ) | |
.data [ d.target ], (d) -> | |
d.id | |
.style | |
stroke: '#DC143C' | |
'stroke-width': '2' | |
opacity: .9 | |
.transition() | |
.duration( 3500 ) | |
.style | |
stroke: '#556B2F' | |
'stroke-width': '1' | |
.transition() | |
.duration( 250 ) | |
.style | |
opacity: 1 | |
d3.select( this ) | |
.style | |
opacity: 1 | |
.transition() | |
.duration( 750 ) | |
.style | |
stroke: "#ddd" | |
opacity: 1 | |
#.transition().each (d) -> # My force charges pretty good, but could expiriment with this method later | |
#d.source.nodeForceViewCharge = -1600 | |
#scope.force.start() # restart force view to make attractor change stick | |
App.Services.factory 'CollapsibleTree', CollapsibleTree | |
App.Services.factory 'CollapsibleTreeLoader', CollapsibleTreeLoader | |
App.Services.factory 'FlattenVotingTree', FlattenVotingTree | |
App.Services.factory 'NodeEmitter', NodeEmitter | |
App.Services.factory 'AnimatingExperiments', AnimatingExperiments |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
�PNG | |