Skip to content

Instantly share code, notes, and snippets.

@boogheta
Last active August 25, 2022 17:26
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save boogheta/0c273a116de41a0ffee0575ff8b7a49f to your computer and use it in GitHub Desktop.
Save boogheta/0c273a116de41a0ffee0575ff8b7a49f to your computer and use it in GitHub Desktop.
Network spatialization with graophology's FA2 on huge networks with PNG exports
convert -delay 5 $1_snapshot_*.png $1.gif
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sigma</title>
</head>
<body>
<style>
html,
body,
#sigma-container {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
font-family: sans-serif;
}
#controls {
position: absolute;
left: 1em;
top: 1em;
text-align: right;
display: none;
}
.input {
position: relative;
display: inline-block;
vertical-align: middle;
}
.input:not(:hover) label {
display: none;
}
.input label {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
background: black;
color: white;
padding: 0.2em;
border-radius: 2px;
margin-top: 0.3em;
font-size: 0.8em;
white-space: nowrap;
}
.input button {
width: 2.5em;
height: 2.5em;
display: inline-block;
text-align: center;
background: white;
outline: none;
border: 1px solid dimgrey;
border-radius: 2px;
cursor: pointer;
margin-right: 5px;
}
#labels-input {
position: absolute;
top: 3em;
left: 0;
}
#labels-threshold {
width: 100%;
}
#search {
position: absolute;
left: 0;
top: 5em;
}
#search input {
width: 100%;
}
#save-as-png {
position: absolute;
left: 0;
top: 8.5em;
background: white;
}
#buttons {
position: absolute;
right: 1em;
top: 1em;
display: flex;
}
form {
border: 1px black solid;
background-color: #666;
color: white;
}
h4 {
margin: 0;
}
fieldset {
border: none;
}
h4,
fieldset > div {
margin-bottom: 0.2em;
}
</style>
<div id="sigma-container"></div>
<div id="controls">
<div id="search">
<input type="search" id="search-input" list="suggestions" placeholder="Search a node..." />
<datalist id="suggestions"></datalist>
</div>
<div class="input"><label for="zoom-in">Zoom in</label><button id="zoom-in">+</button></div>
<div class="input"><label for="zoom-out">Zoom out</label><button id="zoom-out">-</button></div>
<div class="input"><label for="zoom-reset">Reset zoom</label><button id="zoom-reset">⊙</button></div>
<div id="labels-input" class="input">
<label for="labels-threshold">Labels density</label>
<input id="labels-threshold" type="range" min="-10" max="0" step="1" />
</div>
<button type="button" id="save-as-png">Save as PNG</button>
</div>
<div id="buttons">
<form>
<fieldset>
<div id="loadPositions">
<label for="positions">Load the positions CSV (Node,xPos,yPos)</label><br/>
<input type="file" name="positions" id="positions_file"></input><br/><br/>
</div>
<div id="loadEdges" style="opacity: 0">
<label for="positions">Load the edges CSV (Source,Target,Weight)</label><br/>
<input type="file" name="edges" id="edges_file"></input>
</div>
</fieldset>
<fieldset>
<h4 id="title"></h4>
<div>
<label>Number of nodes:</label>
<span id="order"></span>
</div>
<div>
<label>Number of edges:</label>
<span id="size"></span>
</div>
</fieldset>
</form>
</div>
<script src="build/bundle.js"></script>
</body>
</html>
import fetchline from "fetchline";
import {Sigma} from "sigma";
import DirectedGraph from "graphology";
import FileSaver from "file-saver";
const graph = new DirectedGraph();
// Read nodes positions:
const loadPositions = async (positions) => {
console.log("Loading positions file:", positions.name);
const orderSpan = document.getElementById("order"),
title = document.getElementById("title");
title.textContent = "Loading nodes…";
orderSpan.textContent = "…";
document.querySelectorAll("#sigma-container canvas").forEach(e => e.parentNode.removeChild(e))
document.getElementById('positions_file').disabled = "disabled";
let order = 0;
for await (const line of fetchline(URL.createObjectURL(positions))) {
const [node, xPos, yPos] = line.split(/,/);
if (!node || node.toLowerCase() === "node") continue;
try {
graph.addNode(node, {x: parseFloat(xPos), y: parseFloat(yPos)});
order++;
if (order % 1000 == 0)
orderSpan.textContent = order + "…";
} catch(e) {
console.log("ERROR loading node:", line, e);
}
}
orderSpan.textContent = order + "";
document.getElementById("loadEdges").style.opacity = 1;
if (graph.size) {
title.textContent = "Affecting nodes sizes…"
setTimeout(renderGraph, 0);
}
}
// Read edges
const loadEdges = async (edgesfile) => {
console.log("Loading edges file:", edgesfile.name);
const sizeSpan = document.getElementById("size"),
title = document.getElementById("title");
title.textContent = "Loading edges…";
sizeSpan.textContent = "…";
document.querySelectorAll("#sigma-container canvas").forEach(e => e.parentNode.removeChild(e))
document.getElementById('edges_file').disabled = "disabled";
let size = 0;
for await (const line of fetchline(URL.createObjectURL(edgesfile))) {
const [source, target, weight] = line.split(/,/);
if (!source || source.toLowerCase() === "source") continue;
try {
graph.addEdge(source, target, {weight: parseInt(weight)});
size++;
if (size % 10000 == 0)
sizeSpan.textContent = size + "…";
} catch(e) {
console.log("ERROR loading edge:", line, e);
}
}
sizeSpan.textContent = size + "";
if (graph.order) {
title.textContent = "Affecting nodes size…"
setTimeout(renderGraph, 0);
}
}
const renderGraph = () => {
// Adjust nodes sizes
graph.forEachNode((node, { cluster }) => {
graph.mergeNodeAttributes(node, {
size: Math.sqrt(graph.degree(node)) / 6,
//color: colors[cluster + ""],
label: node
});
});
title.textContent = "Rendering graph…";
setTimeout(() => {
// Render the graph:
const container = document.getElementById("sigma-container");
const renderer = new Sigma(graph, container, {
defaultEdgeColor: "#efefef",
minCameraRatio: 0.1,
maxCameraRatio: 10
});
document.getElementById("controls").style.display = "block";
// Enable Zoombuttons:
const camera = renderer.getCamera();
document.getElementById("zoom-in").addEventListener("click", () => {
camera.animatedZoom({ duration: 600 });
});
document.getElementById("zoom-out").addEventListener("click", () => {
camera.animatedUnzoom({ duration: 600 });
});
document.getElementById("zoom-reset").addEventListener("click", () => {
camera.animatedReset({ duration: 600 });
});
// Bind labels threshold to range input
const labelsThresholdRange = document.getElementById("labels-threshold");
labelsThresholdRange.addEventListener("input", () => {
renderer.setSetting("labelRenderedSizeThreshold", -labelsThresholdRange.value);
});
labelsThresholdRange.value = -renderer.getSetting("labelRenderedSizeThreshold") + "";
// Setup nodes search
const searchInput = document.getElementById("search-input");
const searchSuggestions = document.getElementById("suggestions");
let selectedNode = null,
suggestions = [];
const setSearchQuery = (query) => {
if (searchInput.value !== query) searchInput.value = query;
if (query.length > 1) {
const lcQuery = query.toLowerCase();
suggestions = [];
graph.forEachNode((node) => {
if (node.toLowerCase().includes(lcQuery))
suggestions.push(node);
});
if (suggestions.length === 1 && suggestions[0] === query) {
if (selectedNode)
graph.setNodeAttribute(selectedNode, "highlighted", false);
selectedNode = suggestions[0];
suggestions = [];
graph.setNodeAttribute(selectedNode, "highlighted", true);
let view = renderer.getNodeDisplayData(selectedNode);
view.ratio = camera.ratio / 1.5;
camera.animate(view, {duration: 1000});
} else if (selectedNode) {
graph.setNodeAttribute(selectedNode, "highlighted", false);
selectedNode = null;
}
} else if (selectedNode) {
graph.setNodeAttribute(selectedNode, "highlighted", false);
selectedNode = null;
suggestions = [];
}
searchSuggestions.innerHTML = suggestions
.sort()
.map((node) => "<option value=" + node + "></option>")
.join("\n");
// Refresh rendering:
renderer.refresh();
}
searchInput.addEventListener("input", () => {
setSearchQuery(searchInput.value || "");
});
searchInput.addEventListener("blur", () => {
setSearchQuery("");
});
// Enable SavePNG button
document.getElementById("save-as-png").addEventListener("click", async () => {
const { width, height } = renderer.getDimensions();
const pixelRatio = window.devicePixelRatio || 1;
const tmpRoot = document.createElement("DIV");
tmpRoot.style.width = `${width}px`;
tmpRoot.style.height = `${height}px`;
tmpRoot.style.position = "absolute";
tmpRoot.style.right = "101%";
tmpRoot.style.bottom = "101%";
document.body.appendChild(tmpRoot);
const tmpRenderer = new Sigma(graph, tmpRoot, renderer.getSettings());
tmpRenderer.getCamera().setState(camera.getState());
tmpRenderer.refresh();
const canvas = document.createElement("CANVAS");
canvas.setAttribute("width", width * pixelRatio + "");
canvas.setAttribute("height", height * pixelRatio + "");
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, width * pixelRatio, height * pixelRatio);
const canvases = tmpRenderer.getCanvases();
const layers = Object.keys(canvases);
layers.forEach((id) => {
ctx.drawImage(
canvases[id],
0,
0,
width * pixelRatio,
height * pixelRatio,
0,
0,
width * pixelRatio,
height * pixelRatio,
);
});
canvas.toBlob((blob) => {
if (blob) FileSaver.saveAs(blob, "graph.png");
tmpRenderer.kill();
tmpRoot.remove();
}, "image/png");
});
title.textContent = "Graph ready";
}, 0);
}
document.getElementById('positions_file').addEventListener('change', (event) => {
loadPositions(event.target.files[0]);
});
document.getElementById('edges_file').addEventListener('change', (event) => {
loadEdges(event.target.files[0]);
});
{
"name": "CLI network spatializer",
"version": "1.0.0",
"type": "module",
"description": "",
"main": "index.js",
"scripts": {
"start": "kotatsu serve index.js"
},
"license": "MIT",
"dependencies": {
"canvas": "^2.9.3",
"event-stream": "^4.0.1",
"fetchline": "^1.0.1",
"file-saver": "^2.0.5",
"graphology": "^0.24.1",
"graphology-canvas": "^0.4.1",
"graphology-communities-louvain": "^2.0.1",
"graphology-layout": "^0.6.0",
"graphology-layout-forceatlas2": "^0.9.2",
"graphology-metrics": "^2.1.0",
"graphology-types": "^0.24.4",
"lodash": "^4.17.21",
"sigma": "latest"
},
"devDependencies": {
"async": "^3.2.2",
"kotatsu": "^0.22.3"
}
}
// Usage (from a CSV file of edges formatted as source,target,weight
// node spatialize-network.js <EDGELIST_AS_SOURCE-TARGET-WEIGHT.CSV> <NB_FA2_ITERATIONS> <NB_ITERATIONS_BETWEEN_MINIATURE_PNG_SNAPSHOTS>
//
// or, to run more iterations after a previous run, using the positions CSV file dumped by the previous run (which should be named such as "ORIGINALFILE.csv_positions_after_N_FA2Iterations.csv"):
// node spatialize-network.js <POSITIONS_FILE_DUMPED_BY_THIS_SCRIPT.CSV> <NB_EXTRA_FA2_ITERATIONS> <NB_ITERATIONS_BETWEEN_MINIATURE_PNG_SNAPSHOTS>
import fs from 'fs';
import es from 'event-stream';
import DirectedGraph from 'graphology';
import random from 'graphology-layout/random.js';
import forceAtlas2 from 'graphology-layout-forceatlas2';
import louvain from 'graphology-communities-louvain';
import {renderToPNG} from 'graphology-canvas/node.js';
import {density} from 'graphology-metrics/graph/density.js';
const args = process.argv.slice(2);
const filename = args[0];
const fileroot = filename.replace(/.csv(_positions_after_(\d+)_FA2Iterations\.csv)?$/, '');
const preIterations = (/_positions_after_\d+_FA2Iterations\.csv$/.test(filename) ? parseInt(filename.replace(/^.*_positions_after_(\d+)_FA2Iterations\.csv/, '$1')) : 0);
const FA2Iterations = (args.length < 2 ? 1000 : parseInt(args[1]));
const batchIterations = (args.length < 3 ? 100 : parseInt(args[2]));
const setNodesSizes = (args.length >= 4);
let stop = false;
process.on('SIGINT', () => {
stop = true;
console.log("Caught interrupt signal, finishing current batch, plotting final image and saving nodes positions...");
});
function renderPNG(graph, imagefile, size, callback) {
const t0 = Date.now();
renderToPNG(
graph,
imagefile + ".png",
{
width: size,
height: size,
nodes: {defaultColor: '#000'},
edges: {defaultColor: '#ccc'},
},
() => {
console.log(" " + imagefile + '.png rendered in:', (Date.now() - t0)/1000 + "s");
callback();
}
);
}
function runBatchFA2(graph, doneIterations, finalCallback) {
const t0 = Date.now();
forceAtlas2.assign(graph, {
iterations: batchIterations,
getWeight: 'weight',
getEdgeWeight: 'weight',
settings: {
barnesHutOptimize: true,
edgeWeightInfluence: 1,
gravity: 0.05,
scalingRatio: 20,
strongGravityMode: true
}
});
console.log(' FA2 batch of ' + batchIterations + ' iterations processed in:', (Date.now() - t0)/1000 + "s");
doneIterations += batchIterations;
renderPNG(graph, fileroot + "_snapshot_" + String(doneIterations + preIterations).padStart(8, '0'), 512, function(){
if (!stop && doneIterations < FA2Iterations)
runBatchFA2(graph, doneIterations, finalCallback);
else finalCallback(doneIterations);
});
}
function processGraph(graph, time0){
// Displaying graph's stats
console.log('Number of nodes:', graph.order);
console.log('Number of edges:', graph.size);
console.log('Graph density:', density(graph));
/* Commenting this part for now since we do not do everything with the communities nor store them in the output graph
if (preIterations == 0) {
// Computing Louvain communities
const details = louvain.detailed(graph, {
getEdgeWeight: 'weight',
resolution: 0.05
});
time1 = Date.now();
console.log('Louvain processed in:', (time1 - time0)/1000 + "s");
time0 = time1;
console.log('Louvain communities:', details.count);
console.log('Louvain modularity:', details.modularity);
}
*/
// Setup nodes size for plots
if (setNodesSizes) {
graph.forEachNode((node, { cluster }) => {
graph.mergeNodeAttributes(node, {
size: Math.sqrt(graph.degree(node)) / 10,
//color: colors[cluster + ""],
label: node
});
});
let time2 = Date.now();
console.log('Nodes sizes assigned in:', (time2 - time0)/1000 + "s");
time0 = time2;
}
// Spatializing with FA2
console.log('Starting ForceAtlas2 for ' + FA2Iterations + ' iterations by batches of ' + batchIterations);
runBatchFA2(graph, 0, function(doneIterations) {
let time1 = Date.now();
console.log('ForceAtlas2 ' + (stop ? 'partia' : 'fu') + 'lly processed in:', (time1 - time0)/1000 + "s (" + doneIterations + " iterations)");
time0 = time1;
// Rendering final PNG image
const outputFile = fileroot + "_after_" + (doneIterations + preIterations) + "_FA2Iterations";
renderPNG(graph, outputFile, 8192, function() {
// Saving resulting positions to a new CSV file
time0 = Date.now()
const posFile = fileroot + ".csv" + "_positions_after_" + (doneIterations + preIterations) + "_FA2Iterations.csv";
const out = fs.createWriteStream(posFile);
out.write("Node,xPos,yPos\n");
graph.forEachNode(function(node, attrs){
out.write(node + ',' + attrs['x'] + ',' + attrs['y'] + "\n");
});
console.log('Positions stored in ' + posFile + ' in:', (Date.now() - time0)/1000 + "s");
});
});
}
let time0 = Date.now();
// Read edges file line by line and adds nodes/edges
const graph = new DirectedGraph();
fs.createReadStream(fileroot + ".csv")
.pipe(es.split())
.pipe(es.mapSync(function(line) {
const [source, target, weight] = line.split(/,/);
if (!source || source === "Source") return;
graph.mergeNode(source);
graph.mergeNode(target);
graph.addEdge(source, target, {weight: parseInt(weight)});
}))
// Then assign either random positions to node on first run
.on("end", function() {
let time1 = Date.now();
console.log('Graph loaded from edges list in:', (time1 - time0)/1000 + "s");
time0 = time1;
if (preIterations == 0) {
random.assign(graph);
time1 = Date.now();
console.log('Random positions assigned in:', (time1 - time0)/1000 + "s");
time0 = time1;
processGraph(graph, time0);
// or reload positions from a previous run's output
} else {
fs.createReadStream(filename)
.pipe(es.split())
.pipe(es.mapSync(function(line) {
const [node, xPos, yPos] = line.split(/,/);
if (!node || node === "Node") return;
graph.mergeNode(node, {x: parseFloat(xPos), y: parseFloat(yPos)});
}))
.on("end", function() {
time1 = Date.now();
console.log('Positions from previous run assigned in:', (time1 - time0)/1000 + "s");
time0 = time1;
processGraph(graph, time0);
});
}
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment