// get svg dimensions from attributes or styles var communityTypeFieldset = d3.select('#home-view-controls').append('fieldset').attr('id', 'community-toggle'), infoPanel = d3.select('#info-panel'), heatmapSvg = d3.select('#heatmap-stage').append('svg').attr('id', 'heatmap'), graphSvg = d3.select('#graph-stage').append('svg').attr('id', 'graph').append('g'), width = parseInt(graphSvg.style('width'), 10), height = parseInt(graphSvg.style('height'), 10), parentWidth = graphSvg.node().parentNode.clientWidth, parentHeight = graphSvg.node().parentNode.clientHeight, ready = false, $stage = $('#stage'), $loading = $('#loading'); $stage.hide(); // set svg width and height // (this could be the size of the window at runtime) graphSvg .attr('width', parentWidth) .attr('height', parentHeight); // colours for activity heatmap var colourStops = ['#fff5eb','#fee6ce','#fdd0a2','#fdae6b','#fd8d3c','#f16913','#d94801','#8c2d04']; // oranges // alternative colours // ['#2c7bb6', '#00a6ca','#00ccbc','#90eb9d','#ffff8c','#f9d057','#f29e2e','#e76818','#d7191c'] // rainbow // ["#ffffd9","#edf8b1","#c7e9b4","#7fcdbb","#41b6c4","#1d91c0","#225ea8","#253494","#081d58"] // green blue var colours = d3.scaleLinear() .domain(d3.ticks(0,160,colourStops.length)) .range(colourStops).clamp(true); // Append a defs (for definition) element to your SVG var defs = heatmapSvg.append('defs'); // Append a linearGradient element to the defs and give it a unique id var heatmap = defs.append('linearGradient') .attr('id', 'activity-heatmap'); // A color scale var colorScale = d3.scaleLinear() .range(colourStops); // Draw the rectangle and fill with gradient heatmapSvg .attr('width', 300) .attr('height', 20); heatmapSvg.append('rect') .attr('width', 300) .attr('height', 20) .style('fill', 'url(#activity-heatmap)') .append('title').text('Community activity'); // add zoom/pan var zoom = d3.zoom() .on('zoom', zoomed) d3.select('#graph-stage').call(zoom); function zoomed() { graphSvg.attr('transform', d3.event.transform); } // Append multiple color stops by using D3's data/enter step heatmap.selectAll('stop') .data( colorScale.range() ) .enter().append('stop') .attr('offset', (d,i) => i/(colorScale.range().length-1)) .attr('stop-color', d => d); var nodes, links, filteredNodes, filteredLinks, filterDate, dateExtent, extentLinksValue, linkWidthScale, extentNodesValue, nodeAreaScale, extentActivity, selectedCommunity, communityCount, selectedTypes; // use a force graph // .distance and .strength set node spacing var simulation = d3.forceSimulation() .force('charge', d3.forceManyBody().strength(-150)) .force('center', d3.forceCenter(parentWidth / 2, parentHeight / 2)) .force('y', d3.forceY(0)) .force('x', d3.forceX(0)) .alphaTarget(1) .on('tick', ticked); // define transitions to all use the same var transition = d3.transition() .ease(d3.easeElastic) .duration(750); // each link connecting nodes var link = graphSvg.append('g') .attr('class', 'links') .selectAll('.link'); // each node var node = graphSvg.append('g') .attr('class', 'nodes') .selectAll('.node'); // get our data and draw graph function init() { $loading.text('Fetching data'); d3.request("data.json") .header("Content-Type", "application/json") .post("{}", (error, response) => { $loading.text('Got data'); if (error) throw error; $loading.text('Parsing data'); var graph = JSON.parse(response.responseText); $loading.text('Reticulating splines'); nodes = graph.nodes; links = graph.links; $loading.text('Calculating time'); dateExtent = getDateExtent(nodes); filterDate = formatDate(new Date(dateExtent[1])); communityCount = 50; $loading.text('Setting globals'); setGlobalValues(); $loading.text('Setting time'); dateRangeSlider(); $loading.text('Calculating communities'); communityTypeToggle(); communityCountSlider(); $loading.text('Rendering'); renderHomeView(); $loading.hide(); $stage.show(); }); } // set some global variables and values for use everywhere function setGlobalValues() { filteredNodes = getFilteredNodesByDate(); filteredNodes = filteredNodes.slice(0, communityCount); // filteredLinks = getFilteredLinksByDate(); filteredLinks = getFilteredLinksById(); // no dates on link data yet filteredLinks = removeLinksToMissingNodes(); // set scale of nodes extentNodesValue = getNodesExtent(filteredNodes); nodeAreaScale = calcNodeAreaScale(extentNodesValue); // set scale of links extentLinksValue = getLinksExtent(filteredLinks); linkWidthScale = calcLinkWidthScale(extentLinksValue); // get extent of activity extentActivity = calcActivityExtent(); } // sort nodes by follower size descending function sortNodesDesc() { filteredNodes = filteredNodes.sort((a, b) => String(b.followers).localeCompare(String(a.followers))); } // filter nodes by date function getFilteredNodesByDate() { return nodes.filter(obj => { if (obj.date == filterDate) return obj }); } // filter links by date function getFilteredLinksByDate() { return links.filter(obj => { if (obj.date == filterDate) return obj; }); } // filter links by source/target id function getFilteredLinksById() { return links.filter(obj => { if (!selectedCommunity) return obj; // server response has string values if (typeof obj.source == 'string' && typeof obj.target == 'string') { if (obj.source == selectedCommunity.datum().id || obj.target == selectedCommunity.datum().id) return obj; } // d3 mutates values to objects if (typeof obj.source == 'object' && typeof obj.target == 'object') { if (obj.source.id == selectedCommunity.datum().id || obj.target.id == selectedCommunity.datum().id) return obj; } }); } // get nodes from links array already mutated by d3 function getNodesFromLinks() { var nodesToReturn = []; filteredLinks.forEach(link => { nodesToReturn.push(link.source); nodesToReturn.push(link.target); }); nodesToReturn = d3.map(nodesToReturn, d => d.id); return nodesToReturn.values(); } // get mix and max of node followers function getNodesExtent(nodes) { return d3.extent(nodes, d => d.followers); } // get mix and max of link values function getLinksExtent(links) { return d3.extent(links, d => d.value); } // get size of a link to set as width function calcLinkWidthScale(extent) { return d3.scaleLinear() .domain(extent) .range([2,8]); } // get the area for a node to set as radius function calcNodeAreaScale(extent) { return d3.scaleSqrt() // scaleSqrt to size by area .domain(extent) .range([5,40]).clamp(true); } // get the mix and max of total node activity function calcActivityExtent() { return d3.extent(filteredNodes, d => (parseInt(d.likes)) + (parseInt(d.microBlogs)) + (parseInt(d.questions))) } // update graph nodes function nodeUpdate() { node = node.data(filteredNodes, d => d.id); node.exit() .interrupt() .transition(transition) .attr("r", 0) .remove(); node.selectAll('title').remove(); node = node.enter().append('circle') .attr('fill', d => colours( (parseInt(d.likes)) + (parseInt(d.microBlogs)) + (parseInt(d.questions)) )) .attr('r', 0) .merge(node); node.transition(transition).attr('r', d => nodeAreaScale(d.followers)); node.call(d3.drag() .on('start', dragstarted) .on('drag', dragged) .on('end', dragended)); // add labels to each node // node.append('text').text(d => d.title); node.append('title').text(d => d.title); node.on('click', renderCommunityView); } // update graph links function linkUpdate() { link = link.data(filteredLinks, d => `${d.source.id}-${d.target.id}`); link.exit() .interrupt() .transition(transition) .attr('stroke-width', 0) .remove(); link = link.enter().append('line') .attr('stroke-width', 0) .merge(link); link.append('title').text(d => `${d.value}`); link.transition(transition).attr('stroke-width', d => linkWidthScale(d.value)); } // get number of communities to show function communityCountSlider() { $('#community-count-slider').slider({ range: false, min: 0, max: getFilteredNodesByDate().length, step: 1, value: communityCount, slide: ( event, ui ) => { communityCount = ui.value; $('#community-count-value').val(communityCount); }, stop: ( event, ui ) => { renderHomeView(); } }); $('#community-count-value').val(communityCount); } // draw toggles for community types function communityTypeToggle() { var communityTypes = nodes .map(node => node.type) .filter((type, index, array) => { return array.indexOf(type) == index; }); communityTypes.sort(); communityTypeFieldset.append('legend').attr('class', 'heading').text('Community type'); var checkbox = communityTypeFieldset.selectAll('input[name="type"]').data(communityTypes); var label = checkbox.enter() .append('label'); label .append('input') .attr('type', 'checkbox') .attr('name', 'type') .attr('value', d => d) .property('checked', true) .on('change', renderHomeView); var labelText = label.append('span'); labelText.text(d => capitalize(d)); } // capitalize first letter function capitalize(s) { return s[0].toUpperCase() + s.slice(1); } // get nodes by type function getFilteredNodesByType() { selectedTypes = []; document.querySelectorAll('#community-toggle input').forEach(d => { if (d.checked) { selectedTypes.push(d.value); } }); filteredNodes = getFilteredNodesByDate(); sortNodesDesc(); filteredNodes = filteredNodes.slice(0, communityCount); return filteredNodes.filter(function(e) { return this.indexOf(e.type) > -1;}, selectedTypes); } // removes any links that point to nodes that don't exist function removeLinksToMissingNodes() { // rollup nodes into flat array var idArray = []; var idNest = d3.nest() .key(function(d) { return d.id; }) .entries(filteredNodes); idArray = idNest.map(id => id.key); return filteredLinks.filter(link => { // debugger // server response has string values if (typeof link.source == 'string' && typeof link.target == 'string') { if (idArray.indexOf(String(link.source)) > -1 && idArray.indexOf(String(link.target)) > -1) return link; } // d3 mutates values to objects if (typeof link.source == 'object' && typeof link.target == 'object') { if (idArray.indexOf(String(link.source.id)) > -1 && idArray.indexOf(String(link.target.id)) > -1) return link; } }) } // calculate rollup data function calcRollupData() { var data = {}; var sumValuesReducer = (accumulator, currentValue) => accumulator + currentValue.value; var type = d3.nest() .key(function(d) { return d.type; }) .rollup(function(v) { return v.length; }) .entries(filteredNodes); var typeObj = {}; type.forEach(type => { typeObj[type.key.toLowerCase()] = type.value; }); var microBlogs = d3.nest() .key(function(d) { return d.id; }) .rollup(function(v) { return d3.sum(v, g => g.microBlogs); }) .entries(filteredNodes); microBlogs = microBlogs.reduce(sumValuesReducer, 0); var questions = d3.nest() .key(function(d) { return d.id; }) .rollup(function(v) { return d3.sum(v, g => g.questions); }) .entries(filteredNodes); questions = questions.reduce(sumValuesReducer, 0); var likes = d3.nest() .key(function(d) { return d.id; }) .rollup(function(v) { return d3.sum(v, g => g.likes); }) .entries(filteredNodes); likes = likes.reduce(sumValuesReducer, 0); data.communities = typeObj.community || 0; data.divisions = typeObj.division || 0; data.ideas = typeObj.idea || 0; data.microBlogs = microBlogs || 0; data.questions = questions || 0; data.likes = likes || 0; return data; } // draw the home (initial) view function renderHomeView() { filteredNodes = getFilteredNodesByDate(); filteredNodes = filteredNodes.slice(0, communityCount); filteredNodes = getFilteredNodesByType(); // remove link forces simulation.force('link', null); // collision handles elements overlapping by determining radius simulation.force('collision', d3.forceCollide().radius(d => nodeAreaScale(d.followers))); nodeUpdate(); // remove any links - required for transitioning views link = link.data([]); link.exit().remove(); // add nodes simulation.nodes(filteredNodes); simulation.alpha(1).restart(); $('#home-view-controls').removeClass('disabled'); var rollupData = calcRollupData(); infoPanel.selectAll('*').remove(); // clear previous data var infoPanelFieldset = infoPanel.append('fieldset'); infoPanelFieldset.append('legend').attr('class', 'heading').text('Overview'); infoPanelFieldset.append('div').attr('class', 'bold').text('Community total: ' + (rollupData.divisions + rollupData.communities + rollupData.ideas)); infoPanelFieldset.append('div').attr('class', 'divisions').text('Divisional communities: ' + rollupData.divisions); infoPanelFieldset.append('div').attr('class', 'communities').text('Communities of interest: ' + rollupData.communities); infoPanelFieldset.append('div').attr('class', 'ideas').text('Ideas: ' + rollupData.ideas); infoPanelFieldset.append('div').attr('class', 'bold').text('Activity'); infoPanelFieldset.append('div').attr('class', 'microBlogs').text('Microblogs: ' + rollupData.microBlogs); infoPanelFieldset.append('div').attr('class', 'questions').text('Questions: ' + rollupData.questions); infoPanelFieldset.append('div').attr('class', 'likes').text('Likes: ' + rollupData.likes); } // draw the community detail view function renderCommunityView(){ // return to home view if user clicks the currently selected node if ( selectedCommunity && (selectedCommunity.datum().id == d3.select(this).datum().id) ) { selectedCommunity = null; filteredLinks = links; renderHomeView(); return; } // set selected to user click selectedCommunity = d3.select(this); // community info panel infoPanel.selectAll('*').remove(); // clear previous data var infoPanelFieldset = infoPanel.append('fieldset'); infoPanelFieldset.append('legend').attr('class', 'heading').append('a').attr('href', selectedCommunity.datum().url).attr('target', '_blank').text(selectedCommunity.datum().title + ' details'); if (selectedCommunity.datum().description) { infoPanelFieldset.append('div').attr('class', 'description').text('Description: ' + selectedCommunity.datum().description); } if (selectedCommunity.datum().managers) { infoPanelFieldset.append('div').attr('class', 'managers').text('Managers: ' + selectedCommunity.datum().managers); } infoPanelFieldset.append('div').attr('class', 'followers').text('Followers: ' + selectedCommunity.datum().followers); infoPanelFieldset.append('div').attr('class', 'activity').text('Activity: ' + (selectedCommunity.datum().microBlogs + selectedCommunity.datum().questions + selectedCommunity.datum().likes)); infoPanelFieldset.append('div').attr('class', 'microBlogs').text('Microblogs: ' + selectedCommunity.datum().microBlogs); infoPanelFieldset.append('div').attr('class', 'questions').text('Questions: ' + selectedCommunity.datum().questions); infoPanelFieldset.append('div').attr('class', 'likes').text('Likes: ' + selectedCommunity.datum().likes); if (selectedCommunity.datum().linkedIdeas) { infoPanelFieldset.append('div').attr('class', 'linkedIdeas').text('Linked ideas: ' + selectedCommunity.datum().linkedIdeas); } if (selectedCommunity.datum().tags) { infoPanelFieldset.append('div').attr('class', 'tags').text('Tags: ' + selectedCommunity.datum().tags); } // filteredLinks = getFilteredLinksByDate(); filteredLinks = getFilteredLinksById(); // no dates on link data yet filteredLinks = removeLinksToMissingNodes(); simulation.force('link', d3.forceLink(filteredLinks).id(d => d.id).distance(200)); // add links - do this here because d3 mutates filteredLinks simulation.force('link').links(filteredLinks); // only get nodes that are linked to selection filteredNodes = getNodesFromLinks(); // collision handles elements overlapping by determining radius simulation.force('collision', d3.forceCollide().radius(d => nodeAreaScale(d.followers))); nodeUpdate(); linkUpdate(); // add nodes simulation.nodes(filteredNodes); simulation.alpha(1).restart(); $('#home-view-controls').addClass('disabled'); } // update layout on each tick event function ticked() { node .attr('cx', d => d.x = Math.max(nodeAreaScale(d.followers), Math.min(parentWidth - nodeAreaScale(d.followers), d.x))) .attr('cy', d => d.y = Math.max(nodeAreaScale(d.followers), Math.min(parentHeight - nodeAreaScale(d.followers), d.y))); 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); } // drag events 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; } // format dates to match JSON response function formatDate(date) { var d = new Date(date), month = '' + (d.getMonth() + 1), day = '' + d.getDate(), year = d.getFullYear(); if (month.length < 2) month = '0' + month; if (day.length < 2) day = '0' + day; return [year, month, day].join('-'); } // get min and max of all dates function getDateExtent(nodes) { var dateExtent = d3.extent(nodes, d => { var date = new Date(d.date); date = date.getTime(); return date; }); return dateExtent; } // init the date slider function dateRangeSlider() { var sliderMinDate = new Date(dateExtent[0]).toDateString(), sliderMaxDate = new Date(dateExtent[1]).toDateString(); $('#date-range-slider').slider({ range: false, min: dateExtent[0], max: dateExtent[1], step: 86400*1000, // seconds in day * milliseconds // values: [ dateExtent[0], dateExtent[1] ], // slide: ( event, ui ) => { // sliderMinDate = new Date(ui.values[ 0 ]).toDateString(); // sliderMaxDate = new Date(ui.values[ 1 ]).toDateString(); // $('#date-range-value').val( (sliderMinDate) + ' - ' + (sliderMaxDate) ); // }, value: dateExtent[1], slide: ( event, ui ) => { sliderMaxDate = new Date(ui.value).toDateString(); $('#date-range-value').val( (sliderMaxDate) ); }, stop: ( event, ui ) => { filterDate = formatDate(sliderMaxDate); renderHomeView(); } }); // $('#date-range-value').val( sliderMinDate + ' - ' + sliderMaxDate ); $('#date-range-value').val( sliderMaxDate ); } // reset to initial view function reset() { renderHomeView(); } d3.select('#reset').on('click', reset); // begin init();