<div id="sigma-container"></div>
import { ForceAtlas2GPULayout } from "@sigma/layout-fa2-gpu";
import { layerBorder } from "@sigma/node-border";
import { layerImage } from "@sigma/node-image";
import Graph from "graphology";
import forceAtlas2 from "graphology-layout-forceatlas2";
import circular from "graphology-layout/circular";
import Papa from "papaparse";
import Sigma, { DEPTHLESS_STYLES } from "sigma";
import { extremityArrow, layerDashed, layerFill, layerPlain, pathCurved, pathLine } from "sigma/rendering";
import type { SDFShape } from "sigma/rendering";
import { registerControls } from "../_controls";
* This example shows edge labels on a graph with no node labels: countries
* are identified by their flag alone, and the points are only readable on
* the edges. This needs the `edgeLabelAnchors: "allNodes"` setting, since
* by default edge labels are derived from displayed node labels.
type NodeStatus = "normal" | "active";
type EdgeStatus = "normal" | "direct" | "indirect";
const TELEVOTES_COLOR = "#C7928F";
const JURY_COLOR = "#6F7A9E";
const container = document.getElementById("sigma-container") as HTMLElement;
function sdfFlag(): SDFShape {
float sdf_flag(vec2 uv, float size) {
vec2 d = abs(uv) - vec2(size, size * 0.6);
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
return { name: "flag", glsl, uniforms: [], inradiusFactor: 0.6 };
{ label: "Televote + jury", value: "both" },
{ label: "Televote", value: "tv" },
{ label: "Jury", value: "jury" },
{ label: "All", value: "1" },
{ label: "8+", value: "8" },
{ label: "10+", value: "10" },
{ label: "Twelve points", value: "12" },
// The dataset is the Eurovision 2023 final: who gave how many points to
// whom, split between televote and jury votes.
// Source: https://github.com/Spijkervet/eurovision-dataset
const csv = await fetch("/data/eurovision-2023-votes.csv").then((res) => res.text());
const { data: rows } = Papa.parse<{ from: string; to: string; tele: string; jury: string }>(csv, {
const received: Record<string, number> = {};
const countries = new Set<string>();
rows.forEach(({ from, to, tele, jury }) => {
received[to] = (received[to] ?? 0) + +tele + +jury;
const maxReceived = Math.max(...Object.values(received));
const graph = new Graph({ type: "directed" });
[...countries].sort().forEach((id) => {
image: `https://flagcdn.com/w160/${id}.png`,
size: 5 + 5 * Math.sqrt((received[id] ?? 0) / maxReceived),
// One edge per (giver, receiver) pair; televote and jury awards merge into it.
function rebuildEdges() {
rows.forEach(({ from, to, tele, jury }) => {
).filter(([kind, points]) => points >= minPoints && (votes === "both" || votes === kind));
if (!awards.length) return;
graph.addEdge(from, to, {
kind: awards.length === 2 ? "both" : awards[0][0],
label: awards.map(([kind, points]) => `${kind} ${points}`).join(", "),
size: awards.reduce((total, [, points]) => total + points, 0) / 2,
circular.assign(graph, { scale: 200 });
const renderer = new Sigma(graph, container, {
status: "normal" as NodeStatus,
status: "normal" as EdgeStatus,
hasActiveSubgraph: false,
// hovered node's neighborhood:
variables: { image: { type: "string", default: "" } },
// "contain" keeps the whole flag in its atlas cell instead of a square center crop.
layerImage({ textureManagerOptions: { objectFit: "contain" } }),
{ size: 0.08, color: "#000000" },
{ color: "transparent", fill: true },
paths: [pathLine(), pathCurved()],
extremities: [extremityArrow({ lengthRatio: 3, widthRatio: 2 })],
dashColor: { type: "color", default: "transparent" },
dashColor: { attribute: "dashColor" },
dashSize: { value: 2, mode: "relative" },
gapSize: { value: 2, mode: "relative" },
label: { color: "#1c2833", textBorder: { width: 12, color: "#ffffff" } },
// Country names only on hover: the flag is the label.
labelVisibility: { whenState: "isHovered", then: "visible", else: "hidden" },
labelBackgroundColor: "#ffffffcc",
labelBackgroundPadding: 3,
normal: { depth: "nodes" },
active: { depth: "activeNodes" },
{ whenState: "isHovered", then: { depth: "focusedNodes" } },
{ whenState: "isDragged", then: { cursor: "grabbing" } },
{ depth: "edges", parallelPath: "curved", parallelSpread: 0.5, opacity: 0.8 },
tv: { color: TELEVOTES_COLOR },
jury: { color: JURY_COLOR },
// "both": televote base with jury-colored dashes alternating.
both: { color: TELEVOTES_COLOR, dashColor: JURY_COLOR },
direct: { depth: "activeEdges", opacity: 1, labelVisibility: "visible" },
indirect: { depth: "activeEdges", opacity: 0.5 },
// During a focus, only the hovered node's own edges keep their labels.
when: (_attributes, { status }, { hasActiveSubgraph }) => hasActiveSubgraph && status !== "direct",
then: { labelVisibility: "hidden", opacity: 0.1 },
stage: [{ whenState: "isDragging", then: { cursor: "grabbing" } }],
itemSizesReference: "positions",
edgeLabelAnchors: "allNodes",
// Dim everything but the hovered country and its direct voting partners.
function focusNode(node: string | null) {
const neighbors = node ? new Set(graph.neighbors(node)) : new Set<string>();
graph.forEachNode((n) => {
renderer.setNodeState(n, { status: node && (n === node || neighbors.has(n)) ? "active" : "normal" });
graph.forEachEdge((edge, _attributes, source, target) => {
let status: EdgeStatus = "normal";
if (source === node || target === node) status = "direct";
else if (neighbors.has(source) && neighbors.has(target)) status = "indirect";
renderer.setEdgeState(edge, { status });
renderer.setGraphState({ hasActiveSubgraph: !!node });
renderer.on("enterNode", ({ node }) => focusNode(node));
renderer.on("leaveNode", () => focusNode(null));
const inferred = forceAtlas2.inferSettings(graph);
const layout = new ForceAtlas2GPULayout(renderer, {
gravity: inferred.gravity ?? 1,
slowDown: inferred.slowDown ?? 1,
strongGravityMode: inferred.strongGravityMode ?? false,