Loading…
<div id="stage">
<div id="sigma-container"></div>
<div id="loader">
<div class="loader-box">
<div id="loader-label">Loading…</div>
<div class="loader-bar"><div id="loader-bar-fill"></div></div>
<div id="loader-counts"></div>
</div>
</div>
</div>
<style>
#stage {
position: relative;
width: 100%;
height: 100%;
}
#sigma-container {
width: 100%;
height: 100%;
}
#loader {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
}
.loader-box {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 320px;
max-width: 80%;
text-align: center;
color: #333;
}
.loader-bar {
height: 6px;
background: #eee;
border-radius: 3px;
overflow: hidden;
}
#loader-bar-fill {
height: 100%;
width: 0;
background: var(--ruby, #e22653);
}
#loader-counts {
font-size: 0.85rem;
color: #666;
}
</style>
<script>
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({
dataset: {
type: "select",
label: "Dataset",
default: "soc-epinions",
options: Object.entries(DATASETS).map(([value, { label }]) => ({ value, label })),
},
iterationsPerFrame: {
type: "select",
label: "Iterations / frame",
default: "auto",
options: [
{ 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" },
],
},
fa2: {
type: "toggle",
label: "Toggle GPU ForceAtlas2",
default: false,
action: (running) => {
if (running) layout.start();
else layout.stop();
},
},
});
// 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:
const reader = res.body
.pipeThrough(new DecompressionStream("gzip"))
.pipeThrough(new TextDecoderStream())
.getReader();
let buffer = "";
let total: number | null = null;
let edges = 0;
let lastPaint = performance.now();
function handleLine(line: string) {
if (line[0] === "#") {
if (total === null) {
const match = line.match(/Edges:\s*(\d+)/);
if (match) total = +match[1];
}
return;
}
const tab = line.indexOf("\t");
if (tab < 0) return;
const source = line.slice(0, tab);
const target = line.slice(tab + 1).trim();
edges++;
// Self-loops don't affect the layout, skip them:
if (source !== target) graph.mergeEdge(source, target);
}
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
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
// buffer:
if (buffer) handleLine(buffer);
return graph;
}
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) => {
if (total) {
loaderFill.style.width = `${Math.min(100, (edges / total) * 100)}%`;
loaderCounts.textContent = `${edges.toLocaleString("en-US")} / ${total.toLocaleString("en-US")} edges`;
} else {
loaderCounts.textContent = `${edges.toLocaleString("en-US")} edges`;
}
}).catch((error) => {
loaderLabel.textContent = "Failed to load dataset";
loaderCounts.textContent = String(error);
throw 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) => ({
...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, {
styles: {
edges: [DEFAULT_STYLES.edges, { color: "#e6e6e6" }],
nodes: [DEFAULT_STYLES.nodes, { labelVisibility: "hidden" }],
},
});
const inferred = forceAtlas2.inferSettings(graph);
layout = new ForceAtlas2GPULayout(renderer, {
quadTreeTheta: 0.5,
iterationsPerFrame: iterationsPerFrame === "auto" ? "auto" : Number(iterationsPerFrame),
gravity: inferred.gravity ?? 1,
scalingRatio: inferred.scalingRatio ?? 1,
slowDown: inferred.slowDown ?? 1,
strongGravityMode: inferred.strongGravityMode ?? false,
backportInterval: 0,
});
layout.on("stopped", () => setToggle("fa2", false));
setDisabled("fa2", false);
</script>