<div id="sigma-container"></div>
<aside id="legend" class="example-panel">
<h4>About this graph</h4>
Top cited AI papers from <a href="https://openalex.org" target="_blank" rel="noopener">OpenAlex</a>, clustered
with <a href="https://en.wikipedia.org/wiki/Louvain_method" target="_blank" rel="noopener">Louvain</a>
in <a href="https://gephi.org/gephi-lite" target="_blank" rel="noopener">Gephi Lite</a>.
<div class="legend-item">
<svg viewBox="0 0 16 16" class="legend-node-icon">
<polygon points="8,1 15,8 8,15 1,8" fill="none" stroke="currentColor" stroke-width="1.5"></polygon>
<span>Scientific paper</span>
<div class="legend-subitems">
<p>Colors: research community</p>
<p>Sizes: citation count</p>
<div class="legend-item">
<svg width="32" height="12" viewBox="0 0 32 12">
<line x1="2" y1="6" x2="24" y2="6" stroke="#e3a95b" stroke-width="2"></line>
<polygon points="24,3 32,6 24,9" fill="#e3a95b"></polygon>
<div class="legend-item">
<svg width="32" height="12" viewBox="0 0 32 12">
<line x1="2" y1="6" x2="30" y2="6" stroke="#e22653" stroke-width="2"></line>
<span>A and B share authors</span>
<div class="legend-search">
<input type="search" id="search-input" list="suggestions" placeholder="Search a paper…" />
<datalist id="suggestions"></datalist>
@import "../../styles/base.css";
@import "../../styles/example-panel.css";
import Sigma from "sigma";
import { extremityArrow, layerFill, layerPlain, pathCurved, pathLine, pathLoop, sdfDiamond } from "sigma/rendering";
import { layerBorder } from "@sigma/node-border";
import type { Coordinates, LabelAttachmentContent, LabelAttachmentContext } from "sigma/types";
import { DEFAULT_STYLES } from "sigma/types";
import { nodeExtent } from "graphology-metrics/graph/extent";
import { bindWebGLLayer, createColorLayerProgram } from "@sigma/layer-webgl";
import { loadDataset } from "./load-dataset";
import { escape } from "lodash-es";
type NodeStatus = "normal" | "active";
type EdgeStatus = "normal" | "direct" | "indirect";
const FONT_FAMILY = '"Public Sans", Lato, Arial, sans-serif';
const BACKGROUND = "#f0f0f0";
const measureCtx = document.createElement("canvas").getContext("2d")!;
function drawAuthorInfo({ attributes }: LabelAttachmentContext): LabelAttachmentContent {
const author = attributes.author as string;
measureCtx.font = `${fontSize}px ${FONT_FAMILY}`;
const width = Math.ceil(measureCtx.measureText(author).width) + 8;
svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="19">
<text x="4" y="13" font-family='${FONT_FAMILY}' font-size="${fontSize}" fill="#333">${escape(author)}</text>
const graph = await loadDataset();
const [minScore, maxScore] = nodeExtent(graph, "score");
const renderer = new Sigma(graph, document.getElementById("sigma-container") as HTMLElement, {
// "normal": no active subgraph, or node covered by overlay
// "active": in the active subgraph
status: "normal" as NodeStatus,
// "normal": no active subgraph, or edge covered by overlay
// "direct": connects the active node to its neighbor
// "indirect": connects two neighbors of the active node
status: "normal" as EdgeStatus,
hasActiveSubgraph: false,
// an overlay to grey out hidden items (when there is an active graph):
// for the active subgraph:
// for the active node itself:
{ size: 0.1, color: "white", mode: "relative" },
{ fill: true, color: { attribute: "color" } },
authorInfo: drawAuthorInfo,
paths: [pathLine(), pathCurved(), pathLoop()],
extremities: [extremityArrow({ lengthRatio: 6, widthRatio: 4 })],
attribute: "modularityClass",
backdropShadowColor: "transparent",
backdropLabelPadding: 10,
labelBackgroundColor: "#ffffff66",
normal: { depth: "nodes" },
active: { depth: "activeNodes" },
when: (_attributes, { isHovered, isHighlighted }) => isHovered || isHighlighted,
labelVisibility: "visible",
labelAttachment: "authorInfo",
backdropVisibility: "visible",
when: (_attributes, { status }, { hasActiveSubgraph }) => hasActiveSubgraph && status === "normal",
labelVisibility: "hidden",
color: { attribute: "type", dict: EDGE_PALETTE },
cites: { head: "arrow" },
direct: { depth: "activeEdges", opacity: 1, size: 50 },
indirect: { depth: "activeEdges", opacity: 0.5, size: 50 },
itemSizesReference: "positions",
labelRenderedSizeThreshold: 6,
const Overlay = createColorLayerProgram({ color: `${BACKGROUND}00` });
bindWebGLLayer("overlay", renderer, Overlay);
// Build title-to-node index for search
const titleToNode = new Map<string, string>();
graph.forEachNode((node, attrs) => {
titleToNode.set(attrs.label as string, node);
let hoveredNode: string | null = null;
let selectedNode: string | null = null;
let previouslyHighlightedNode: string | null = null;
const searchInput = document.getElementById("search-input") as HTMLInputElement;
const searchSuggestions = document.getElementById("suggestions") as HTMLDataListElement;
function focusNode(node: string | null) {
const neighbors = new Set(graph.neighbors(node));
graph.forEachNode((n) => {
const isActive = n === node || neighbors.has(n);
renderer.setNodeState(n, { status: isActive ? "active" : "normal" });
graph.forEachEdge((edge) => {
const [source, target] = graph.extremities(edge);
const sourceIsFocus = source === node;
const targetIsFocus = target === node;
let status: EdgeStatus = "normal";
if (sourceIsFocus || targetIsFocus) {
} else if (neighbors.has(source) && neighbors.has(target)) {
renderer.setEdgeState(edge, { status });
graph.forEachNode((n) => renderer.setNodeState(n, { status: "normal" }));
graph.forEachEdge((e) => renderer.setEdgeState(e, { status: "normal" }));
Overlay.setColor(node ? `${BACKGROUND}cc` : `${BACKGROUND}00`);
renderer.setGraphState({ hasActiveSubgraph: !!node });
function updateSuggestions(query: string) {
searchSuggestions.innerHTML = "";
const lcQuery = query.toLowerCase();
.filter((n) => (graph.getNodeAttribute(n, "label") as string).toLowerCase().includes(lcQuery))
searchSuggestions.innerHTML = matches
.map((n) => `<option value="${graph.getNodeAttribute(n, "label")}"></option>`)
function selectNode(node: string | null) {
// Reset previous highlighted node, set new one
if (previouslyHighlightedNode) renderer.setNodeState(previouslyHighlightedNode, { isHighlighted: false });
previouslyHighlightedNode = node;
if (node) renderer.setNodeState(node, { isHighlighted: true });
focusNode(hoveredNode || selectedNode);
const nodePosition = renderer.getNodeDisplayData(node) as Coordinates;
renderer.getCamera().animate(nodePosition, { duration: 500 });
let suggestionsTimeout: ReturnType<typeof setTimeout> | null = null;
searchInput.addEventListener("input", () => {
const query = searchInput.value || "";
// Check if the user selected an exact match from suggestions
const exactNode = titleToNode.get(query);
// Clear previous selection
if (selectedNode) selectNode(null);
// Debounce suggestions update
if (suggestionsTimeout) clearTimeout(suggestionsTimeout);
suggestionsTimeout = setTimeout(() => updateSuggestions(query), 200);
searchInput.addEventListener("blur", () => {
if (selectedNode) selectNode(null);
searchSuggestions.innerHTML = "";
// Hover (takes priority over search selection)
renderer.on("enterNode", ({ node }) => {
renderer.on("leaveNode", () => {