Last active
August 25, 2022 17:26
-
-
Save boogheta/0c273a116de41a0ffee0575ff8b7a49f to your computer and use it in GitHub Desktop.
Network spatialization with graophology's FA2 on huge networks with PNG exports
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
convert -delay 5 $1_snapshot_*.png $1.gif |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"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" | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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