sigma
Version:
A JavaScript library dedicated to graph drawing.
1,250 lines (1,003 loc) • 37.6 kB
text/typescript
/**
* Sigma.js
* ========
* @module
*/
import { EventEmitter } from "events";
import graphExtent from "graphology-metrics/extent";
import { NodeKey, EdgeKey } from "graphology-types";
import Graph from "graphology";
import Camera from "./core/camera";
import MouseCaptor from "./core/captors/mouse";
import QuadTree from "./core/quadtree";
import {
Coordinates,
Dimensions,
EdgeAttributes,
Extent,
Listener,
MouseCoords,
NodeAttributes,
PlainObject,
} from "./types";
import {
createElement,
getPixelRatio,
createNormalizationFunction,
NormalizationFunction,
assignDeep,
cancelFrame,
matrixFromCamera,
requestFrame,
validateGraph,
zIndexOrdering,
} from "./utils";
import { labelsToDisplayFromGrid, edgeLabelsToDisplayFromNodes } from "./core/labels";
import { Settings, DEFAULT_SETTINGS, validateSettings } from "./settings";
import { INodeProgram } from "./rendering/webgl/programs/common/node";
import { IEdgeProgram } from "./rendering/webgl/programs/common/edge";
import TouchCaptor from "./core/captors/touch";
const { nodeExtent, edgeExtent } = graphExtent;
/**
* Constants.
*/
const PIXEL_RATIO = getPixelRatio();
const WEBGL_OVERSAMPLING_RATIO = getPixelRatio();
/**
* Important functions.
*/
function applyNodeDefaults(settings: Settings, key: NodeKey, data: NodeAttributes): void {
if (!data.hasOwnProperty("x") || !data.hasOwnProperty("y"))
throw new Error(
`Sigma: could not find a valid position (x, y) for node "${key}". All your nodes must have a number "x" and "y". Maybe your forgot to apply a layout or your "nodeReducer" is not returning the correct data?`,
);
if (!data.color) data.color = settings.defaultNodeColor;
if (!data.label) data.label = "";
if (!data.size) data.size = 2;
if (!data.hasOwnProperty("hidden")) data.hidden = false;
if (!data.hasOwnProperty("highlighted")) data.highlighted = false;
}
function applyEdgeDefaults(settings: Settings, key: EdgeKey, data: EdgeAttributes): void {
if (!data.color) data.color = settings.defaultEdgeColor;
if (!data.label) data.label = "";
if (!data.size) data.size = 0.5;
if (!data.hasOwnProperty("hidden")) data.hidden = false;
}
/**
* Main class.
*
* @constructor
* @param {Graph} graph - Graph to render.
* @param {HTMLElement} container - DOM container in which to render.
* @param {object} settings - Optional settings.
*/
export default class Sigma extends EventEmitter {
private settings: Settings;
private graph: Graph;
private mouseCaptor: MouseCaptor;
private touchCaptor: TouchCaptor;
private container: HTMLElement;
private elements: PlainObject<HTMLCanvasElement> = {};
private canvasContexts: PlainObject<CanvasRenderingContext2D> = {};
private webGLContexts: PlainObject<WebGLRenderingContext> = {};
private activeListeners: PlainObject<Listener> = {};
private quadtree: QuadTree = new QuadTree();
private nodeDataCache: Record<NodeKey, Partial<NodeAttributes>> = {};
private edgeDataCache: Record<EdgeKey, Partial<EdgeAttributes>> = {};
private nodeKeyToIndex: Record<NodeKey, number> = {};
private edgeKeyToIndex: Record<EdgeKey, number> = {};
private nodeExtent: { x: Extent; y: Extent; z: Extent } | null = null;
private edgeExtent: { z: Extent } | null = null;
private normalizationFunction: NormalizationFunction = createNormalizationFunction({
x: [-Infinity, Infinity],
y: [-Infinity, Infinity],
});
// Starting dimensions
private width = 0;
private height = 0;
// State
private highlightedNodes: Set<NodeKey> = new Set();
private displayedLabels: Set<NodeKey> = new Set();
private hoveredNode: NodeKey | null = null;
private renderFrame: number | null = null;
private renderHighlightedNodesFrame: number | null = null;
private needToProcess = false;
private needToSoftProcess = false;
// programs
private nodePrograms: { [key: string]: INodeProgram } = {};
private edgePrograms: { [key: string]: IEdgeProgram } = {};
private camera: Camera;
constructor(graph: Graph, container: HTMLElement | null, settings: Partial<Settings> = {}) {
super();
this.settings = assignDeep<Settings>({}, DEFAULT_SETTINGS, settings);
// Validating
validateSettings(this.settings);
validateGraph(graph);
if (!(container instanceof HTMLElement)) throw new Error("Sigma: container should be an html element.");
// Properties
this.graph = graph;
this.container = container;
this.initializeCache();
// Initializing contexts
this.createWebGLContext("edges");
this.createWebGLContext("nodes");
this.createCanvasContext("edgeLabels");
this.createCanvasContext("labels");
this.createCanvasContext("hovers");
this.createCanvasContext("mouse");
// Blending
let gl = this.webGLContexts.nodes;
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.enable(gl.BLEND);
gl = this.webGLContexts.edges;
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.enable(gl.BLEND);
// Loading programs
for (const type in this.settings.nodeProgramClasses) {
const NodeProgramClass = this.settings.nodeProgramClasses[type];
this.nodePrograms[type] = new NodeProgramClass(this.webGLContexts.nodes);
}
for (const type in this.settings.edgeProgramClasses) {
const EdgeProgramClass = this.settings.edgeProgramClasses[type];
this.edgePrograms[type] = new EdgeProgramClass(this.webGLContexts.edges);
}
// Initial resize
this.resize();
// Initializing the camera
this.camera = new Camera();
// Binding camera events
this.bindCameraHandlers();
// Initializing captors
this.mouseCaptor = new MouseCaptor(this.elements.mouse, this.camera);
this.touchCaptor = new TouchCaptor(this.elements.mouse, this.camera);
// Binding event handlers
this.bindEventHandlers();
// Binding graph handlers
this.bindGraphHandlers();
// Processing data for the first time & render
this.process();
this.render();
}
/**---------------------------------------------------------------------------
* Internal methods.
**---------------------------------------------------------------------------
*/
/**
* Internal function used to create a canvas element.
* @param {string} id - Context's id.
* @return {Sigma}
*/
private createCanvas(id: string): HTMLCanvasElement {
const canvas: HTMLCanvasElement = createElement<HTMLCanvasElement>(
"canvas",
{
position: "absolute",
},
{
class: `sigma-${id}`,
},
);
this.elements[id] = canvas;
this.container.appendChild(canvas);
return canvas;
}
/**
* Internal function used to create a canvas context and add the relevant
* DOM elements.
*
* @param {string} id - Context's id.
* @return {Sigma}
*/
private createCanvasContext(id: string): this {
const canvas = this.createCanvas(id);
const contextOptions = {
preserveDrawingBuffer: false,
antialias: false,
};
this.canvasContexts[id] = canvas.getContext("2d", contextOptions) as CanvasRenderingContext2D;
return this;
}
/**
* Internal function used to create a canvas context and add the relevant
* DOM elements.
*
* @param {string} id - Context's id.
* @return {Sigma}
*/
private createWebGLContext(id: string): this {
const canvas = this.createCanvas(id);
const contextOptions = {
preserveDrawingBuffer: false,
antialias: false,
};
let context;
// First we try webgl2 for an easy performance boost
context = canvas.getContext("webgl2", contextOptions);
// Else we fall back to webgl
if (!context) context = canvas.getContext("webgl", contextOptions);
// Edge, I am looking right at you...
if (!context) context = canvas.getContext("experimental-webgl", contextOptions);
this.webGLContexts[id] = context as WebGLRenderingContext;
return this;
}
/**
* Method used to initialize display data cache.
*
* @return {Sigma}
*/
private initializeCache(): void {
const graph = this.graph;
// NOTE: the data caches are never reset to avoid paying a GC cost
// But this could prove to be a bad decision. In which case just "reset"
// them here.
let i = 0;
graph.forEachNode((key) => {
this.nodeKeyToIndex[key] = i++;
this.nodeDataCache[key] = {};
});
i = 0;
graph.forEachEdge((key) => {
this.edgeKeyToIndex[key] = i++;
this.edgeDataCache[key] = {};
});
}
/**
* Method binding camera handlers.
*
* @return {Sigma}
*/
private bindCameraHandlers(): this {
this.activeListeners.camera = () => {
this._scheduleRefresh();
};
this.camera.on("updated", this.activeListeners.camera);
return this;
}
/**
* Method binding event handlers.
*
* @return {Sigma}
*/
private bindEventHandlers(): this {
// Handling window resize
this.activeListeners.handleResize = () => {
this.needToSoftProcess = true;
this._scheduleRefresh();
};
window.addEventListener("resize", this.activeListeners.handleResize);
// Function checking if the mouse is on the given node
const mouseIsOnNode = (mouseX: number, mouseY: number, nodeX: number, nodeY: number, size: number): boolean => {
return (
mouseX > nodeX - size &&
mouseX < nodeX + size &&
mouseY > nodeY - size &&
mouseY < nodeY + size &&
Math.sqrt(Math.pow(mouseX - nodeX, 2) + Math.pow(mouseY - nodeY, 2)) < size
);
};
// Function returning the nodes in the mouse's quad
const getQuadNodes = (mouseX: number, mouseY: number) => {
const mouseGraphPosition = this.camera.viewportToFramedGraph(
{ width: this.width, height: this.height },
{ x: mouseX, y: mouseY },
);
// TODO: minus 1? lol
return this.quadtree.point(mouseGraphPosition.x, 1 - mouseGraphPosition.y);
};
// Handling mouse move
this.activeListeners.handleMove = (e: Coordinates): void => {
// NOTE: for the canvas renderer, testing the pixel's alpha should
// give some boost but this slows things down for WebGL empirically.
// TODO: this should be a method from the camera (or can be passed to graph to display somehow)
const sizeRatio = Math.pow(this.camera.getState().ratio, 0.5);
const quadNodes = getQuadNodes(e.x, e.y);
const dimensions = { width: this.width, height: this.height };
// We will hover the node whose center is closest to mouse
let minDistance = Infinity,
nodeToHover = null;
for (let i = 0, l = quadNodes.length; i < l; i++) {
const node = quadNodes[i];
const data = this.nodeDataCache[node] as NodeAttributes;
const pos = this.camera.framedGraphToViewport(dimensions, data);
const size = data.size / sizeRatio;
if (mouseIsOnNode(e.x, e.y, pos.x, pos.y, size)) {
const distance = Math.sqrt(Math.pow(e.x - pos.x, 2) + Math.pow(e.y - pos.y, 2));
// TODO: sort by min size also for cases where center is the same
if (distance < minDistance) {
minDistance = distance;
nodeToHover = node;
}
}
}
if (nodeToHover && this.hoveredNode !== nodeToHover && !this.nodeDataCache[nodeToHover].hidden) {
// Handling passing from one node to the other directly
if (this.hoveredNode) this.emit("leaveNode", { node: this.hoveredNode });
this.hoveredNode = nodeToHover;
this.emit("enterNode", { node: nodeToHover });
this.scheduleHighlightedNodesRender();
return;
}
// Checking if the hovered node is still hovered
if (this.hoveredNode) {
const data = this.nodeDataCache[this.hoveredNode] as NodeAttributes;
const pos = this.camera.framedGraphToViewport(dimensions, data);
const size = data.size / sizeRatio;
if (!mouseIsOnNode(e.x, e.y, pos.x, pos.y, size)) {
const node = this.hoveredNode;
this.hoveredNode = null;
this.emit("leaveNode", { node });
return this.scheduleHighlightedNodesRender();
}
}
};
// Handling click
const createClickListener = (eventType: string): ((e: MouseCoords) => void) => {
return (e) => {
const sizeRatio = Math.pow(this.camera.getState().ratio, 0.5);
const quadNodes = getQuadNodes(e.x, e.y);
const dimensions = { width: this.width, height: this.height };
for (let i = 0, l = quadNodes.length; i < l; i++) {
const node = quadNodes[i];
const data = this.nodeDataCache[node] as NodeAttributes;
const pos = this.camera.framedGraphToViewport(dimensions, data);
const size = data.size / sizeRatio;
if (mouseIsOnNode(e.x, e.y, pos.x, pos.y, size))
return this.emit(`${eventType}Node`, { node, captor: e, event: e });
}
return this.emit(`${eventType}Stage`, { event: e });
};
};
this.activeListeners.handleClick = createClickListener("click");
this.activeListeners.handleRightClick = createClickListener("rightClick");
this.activeListeners.handleDown = createClickListener("down");
this.mouseCaptor.on("mousemove", this.activeListeners.handleMove);
this.mouseCaptor.on("click", this.activeListeners.handleClick);
this.mouseCaptor.on("rightClick", this.activeListeners.handleRightClick);
this.mouseCaptor.on("mousedown", this.activeListeners.handleDown);
// TODO
// Deal with Touch captor events
return this;
}
/**
* Method binding graph handlers
*
* @return {Sigma}
*/
private bindGraphHandlers(): this {
const graph = this.graph;
this.activeListeners.graphUpdate = () => {
this.needToProcess = true;
this._scheduleRefresh();
};
this.activeListeners.softGraphUpdate = () => {
this.needToSoftProcess = true;
this._scheduleRefresh();
};
this.activeListeners.addNodeGraphUpdate = (e: { key: NodeKey }): void => {
// Adding entry to cache
this.nodeKeyToIndex[e.key] = graph.order - 1;
this.nodeDataCache[e.key] = {};
this.activeListeners.graphUpdate();
};
this.activeListeners.addEdgeGraphUpdate = (e: { key: EdgeKey }): void => {
// Adding entry to cache
this.nodeKeyToIndex[e.key] = graph.order - 1;
this.edgeDataCache[e.key] = {};
this.activeListeners.graphUpdate();
};
// TODO: clean cache on drop!
// TODO: bind this on composed state events
// TODO: it could be possible to update only specific node etc. by holding
// a fixed-size pool of updated items
graph.on("nodeAdded", this.activeListeners.addNodeGraphUpdate);
graph.on("nodeDropped", this.activeListeners.graphUpdate);
graph.on("nodeAttributesUpdated", this.activeListeners.softGraphUpdate);
graph.on("eachNodeAttributesUpdated", this.activeListeners.graphUpdate);
graph.on("edgeAdded", this.activeListeners.addEdgeGraphUpdate);
graph.on("edgeDropped", this.activeListeners.graphUpdate);
graph.on("edgeAttributesUpdated", this.activeListeners.softGraphUpdate);
graph.on("eachEdgeAttributesUpdated", this.activeListeners.graphUpdate);
graph.on("edgesCleared", this.activeListeners.graphUpdate);
graph.on("cleared", this.activeListeners.graphUpdate);
return this;
}
/**
* Method used to process the whole graph's data.
*
* @return {Sigma}
*/
private process(keepArrays = false): this {
const graph = this.graph,
settings = this.settings;
// Clearing the quad
this.quadtree.clear();
// Clear the highlightedNodes
this.highlightedNodes = new Set();
// Computing extents
const nodeExtentProperties = ["x", "y", "z"];
if (this.settings.zIndex) {
nodeExtentProperties.push("z");
this.edgeExtent = edgeExtent(graph, ["z"]) as { z: Extent };
}
this.nodeExtent = nodeExtent(graph, nodeExtentProperties) as { x: Extent; y: Extent; z: Extent };
// Rescaling function
this.normalizationFunction = createNormalizationFunction(this.nodeExtent);
const nodeProgram = this.nodePrograms[this.settings.defaultNodeType];
if (!keepArrays) nodeProgram.allocate(graph.order);
let nodes: NodeKey[] = graph.nodes();
// Handling node z-index
// TODO: z-index needs us to compute display data before hand
if (this.settings.zIndex)
nodes = zIndexOrdering<NodeKey>(
this.nodeExtent.z,
(node: NodeKey): number => graph.getNodeAttribute(node, "z"),
nodes,
);
for (let i = 0, l = nodes.length; i < l; i++) {
const node = nodes[i];
// Node display data resolution:
// 1. First we get the node's attributes
// 2. We optionally reduce them using the function provided by the user
// Note that this function must return a total object and won't be merged
// 3. We apply our defaults, while running some vital checks
// 4. We apply the normalization function
let data = graph.getNodeAttributes(node) as NodeAttributes;
if (settings.nodeReducer) data = settings.nodeReducer(node, data);
// We shallow copy the data to avoid mutating both the graph and the reducer's result
data = Object.assign(this.nodeDataCache[node], data);
applyNodeDefaults(this.settings, node, data);
this.normalizationFunction.applyTo(data);
this.quadtree.add(node, data.x, 1 - data.y, data.size / this.width);
nodeProgram.process(data, data.hidden, i);
// Save the node in the highlighted set if needed
if (data.highlighted && !data.hidden) this.highlightedNodes.add(node);
this.nodeKeyToIndex[node] = i;
}
// TODO: maybe we should bind and buffer as part of rendering?
// We also need to find when it is useful and when it's really not
nodeProgram.bind();
nodeProgram.bufferData();
const edgeProgram = this.edgePrograms[this.settings.defaultEdgeType];
if (!keepArrays) edgeProgram.allocate(graph.size);
let edges: EdgeKey[] = graph.edges();
// Handling edge z-index
if (this.settings.zIndex && this.edgeExtent)
edges = zIndexOrdering(this.edgeExtent.z, (edge: EdgeKey): number => graph.getEdgeAttribute(edge, "z"), edges);
for (let i = 0, l = edges.length; i < l; i++) {
const edge = edges[i];
// Edge display data resolution:
// 1. First we get the edge's attributes
// 2. We optionally reduce them using the function provided by the user
// Note that this function must return a total object and won't be merged
// 3. We apply our defaults, while running some vital checks
let data = graph.getEdgeAttributes(edge) as EdgeAttributes;
if (settings.edgeReducer) data = settings.edgeReducer(edge, data);
// We shallow copy the data to avoid mutating both the graph and the reducer's result
data = Object.assign(this.edgeDataCache[edge], data);
applyEdgeDefaults(this.settings, edge, data);
const extremities = graph.extremities(edge),
sourceData = this.nodeDataCache[extremities[0]] as NodeAttributes,
targetData = this.nodeDataCache[extremities[1]] as NodeAttributes;
const hidden = data.hidden || sourceData.hidden || targetData.hidden;
edgeProgram.process(sourceData, targetData, data, hidden, i);
this.nodeKeyToIndex[edge] = i;
}
// Computing edge indices if necessary
if (!keepArrays && typeof edgeProgram.computeIndices === "function") edgeProgram.computeIndices();
// TODO: maybe we should bind and buffer as part of rendering?
// We also need to find when it is useful and when it's really not
edgeProgram.bind();
edgeProgram.bufferData();
return this;
}
/**
* Method that decides whether to reprocess graph or not, and then render the
* graph.
*
* @return {Sigma}
*/
private _refresh(): this {
// Do we need to process data?
if (this.needToProcess) {
this.process();
} else if (this.needToSoftProcess) {
this.process(true);
}
// Resetting state
this.needToProcess = false;
this.needToSoftProcess = false;
// Rendering
this.render();
return this;
}
/**
* Method that schedules a `_refresh` call if none has been scheduled yet. It
* will then be processed next available frame.
*
* @return {Sigma}
*/
private _scheduleRefresh(): this {
if (!this.renderFrame) {
this.renderFrame = requestFrame(() => {
this._refresh();
this.renderFrame = null;
});
}
return this;
}
/**
* Method used to render labels.
*
* @return {Sigma}
*/
private renderLabels(): this {
if (!this.settings.renderLabels) return this;
const cameraState = this.camera.getState();
const dimensions = { width: this.width, height: this.height };
// Finding visible nodes to display their labels
let visibleNodes: NodeKey[];
if (cameraState.ratio >= 1) {
// Camera is unzoomed so no need to ask the quadtree for visible nodes
visibleNodes = this.graph.nodes();
} else {
// Let's ask the quadtree
const viewRectangle = this.camera.viewRectangle(dimensions);
visibleNodes = this.quadtree.rectangle(
viewRectangle.x1,
1 - viewRectangle.y1,
viewRectangle.x2,
1 - viewRectangle.y2,
viewRectangle.height,
);
}
// Selecting labels to draw
const gridSettings = this.settings.labelGrid;
const labelsToDisplay = labelsToDisplayFromGrid({
cache: this.nodeDataCache as Record<NodeKey, NodeAttributes>,
camera: this.camera,
cell: gridSettings.cell,
dimensions,
displayedLabels: this.displayedLabels,
fontSize: this.settings.labelSize,
graph: this.graph,
renderedSizeThreshold: gridSettings.renderedSizeThreshold,
visibleNodes,
});
// Drawing labels
const context = this.canvasContexts.labels;
const sizeRatio = Math.pow(cameraState.ratio, 0.5);
for (let i = 0, l = labelsToDisplay.length; i < l; i++) {
const data = this.nodeDataCache[labelsToDisplay[i]] as NodeAttributes;
const { x, y } = this.camera.framedGraphToViewport(dimensions, data);
// TODO: we can cache the labels we need to render until the camera's ratio changes
// TODO: this should be computed in the canvas components?
const size = data.size / sizeRatio;
this.settings.labelRenderer(
context,
{
key: labelsToDisplay[i],
label: data.label,
color: "#000",
size,
x,
y,
},
this.settings,
);
}
// Caching visible nodes and displayed labels
this.displayedLabels = new Set(labelsToDisplay);
return this;
}
/**
* Method used to render edge labels, based on which node labels were
* rendered.
*
* @return {Sigma}
*/
private renderEdgeLabels(): this {
if (!this.settings.renderEdgeLabels) return this;
const cameraState = this.camera.getState();
const sizeRatio = Math.pow(cameraState.ratio, 0.5);
const context = this.canvasContexts.edgeLabels;
const dimensions = { width: this.width, height: this.height };
// Clearing
context.clearRect(0, 0, this.width, this.height);
const edgeLabelsToDisplay = edgeLabelsToDisplayFromNodes({
nodeDataCache: this.nodeDataCache as Record<NodeKey, NodeAttributes>,
edgeDataCache: this.edgeDataCache as Record<NodeKey, EdgeAttributes>,
graph: this.graph,
hoveredNode: this.hoveredNode,
displayedNodeLabels: this.displayedLabels,
highlightedNodes: this.highlightedNodes,
});
for (let i = 0, l = edgeLabelsToDisplay.length; i < l; i++) {
const edge = edgeLabelsToDisplay[i],
extremities = this.graph.extremities(edge),
sourceData = this.nodeDataCache[extremities[0]] as NodeAttributes,
targetData = this.nodeDataCache[extremities[1]] as NodeAttributes,
edgeData = this.edgeDataCache[edgeLabelsToDisplay[i]] as EdgeAttributes;
const { x: sourceX, y: sourceY } = this.camera.framedGraphToViewport(dimensions, sourceData);
const { x: targetX, y: targetY } = this.camera.framedGraphToViewport(dimensions, targetData);
// TODO: we can cache the labels we need to render until the camera's ratio changes
// TODO: this should be computed in the canvas components?
const size = edgeData.size / sizeRatio;
this.settings.edgeLabelRenderer(
context,
{
key: edge,
label: edgeData.label,
color: edgeData.color,
size,
},
{
key: extremities[0],
x: sourceX,
y: sourceY,
},
{
key: extremities[1],
x: targetX,
y: targetY,
},
this.settings,
);
}
return this;
}
/**
* Method used to render the highlighted nodes.
*
* @return {Sigma}
*/
private renderHighlightedNodes(): void {
const camera = this.camera;
const sizeRatio = Math.pow(camera.getState().ratio, 0.5);
const context = this.canvasContexts.hovers;
// Clearing
context.clearRect(0, 0, this.width, this.height);
// Rendering
const render = (node: NodeKey): void => {
const data = this.nodeDataCache[node] as NodeAttributes;
const { x, y } = camera.framedGraphToViewport({ width: this.width, height: this.height }, data);
const size = data.size / sizeRatio;
this.settings.hoverRenderer(
context,
{
key: node,
label: data.label,
color: data.color,
size,
x,
y,
},
this.settings,
);
};
if (this.hoveredNode && !this.nodeDataCache[this.hoveredNode].hidden) {
render(this.hoveredNode);
}
this.highlightedNodes.forEach(render);
}
/**
* Method used to schedule a hover render.
*
*/
private scheduleHighlightedNodesRender(): void {
if (this.renderHighlightedNodesFrame || this.renderFrame) return;
this.renderHighlightedNodesFrame = requestFrame(() => {
// Resetting state
this.renderHighlightedNodesFrame = null;
// Rendering
this.renderHighlightedNodes();
this.renderEdgeLabels();
});
}
/**
* Method used to render.
*
* @return {Sigma}
*/
private render(): this {
// If a render was scheduled, we cancel it
if (this.renderFrame) {
cancelFrame(this.renderFrame);
this.renderFrame = null;
this.needToProcess = false;
this.needToSoftProcess = false;
}
// First we need to resize
this.resize();
// Clearing the canvases
this.clear();
// If we have no nodes we can stop right there
if (!this.graph.order) return this;
// TODO: improve this heuristic or move to the captor itself?
// TODO: deal with the touch captor here as well
const mouseCaptor = this.mouseCaptor;
const moving =
this.camera.isAnimated() ||
mouseCaptor.isMoving ||
mouseCaptor.draggedEvents ||
mouseCaptor.currentWheelDirection;
// Then we need to extract a matrix from the camera
const cameraState = this.camera.getState(),
cameraMatrix = matrixFromCamera(cameraState, {
width: this.width,
height: this.height,
});
let program;
// Drawing nodes
program = this.nodePrograms[this.settings.defaultNodeType];
program.render({
matrix: cameraMatrix,
width: this.width,
height: this.height,
ratio: cameraState.ratio,
nodesPowRatio: 0.5,
scalingRatio: WEBGL_OVERSAMPLING_RATIO,
});
// Drawing edges
if (!this.settings.hideEdgesOnMove || !moving) {
program = this.edgePrograms[this.settings.defaultEdgeType];
program.render({
matrix: cameraMatrix,
width: this.width,
height: this.height,
ratio: cameraState.ratio,
edgesPowRatio: 0.5,
scalingRatio: WEBGL_OVERSAMPLING_RATIO,
});
}
// Do not display labels on move per setting
if (this.settings.hideLabelsOnMove && moving) return this;
this.renderLabels();
this.renderEdgeLabels();
this.renderHighlightedNodes();
return this;
}
/**---------------------------------------------------------------------------
* Public API.
**---------------------------------------------------------------------------
*/
/**
* Method returning the renderer's camera.
*
* @return {Camera}
*/
getCamera(): Camera {
return this.camera;
}
/**
* Method returning the renderer's graph.
*
* @return {Graph}
*/
getGraph(): Graph {
return this.graph;
}
/**
* Method returning the mouse captor.
*
* @return {MouseCaptor}
*/
getMouseCaptor(): MouseCaptor {
return this.mouseCaptor;
}
/**
* Method returning the touch captor.
*
* @return {TouchCaptor}
*/
getTouchCaptor(): TouchCaptor {
return this.touchCaptor;
}
/**
* Method returning the current renderer's dimensions.
*
* @return {Dimensions}
*/
getDimensions(): Dimensions {
return { width: this.width, height: this.height };
}
/**
* Method used to get all the sigma node attributes.
* It's usefull for example to get the position of a node
* and to get values that are set by the nodeReducer
*
* @param {string} key - The node's key.
* @return {Partial<NodeAttributes>} A copy of the desired node's attribute or undefined if not found
*/
getNodeAttributes(key: NodeKey): Partial<NodeAttributes> | undefined {
const node = this.nodeDataCache[key];
return node ? Object.assign({}, node) : undefined;
}
/**
* Method used to get all the sigma edge attributes.
* It's usefull for example to get values that are set by the edgeReducer.
*
* @param {string} key - The edge's key.
* @return {Partial<EdgeAttributes> | undefined} A copy of the desired edge's attribute or undefined if not found
*/
getEdgeAttributes(key: EdgeKey): Partial<EdgeAttributes> | undefined {
const edge = this.edgeDataCache[key];
return edge ? Object.assign({}, edge) : undefined;
}
/**
* Method returning the current value for a given setting key.
*
* @param {string} key - The setting key to get.
* @return {any} The value attached to this setting key or undefined if not found
*/
getSetting<K extends keyof Settings>(key: K): Settings[K] | undefined {
return this.settings[key];
}
/**
* Method setting the value of a given setting key. Note that this will schedule
* a new render next frame.
*
* @param {string} key - The setting key to set.
* @param {any} value - The value to set.
* @return {Sigma}
*/
setSetting<K extends keyof Settings>(key: K, value: Settings[K]): this {
this.settings[key] = value;
validateSettings(this.settings);
this.needToProcess = true; // TODO: some keys may work with only needToSoftProcess or even nothing
this._scheduleRefresh();
return this;
}
/**
* Method updating the value of a given setting key using the provided function.
* Note that this will schedule a new render next frame.
*
* @param {string} key - The setting key to set.
* @param {any} value - The value to set.
* @return {Sigma}
*/
updateSetting<K extends keyof Settings>(key: K, updater: (value: Settings[K]) => Settings[K]): this {
this.settings[key] = updater(this.settings[key]);
validateSettings(this.settings);
this.needToProcess = true; // TODO: some keys may work with only needToSoftProcess or even nothing
this._scheduleRefresh();
return this;
}
/**
* Method used to resize the renderer.
*
* @return {Sigma}
*/
resize(): this {
const previousWidth = this.width,
previousHeight = this.height;
this.width = this.container.offsetWidth;
this.height = this.container.offsetHeight;
if (this.width === 0) throw new Error("Sigma: container has no width.");
if (this.height === 0) throw new Error("Sigma: container has no height.");
// If nothing has changed, we can stop right here
if (previousWidth === this.width && previousHeight === this.height) return this;
// Sizing dom elements
for (const id in this.elements) {
const element = this.elements[id];
element.style.width = this.width + "px";
element.style.height = this.height + "px";
}
// Sizing canvas contexts
for (const id in this.canvasContexts) {
this.elements[id].setAttribute("width", this.width * PIXEL_RATIO + "px");
this.elements[id].setAttribute("height", this.height * PIXEL_RATIO + "px");
if (PIXEL_RATIO !== 1) this.canvasContexts[id].scale(PIXEL_RATIO, PIXEL_RATIO);
}
// Sizing WebGL contexts
for (const id in this.webGLContexts) {
this.elements[id].setAttribute("width", this.width * WEBGL_OVERSAMPLING_RATIO + "px");
this.elements[id].setAttribute("height", this.height * WEBGL_OVERSAMPLING_RATIO + "px");
this.webGLContexts[id].viewport(
0,
0,
this.width * WEBGL_OVERSAMPLING_RATIO,
this.height * WEBGL_OVERSAMPLING_RATIO,
);
}
return this;
}
/**
* Method used to clear all the canvases.
*
* @return {Sigma}
*/
clear(): this {
this.webGLContexts.nodes.clear(this.webGLContexts.nodes.COLOR_BUFFER_BIT);
this.webGLContexts.edges.clear(this.webGLContexts.edges.COLOR_BUFFER_BIT);
this.canvasContexts.labels.clearRect(0, 0, this.width, this.height);
this.canvasContexts.hovers.clearRect(0, 0, this.width, this.height);
this.canvasContexts.edgeLabels.clearRect(0, 0, this.width, this.height);
return this;
}
/**
* Method used to refresh all computed data.
*
* @return {Sigma}
*/
refresh(): this {
this.needToProcess = true;
this._refresh();
return this;
}
/**
* Method used to refresh all computed data, at the next available frame.
* If this method has already been called this frame, then it will only render once at the next available frame.
*
* @return {Sigma}
*/
scheduleRefresh(): this {
this.needToProcess = true;
this._scheduleRefresh();
return this;
}
/**
* Method used to translate a point's coordinates from the viewport system (pixel distance from the top-left of the
* stage) to the graph system (the reference system of data as they are in the given graph instance).
*
* @param {Coordinates} viewportPoint
*/
viewportToGraph(viewportPoint: Coordinates): Coordinates {
return this.normalizationFunction.inverse(this.camera.viewportToFramedGraph(this.getDimensions(), viewportPoint));
}
/**
* Method used to translate a point's coordinates from the graph system (the reference system of data as they are in
* the given graph instance) to the viewport system (pixel distance from the top-left of the stage).
*
* @param {Coordinates} graphPoint
*/
graphToViewport(graphPoint: Coordinates): Coordinates {
return this.camera.framedGraphToViewport(this.getDimensions(), this.normalizationFunction(graphPoint));
}
/**
* Method used to shut the container & release event listeners.
*
* @return {undefined}
*/
kill(): void {
const graph = this.graph;
// Emitting "kill" events so that plugins and such can cleanup
this.emit("kill");
// Releasing events
this.removeAllListeners();
// Releasing camera handlers
this.camera.removeListener("updated", this.activeListeners.camera);
// Releasing DOM events & captors
window.removeEventListener("resize", this.activeListeners.handleResize);
this.mouseCaptor.kill();
this.touchCaptor.kill();
// Releasing graph handlers
graph.removeListener("nodeAdded", this.activeListeners.addNodeGraphUpdate);
graph.removeListener("nodeDropped", this.activeListeners.graphUpdate);
graph.removeListener("nodeAttributesUpdated", this.activeListeners.softGraphUpdate);
graph.removeListener("eachNodeAttributesUpdated", this.activeListeners.graphUpdate);
graph.removeListener("edgeAdded", this.activeListeners.addEdgeGraphUpdate);
graph.removeListener("edgeDropped", this.activeListeners.graphUpdate);
graph.removeListener("edgeAttributesUpdated", this.activeListeners.softGraphUpdate);
graph.removeListener("eachEdgeAttributesUpdated", this.activeListeners.graphUpdate);
graph.removeListener("edgesCleared", this.activeListeners.graphUpdate);
graph.removeListener("cleared", this.activeListeners.graphUpdate);
// Releasing cache & state
this.quadtree = new QuadTree();
this.nodeDataCache = {};
this.edgeDataCache = {};
this.highlightedNodes = new Set();
this.displayedLabels = new Set();
// Clearing frames
if (this.renderFrame) {
cancelFrame(this.renderFrame);
this.renderFrame = null;
}
if (this.renderHighlightedNodesFrame) {
cancelFrame(this.renderHighlightedNodesFrame);
this.renderHighlightedNodesFrame = null;
}
// Destroying canvases
const container = this.container;
while (container.firstChild) container.removeChild(container.firstChild);
}
}