Published
Edited
May 31, 2019
1 fork
4 stars
Insert cell
Insert cell
Insert cell
Insert cell
num_nodes = 100
Insert cell
num_clusters = 3
Insert cell
Insert cell
Insert cell
viewof user_propensities = make_propensity_matrix({
num_clusters: 3,
target: d3.select(standard_pane).select('svg#controls').node(),
width: width/2,
height: height
})
Insert cell
standard_sbm_viz = {
const sbm = gen_sbm(num_nodes, user_propensities);
const viz_loc = d3.select(standard_pane).select('svg#viz').node();
draw_network({links: sbm.edges, nodes: sbm.nodes}, viz_loc);
}
Insert cell
Insert cell
Insert cell
viewof dc_propensities = make_propensity_matrix({
num_clusters: 3,
target: d3.select(dc_sbm_pane).select('svg#controls').node(),
width: width/2,
height: height
})
Insert cell
dc_sbm_viz = {
const sbm = gen_sbm(num_nodes, dc_propensities, () => gen_expon(15));
const viz_loc = d3.select(dc_sbm_pane).select('svg#viz').node();
draw_network({links: sbm.edges, nodes: sbm.nodes}, viz_loc);
}
Insert cell
Insert cell
interconnected_pane = make_sbm_pane(DOM)
Insert cell
viewof interconnected_propensities = make_propensity_matrix({
target: d3.select(interconnected_pane).select('svg#controls').node(),
width: width/2,
height: height,
mode: 'inter_connected',
})
Insert cell
interconnected_sbm = {
const sbm = gen_sbm(num_nodes,interconnected_propensities);
const viz_loc = d3.select(interconnected_pane).select('svg#viz').node();
draw_network({links: sbm.edges, nodes: sbm.nodes}, viz_loc);
}
Insert cell
Insert cell
Insert cell
viewof non_interconnected_propensities = make_propensity_matrix({
target: d3.select(non_interconnected_pane).select('svg#controls').node(),
width: width/2,
height: height,
mode: 'no_inter_connections',
title: 'SBM w/ no interconnections',
})
Insert cell
non_interconnected_sbm = {
const sbm = gen_sbm(num_nodes, non_interconnected_propensities);
const viz_loc = d3.select(non_interconnected_pane).select('svg#viz').node();
draw_network({links: sbm.edges, nodes: sbm.nodes}, viz_loc);
}
Insert cell
function random_edge_propensities(num_clusters, edge_dist = () => gen_discrete_unif(10, 100), mode){
const propensities = [];

for(let r = 0; r < num_clusters; r++){
// Since we're undirected we only all unique combinations
for(let s = r; s < num_clusters; s++){
propensities.push({
clust_a: r,
clust_b: s,
avg_edges: edge_dist()
});
}
}
if(mode == 'inter_connected'){
propensities.forEach(p => {
if(p.clust_a !== p.clust_b){
p.avg_edges = 0;
}
})
} else if(mode == 'no_inter_connections'){
propensities.forEach(p => {
if(p.clust_a === p.clust_b){
p.avg_edges = 0;
}
})
}
return propensities;
}
Insert cell
make_sbm_pane = DOM => {
// Sets up two side-by-side svgs to be used.
const main_div = DOM.element('div');
const svgs = d3.select(main_div)
.style('display', 'flex')
.selectAll('svg')
.data(['controls', 'viz'])
.join('svg')
.attr('height', height)
.attr('width', width/2)
.attr('id', d => d);

return main_div;
}
Insert cell
function make_propensity_matrix({
num_clusters = 3,
target,
width,
height,
mode = 'random',
title = 'SBM cluster connection propensities',
title_font_size = 20,
}){
const margin = {top: 50, sides: 50};
const count_range = [0, 100];
const h = height - margin.top - margin.sides;
const w = width - 2*margin.sides;
const edge_length = Math.min(w, h);
const title_color = d3.hsl("#252525");
const secondary_text_color = title_color.brighter();
// How big the dots indicating column/row cluster are.
const legend_r = margin.sides/4;
// Setup SVG and the node to pass back at end
const svg = d3.select(target).html('');
const chart = svg.node();
const g = svg.append('g')
.attr("transform", `translate(${margin.sides + w/2 - edge_length/2}, ${margin.top}) `);
// Initialize edge counts randomly;
const props = random_edge_propensities(num_clusters, () => gen_discrete_unif(...count_range), mode);
chart.value = props; // Assign to .value so viewof can see current value

// Initialize number of nodes per cluster randomly
const clusters = unique([...props.map(d => d.clust_a), ...props.map(d => d.clust_b) ]);

// Scales and sizes.
const cell_pos = d3.scaleBand()
.padding(0.05)
.domain(clusters)
.rangeRound([0, edge_length]);
const cell_width = cell_pos.bandwidth();

const cell_color = d3.scaleLinear()
.domain(count_range)
.range(['steelblue', 'orangered']);
// Start drawing things
const cells = g.selectAll('g.cells')
.data(props)
.join('g')
.attr("transform", d => `translate(${cell_pos(d.clust_a)},${cell_pos(d.clust_b)})`);

cells.append('rect')
.attr('width', cell_width)
.attr('height', cell_width)
.attr('stroke', 'black')
.attr('stroke-width', 0)
.attr('rx', 5)
.attr('ry', 5)
.attr('fill', d => cell_color(d.avg_edges));

cells.append('text')
.text(d => d.avg_edges)
.attr('x', cell_width/2)
.attr('y', cell_width/2)
.attr('fill', 'white')
.attr('font-size', '1.5rem')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle');
// Draw axes
const x_axis = g.append('g')
.attr("transform", `translate(0, ${edge_length + legend_r})`);
const y_axis = g.append('g')
.attr("transform", `translate(${-legend_r}, 0)`);
// Draw the points colored by their cluster id
x_axis.selectAll('circle.legend_point')
.data(clusters)
.join('circle')
.attr('class', 'legend_point')
.attr('cx', d => cell_pos(d) + cell_width/2);
y_axis.selectAll('circle.legend_point')
.data(clusters)
.join('circle')
.attr('class', 'legend_point')
.attr('cy', d => cell_pos(d) + cell_width/2);
g.selectAll('.legend_point')
.attr('r', legend_r)
.attr('fill', d => color({cluster: d}));
// Write out the axis labels
x_axis.append('text')
.attr('class', 'axis_text')
.attr('x', edge_length/2)
.attr('y', margin.sides*0.5)
.text('To cluster');
y_axis.append('text')
.attr('class', 'axis_text')
.attr('x', -edge_length/2)
.attr('y', -margin.sides*0.5)
.attr('transform', 'rotate(-90)')
.text('From cluster');
g.selectAll('.axis_text')
.attr('text-anchor', 'middle')
.attr('font-size', '0.85em')
.attr('dominant-baseline', 'middle')
.attr('fill', secondary_text_color);
// title
svg.append('text')
.text(title)
.attr('x', width - 10)
.attr('y', title_font_size)
.attr('text-anchor', 'end')
.attr('fill', title_color)
.attr('font-size', title_font_size);
// instructions
svg.append('text')
.text('drag cell to change average edge counts')
.attr('x', width - 10)
.attr('y', title_font_size*2)
.attr('text-anchor', 'end')
.attr('font-size', title_font_size/1.7)
.attr('fill', secondary_text_color);

cells.call(
d3.drag()
.on("start", function(){ embolden(this) })
.on("drag", function(d){
const new_avg_edges = clamp(d.avg_edges + sign(d3.event.dx), ...count_range);
d.avg_edges = new_avg_edges;
// Update color
d3.select(this).select('rect').attr('fill', d => cell_color(d.avg_edges));
// Update text
d3.select(this).select('text').text(d => d.avg_edges);
})
.on("end", function(d){
chart.value = props;
chart.dispatchEvent(new CustomEvent("input"));
embolden(this);
})
);
function embolden(el){
// Give (or remove) border to emphasize selection
const bold_stroke = 4;
const square = d3.select(el).select('rect');
const currently_bold = +square.attr('stroke-width') === bold_stroke;
square.attr('stroke-width', currently_bold ? 0 : bold_stroke);
}
return chart;
}
Insert cell
function gen_sbm(num_nodes, edge_propensities, propensity_dist = () => 1){
const num_clusters = n_unique([
...edge_propensities.map(d => d.clust_a),
...edge_propensities.map(d => d.clust_b) ]);

// ========== Draw cluster-to-cluster edge counts from Poisson =======/
// draw edge connection numbers between the groups from the poisson model.
// These values are just the group-to-group connections and have yet to be assigned to individual nodes.
const group_to_group_connections = edge_propensities
.map(({clust_a, clust_b, avg_edges}) => ({
clust_a, clust_b,
// Draw from poisson distribution with rate as average num of edges
n_edges: gen_pois(avg_edges)
}));

// ========== Assign individual nodes to clusters =======/

// First we will generate the nodes that take up our network
const nodes = init_array(num_nodes)
.map(i => ({
id: i, // Assign id to node
cluster: i % num_clusters, // Cycle through available groups to fill
raw_prop: propensity_dist(), // Assign an unormalized edge propensity to node
}))

// Next we will group nodes by their cluster and get normalized propensities
const group_node_propensities = init_array(num_clusters, () => []);
const cluster_total_propensities = init_array(num_clusters, i => 0);

nodes.forEach( ({id, cluster, raw_prop}) => {
// Fill in the current nodes index and propensity to group entry
group_node_propensities[cluster] = [...group_node_propensities[cluster], {id, raw_prop} ];

// Accumulate the total propensity values for current group
cluster_total_propensities[cluster] += raw_prop;
});

// Now normalize propensity values so they sum to 1
for(let r = 0; r < num_clusters; r++){
group_node_propensities[r]
.forEach(node => {
node.norm_prop = node.raw_prop/cluster_total_propensities[r];
});
}

// ========== Divy up edges between nodes =======/
// To do this we will loop through each group-to-group pair and draw
// however many edges needed from each, assigning the source and target
// of the edge to nodes in the respective clusters based on their node's
// normalized propensities for connection.

// Grab a random node from a cluster based on propensity for edge
const grab_node = (clust_id) => {
const clust_nodes = group_node_propensities[clust_id];
const node_inds = clust_nodes.map(d => d.id);
const node_props = clust_nodes.map(d => d.norm_prop);
// choose node
return weighted_sample(node_props, node_inds);
};

// Draw a given number of edges between two groups given their propensities
const draw_edges_between_clusts = ({clust_a, clust_b, n_edges}) =>
init_array(n_edges, null)
.map(() => ({ source: grab_node(clust_a), target: grab_node(clust_b) }));

// Loop through every group-to-group pair and assign edges to nodes
const edges = group_to_group_connections
.reduce(
(all, cur_con) => [...all, ...draw_edges_between_clusts(cur_con)],
[]
);

// Strip out nodes with no connections
const connected_nodes = unique([
...edges.map(d => d.source),
...edges.map(d => d.target)
]);
const nodes_to_keep = nodes.filter(d => connected_nodes.includes(d.id));

return {nodes: nodes_to_keep, edges};
}
Insert cell
draw_network = function(data, svg_raw){
// create copies of the links and nodes to not cause problems with future visualizations.
const links = copy_array(data.links);
const nodes = copy_array(data.nodes);
const svg = d3.select(svg_raw)
.attr("viewBox", [-width/2, -height/2, width, height])
.html('');
// Initialize the simulation
const simulation = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).id(d => d.id))
.force("charge", d3.forceManyBody())
.force("x", d3.forceX())
.force("y", d3.forceY());

