sigma
Version:
A JavaScript library dedicated to graph drawing.
304 lines (249 loc) • 8.4 kB
text/typescript
/**
* Sigma.js Utils
* ===============
*
* Various helper functions & classes used throughout the library.
* @module
*/
import Graph from "graphology";
import { Attributes } from "graphology-types";
import isGraph from "graphology-utils/is-graph";
import { CameraState, Coordinates, Extent, PlainObject } from "../types";
import { multiply, identity, scale, rotate, translate } from "./matrices";
/**
* Checks whether the given value is a plain object.
*
* @param {mixed} value - Target value.
* @return {boolean}
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
export function isPlainObject(value: any): boolean {
return typeof value === "object" && value !== null && value.constructor === Object;
}
/**
* Very simple recursive Object.assign-like function.
*
* @param {object} target - First object.
* @param {object} [...objects] - Objects to merge.
* @return {object}
*/
export function assignDeep<T>(target: Partial<T> | undefined, ...objects: Array<Partial<T | undefined>>): T {
target = target || {};
for (let i = 0, l = objects.length; i < l; i++) {
const o = objects[i];
if (!o) continue;
for (const k in o) {
if (isPlainObject(o[k])) {
target[k] = assignDeep(target[k], o[k]);
} else {
target[k] = o[k];
}
}
}
return target as T;
}
/**
* Just some dirty trick to make requestAnimationFrame and cancelAnimationFrame "work" in Node.js, for unit tests:
*/
export const requestFrame =
typeof requestAnimationFrame !== "undefined"
? (callback: FrameRequestCallback) => requestAnimationFrame(callback)
: (callback: FrameRequestCallback) => setTimeout(callback, 0);
export const cancelFrame =
typeof cancelAnimationFrame !== "undefined"
? (requestID: number) => cancelAnimationFrame(requestID)
: (requestID: number) => clearTimeout(requestID);
/**
* Function used to create DOM elements easily.
*
* @param {string} tag - Tag name of the element to create.
* @param {object} style - Styles map.
* @param {object} attributes - Attributes map.
* @return {HTMLElement}
*/
export function createElement<T extends HTMLElement>(
tag: string,
style?: Partial<CSSStyleDeclaration>,
attributes?: PlainObject<string>,
): T {
const element: T = document.createElement(tag) as T;
if (style) {
for (const k in style) {
element.style[k] = style[k] as string;
}
}
if (attributes) {
for (const k in attributes) {
element.setAttribute(k, attributes[k]);
}
}
return element;
}
/**
* Function returning the browser's pixel ratio.
*
* @return {number}
*/
export function getPixelRatio(): number {
if (typeof window.devicePixelRatio !== "undefined") return window.devicePixelRatio;
return 1;
}
/**
* Factory returning a function normalizing the given node's position & size.
*
* @param {object} extent - Extent of the graph.
* @return {function}
*/
export interface NormalizationFunction {
(data: Coordinates): Coordinates;
inverse(data: Coordinates): Coordinates;
applyTo(data: Coordinates): void;
}
export function createNormalizationFunction(extent: { x: Extent; y: Extent }): NormalizationFunction {
const {
x: [minX, maxX],
y: [minY, maxY],
} = extent;
let ratio = Math.max(maxX - minX, maxY - minY);
if (ratio === 0) ratio = 1;
const dX = (maxX + minX) / 2,
dY = (maxY + minY) / 2;
const fn = (data: Coordinates): Coordinates => {
return {
x: 0.5 + (data.x - dX) / ratio,
y: 0.5 + (data.y - dY) / ratio,
};
};
// TODO: possibility to apply this in batch over array of indices
fn.applyTo = (data: Coordinates): void => {
data.x = 0.5 + (data.x - dX) / ratio;
data.y = 0.5 + (data.y - dY) / ratio;
};
fn.inverse = (data: Coordinates): Coordinates => {
return {
x: dX + ratio * (data.x - 0.5),
y: dY + ratio * (data.y - 0.5),
};
};
fn.ratio = ratio;
return fn;
}
/**
* Function ordering the given elements in reverse z-order so they drawn
* the correct way.
*
* @param {number} extent - [min, max] z values.
* @param {function} getter - Z attribute getter function.
* @param {array} elements - The array to sort.
* @return {array} - The sorted array.
*/
export function zIndexOrdering<T>(extent: [number, number], getter: (e: T) => number, elements: Array<T>): Array<T> {
// If k is > n, we'll use a standard sort
return elements.sort(function (a, b) {
const zA = getter(a) || 0,
zB = getter(b) || 0;
if (zA < zB) return -1;
if (zA > zB) return 1;
return 0;
});
// TODO: counting sort optimization
}
/**
* WebGL utils
* ===========
*/
/**
* Memoized function returning a float-encoded color from various string
* formats describing colors.
*/
const FLOAT_COLOR_CACHE: { [key: string]: number } = {};
const INT8 = new Int8Array(4);
const INT32 = new Int32Array(INT8.buffer, 0, 1);
const FLOAT32 = new Float32Array(INT8.buffer, 0, 1);
const RGBA_TEST_REGEX = /^\s*rgba?\s*\(/;
const RGBA_EXTRACT_REGEX = /^\s*rgba?\s*\(\s*([0-9]*)\s*,\s*([0-9]*)\s*,\s*([0-9]*)(?:\s*,\s*(.*)?)?\)\s*$/;
export function floatColor(val: string): number {
// If the color is already computed, we yield it
if (typeof FLOAT_COLOR_CACHE[val] !== "undefined") return FLOAT_COLOR_CACHE[val];
let r = 0,
g = 0,
b = 0,
a = 1;
// Handling hexadecimal notation
if (val[0] === "#") {
if (val.length === 4) {
r = parseInt(val.charAt(1) + val.charAt(1), 16);
g = parseInt(val.charAt(2) + val.charAt(2), 16);
b = parseInt(val.charAt(3) + val.charAt(3), 16);
} else {
r = parseInt(val.charAt(1) + val.charAt(2), 16);
g = parseInt(val.charAt(3) + val.charAt(4), 16);
b = parseInt(val.charAt(5) + val.charAt(6), 16);
}
}
// Handling rgb notation
else if (RGBA_TEST_REGEX.test(val)) {
const match = val.match(RGBA_EXTRACT_REGEX);
if (match) {
r = +match[1];
g = +match[2];
b = +match[3];
if (match[4]) a = +match[4];
}
}
a = (a * 255) | 0;
INT32[0] = ((a << 24) | (b << 16) | (g << 8) | r) & 0xfeffffff;
const color = FLOAT32[0];
FLOAT_COLOR_CACHE[val] = color;
return color;
}
/**
* Function returning a matrix from the current state of the camera.
*/
// TODO: it's possible to optimize this drastically!
export function matrixFromCamera(state: CameraState, dimensions: { width: number; height: number }): Float32Array {
const { angle, ratio, x, y } = state;
const { width, height } = dimensions;
const matrix = identity();
const smallestDimension = Math.min(width, height);
const cameraCentering = translate(identity(), -x, -y),
cameraScaling = scale(identity(), 1 / ratio),
cameraRotation = rotate(identity(), -angle),
viewportScaling = scale(identity(), 2 * (smallestDimension / width), 2 * (smallestDimension / height));
// Logical order is reversed
multiply(matrix, viewportScaling);
multiply(matrix, cameraRotation);
multiply(matrix, cameraScaling);
multiply(matrix, cameraCentering);
return matrix;
}
/**
* Function extracting the color at the given pixel.
*/
export function extractPixel(gl: WebGLRenderingContext, x: number, y: number, array: Uint8Array): Uint8Array {
const data = array || new Uint8Array(4);
gl.readPixels(x, y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, data);
return data;
}
/**
* Function used to know whether given webgl context can use 32 bits indices.
*/
export function canUse32BitsIndices(gl: WebGLRenderingContext): boolean {
const webgl2 = typeof WebGL2RenderingContext !== "undefined" && gl instanceof WebGL2RenderingContext;
return webgl2 || !!gl.getExtension("OES_element_index_uint");
}
/**
* Check if the graph variable is a valid graph, and if sigma can render it.
*/
export function validateGraph(graph: Graph): void {
// check if it's a valid graphology instance
if (!isGraph(graph)) throw new Error("Sigma: invalid graph instance.");
// check if nodes have x/y attributes
graph.forEachNode((key: string, attributes: Attributes) => {
if (!Number.isFinite(attributes.x) || !Number.isFinite(attributes.y)) {
throw new Error(
`Sigma: Coordinates of node ${key} are invalid. A node must have a numeric 'x' and 'y' attribute.`,
);
}
});
}