Hover and search highlight
The most common interactive pattern in graph visualization: highlight a node and its neighbors on hover, and let users search for nodes by label.
Live example
Hover any node to highlight its neighborhood. Use the search field to filter nodes by label.
import Graph from "graphology";import Sigma from "sigma";import type { Coordinates } from "sigma/types";
import data from "../_data/data.json";
const container = document.getElementById("sigma-container") as HTMLElement;const searchInput = document.getElementById("search-input") as HTMLInputElement;const searchSuggestions = document.getElementById("suggestions") as HTMLDataListElement;
const graph = new Graph();graph.import(data);
const GREY = "#f6f6f6";
const renderer = new Sigma(graph, container, { customNodeState: { isActive: false, }, customEdgeState: { isActive: false, }, customGraphState: { hasActiveSubgraph: false, }, primitives: { depthLayers: ["edges", "nodes", "topEdges", "topNodes", "activeNodes"], }, styles: { nodes: [ { color: { attribute: "color" }, size: { attribute: "size" }, label: { attribute: "label" }, }, { whenState: "isHighlighted", then: { labelVisibility: "visible" }, }, { when: (_attrs, state, graphState) => graphState.hasActiveSubgraph && !state.isActive && !state.isHighlighted, then: { color: GREY, label: "" }, }, { whenState: "isActive", then: { depth: "topNodes", labelVisibility: "visible" }, }, { whenState: "isHovered", then: { depth: "activeNodes", labelVisibility: "visible" }, }, ], edges: [ { color: { attribute: "color", defaultValue: "#ccc" }, size: { attribute: "size", defaultValue: 1 }, }, { when: (_attrs, state, graphState) => graphState.hasActiveSubgraph && !state.isActive, then: { color: GREY }, }, { whenState: "isActive", then: { depth: "topEdges" }, }, ], },});
searchSuggestions.innerHTML = graph .nodes() .map((node) => `<option value="${graph.getNodeAttribute(node, "label")}"></option>`) .join("\n");
let hoveredNode: string | null = null;let activeSearchNodes: Set<string> | null = null;
function setActiveSubgraph(activeNodes: Set<string> | null) { graph.forEachNode((node) => { renderer.setNodeState(node, { isActive: !!activeNodes && activeNodes.has(node) }); }); graph.forEachEdge((edge) => { const [source, target] = graph.extremities(edge); renderer.setEdgeState(edge, { isActive: !!activeNodes && activeNodes.has(source) && activeNodes.has(target), }); }); renderer.setGraphState({ hasActiveSubgraph: !!activeNodes && activeNodes.size > 0 });}
function refreshActiveSubgraph() { if (hoveredNode) { const neighbors = new Set(graph.neighbors(hoveredNode)); neighbors.add(hoveredNode); setActiveSubgraph(neighbors); } else { setActiveSubgraph(activeSearchNodes); } renderer.refresh({ skipIndexation: true });}
function setSearchQuery(query: string) { if (searchInput.value !== query) searchInput.value = query;
let selectedNode: string | null = null;
if (query) { const lcQuery = query.toLowerCase(); const suggestions = graph .nodes() .map((n) => ({ id: n, label: graph.getNodeAttribute(n, "label") as string })) .filter(({ label }) => label.toLowerCase().includes(lcQuery));
if (suggestions.length === 1 && suggestions[0].label === query) { selectedNode = suggestions[0].id; activeSearchNodes = null; } else { activeSearchNodes = new Set(suggestions.map(({ id }) => id)); } } else { activeSearchNodes = null; }
graph.forEachNode((node) => renderer.setNodeState(node, { isHighlighted: node === selectedNode })); refreshActiveSubgraph();
if (selectedNode) { const nodePosition = renderer.getNodeDisplayData(selectedNode) as Coordinates; renderer.getCamera().animate(nodePosition, { duration: 500 }); }}
searchInput.addEventListener("input", () => setSearchQuery(searchInput.value || ""));searchInput.addEventListener("blur", () => setSearchQuery(""));
renderer.on("enterNode", ({ node }) => { hoveredNode = node; refreshActiveSubgraph();});renderer.on("leaveNode", () => { hoveredNode = null; refreshActiveSubgraph();});How it works
This example uses sigma’s v4 state management system. No reducers are needed for this pattern.
1. Define custom state types
Giving the Sigma constructor some base custom states for nodes, edges and/or the graph itself, will make it infer the
custom state types, which then eases manipulating these states from the styles, event handlers, etc…
Here, we simply extend base Sigma states, by allowing flagging some nodes and edges as “active”, also with a flag on the graph itself:
const renderer = new Sigma(graph, container, { // ... customNodeState: { isActive: false, }, customEdgeState: { isActive: false, }, customGraphState: { hasActiveSubgraph: false, }, // ...});2. Use conditional style rules
Styles define how elements look based on their state:
const renderer = new Sigma(graph, container, { styles: { nodes: [ { color: { attribute: "color" }, size: { attribute: "size" } }, // Grey out inactive nodes when there's an active subgraph { when: (attrs, state, graphState) => graphState.hasActiveSubgraph && !state.isActive && !state.isHighlighted, then: { color: "#f6f6f6", label: "" }, }, // Render active nodes on top using custom depth layers { whenState: "isActive", then: { depth: "topNodes", labelVisibility: "visible" }, }, ], },});3. Update state on interaction
renderer.on("enterNode", ({ node }) => { const neighbors = new Set(graph.neighbors(node)); neighbors.add(node);
graph.forEachNode((n) => { renderer.setNodeState(n, { isActive: neighbors.has(n) }); }); renderer.setGraphState({ hasActiveSubgraph: true });});
renderer.on("leaveNode", () => { graph.forEachNode((n) => renderer.setNodeState(n, { isActive: false })); renderer.setGraphState({ hasActiveSubgraph: false });});Key concepts
- Custom state: provide
customNodeState/customEdgeState/customGraphStatewith your own fields - Conditional styles: use
whenpredicates to apply styles based on state - Depth layers: use
primitives.depthLayersto render highlighted elements on top of the rest setNodeState/setGraphState: update state without touching the graph data
Alternative: using reducers
The approach above handles everything through conditional style rules. For more complex logic (such as neighbor lookups, or cross-referencing multiple state fields), you can use reducers as an escape hatch. Reducers receive the full context (key, computed styles, attributes, state, graph state, graph), and return modified display data:
const renderer = new Sigma(graph, container, { // Extend graph state with a custom field to track the hovered node customGraphState: { hoveredNode: null as string | null, }, // Use that graph state in the reducers for conditional styling nodeReducer: (key, computed, _attrs, _state, graphState, graph) => { const { hoveredNode } = graphState; if (hoveredNode && key !== hoveredNode && !graph.neighbors(hoveredNode).includes(key)) { return { ...computed, label: "", color: "#f6f6f6" }; } return computed; }, edgeReducer: (key, computed, _attrs, _state, graphState, graph) => { const { hoveredNode } = graphState; if (hoveredNode) { const [source, target] = graph.extremities(key); if (source !== hoveredNode && target !== hoveredNode) { return { ...computed, hidden: true }; } } return computed; },});
// Update the custom graph state on hoverrenderer.on("enterNode", ({ node }) => renderer.setGraphState({ hoveredNode: node }));renderer.on("leaveNode", () => renderer.setGraphState({ hoveredNode: null }));Reducers are more powerful but harder to optimize. Prefer conditional styles when they suffice.
For a deeper understanding of the styles system, see Styles and primitives.