<div id="sigma-container"></div>
<div id="loader-label">Loading…</div>
<div class="loader-bar"><div id="loader-bar-fill"></div></div>
<div id="loader-counts"></div>
background: var(--ruby, #e22653);
import { ForceAtlas2GPULayout } from "@sigma/layout-fa2-gpu";
import Graph from "graphology";
import forceAtlas2 from "graphology-layout-forceatlas2";
import random from "graphology-layout/random";
import seedrandom from "seedrandom";
import Sigma from "sigma";
import { DEFAULT_STYLES } from "sigma/types";
import { DATASETS } from "../../../scripts/datasets.mjs";
import { registerControls } from "../_controls";
let layout: ForceAtlas2GPULayout;
const { dataset, iterationsPerFrame, setToggle, setDisabled } = registerControls({
options: Object.entries(DATASETS).map(([value, { label }]) => ({ value, label })),
label: "Iterations / frame",
{ label: "Auto (adaptive)", value: "auto" },
{ label: "1", value: "1" },
{ label: "5", value: "5" },
{ label: "10", value: "10" },
{ label: "25", value: "25" },
{ label: "50", value: "50" },
{ label: "100", value: "100" },
label: "Toggle GPU ForceAtlas2",
if (running) layout.start();
// The layout only exists once the graph is loaded:
setDisabled("fa2", true);
const loaderLabel = document.getElementById("loader-label") as HTMLElement;
const loaderFill = document.getElementById("loader-bar-fill") as HTMLElement;
const loaderCounts = document.getElementById("loader-counts") as HTMLElement;
* Streams a SNAP edge list ("#" comments, then one "source target" pair
* per line) into a graph, reporting progress as edges arrive.
async function loadEdgeList(url: string, onProgress: (edges: number, total: number | null) => void): Promise<Graph> {
const graph = new Graph({ type: "undirected" });
const res = await fetch(url);
if (!res.ok || !res.body) throw new Error(`Cannot fetch ${url} (HTTP ${res.status})`);
// The server sends the raw gzip bytes (no "Content-Encoding" header), so
// the browser does not decompress them for us:
.pipeThrough(new DecompressionStream("gzip"))
.pipeThrough(new TextDecoderStream())
let total: number | null = null;
let lastPaint = performance.now();
function handleLine(line: string) {
const match = line.match(/Edges:\s*(\d+)/);
if (match) total = +match[1];
const tab = line.indexOf("\t");
const source = line.slice(0, tab);
const target = line.slice(tab + 1).trim();
// Self-loops don't affect the layout, skip them:
if (source !== target) graph.mergeEdge(source, target);
const { done, value } = await reader.read();
const lines = buffer.split("\n");
buffer = lines.pop() as string;
for (const line of lines) handleLine(line);
onProgress(edges, total);
// reader.read() resolves as a microtask, which never lets the page
// paint. So, explicitly wait for a frame from time to time so the
// progress bar actually shows up:
if (performance.now() - lastPaint > 100) {
await new Promise((resolve) => requestAnimationFrame(resolve));
lastPaint = performance.now();
// If the file does not end with a newline, its last line is still in the
if (buffer) handleLine(buffer);
const info = DATASETS[dataset as keyof typeof DATASETS] ?? DATASETS["soc-epinions"];
loaderLabel.textContent = `Loading ${info.label}…`;
const graph = await loadEdgeList(`/data/${info.file}`, (edges, total) => {
loaderFill.style.width = `${Math.min(100, (edges / total) * 100)}%`;
loaderCounts.textContent = `${edges.toLocaleString("en-US")} / ${total.toLocaleString("en-US")} edges`;
loaderCounts.textContent = `${edges.toLocaleString("en-US")} edges`;
loaderLabel.textContent = "Failed to load dataset";
loaderCounts.textContent = String(error);
loaderLabel.textContent = "Preparing graph…";
await new Promise((resolve) => requestAnimationFrame(resolve));
const rng = seedrandom("sigma");
random.assign(graph, { scale: 50000, center: 0, rng });
graph.updateEachNodeAttributes((node, attrs) => ({
size: (1 + Math.log1p(graph.degree(node))) * 10,
(document.getElementById("loader") as HTMLElement).style.display = "none";
const container = document.getElementById("sigma-container") as HTMLElement;
const renderer = new Sigma(graph, container, {
edges: [DEFAULT_STYLES.edges, { color: "#e6e6e6" }],
nodes: [DEFAULT_STYLES.nodes, { labelVisibility: "hidden" }],
const inferred = forceAtlas2.inferSettings(graph);
layout = new ForceAtlas2GPULayout(renderer, {
iterationsPerFrame: iterationsPerFrame === "auto" ? "auto" : Number(iterationsPerFrame),
gravity: inferred.gravity ?? 1,
scalingRatio: inferred.scalingRatio ?? 1,
slowDown: inferred.slowDown ?? 1,
strongGravityMode: inferred.strongGravityMode ?? false,
layout.on("stopped", () => setToggle("fa2", false));
setDisabled("fa2", false);