I wanted to have some practice with d3 force and got obsessed and too carried away...
For better viewing on a wider screen, open in a new tab!
Built with blockbuilder.org
license: mit |
I wanted to have some practice with d3 force and got obsessed and too carried away...
For better viewing on a wider screen, open in a new tab!
Built with blockbuilder.org
var distribute = function () { | |
/////////////////////////////////////////////////////////////////////////// | |
///////////////////////////////// Globals ///////////////////////////////// | |
/////////////////////////////////////////////////////////////////////////// | |
var simulation, circles, g, DURATION, DELAY, nodes, distributed, scattered | |
var canvasDim = { width: screen.width*0.9, height: screen.height*0.9} | |
var margin = {top: 50, right: 50, bottom: 50, left: 50} | |
var width = canvasDim.width - margin.left - margin.right; | |
var height = canvasDim.height - margin.top - margin.bottom; | |
var simulation = d3.forceSimulation() | |
var modal = d3.select(".modal-content2") | |
var modalDiv = modal.append('div') | |
.attr('id', "div-content2") | |
.attr('width', width) | |
.attr('height', height) | |
.attr('float', 'left') | |
/////////////////////////////////////////////////////////////////////////// | |
///////////////////////////// Create scales /////////////////////////////// | |
/////////////////////////////////////////////////////////////////////////// | |
var color = ["#E47D06", "#DB0131", "#AF0158", "#7F378D", "#3465A8", "#0AA174", "#7EB852"] | |
var category = ["1", "2", "3", "4", "5", "6", "7"] | |
var colorScale = d3.scaleOrdinal() | |
.domain(category) | |
.range(color) | |
/////////////////////////////////////////////////////////////////////////// | |
///////////////////////////// Create sliders ////////////////////////////// | |
/////////////////////////////////////////////////////////////////////////// | |
// Step slide from 1000 to 2000 for transition duration in steps of 100 | |
var sliderStepDuration = d3.sliderBottom() | |
.min(1000) | |
.max(2000) | |
.width(260) | |
.tickFormat(d3.format('')) | |
.ticks(4) | |
.step(500) | |
.default(2000) | |
.on('onchange', val => { | |
d3.select('p#value-step-duration').text(d3.format('.2%')(val)) | |
}); | |
// Step slide from 5 to 50 for transition duration in steps of 5 | |
var sliderStepDelay = d3.sliderBottom() | |
.min(5) | |
.max(50) | |
.width(260) | |
.tickFormat(d3.format('')) | |
.ticks(10) | |
.step(5) | |
.default(10) | |
.on('onchange', val => { | |
d3.select('p#value-step-delay').text(d3.format('.2%')(val)); | |
}); | |
var gStep1 = d3.select('div#slider-step1') | |
.append('svg') | |
.attr('width', 300) | |
.attr('height', 90) | |
.append('g') | |
.attr('transform', 'translate(20, 20)') | |
var gStep2 = d3.select('div#slider-step2') | |
.append('svg') | |
.attr('width', 300) | |
.attr('height', 90) | |
.append('g') | |
.attr('transform', 'translate(20, 20)') | |
gStep1.call(sliderStepDuration) | |
gStep2.call(sliderStepDelay) | |
/////////////////////////////////////////////////////////////////////////// | |
/////////////////////////////////// CORE ////////////////////////////////// | |
/////////////////////////////////////////////////////////////////////////// | |
return { | |
clear : function () { | |
modal.select("svg").remove() | |
}, | |
run : function () { | |
//////////////////// Set up and initiate containers /////////////////////// | |
var svg = modalDiv.append("svg") | |
.attr("width", width + margin.left + margin.right) | |
.attr("height", height + margin.top + margin.bottom) | |
g = svg.append("g") | |
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')') | |
if(screen.width > 420){ | |
svg.append('rect') | |
.attr("width", width*0.8) | |
.attr("height", height*0.95) | |
.attr('fill', 'none') | |
.style('stroke-width', '2px') | |
.style('rx', '6px') | |
.style('stroke', 'black') | |
.attr('x', width/4) | |
.attr('y', 10) | |
} | |
///////////////// Run animation sequence based on chosen parameters /////////////////// | |
initAllData() | |
getParameters() | |
run_scatter() | |
function initAllData() { | |
nodes = getData() | |
distributed = distributedData(nodes) // run this function first to get IDs of nodes | |
scattered = scatteredData(distributed) // modify x-y coordinates only | |
} | |
function getParameters() { | |
DURATION = sliderStepDuration.value() // get current slider value | |
DELAY = sliderStepDelay.value() | |
d3.select('p#value-step-duration').text(d3.format('')(DURATION)); | |
d3.select('p#value-step-delay').text(d3.format('')(DELAY)); | |
TRANSITION_TYPE = d3.select("input[name='transition_type']:checked").property('value'); | |
d3.select('.run_cluster_sorted').on("click", function () { run_cluster_sorted() }); | |
d3.select('.run_cluster_bubbles').on("click", function () { run_cluster_bubbles() }); | |
d3.select('.reset_button').on("click", function () { reset() }); | |
d3.select('.submit_button').on("click", function () { to_distributed() }); | |
//console.log(TRANSITION_TYPE, DURATION, DELAY) | |
} | |
function run_scatter() { | |
update(scattered, 'transition_by_default', DURATION, '') // transition nodes to new positions | |
scatter(scattered) // run force simulation to prevent collision of nodes | |
distributed.forEach((d,i) => { | |
d.length = getPathLength(scattered, d) | |
}) | |
} | |
function run_cluster_sorted() { | |
cluster_sorted(scattered) // modify x-y coordinates | |
distributed.forEach((d,i) => { | |
d.length = getPathLength(scattered, d) // recalculate shortest distance between clustered and distributed layout for each node | |
}) | |
} | |
function run_cluster_bubbles() { | |
cluster_bubbles(scattered) // modify x-y coordinates | |
distributed.forEach((d,i) => { | |
d.length = getPathLength(scattered, d) | |
}) | |
} | |
function to_distributed() { | |
getParameters() | |
simulation.stop() | |
update(distributed, TRANSITION_TYPE, DURATION, DELAY) | |
} | |
function reset() { | |
simulation.stop() | |
initAllData() | |
run_scatter() | |
} | |
} | |
} | |
/////////////////////////////////////////////////////////////////////////// | |
///////////////////////// Find length of trajectory /////////////////////// | |
/////////////////////////////////////////////////////////////////////////// | |
function getPathLength(other, d) { | |
var d2 = other.find(b=>b.id==d.id) | |
var len = Math.sqrt((Math.pow(d.x-d2.x,2))+(Math.pow(d.y-d2.y,2))) | |
return len | |
} | |
/////////////////////////////////////////////////////////////////////////// | |
//////////////////////////// Data Processing ////////////////////////////// | |
/////////////////////////////////////////////////////////////////////////// | |
function getData() { | |
var nodes = [] | |
d3.range(1,201).map((d,i) => { | |
var rand = Math.round(randn_bm(1, 8, 1)) | |
nodes.push({ | |
'band': rand | |
}) | |
}) | |
return nodes | |
} | |
/////////////////////////////////////////////////////////////////////////// | |
//////////////////////////////// Scatter plot ///////////////////////////// | |
/////////////////////////////////////////////////////////////////////////// | |
function scatteredData(data) { | |
var xScale = d3.scaleLinear() | |
.range([width/4, width]) | |
var yScale = d3.scaleLinear() | |
.range([0, height*(1/2)]) | |
// modify x-y coordinates of nodes to form a scattered distribution | |
var nodes = data.map(function(d, i) { | |
return { | |
id: d.id, | |
x: xScale(+Math.random()), | |
y: yScale(+Math.random()), | |
color: d.color, | |
band: d.band, | |
radius: d.radius | |
} | |
}) | |
let xMax = d3.max(nodes, d=> +d.x) * 1.1 | |
let xMin = d3.min(nodes, d=> + d.x) - xMax/15 | |
let yMax = d3.max(nodes, d=> +d.y) * 1.1 | |
let yMin = d3.min(nodes, d=> + d.y) - yMax/15 | |
xScale.domain([xMin, xMax]) | |
yScale.domain([yMin, yMax]) | |
return nodes | |
} | |
function scatter(data) { | |
var buffer = screen.width < 420 ? 0.5 : 3 | |
simulation = simulation.nodes(data, d=>d.id) | |
.force('charge', d3.forceManyBody().strength(-20)) | |
.force("collide", d3.forceCollide(function(d,i) { return d.radius + buffer})) | |
.force("cluster", forceCluster()) | |
.force("x", d3.forceX(function (d) { return d.x })) | |
.force("y", d3.forceY(function (d) { return d.y })) | |
simulation.force("cluster").strength(0) | |
simulation.on('tick', ticked); | |
simulation.alpha(0.02).restart() | |
function ticked() { | |
circles | |
.attr('cx', (d) => d.x) | |
.attr('cy', (d) => d.y) | |
} | |
} | |
/////////////////////////////////////////////////////////////////////////// | |
//////////////////////////////// Cluster plot ///////////////////////////// | |
/////////////////////////////////////////////////////////////////////////// | |
// 1) Nodes are clustered in the middle, but not sorted | |
function cluster_default() { | |
simulation.stop(); | |
simulation | |
.force('x', d3.forceX(width/2)) | |
.force('y', d3.forceY(height/2)) | |
simulation.alpha(0.5).restart() | |
} | |
// 2) Nodes are clustered in the middle, and sorted horizontally | |
function cluster_sorted(nodes) { | |
var xScaleCluster = d3.scaleBand() | |
.range([width*(1/4), width*(3/4)]) | |
.domain(category) | |
simulation.stop(); | |
nodes.forEach(d=>{ | |
d.x = xScaleCluster(d.band) | |
}) | |
var responsiveCharge = screen.width < 420 ? -5 : -20 // modify the force charge based on screen size | |
simulation.nodes(nodes) | |
.force('charge', d3.forceManyBody().strength(responsiveCharge)) | |
.force("cluster", forceCluster()) | |
//.force('center', d3.forceCenter(width/2, height/2)) | |
//.force('x', d3.forceX(function (d) { return xScaleCluster(d.band) }).strength(0.3)) | |
.force('x', d3.forceX((width/4) + (width*(3/4))/2)) | |
.force('y', d3.forceY(height/2-100)) | |
simulation.force("cluster").strength(0) | |
simulation.velocityDecay(0.1).alpha(0.5).restart() | |
} | |
// 3) Nodes are clustered in the middle, but clusters pulled separately from other clusters based on their centroid | |
//https://bl.ocks.org/mbostock/7881887 | |
function cluster_bubbles() { | |
simulation.stop(); | |
var responsiveCharge = screen.width < 420 ? -10 : -45 | |
simulation | |
.force('charge', d3.forceManyBody().strength(responsiveCharge)) | |
.force("cluster", forceCluster()) | |
.force('x', d3.forceX((width/4) + (width*(3/4))/2)) | |
.force('y', d3.forceY(height/2-100)) | |
simulation.force("cluster").strength(0.8) | |
simulation.velocityDecay(0.5).alpha(0.5).restart() | |
} | |
function forceCluster() { | |
var strength = 0.8; | |
let nodes; | |
function force(alpha) { | |
const centroids = d3.rollup(nodes, centroid, d => d.band); | |
const l = alpha * strength; | |
for (const d of nodes) { | |
const {x: cx, y: cy} = centroids.get(d.band); | |
d.vx -= (d.x - cx) * l; | |
d.vy -= (d.y - cy) * l; | |
} | |
} | |
force.initialize = _ => nodes = _; | |
force.strength = function(_) { | |
return arguments.length ? (strength = +_, force) : strength; | |
}; | |
return force; | |
} | |
function centroid(nodes) { | |
let x = 0; | |
let y = 0; | |
let z = 0; | |
for (const d of nodes) { | |
let k = d.radius ** 2; | |
x += d.x * k; | |
y += d.y * k; | |
z += k; | |
} | |
return {x: x / z, y: y / z}; | |
} | |
// Drag functions used for interactivity | |
function dragstarted(d) { | |
if (!d3.event.active) simulation.alphaTarget(0.3).restart(); | |
d.fx = d.x; | |
d.fy = d.y; | |
} | |
function dragged(d) { | |
d.fx = d3.event.x; | |
d.fy = d3.event.y; | |
} | |
function dragended(d) { | |
if (!d3.event.active) simulation.alphaTarget(0); | |
d.fx = null; | |
d.fy = null; | |
} | |
/////////////////////////////////////////////////////////////////////////// | |
///////////////////////////// Distribution plot /////////////////////////// | |
/////////////////////////////////////////////////////////////////////////// | |
function distributedData(nodes) { | |
var nodeRadius = screen.width < 420 ? 4 : 8 | |
var tilesPerRow = 8 | |
var tileSize = nodeRadius * 1.5 | |
var barWidth = screen.width < 420 ? 50 : (screen.width <= 1024 ? 150 : 200) | |
// find count of nodes within each category | |
var counts = nodes.reduce((p, c) => { | |
var name = c.band; // key property | |
if (!p.hasOwnProperty(name)) { | |
p[name] = 0; | |
} | |
p[name]++; | |
return p; | |
}, {}); | |
countsExtended = Object.keys(counts).map(k => { | |
var circles_arr = nodes.filter(d=>d.band==k) | |
return {name: k, count: counts[k]}; circles_arr: circles_arr}); | |
// get x-y coordinates of all tiles first without rendering the dotted bar chart | |
var dataAll = countsExtended.map(d=>d.count) | |
var arrays = [] | |
dataAll.map((d,i) => { | |
var tiles = getTiles(d, i) | |
arrays.push(tiles) | |
}) | |
var distributed = [].concat.apply([], arrays) | |
return distributed | |
function getTiles(num, counter) { | |
var tiles = [] | |
var leftBuffer = screen.width <= 1024 ? 0 : width/4 | |
var bottomBuffer = screen.width <=1024 ? 0 : 100 | |
for(var i = 0; i < num; i++) { | |
var rowNumber = Math.floor(i / tilesPerRow) | |
tiles.push({ | |
x: ((i % tilesPerRow) * tileSize) + (counter * barWidth) + tileSize + leftBuffer, | |
y: -(rowNumber + 1) * tileSize + height - bottomBuffer, | |
color: color[counter], | |
band: (counter+1).toString(), | |
id: counter + '-' + i, // index each node | |
radius: (tileSize/1.5)/2 | |
}); | |
} | |
return tiles | |
} | |
} | |
/////////////////////////////////////////////////////////////////////////// | |
//////////////////////////// Updated node positions /////////////////////// | |
/////////////////////////////////////////////////////////////////////////// | |
function update(data, type, DURATION, DELAY) { | |
circles = g.selectAll('circle').data(data, d=>d.id) | |
circles.exit().remove() | |
var entered_circles = circles | |
.enter() | |
.append('circle') | |
.style('opacity', 1) | |
.attr('id', d => d.id) | |
.attr('cx', function(d) { | |
//console.log(d.id, d.x) | |
return d.x }) | |
.attr('cy', d => d.y) | |
.style('fill', d => d.color) | |
.attr('r', d => d.radius) | |
.call(d3.drag() | |
.on("start", dragstarted) | |
.on("drag", dragged) | |
.on("end", dragended)) | |
circles = circles.merge(entered_circles) | |
var t = d3.transition() | |
.duration(DURATION) | |
.ease(d3.easeQuadOut) | |
if(type=='transition_by_default'){ | |
circles.transition(t) | |
.attr('cx', d => d.x) | |
.attr('cy', d => d.y) | |
} else if(type=='transition_by_index'){ | |
// transition each node one by one within each group at the same time | |
category.map((d,i)=> { | |
circles.filter("circle[id^='" + i.toString() + "']") | |
.transition(t) | |
.delay(function(d,i){ return DELAY*i }) // transition each node one by one based on index | |
.attr('cx', d => d.x) | |
.attr('cy', d => d.y) | |
}) | |
} else if(type=='transition_by_length'){ | |
// transition each node one by one within each group at the same time | |
category.map((d,i)=> { | |
circles.filter("circle[id^='" + i.toString() + "']") | |
.transition(t) | |
.delay(function(d,i){ return d.length*DELAY }) // transition each node one by one based on length of trajectory | |
.attr('cx', d => d.x) | |
.attr('cy', d => d.y) | |
}) | |
} | |
} | |
/////////////////////////////////////////////////////////////////////////// | |
///////////////////////////// Helper functions //////////////////////////// | |
/////////////////////////////////////////////////////////////////////////// | |
function randn_bm(min, max, skew) { | |
var u = 0, v = 0; | |
while(u === 0) u = Math.random(); //Converting [0,1) to (0,1) | |
while(v === 0) v = Math.random(); | |
let num = Math.sqrt( -2.0 * Math.log( u ) ) * Math.cos( 2.0 * Math.PI * v ); | |
num = num / 10.0 + 0.5; // Translate to 0 -> 1 | |
if (num > 1 || num < 0) num = randn_bm(min, max, skew); // resample between 0 and 1 if out of range | |
num = Math.pow(num, skew); // Skew | |
num *= max - min; // Stretch to fill range | |
num += min; // offset to min | |
return num; | |
} | |
}() |
body { | |
background-color: white; | |
color: black; | |
font-family: 'Karla', sans-serif; | |
} | |
.btn { | |
color: black; | |
background: transparent; | |
border: 2px solid black; | |
border-radius: 6px; | |
padding: 8px 16px; | |
text-align: center; | |
display: inline-block; | |
font-size: 0.8em; | |
margin: 4px 2px; | |
-webkit-transition-duration: 0.4s; /* Safari */ | |
transition-duration: 0.4s; | |
cursor: pointer; | |
text-decoration: none; | |
text-transform: uppercase; | |
} | |
.btn:hover { | |
background-color: #E47D06; | |
color: black; | |
} | |
/* if mobile device max width 420px */ | |
@media screen and (max-width: 576px){ | |
label, input[type='radio'] { | |
font-size: 0.8em; | |
} | |
.btn { | |
padding: 4px 8px; | |
text-align: center; | |
display: inline-block; | |
font-size: 0.6em; | |
margin: 2px 1px; | |
} | |
} | |
#panel-1 { | |
text-align: left; | |
float:left; | |
color:black; | |
position: absolute; | |
width: 300px; | |
top: 50px; | |
left: 50px; | |
z-index: 1; | |
} | |
#panel-2 { | |
text-align: left; | |
float:left; | |
color:white; | |
position: absolute; | |
width: 300px; | |
top: 1000px; | |
left: 50px; | |
z-index: 1; | |
} | |
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta name="viewport" content="width=device-width"> | |
<script src="https://d3js.org/d3.v4.min.js"></script> | |
<script src="https://d3js.org/d3-color.v1.min.js"></script> | |
<script src="https://d3js.org/d3-interpolate.v1.min.js"></script> | |
<script src="https://d3js.org/d3-scale-chromatic.v1.min.js"></script> | |
<script src="https://d3js.org/d3-array.v2.min.js"></script> | |
<script src="https://unpkg.com/d3-simple-slider"></script> | |
<link rel="stylesheet" href="general.css" /> | |
</head> | |
<body> | |
<div id="body"> | |
<div class="modal"> | |
<div class="modal-content2" id="modal-fullscreen"> | |
<div id='panel-1'> | |
<label>First, select chart type to transition from: </label> | |
<div id="section"> | |
<input name="start_type" | |
type="button" | |
class='btn run_cluster_sorted' | |
value="Cluster blob"/> | |
<input name="start_type" | |
type="button" | |
class="btn run_cluster_bubbles" | |
value="Cluster bubbles" /> | |
<input name="start_type" | |
type="button" | |
class='btn reset_button' | |
value="Reset to scatter" | |
checked /> | |
</div> | |
<label>Next, select transition type: </label> | |
<div id="section"> | |
<input name="transition_type" | |
type="radio" | |
value="transition_by_index" | |
> Index<br> | |
<input name="transition_type" | |
type="radio" | |
value="transition_by_length" | |
> Length<br> | |
<input name="transition_type" | |
type="radio" | |
value="transition_by_default" | |
checked > Default<br> | |
</div> | |
<label>Finally, select transition parameters: </label> | |
<div id='slider-step1'></div> | |
<div id='slider-step2'></div> | |
<div id="section"> | |
<input name="submit" | |
class="btn submit_button" | |
type="button" | |
value="Morph to dot plot!"/> | |
</div> | |
</div> | |
<div id='panel-2'> | |
<input name="start_type" | |
type="button" | |
class="btn run_add" | |
value="Add" /> | |
<input name="start_type" | |
type="button" | |
class="btn run_move" | |
value="Move" /> | |
<input name="type" | |
type="button" | |
class='btn changeFocitoIndustry' | |
value="Regroup"/> | |
<input name="type" | |
type="button" | |
class='btn run_change_foci' | |
value="Change Foci"/> | |
<input name="type" | |
type="button" | |
class="btn changeFocitoCrime" | |
value="Reset"/> | |
</div> | |
</div> | |
</div> | |
</div> | |
<script src="distribute_nodes.js"></script> | |
<script src="moving_bubbles.js"></script> | |
<script> | |
distribute.run() | |
moving_bubbles.run() | |
</script> | |
</body> | |
</html> |
var moving_bubbles = function () { | |
/////////////////////////////////////////////////////////////////////////// | |
///////////////////////////////// Globals ///////////////////////////////// | |
/////////////////////////////////////////////////////////////////////////// | |
const FOCI_LENGTH = 10; | |
const FOCI_STROKE_WIDTH = 2; | |
const HEIGHT = screen.width < 420 ? screen.width*0.90 : screen.width*0.50; | |
const INTERVAL_DURATION = 5000; | |
const MARGIN = 20; | |
const MUTATE_PROBABILITY = 0.02; | |
const CIRCLE_RADIUS = screen.width < 420 ? 2 : 3.5; | |
const WIDTH = screen.width < 420 ? screen.width*0.90 : screen.width*0.50; | |
var res = [] | |
var centroids, progress, circles, timer | |
let center = [WIDTH / 2, HEIGHT / 2]; | |
let centerRadius = Math.min(WIDTH / 2, HEIGHT / 2) * 0.68; | |
let foci = null; | |
let fociCount = null; | |
let nodeCount = null; | |
let nodes = null; | |
let svg = null; | |
var t = 0 | |
var NUM_FLICKS = 30 | |
var chargeStrength = screen.width < 420 ? -2 : -5 | |
var fociStrength = 0.2 | |
var simulation = d3.forceSimulation() | |
var modal = d3.select(".modal-content2") | |
var modalDiv = modal.append('div') | |
.attr('id', "div-content2-1") | |
.attr('width', WIDTH) | |
.attr('height', HEIGHT) | |
.attr('transform', 'translate(' + MARGIN + ',' + screen.height + ')') | |
.attr('float', 'left') | |
/////////////////////////////////////////////////////////////////////////// | |
///////////////////////////// Create scales /////////////////////////////// | |
/////////////////////////////////////////////////////////////////////////// | |
var money_laundering = ['#F48FB1', '#F06292', '#EC407A', '#E91E63', '#D81B60', '#C2185B', 'red'] | |
var tax_customs_violation = ["#D4E157", "#CDDC39", "#C0CA33", "#AFB42B", "#9E9D24", "#827717", 'red'] | |
var cybercrime = ['#c7e9c0','#a1d99b','#74c476','#41ab5d','#238b45','#005a32', 'red'] | |
var organized_crime = ['#c6dbef','#9ecae1','#6baed6','#4292c6','#2171b5','#084594', 'red'] | |
var terrorism = ['#d9d9d9','#bdbdbd','#969696','#737373','#525252','#252525', 'red'] | |
var sanctions = ['#dadaeb','#bcbddc','#9e9ac8','#807dba','#6a51a3','#4a1486', 'red'] | |
var trafficking = ['#fdd0a2','#fdae6b','#fd8d3c','#f16913','#d94801','#8c2d04', 'red'] | |
var colors = [{key:'Money Laundering', colors: money_laundering}, | |
{key:'Tax & Customs Violation', colors: tax_customs_violation}, | |
{key:'Cybercrime', colors: cybercrime}, | |
{key:'Organised Crime', colors: organized_crime}, | |
{key:'Terrorism', colors: terrorism}, | |
{key:'Sanctions', colors: sanctions}, | |
{key:'Trafficking in Stolen Goods', colors: trafficking}] | |
var focisCrime = ['Money Laundering', 'Tax & Customs Violation', 'Cybercrime', 'Organised Crime', 'Terrorism', 'Sanctions', 'Trafficking in Stolen Goods'] | |
var focisScore = ['0.2-0.39', '0.4-0.59', '0.6-0.69', '0.7-0.79', '0.8-0.99', '0.9-0.99', '1' ] | |
var colorScale = d3.scaleOrdinal() | |
.domain(focisScore) | |
var focis = [{key:'crime', categories: focisCrime}, {key:'score', categories: focisScore}] | |
var crimeFoci = onFociCountChange(focisCrime) // create a foci for each crime category | |
var scoreFoci = onFociCountChange(focisScore) // create a foci for each score category | |
var crimeFoci_new = [] | |
Object.keys(crimeFoci).map(function(key, index) { | |
crimeFoci_new.push({ | |
x: crimeFoci[index].x, | |
y: crimeFoci[index].y, | |
key: crimeFoci[index].key, | |
color: crimeFoci[index].color | |
}) | |
}) | |
var yScale = d3.scaleBand() | |
.domain(focisCrime) | |
.range([HEIGHT+120, 40]) | |
///////////////////////////////////////////////////////////////////////////// | |
////////////////////////////////// INITIALIZE /////////////////////////////// | |
///////////////////////////////////////////////////////////////////////////// | |
return { | |
clear : function () { | |
modal.select("svg").remove() | |
}, | |
run : function () { | |
svg = modalDiv.append("svg") | |
.attr("width", WIDTH + 2 * MARGIN) | |
.attr("height", HEIGHT + 2 * MARGIN) | |
.append("g") | |
.attr("class", "margin") | |
.attr("transform", `translate(${MARGIN}, ${MARGIN})`); | |
if(screen.width > 420){ | |
svg.append('rect') | |
.attr("width", WIDTH) | |
.attr("height", HEIGHT) | |
.attr('fill', 'none') | |
.style('stroke-width', '2px') | |
.style('rx', '6px') | |
.style('stroke', 'black') | |
} | |
gCircle = svg.append('g').attr('class', 'circles') | |
d3.range(1,400).map((d,i) => { | |
res.push({ | |
id: i, | |
crime0: crimeFoci[getRandomArbitrary(0,6)].key, | |
binned0 : scoreFoci[getRandomArbitrary(0,6)].key, | |
crime1: crimeFoci[getRandomArbitrary(0,6)].key, | |
binned1 : scoreFoci[getRandomArbitrary(0,6)].key, | |
crime2: crimeFoci[getRandomArbitrary(0,6)].key, | |
binned2 : scoreFoci[getRandomArbitrary(0,6)].key | |
}) | |
}) | |
init() | |
} | |
} | |
function init() { | |
var res_nested_crime = d3.nest() | |
.key(d=>d.crime0) | |
.entries(res) | |
nodes = [] | |
res_nested_crime.map(d=> { | |
var n = createNodes(focis, d.key, d.values) | |
nodes.push(n) | |
}) | |
nodes = [].concat.apply([], nodes) | |
//nodes = createNewNodes(focis, nodes) // create a new set of nodes to 'fly in' | |
nodes.forEach((d,i)=>{ | |
d.class = d.binned0 == '1' ? "new" : undefined | |
}) | |
simulate(nodes, crimeFoci, true, 'groupCrime') // kick off simulation | |
d3.select('.changeFocitoCrime').on("click", function () { | |
simulate_group(nodes, foci, true) | |
init() | |
}); | |
d3.select('.run_add').on("click", function () { simulate(nodes, scoreFoci, false, 'pull') }); | |
d3.select('.run_move').on("click", function () { changeNodeFoci(nodes, crimeFoci, false) }); | |
d3.select('.run_align').on("click", function () { simulate(nodes, crimeFoci, false, 'align') }); | |
d3.select('.run_change_foci').on("click", function () { simulate_group(nodes, crimeFoci, false) }); | |
d3.select('.changeFocitoIndustry').on("click", function () { changeFocitoIndustry(nodes, scoreFoci, false) }); | |
} | |
//////////////////////////////////////////////////////////////////////////////// | |
////////////////////////////////// Simulate nodes ////////////////////////////// | |
//////////////////////////////////////////////////////////////////////////////// | |
function simulate(nodes, foci, bg, type) { | |
if(type=='groupCrime'){ | |
nodes.forEach(d=>{ | |
d.x1 = d.class=='new' ? foci[d.focus].x : d.x | |
d.y1 = d.class=='new' ? foci[d.focus].y : d.y | |
d.opacity = d.class=='new' ? 1 : 0 | |
}) | |
} else if (type=='pull'){ | |
fociStrength = 0.1 | |
nodes.forEach(d=>{ | |
d.fx = null | |
d.fy = null | |
d.x1 = foci[d.focus].x | |
d.y1 = foci[d.focus].y | |
d.opacity = 1 | |
}) | |
} else if (type=='groupIndustry'){ | |
nodes.forEach(d=>{ | |
d.fx = null | |
d.fy = null | |
d.x1 = foci[d.focus].x | |
d.y1 = foci[d.focus].y | |
}) | |
} else if (type=='pool'){ | |
nodes.forEach(d=>{ | |
d.fx = null | |
d.fy = null | |
d.x1 = WIDTH/2 | |
d.y1 = HEIGHT/2 | |
}) | |
} | |
if(bg==true){ | |
startSimulationBackground() | |
} else if(bg==false){ | |
if(type=='groupCrime'){ | |
updateCircles(nodes, 'groupCrime') // render nodes | |
} | |
startSimulation() | |
} | |
} | |
/////////////// Group nodes according to subgraphs (with simulation) /////////////// | |
function simulate_group(nodes, foci, terminator) { | |
setTimeout(function() { changeNodeGroup(NUM_FLICKS, foci, terminator) }, 1000) | |
// show progress bar to indicate time to end of animation | |
var segmentWidth = WIDTH-40 | |
progress = svg.append('rect') | |
.attr('class', 'progress-bar') | |
.attr('rx', 10) | |
.attr('ry', 10) | |
.attr('fill', 'lightgray') | |
.attr('height', 5) | |
.attr('width', 0) | |
.attr('x', 20) | |
.attr('y', 10) | |
function moveProgressBar(t, NUM_FLICKS){ | |
progress.transition() | |
.duration(800) | |
.attr('fill', 'black') | |
.attr('width', function(){ | |
return t/NUM_FLICKS * segmentWidth; | |
}); | |
} | |
function changeNodeGroup(NUM_FLICKS, foci, terminator) { | |
console.log(terminator) | |
var animation_interval = d3.interval(function(){ | |
t += 1 // update time | |
if (t > NUM_FLICKS) { animation_interval.stop(); simulation.stop(); return true } // stop simulation after 10 timesteps | |
if (terminator==true) { | |
animation_interval.stop(); | |
simulation.stop(); | |
svg.select('.progress-bar').remove() | |
return true | |
} | |
assignOtherSubgraph(t) | |
moveProgressBar(t, NUM_FLICKS) | |
}, 800, d3.now() - 800) | |
function assignOtherSubgraph(t) { | |
var newFoci = getRandomArbitrary(1, 7) | |
var newI = getRandomArbitrary(0, 400) | |
nodes[newI].x = foci[newFoci].x | |
nodes[newI].y = foci[newFoci].y | |
simulation.nodes(nodes) | |
simulation.velocityDecay(0.4).alpha(0.5).restart() | |
} | |
} | |
} | |
//////////////////// Reinitialize simulation (show tick movement) //////////////////// | |
function startSimulation() { | |
var buffer = screen.width < 420 ? 0.5 : 2.5 | |
simulation.nodes(nodes) | |
.force("charge", d3.forceManyBody().strength(chargeStrength).distanceMin(CIRCLE_RADIUS)) | |
.force("collide", d3.forceCollide(CIRCLE_RADIUS + buffer)) | |
.force("position-x", d3.forceX(d=>d.x1).strength(fociStrength)) | |
.force('position-y', d3.forceY(d=>d.y1).strength(fociStrength)) | |
.on("tick", onSimulationTick) | |
simulation.velocityDecay(0.3).alpha(0.5).restart() | |
} | |
function onSimulationTick() { | |
svg.selectAll("circle") | |
.attr('opacity', d => d.opacity) | |
.attr("cx", d => d.x) | |
.attr("cy", d => d.y) | |
} | |
//////////////////// Reinitialize simulation (in the background) //////////////////// | |
function startSimulationBackground() { | |
simulation.nodes(nodes) | |
.force("charge", d3.forceManyBody().strength(chargeStrength).distanceMin(CIRCLE_RADIUS)) | |
.force("collide", d3.forceCollide(CIRCLE_RADIUS + 2.5)) | |
.force("position-x", d3.forceX(d=>d.x1).strength(fociStrength)) | |
.force('position-y', d3.forceY(d=>d.y1).strength(fociStrength)) | |
.stop() | |
simulation.velocityDecay(0.3).alpha(0.5).restart() | |
for (var i = 0; i < 150; ++i) simulation.tick() | |
updateCircles(nodes, 'groupCrime') // render nodes | |
} | |
///////////////////////////////////////////////////////////////////////////// | |
/////////////////////// Create foci points and nodes //////////////////////// | |
///////////////////////////////////////////////////////////////////////////// | |
////////////////////// Assign focus point of each category ////////////////// | |
function onFociCountChange(focis) { | |
fociCount = focis.length | |
foci = {}; | |
for (let i = 0; i < fociCount; i++) { | |
let focus = createFocus(i, focis[i], fociCount); | |
foci[i] = focus; | |
} | |
return foci | |
} | |
/////////////////////// Calculate focus point of each category /////////////// | |
function createFocus(index, key, fociCount) { | |
let angle = 2 * Math.PI / fociCount * index; | |
return { | |
key: key, | |
index: index, | |
angle: angle, | |
color: colors.find(c=>c.key == key) ? colors.find(c=>c.key == key).colors[5] : null, | |
//color: d3.interpolateRainbow(index / fociCount), | |
x: center[0] + centerRadius * Math.cos(angle), | |
y: center[1] + centerRadius * Math.sin(angle) | |
}; | |
} | |
////////////////////////////////// Create nodes ////////////////////////////// | |
function createNodes(focis, key, data) { | |
var crime = focis.find(d=>d.key == 'crime').categories | |
var score = focis.find(d=>d.key == 'score').categories | |
var n = [] | |
colorScale.range(colors.find(c=>c.key == key).colors) | |
data.map((d,i)=> { | |
n.push({ | |
index: i, // unique index for each node | |
id: d.id, // unique ID for each entity | |
country: d.country, | |
subgraph: d.subgraph, | |
crime0: d.crime0, | |
binned0: d.binned0, | |
focus: crime.indexOf(d.crime0), //based on first crime category | |
focus1: crime.indexOf(d.crime1), //based on second crime category | |
focus2: crime.indexOf(d.crime2), //based on third crime category | |
focus_score: score.indexOf(d.binned0), // based on association score category | |
r: CIRCLE_RADIUS, | |
color: colorScale(d.binned0), | |
strokeFill: colorScale(d.binned0), | |
//x: crimeFoci_new.find(f=>f.key == d.crime0).x, | |
//y: crimeFoci_new.find(f=>f.key == d.crime0).y, | |
x: randBetween(0, WIDTH), | |
y: randBetween(0, HEIGHT), | |
same_subgraph: d.same_subgraph | |
}) | |
}) | |
return n | |
} | |
function createNewNodes(focis, nodes) { | |
var subgraphs = nodes.map(d=>d.subgraph).filter(onlyUnique) | |
var crime = focis.find(d=>d.key == 'crime').categories | |
var score = focis.find(d=>d.key == 'score').categories | |
var beyondWidth = [screen.width+150+Math.random(), -150+Math.random()] | |
var beyondHeight = [screen.height+150+Math.random(), -150+Math.random()] | |
var newNodes = [] | |
d3.range(0, 100).map(d=> { | |
var binned0 = focisScore[getRandomArbitrary(0,score.length-1)] // randomly assigned a crime category | |
var crime0 = focisCrime[getRandomArbitrary(0,crime.length-1)] // randomly assigned an association score | |
colorScale.range(colors.find(c=>c.key == crime0).colors) | |
newNodes.push({ | |
crime0: crime0, | |
binned0: binned0, | |
subgraph: subgraphs[getRandomArbitrary(0,subgraphs.length-1)], | |
focus: crime.indexOf(crime0), | |
focus_score: score.indexOf(binned0), | |
r: CIRCLE_RADIUS, | |
color: colorScale(binned0), | |
strokeFill: 'black', | |
x: beyondWidth[getRandomArbitrary(0,1)], | |
y: beyondHeight[getRandomArbitrary(0,1)], | |
class: 'new', | |
}) | |
}) | |
nodes.push(newNodes) | |
nodes = [].concat.apply([], nodes) | |
nodes.forEach((d,i)=>{ | |
d.index = i | |
}) | |
return nodes | |
} | |
///////////////////////// Update focus point of some nodes ////////////////////// | |
function changeNodeFoci(nodes, focis, bg) { | |
nodes.map((d,i) => { | |
if (d.focus1) { | |
let point = d; | |
let newFocus = d.focus1; // change focus point | |
point.focus = newFocus; | |
} | |
}) | |
simulate(nodes, focis, bg, 'pull') | |
} | |
/////////////////////// Update foci based on industry categories ////////////////// | |
function changeFocitoIndustry(nodes, focis, bg) { | |
nodes.map((d,i) => { | |
if (d.focus_score!=-1) { | |
let point = d; | |
let newFocus = d.focus_score; // change focus point | |
point.focus = newFocus; | |
} | |
}) | |
//updateLabels(scoreFoci, 'groupIndustry') | |
simulate(nodes, focis, bg, 'groupIndustry') | |
} | |
/////////////////////// Update foci based on score categories ////////////////// | |
function changeFocitoScore(data) { | |
svg1.selectAll('text').remove() | |
svg1.selectAll('circle').remove() | |
svg.selectAll('text').remove() | |
var pointsBar = createDots(data, 'bar') | |
var pointsBar1 = createDots(data, 'tiledbar') | |
updateCircles(pointsBar, 'groupScore') | |
setTimeout(function(){ | |
updateCircles(pointsBar1, 'groupScore') | |
}, 3000) | |
var ASFocis = [] | |
focisScore.map((d,i)=>{ | |
ASFocis.push({ | |
key : d, | |
x : ((i*150)+100), | |
y : HEIGHT-100 | |
}) | |
}) | |
updateLabels(ASFocis, 'groupScore') | |
} | |
/////////////////////////////////////////////////////////////////////////////////// | |
//////////////////////// Update node, labels, misc positions ////////////////////// | |
/////////////////////////////////////////////////////////////////////////////////// | |
function updateCircles(data, type) { | |
circles = gCircle.selectAll("circle").data(data, d=>d.index); | |
circles.exit().remove() | |
var entered_circles = circles | |
.enter() | |
.append("circle") | |
.classed("node", true) | |
.classed("enter", true) | |
.attr("id", d => d.index) | |
.attr("r", d => d.r) | |
.attr("fill", d => d.color) | |
.attr('stroke', d => d.strokeFill) | |
.attr('stroke-width', '1px') | |
.attr("cx", d => d.x) | |
.attr("cy", d => d.y) | |
.attr("opacity", d => d.opacity) | |
circles = circles.merge(entered_circles) | |
var t = d3.transition() | |
.duration(2000) | |
.ease(d3.easeQuadOut) | |
if(type=='groupCrime'){ | |
circles | |
.classed("enter", false) | |
.classed("update", true) | |
.transition().duration(500) // comment this line if you want to let simulation transition the nodes | |
.attr("fill", d => d.color) | |
.attr('cx', d => d.x) | |
.attr('cy', d => d.y) | |
.attr("opacity", d => d.opacity) | |
} else if(type=='groupScore'){ | |
simulation.stop() | |
circles | |
.classed("enter", false) | |
.classed("update", true) | |
circles.transition(t) | |
.attr("fill", d => d.color) | |
.attr('cx', d => d.x) | |
.attr('cy', d => d.y) | |
} | |
} | |
function randBetween(min, max) { | |
return min + (max - min) * Math.random(); | |
} | |
function getRandomArbitrary(min, max) { | |
return Math.round(Math.random() * (max - min) + min) | |
} | |
}() |