/************************************************************* * Constants, selections, and initialised variables **************************************************************/ const w = window; const d = document; const e = d.documentElement; const g = d.getElementsByTagName('body')[0]; const width = w.innerWidth || e.clientWidth || g.clientWidth; const headerHeight = d3.select('.header').style('height').split('p')[0]; const keyHeight = d3.select('.key').style('height').split('p')[0]; const height = (w.innerHeight|| e.clientHeight|| g.clientHeight) - headerHeight - keyHeight - 10; const radiusScaler = 10; let svg = d3.select('.screen').attr('width', width).attr('height', height); let link = svg.append('g').attr('class', 'links').selectAll('line'); let node = svg.append('g').attr('class', 'nodes').selectAll('circle'); let key = d3.select('.key'); let headline = d3.select('.headline'); let year = 1; let numDynamicallyAddedNodes = 0; // Storage of reference nodes and year data let ref = {}; let yearData = {}; // Arrays of nodes/links that are used / updated to create the actual visualization let links let nodes; let headlines; // store json data in graph var let graph; let simulation = d3.forceSimulation() .force('charge', d3.forceManyBody()) .force('collide', d3.forceCollide()) .force('link', d3.forceLink()) .force('center', d3.forceCenter()) .force('forceY', d3.forceY()); /************************************************************* * Read in data and intitialize chart **************************************************************/ d3.json('data.json', (error, _graph) => { if (error) { console.log('error fetching dataz!', error); } graph = d3.hierarchy(_graph.base); nodes = graph.descendants(); links = graph.links(); // populate main reference pointers in node storage object nodes.forEach(el => { ref[el.data.id] = el; }); // populate each year's data in yearData for (let year in _graph) { if (year !== 'base') { yearData[year] = _graph[year]; } } // Add 1st year's nodes / links yearData['year1'].forEach(element => { addNodes(element); }) update(); }); d3.json('headlines.json', (error, data) => { if (error) { console.log('error retrieving headlines', error); } headlines = data; updateHeadline(1); }) /************************************************************* * Update method and helpers: refreshes chart (i.e. after new data) **************************************************************/ // This is the main flow! function update() { updateSizes(ref.SORT); initializeDisplay(); updateForces(); simulation.alpha(1).restart(); } // Makes the values equal to the sum of all descendant values-- Used to set radius // Set count property which equals the number of children leaf nodes function updateSizes(root) { let aggArea = 0; let count = 0; root.children.forEach(child => { // if at a leaf node if (!child.children) { child.value = computeValueWithInterest(child.data.value, child.data.year, year); child.count = 1; } else { updateSizes(child); } aggArea += child.value || 0; count += child.count; }) root.value = aggArea; root.count = count; } function initializeDisplay() { let t = d3.transition().duration(1000); // create link svg elements link = link.data(links); link.exit().remove(); link = link.enter().append('line').merge(link); // create node svg elements node = node.data(nodes, d => d.data.id); node.exit().remove(); node = node .enter().append('circle') .attr('fill', d => chooseColor(d)) .attr('r', 0) .call(d3.drag() .on('start', dragStarted) .on('drag', dragged) .on('end', dragEnded) ) .merge(node) .on('mouseenter', mouseEnter) .on('mouseleave', mouseLeave) node.transition(t) .attr('r', computeRadius); } function updateForces() { simulation .nodes(nodes) .on('tick', ticked); simulation.force('charge') // .strength((d, i) => d.data.id === 'SORT' ? 0 : -120); .strength((d, i) => d.data.id === 'SORT' ? 0 : -1 * d.value); simulation.force('center') .x(width / 2) .y(height / 2); // Makes layout tend horizontally simulation.force('forceY') .y(height / 2) .strength(0.04); simulation.force('link') .links(links) .iterations(10) .distance(d => { const sourceRadius = computeRadius(d.source); const targetRadius = computeRadius(d.target); return sourceRadius + targetRadius + 5; }); simulation.force('collide') .radius(d => Math.sqrt(d.value / Math.PI) * radiusScaler + 3) .strength(1); } function computeValueWithInterest(value, startYear, currYear) { const intRate = 0.2; const timesCompoundedPerYear = 4; return value * (1 + (intRate / timesCompoundedPerYear)) ** (timesCompoundedPerYear * (currYear - startYear)); } function computeRadius(node) { return Math.sqrt(node.value / Math.PI) * radiusScaler; } function chooseColor(element) { let id = element.data.id; if (id === 'SORT') { return 'rgb(255, 77, 120)'; } else if (id === 'FI') { return 'rgb(56, 60, 70)' } else if (id === 'Match') { return 'rgb(23, 63, 88)'; } else if (id === 'B2C') { return 'rgb(48, 157, 191)'; } else if (element.children) { return 'rgb(36, 218, 229)'; } else { return 'rgb(230, 255, 250)'; } } function ticked() { link .attr('x1', d => d.source.x) .attr('y1', d => d.source.y) .attr('x2', d => d.target.x) .attr('y2', d => d.target.y); node .attr('cx', d => d.x) .attr('cy', d => d.y); } /************************************************************* * Updates based on changing year slider **************************************************************/ function changeYear(val) { updateHeadline(val); d3.select('#year_slider_output').text(val); if (+val > year) { addYear(+val); } else { removeYear(+val); } mouseLeave(); year = val; } function addYear(year) { yearData['year' + year].forEach(entry => { if (entry.id === 'nodesToAdd') { entry.categories.forEach(category => { let possibleParents = category.parent === 'B2C' ? [ref[category.parent]] : ref[category.parent].children; for (let i = 0; i < category.count; i++) { const randInd = Math.floor(Math.random() * possibleParents.length); const parent = possibleParents[randInd]; const newNode = createNode(parent, year); addNodes(newNode); } }) } else { addNodes(entry); } }); update(); }; function removeYear(year) { let removedNodes = new Set(); // Keep sizes up to date by removing references for to be removed nodes from category's children array Object.keys(ref).forEach(category => { if (category !== 'SORT' && ref[category].children) { ref[category].children = ref[category].children.filter(child => child.data.year <= +year); } }); // Remove node pointers in ref for nodes removed by year so can create new ones if year is readded Object.keys(ref).forEach(key => { if (ref[key].data.year > year) { delete ref[key]; } }); nodes = nodes.filter(node => { if (node.data.year <= year) { return true; } removedNodes.add(node); return false; }); links = links.filter(link => !(removedNodes.has(link.target) || removedNodes.has(link.source))); update(); }; function updateHeadline(year) { const nums = headlines['year' + year]; headline.select('.num-accounts').html(nums.numAccounts); headline.select('.aum').html(nums.aum); headline.select('.avg-balance').html(nums.avgBalance); headline.select('.revenue').html(nums.revenue); } /************************************************************* * Adding and removing nodes / links **************************************************************/ function createNode(parent, year) { const value = parent.data.parent === 'FI' ? 4 : 1; numDynamicallyAddedNodes++; return { id: parent.data.id + numDynamicallyAddedNodes, parent: parent.data.id, year, value } } function addNodes(element) { let parent = ref[element.parent]; let hier = d3.hierarchy(element); let currNodes = hier.descendants(); let currLinks = hier.links(); // Store references to each node based on id currNodes.forEach(el => { if (!ref[el.data.id]) { ref[el.data.id] = el; } }); positioner(currNodes, parent); nodes = nodes.concat(currNodes); links = links.concat(currLinks); links.push({source: parent, target: currNodes[0]}); if (!parent.children) { parent.children = []; } parent.children.push(currNodes[0]); } // Adds initial positions so there isn't as much of a jerky randomness when inputted function positioner(nodeList, parentNode) { nodeList.forEach(el => { let offset = 20 * Math.random(); el.x = parentNode.x > width / 2 ? parentNode.x + offset : parentNode.x - offset; el.y = parentNode.y > height / 2 ? parentNode.y + offset : parentNode.y - offset; }); } /************************************************************* * Hovering over nodes functionality **************************************************************/ function mouseEnter(circle) { // uncomment below if want to jerk a little / 'be alive' on hovers // if (!d3.event.active) { // simulation.alpha(0.05).restart(); // } createKeySelection(circle) .transition() .duration(400) .style('color', 'rgb(255, 77, 120)'); } function mouseLeave(circle) { if (circle) { createKeySelection(circle) .transition() .duration(800) .style('color', 'rgb(23, 63, 88)'); } } function createKeySelection(circle) { if (!circle.data.children) { return key.select('.key-customers'); } else if (circle.data.id === 'SORT') { return key.select('.key-sort'); } else if (circle.data.id === 'FI' || circle.data.parent === 'FI') { return key.select('.key-fi'); } else if (circle.data.id === 'Match' || circle.data.parent === 'Match') { return key.select('.key-match'); } else if (circle.data.id === 'B2C' || circle.data.parent === 'B2C') { return key.select('.key-b2c'); } } /************************************************************* * Dragging functionality **************************************************************/ 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.001); } d.fx = null; d.fy = null; }