sigma
Version:
A JavaScript library dedicated to graph drawing.
340 lines (278 loc) • 10.8 kB
text/typescript
/**
* Sigma.js Labels Heuristics
* ===========================
*
* Miscelleneous heuristics related to label display.
* @module
*/
import Graph from "graphology";
import { EdgeKey, NodeKey } from "graphology-types";
import { Dimensions, EdgeAttributes, NodeAttributes } from "../types";
import Camera from "./camera";
/**
* Constants.
*/
// Dimensions of a normal cell
const DEFAULT_CELL = {
width: 250,
height: 175,
};
// Dimensions of an unzoomed cell. This one is usually larger than the normal
// one to account for the fact that labels will more likely collide.
const DEFAULT_UNZOOMED_CELL = {
width: 400,
height: 300,
};
/**
* Helpers.
*/
function collision(
x1: number,
y1: number,
w1: number,
h1: number,
x2: number,
y2: number,
w2: number,
h2: number,
): boolean {
return x1 < x2 + w2 && x1 + w1 > x2 && y1 < y2 + h2 && y1 + h1 > y2;
}
// TODO: cache camera position of selected nodes to avoid costly computations
// in anti-collision step
// TOOD: document a little bit more so future people can understand this mess
/**
* Label grid heuristic selecting labels to display.
*
* @param {object} params - Parameters:
* @param {object} cache - Cache storing nodes' data.
* @param {Camera} camera - The renderer's camera.
* @param {Set} displayedLabels - Currently displayed labels.
* @param {Array} visibleNodes - Nodes visible for this render.
* @param {Graph} graph - The rendered graph.
* @return {Array} - The selected labels.
*/
export function labelsToDisplayFromGrid(params: {
cache: { [key: string]: NodeAttributes };
camera: Camera;
cell: { width: number; height: number } | null;
dimensions: Dimensions;
displayedLabels: Set<NodeKey>;
fontSize: number;
graph: Graph;
renderedSizeThreshold: number;
visibleNodes: NodeKey[];
}): NodeKey[] {
const {
cache,
camera,
cell: userCell,
dimensions,
displayedLabels,
fontSize = 14,
graph,
renderedSizeThreshold = -Infinity,
visibleNodes,
} = params;
const cameraState = camera.getState(),
previousCameraState = camera.getPreviousState();
const previousCamera = new Camera();
previousCamera.setState(previousCameraState);
// TODO: should factorize. This same code is used quite a lot throughout the codebase
// TODO: POW RATIO is currently default 0.5 and harcoded
const sizeRatio = Math.pow(cameraState.ratio, 0.5);
// State
const zooming = cameraState.ratio < previousCameraState.ratio,
panning = cameraState.x !== previousCameraState.x || cameraState.y !== previousCameraState.y,
unzooming = cameraState.ratio > previousCameraState.ratio,
unzoomedPanning = panning && !zooming && !unzooming && cameraState.ratio >= 1,
zoomedPanning = panning && displayedLabels.size && !zooming && !unzooming;
// Trick to discretize unzooming
if (unzooming && Math.trunc(cameraState.ratio * 100) % 5 !== 0) return Array.from(displayedLabels);
// If panning while unzoomed, we shouldn't change label selection
if (unzoomedPanning && displayedLabels.size !== 0) return Array.from(displayedLabels);
// When unzoomed & zooming
if (zooming && cameraState.ratio >= 1) return Array.from(displayedLabels);
// Adapting cell dimensions
let cell = userCell ? userCell : DEFAULT_CELL;
if (cameraState.ratio >= 1.3) cell = DEFAULT_UNZOOMED_CELL;
const cwr = dimensions.width % cell.width;
const cellWidth = cell.width + cwr / Math.floor(dimensions.width / cell.width);
const chr = dimensions.height % cell.height;
const cellHeight = cell.height + chr / Math.floor(dimensions.height / cell.height);
const adjustedWidth = dimensions.width + cellWidth,
adjustedHeight = dimensions.height + cellHeight,
adjustedX = -cellWidth,
adjustedY = -cellHeight;
const panningWidth = dimensions.width + cellWidth / 2,
panningHeight = dimensions.height + cellHeight / 2,
panningX = -(cellWidth / 2),
panningY = -(cellHeight / 2);
const worthyLabels: Array<NodeKey> = [];
const grid: Record<string, NodeKey> = {};
let maxSize = -Infinity,
biggestNode: NodeKey | null = null;
for (let i = 0, l = visibleNodes.length; i < l; i++) {
const node = visibleNodes[i],
nodeData = cache[node];
// We filter hidden nodes
if (nodeData.hidden) continue;
// We filter nodes having a rendered size less than a certain thresold
if (nodeData.size / sizeRatio < renderedSizeThreshold) continue;
// Finding our node's cell in the grid
const pos = camera.framedGraphToViewport(dimensions, nodeData);
// Node is not actually visible on screen
// NOTE: can optimize margin on the right side (only if we know where the labels go)
if (pos.x < adjustedX || pos.x > adjustedWidth || pos.y < adjustedY || pos.y > adjustedHeight) continue;
// Keeping track of the maximum node size for certain cases
if (nodeData.size > maxSize) {
maxSize = nodeData.size;
biggestNode = node;
}
// If panning when zoomed, we consider only displayed labels and newly
// visible nodes
if (zoomedPanning) {
const ppos = previousCamera.framedGraphToViewport(dimensions, nodeData);
// Was node visible earlier?
if (ppos.x >= panningX && ppos.x <= panningWidth && ppos.y >= panningY && ppos.y <= panningHeight) {
// Was the label displayed?
if (!displayedLabels.has(node)) continue;
}
}
const xKey = Math.floor(pos.x / cellWidth),
yKey = Math.floor(pos.y / cellHeight);
const key = `${xKey}§${yKey}`;
if (typeof grid[key] === "undefined") {
// This cell is not yet occupied
grid[key] = node;
} else {
// We must solve a conflict in this cell
const currentNode = grid[key],
currentNodeData = cache[currentNode];
// We prefer already displayed labels
if (displayedLabels.size > 0) {
const n1 = displayedLabels.has(node),
n2 = displayedLabels.has(currentNode);
if (!n1 && n2) {
continue;
}
if (n1 && !n2) {
grid[key] = node;
continue;
}
if ((zoomedPanning || zooming) && n1 && n2) {
worthyLabels.push(node);
continue;
}
}
// In case of size & degree equality, we use the node's key so that the
// process remains deterministic
let won = false;
if (nodeData.size > currentNodeData.size) {
won = true;
} else if (nodeData.size === currentNodeData.size) {
const nodeDegree = graph.degree(node),
currentNodeDegree = graph.degree(currentNode);
if (nodeDegree > currentNodeDegree) {
won = true;
} else if (nodeDegree === currentNodeDegree) {
if (node > currentNode) won = true;
}
}
if (won) grid[key] = node;
}
}
// Compiling the labels
let biggestNodeShown: boolean = worthyLabels.some((node) => node === biggestNode);
for (const key in grid) {
const node = grid[key];
if (node === biggestNode) biggestNodeShown = true;
worthyLabels.push(node);
}
// Always keeping biggest node shown on screen
if (!biggestNodeShown && biggestNode) worthyLabels.push(biggestNode);
// Basic anti-collision
const collisions = new Set();
for (let i = 0, l = worthyLabels.length; i < l; i++) {
const n1 = worthyLabels[i],
d1 = cache[n1],
p1 = camera.framedGraphToViewport(dimensions, d1);
if (collisions.has(n1)) continue;
for (let j = i + 1; j < l; j++) {
const n2 = worthyLabels[j],
d2 = cache[n2],
p2 = camera.framedGraphToViewport(dimensions, d2);
const c = collision(
// First abstract bbox
p1.x,
p1.y,
d1.label.length * 8,
fontSize,
// Second abstract bbox
p2.x,
p2.y,
d2.label.length * 8,
fontSize,
);
if (c) {
// NOTE: add degree as tie-breaker here if required in the future
// NOTE: add final stable tie-breaker using node key if required
if (d1.size < d2.size) collisions.add(n1);
else collisions.add(n2);
}
}
}
// console.log(collisions)
return worthyLabels.filter((l) => !collisions.has(l));
}
/**
* Label heuristic selecting edge labels to display, based on displayed node
* labels
*
* @param {object} params - Parameters:
* @param {object} nodeDataCache - Cache storing nodes data.
* @param {object} edgeDataCache - Cache storing edges data.
* @param {Set} displayedNodeLabels - Currently displayed node labels.
* @param {Set} highlightedNodes - Highlighted nodes.
* @param {Graph} graph - The rendered graph.
* @param {string} hoveredNode - Hovered node (optional)
* @return {Array} - The selected labels.
*/
export function edgeLabelsToDisplayFromNodes(params: {
nodeDataCache: { [key: string]: NodeAttributes };
edgeDataCache: { [key: string]: EdgeAttributes };
displayedNodeLabels: Set<NodeKey>;
highlightedNodes: Set<NodeKey>;
graph: Graph;
hoveredNode: NodeKey | null;
}): Array<EdgeKey> {
const { nodeDataCache, edgeDataCache, graph, hoveredNode, highlightedNodes, displayedNodeLabels } = params;
const worthyEdges = new Set<EdgeKey>();
const displayedNodeLabelsArray = Array.from(displayedNodeLabels);
// Each edge connecting a highlighted node has its label displayed if the other extremity is not hidden:
const highlightedNodesArray = Array.from(highlightedNodes);
if (hoveredNode && !highlightedNodes.has(hoveredNode)) highlightedNodesArray.push(hoveredNode);
for (let i = 0; i < highlightedNodesArray.length; i++) {
const key = highlightedNodesArray[i];
const edges = graph.edges(key);
for (let j = 0; j < edges.length; j++) {
const edgeKey = edges[j];
const extremities = graph.extremities(edgeKey),
sourceData = nodeDataCache[extremities[0]],
targetData = nodeDataCache[extremities[1]],
edgeData = edgeDataCache[edgeKey];
if (edgeData.hidden && sourceData.hidden && targetData.hidden) {
worthyEdges.add(edgeKey);
}
}
}
// Each edge connecting two nodes with visible labels has its label displayed:
for (let i = 0; i < displayedNodeLabelsArray.length; i++) {
const key = displayedNodeLabelsArray[i];
const edges = graph.outboundEdges(key);
for (let j = 0; j < edges.length; j++)
if (!edgeDataCache[edges[j]].hidden && displayedNodeLabels.has(graph.opposite(key, edges[j])))
worthyEdges.add(edges[j]);
}
return Array.from(worthyEdges);
}