|
|
|
// Constants |
|
var COLOR = { |
|
HIGH: "#39ace7", |
|
LOW: "#0784b5", |
|
BACKGROUND: "#2d383c", |
|
FADED: "#414c50", |
|
TEXT: "#39ace7" |
|
}; |
|
|
|
// Read the JSON data |
|
d3.json('.graph.json').then(data => { |
|
|
|
// ======================================== |
|
// Data manipulations |
|
// ======================================== |
|
|
|
// Filter links with low occurrence to avoid freezing the browser |
|
data.links = data.links.filter(link => link.cooccurrence > 5); |
|
|
|
// Remove nodes with no connections |
|
var nodes_with_links = new Set(); |
|
data.links.forEach(link => { |
|
nodes_with_links.add(link.source); |
|
nodes_with_links.add(link.target); |
|
}) |
|
data.nodes = data.nodes.filter(node => nodes_with_links.has(node.id)); |
|
|
|
// Index nodes and connections by id to speed up lookups |
|
var nodes_are_linked = {}, |
|
nodes_by_id = {}; |
|
data.links.forEach(link => { |
|
nodes_are_linked[`${link.source}-${link.target}`] = true; |
|
nodes_are_linked[`${link.target}-${link.source}`] = true; |
|
}) |
|
data.nodes.forEach(node => { |
|
nodes_are_linked[`${node.id}-${node.id}`] = true; |
|
nodes_by_id[node.id] = node; |
|
}) |
|
|
|
// Detect clusters |
|
var clusters = new Set(); |
|
data.links.forEach(link => { |
|
var added_to = []; |
|
clusters.forEach(cluster => { |
|
if (cluster.has(link.source) || cluster.has(link.target)) { |
|
cluster.add(link.source); |
|
cluster.add(link.target); |
|
added_to.push(cluster); |
|
} |
|
}) |
|
if (added_to.length == 0) { |
|
clusters.add(new Set([link.source, link.target])); |
|
} else if (added_to.length > 1) { |
|
var merged = new Set(); |
|
added_to.forEach(cluster => { |
|
cluster.forEach(node_id => merged.add(node_id)); |
|
clusters.delete(cluster); |
|
}) |
|
clusters.add(merged); |
|
} |
|
}) |
|
clusters = Array.from(clusters); |
|
|
|
// Index clusters by node id |
|
var clusters_by_id = {}; |
|
clusters.forEach(cluster => cluster.forEach(node_id => clusters_by_id[node_id] = cluster)); |
|
|
|
// ======================================== |
|
// SVG manipulations |
|
// ======================================== |
|
|
|
// Define colors |
|
var min_values_color = COLOR.LOW, |
|
max_values_color = COLOR.HIGH, |
|
fade_out_color = COLOR.FADED, |
|
background_color = COLOR.BACKGROUND; |
|
|
|
// Define scale functions |
|
var freqs = d3.extent(data.nodes, node => node.frequency), |
|
coocs = d3.extent(data.links, link => link.cooccurrence), |
|
node_radius = d3.scaleLog().domain(freqs).range([3, 20]), |
|
node_color = d3.scaleLog().domain(freqs).range([min_values_color, max_values_color]), |
|
link_color = d3.scaleLog().domain(coocs).range([min_values_color, max_values_color]), |
|
link_size = d3.scaleLog().domain(coocs).range([0.8, 3]); |
|
|
|
var width = 960, |
|
height = 500; |
|
|
|
// Define the parent SVG |
|
var svg = d3.select("#viz") |
|
.append("svg") |
|
.attr("width", width) |
|
.attr("height", height) |
|
.style("background-color", background_color) |
|
.append('g') |
|
.attr("transform", `translate(${width / 2}, ${height / 2})`); |
|
|
|
// Define the links |
|
var links = svg.selectAll('.link') |
|
.data(data.links) |
|
.enter() |
|
.append('path') |
|
.attr('class', 'link') |
|
.style('fill', 'none') |
|
.style('stroke', fade_out_color) |
|
.style('stroke-width', d => link_size(d.cooccurrence)); |
|
|
|
// Define the nodes |
|
var nodes = svg.selectAll('.node') |
|
.data(data.nodes) |
|
.enter() |
|
.append('circle') |
|
.attr('class', 'node') |
|
.attr('r', d => node_radius(d.frequency)) |
|
.style('fill', fade_out_color) |
|
.style('stroke', fade_out_color) |
|
.style('stroke-width', 0.5) |
|
.on('mouseover', mouseover_node) |
|
.on('mouseout', mouseout_node) |
|
.on('click', click_node); |
|
|
|
// Needed to fix Firefox behaviour |
|
var is_mouseover = false; |
|
|
|
// Define the node labels (shown on mouseover/click) |
|
var texts = svg.selectAll('text') |
|
.data(data.nodes) |
|
.enter() |
|
.append('text') |
|
.attr("pointer-events", "none") |
|
.attr("alignment-baseline", "middle") |
|
.attr('visibility', 'hidden') |
|
.style("font-family", "Helvetica, Arial") |
|
.style("font-size", "12px") |
|
.style('fill', COLOR.TEXT) |
|
.text(d => d.name); |
|
|
|
// Start the force simulation |
|
var simulation = d3.forceSimulation(data.nodes) |
|
.force('center', d3.forceCenter()) |
|
.force('charge', d3.forceManyBody().strength(0)) |
|
.force('collision', d3.forceCollide().radius(d => node_radius(d.frequency) + 3)) |
|
.force('radial', d3.forceRadial(200)) |
|
.on('tick', tick); |
|
|
|
// ======================================== |
|
// Functions |
|
// ======================================== |
|
|
|
function tick() { |
|
texts |
|
.attr("text-anchor", d => d.x < 0 ? 'end' : 'start') |
|
.attr('transform', rotate_text); |
|
|
|
nodes |
|
.attr('cx', d => d.x) |
|
.attr('cy', d => d.y); |
|
|
|
links |
|
.attr('d', link_path); |
|
} |
|
|
|
function mouseover_node(d_node) { |
|
if (!is_mouseover) { |
|
is_mouseover = true; |
|
|
|
links |
|
.style('stroke', d_link => link_is_connected(d_node, d_link) ? link_color(d_link.cooccurrence) : fade_out_color) |
|
.filter(d_link => link_is_connected(d_node, d_link)).raise(); |
|
|
|
nodes |
|
.style('fill', d_other_node => node_is_connected(d_node, d_other_node) ? node_color(d_other_node.frequency) : fade_out_color) |
|
.style('stroke', d_other_node => node_is_connected(d_node, d_other_node) ? d3.color(node_color(d_other_node.frequency)).darker() : fade_out_color) |
|
.filter(d_other_node => node_is_connected(d_node, d_other_node)).raise(); |
|
|
|
texts |
|
.attr('visibility', d_other_node => node_is_connected(d_node, d_other_node) ? 'visible' : 'hidden') |
|
.filter(d_other_node => node_in_cluster(d_node, d_other_node)).raise(); |
|
} |
|
} |
|
|
|
function mouseout_node() { |
|
is_mouseover = false; |
|
|
|
links |
|
.style('stroke', fade_out_color); |
|
|
|
nodes |
|
.style('fill', fade_out_color) |
|
.style('stroke', fade_out_color); |
|
|
|
texts |
|
.attr('visibility', 'hidden'); |
|
} |
|
|
|
function click_node(d_node) { |
|
links |
|
.style('stroke', d_link => link_in_cluster(d_node, d_link) ? link_color(d_link.cooccurrence) : fade_out_color) |
|
.filter(d_link => link_in_cluster(d_node, d_link)) |
|
.raise(); |
|
|
|
nodes |
|
.style('fill', d_other_node => node_in_cluster(d_node, d_other_node) ? node_color(d_other_node.frequency) : fade_out_color) |
|
.style('stroke', d_other_node => node_in_cluster(d_node, d_other_node) ? d3.color(node_color(d_other_node.frequency)).darker() : fade_out_color) |
|
.filter(d_other_node => node_in_cluster(d_node, d_other_node)).raise(); |
|
|
|
texts |
|
.attr('visibility', d_other_node => node_in_cluster(d_node, d_other_node) ? 'visible' : 'hidden') |
|
.filter(d_other_node => node_in_cluster(d_node, d_other_node)).raise(); |
|
} |
|
|
|
function rotate_text(d_node) { |
|
var alpha = Math.atan2(-d_node.y, d_node.x), |
|
distance = node_radius(d_node.frequency) + 10, |
|
x = d_node.x + Math.cos(alpha)*(distance), |
|
y = d_node.y - Math.sin(alpha)*(distance), |
|
to_degrees = angle => angle * 180 / Math.PI, |
|
rotation = d_node.x < 0 ? 180 - to_degrees(alpha) : 360 - to_degrees(alpha); |
|
return `translate(${x}, ${y})rotate(${rotation})`; |
|
} |
|
|
|
function link_path(d_link) { |
|
var source = nodes_by_id[d_link.source], |
|
target = nodes_by_id[d_link.target], |
|
p = d3.path(); |
|
p.moveTo(source.x, source.y); |
|
p.quadraticCurveTo(0, 0, target.x, target.y); |
|
return p.toString(); |
|
} |
|
|
|
function link_is_connected(node_data, link_data) { |
|
return node_data.id === link_data.source || node_data.id === link_data.target; |
|
} |
|
|
|
function node_is_connected(node_data, other_node_data) { |
|
return nodes_are_linked[`${node_data.id}-${other_node_data.id}`]; |
|
} |
|
|
|
function link_in_cluster(node_data, link_data) { |
|
var cluster = clusters_by_id[node_data.id]; |
|
return cluster.has(link_data.source) || cluster.has(link_data.target); |
|
} |
|
|
|
function node_in_cluster(node_data, other_node_data) { |
|
var cluster = clusters_by_id[node_data.id]; |
|
return cluster.has(other_node_data.id) |
|
} |
|
|
|
}) |