const link = svg.selectAll("line")
.data(links)
.join("line")
.attr("stroke", "#999")
.attr("stroke-opacity", 0.6)
.attr("stroke-width", 2);

const node = svg.selectAll("circle")
.data(nodes)
.join("circle")
.attr("stroke", "#fff")
.style("stroke-width", '1.5px')
.attr("r", 5)
.attr("fill", color)
.call(drag(simulation));

simulation.on("tick", () => {
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);

const [min_x, max_x] = d3.extent(nodes, d => d.x);
const [min_y, max_y] = d3.extent(nodes, d => d.y);
const view_w = max_x - min_x;
const view_h = max_y - min_y;
const padding_scale = 0.1;
const padding = {x: view_w*padding_scale, y: view_h*padding_scale};

svg.attr("viewBox", [min_x - padding.x, min_y - padding.y, view_w + 2*padding.x, view_h + 2*padding.y]);
})

return svg.node();
}
Insert cell
Insert cell
Insert cell
height = 400
Insert cell
d3 = require("d3@5")
Insert cell
Insert cell
function gen_expon(lambda){
return -Math.log(1-Math.random())/lambda;
}
Insert cell
function gen_pois(lambda, max_its = 1000){
let i = -1, cum_sum = 0;
while(cum_sum < 1 && i < max_its){
i++;
cum_sum += gen_expon(lambda);
}
return i;
}
Insert cell
function gen_discrete_unif(min = 0, max = 100){
const range = max - min;
return Math.round(Math.random()*range) + min;
}
Insert cell
import {init_array} from "@nstrayer/javascript-statistics-snippets@84"
Insert cell
import {weighted_sample} from "@nstrayer/javascript-statistics-snippets"
Insert cell
function unique(vec){
return [...new Set(vec)];
}
Insert cell
function n_unique(vec){
return unique(vec).length;
}
Insert cell
function clamp(x, min, max){
return Math.max(min, Math.min(x, max));
}
Insert cell
function sign(x){
return x > 0 ? 1 : -1;
}
Insert cell
// Do a psuedo deep copy on an array of objects.
copy_array = arr => arr.map(obj => Object.assign({}, obj));
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more