sigma
Version:
A JavaScript library aimed at visualizing graphs of thousands of nodes and edges.
386 lines (385 loc) • 15 kB
JavaScript
;
var __read = (this && this.__read) || function (o, n) {
var m = typeof Symbol === "function" && o[Symbol.iterator];
if (!m) return o;
var i = m.call(o), r, ar = [], e;
try {
while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value);
}
catch (error) { e = { error: error }; }
finally {
try {
if (r && !r.done && (m = i["return"])) m.call(i);
}
finally { if (e) throw e.error; }
}
return ar;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.validateGraph = exports.canUse32BitsIndices = exports.extractPixel = exports.getMatrixImpact = exports.matrixFromCamera = exports.getCorrectionRatio = exports.floatColor = exports.floatArrayColor = exports.parseColor = exports.zIndexOrdering = exports.createNormalizationFunction = exports.graphExtent = exports.getPixelRatio = exports.createElement = exports.cancelFrame = exports.requestFrame = exports.assignDeep = exports.assign = exports.isPlainObject = void 0;
var is_graph_1 = __importDefault(require("graphology-utils/is-graph"));
var matrices_1 = require("./matrices");
var data_1 = require("./data");
/**
* 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
function isPlainObject(value) {
return typeof value === "object" && value !== null && value.constructor === Object;
}
exports.isPlainObject = isPlainObject;
/**
* Helper to use Object.assign with more than two objects.
*
* @param {object} target - First object.
* @param {object} [...objects] - Objects to merge.
* @return {object}
*/
function assign(target) {
var objects = [];
for (var _i = 1; _i < arguments.length; _i++) {
objects[_i - 1] = arguments[_i];
}
target = target || {};
for (var i = 0, l = objects.length; i < l; i++) {
var o = objects[i];
if (!o)
continue;
Object.assign(target, o);
}
return target;
}
exports.assign = assign;
/**
* Very simple recursive Object.assign-like function.
*
* @param {object} target - First object.
* @param {object} [...objects] - Objects to merge.
* @return {object}
*/
function assignDeep(target) {
var objects = [];
for (var _i = 1; _i < arguments.length; _i++) {
objects[_i - 1] = arguments[_i];
}
target = target || {};
for (var i = 0, l = objects.length; i < l; i++) {
var o = objects[i];
if (!o)
continue;
for (var k in o) {
if (isPlainObject(o[k])) {
target[k] = assignDeep(target[k], o[k]);
}
else {
target[k] = o[k];
}
}
}
return target;
}
exports.assignDeep = assignDeep;
/**
* Just some dirty trick to make requestAnimationFrame and cancelAnimationFrame "work" in Node.js, for unit tests:
*/
exports.requestFrame = typeof requestAnimationFrame !== "undefined"
? function (callback) { return requestAnimationFrame(callback); }
: function (callback) { return setTimeout(callback, 0); };
exports.cancelFrame = typeof cancelAnimationFrame !== "undefined"
? function (requestID) { return cancelAnimationFrame(requestID); }
: function (requestID) { return 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}
*/
function createElement(tag, style, attributes) {
var element = document.createElement(tag);
if (style) {
for (var k in style) {
element.style[k] = style[k];
}
}
if (attributes) {
for (var k in attributes) {
element.setAttribute(k, attributes[k]);
}
}
return element;
}
exports.createElement = createElement;
/**
* Function returning the browser's pixel ratio.
*
* @return {number}
*/
function getPixelRatio() {
if (typeof window.devicePixelRatio !== "undefined")
return window.devicePixelRatio;
return 1;
}
exports.getPixelRatio = getPixelRatio;
/**
* Function returning the graph's node extent in x & y.
*
* @param {Graph}
* @return {object}
*/
function graphExtent(graph) {
if (!graph.order)
return { x: [0, 1], y: [0, 1] };
var xMin = Infinity;
var xMax = -Infinity;
var yMin = Infinity;
var yMax = -Infinity;
graph.forEachNode(function (_, attr) {
var x = attr.x, y = attr.y;
if (x < xMin)
xMin = x;
if (x > xMax)
xMax = x;
if (y < yMin)
yMin = y;
if (y > yMax)
yMax = y;
});
return { x: [xMin, xMax], y: [yMin, yMax] };
}
exports.graphExtent = graphExtent;
function createNormalizationFunction(extent) {
var _a = __read(extent.x, 2), minX = _a[0], maxX = _a[1], _b = __read(extent.y, 2), minY = _b[0], maxY = _b[1];
var ratio = Math.max(maxX - minX, maxY - minY), dX = (maxX + minX) / 2, dY = (maxY + minY) / 2;
if (ratio === 0 || Math.abs(ratio) === Infinity || isNaN(ratio))
ratio = 1;
if (isNaN(dX))
dX = 0;
if (isNaN(dY))
dY = 0;
var fn = function (data) {
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 = function (data) {
data.x = 0.5 + (data.x - dX) / ratio;
data.y = 0.5 + (data.y - dY) / ratio;
};
fn.inverse = function (data) {
return {
x: dX + ratio * (data.x - 0.5),
y: dY + ratio * (data.y - 0.5),
};
};
fn.ratio = ratio;
return fn;
}
exports.createNormalizationFunction = createNormalizationFunction;
/**
* 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.
*/
function zIndexOrdering(extent, getter, elements) {
// If k is > n, we'll use a standard sort
return elements.sort(function (a, b) {
var zA = getter(a) || 0, zB = getter(b) || 0;
if (zA < zB)
return -1;
if (zA > zB)
return 1;
return 0;
});
// TODO: counting sort optimization
}
exports.zIndexOrdering = zIndexOrdering;
/**
* WebGL utils
* ===========
*/
/**
* Memoized function returning a float-encoded color from various string
* formats describing colors.
*/
var INT8 = new Int8Array(4);
var INT32 = new Int32Array(INT8.buffer, 0, 1);
var FLOAT32 = new Float32Array(INT8.buffer, 0, 1);
var RGBA_TEST_REGEX = /^\s*rgba?\s*\(/;
var RGBA_EXTRACT_REGEX = /^\s*rgba?\s*\(\s*([0-9]*)\s*,\s*([0-9]*)\s*,\s*([0-9]*)(?:\s*,\s*(.*)?)?\)\s*$/;
function parseColor(val) {
var r = 0; // byte
var g = 0; // byte
var b = 0; // byte
var a = 1; // float
// 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);
}
if (val.length === 9) {
a = parseInt(val.charAt(7) + val.charAt(8), 16) / 255;
}
}
// Handling rgb notation
else if (RGBA_TEST_REGEX.test(val)) {
var match = val.match(RGBA_EXTRACT_REGEX);
if (match) {
r = +match[1];
g = +match[2];
b = +match[3];
if (match[4])
a = +match[4];
}
}
return { r: r, g: g, b: b, a: a };
}
exports.parseColor = parseColor;
var FLOAT_COLOR_CACHE = {};
for (var htmlColor in data_1.HTML_COLORS) {
FLOAT_COLOR_CACHE[htmlColor] = floatColor(data_1.HTML_COLORS[htmlColor]);
// Replicating cache for hex values for free
FLOAT_COLOR_CACHE[data_1.HTML_COLORS[htmlColor]] = FLOAT_COLOR_CACHE[htmlColor];
}
function floatArrayColor(val) {
val = data_1.HTML_COLORS[val] || val;
// NOTE: this variant is not cached because it is mostly used for uniforms
var _a = parseColor(val), r = _a.r, g = _a.g, b = _a.b, a = _a.a;
return new Float32Array([r / 255, g / 255, b / 255, a]);
}
exports.floatArrayColor = floatArrayColor;
function floatColor(val) {
// If the color is already computed, we yield it
if (typeof FLOAT_COLOR_CACHE[val] !== "undefined")
return FLOAT_COLOR_CACHE[val];
var parsed = parseColor(val);
var r = parsed.r, g = parsed.g, b = parsed.b;
var a = parsed.a;
a = (a * 255) | 0;
INT32[0] = ((a << 24) | (b << 16) | (g << 8) | r) & 0xfeffffff;
var color = FLOAT32[0];
FLOAT_COLOR_CACHE[val] = color;
return color;
}
exports.floatColor = floatColor;
/**
* In sigma, the graph is normalized into a [0, 1], [0, 1] square, before being given to the various renderers. This
* helps dealing with quadtree in particular.
* But at some point, we need to rescale it so that it takes the best place in the screen, ie. we always want to see two
* nodes "touching" opposite sides of the graph, with the camera being at its default state.
*
* This function determines this ratio.
*/
function getCorrectionRatio(viewportDimensions, graphDimensions) {
var viewportRatio = viewportDimensions.height / viewportDimensions.width;
var graphRatio = graphDimensions.height / graphDimensions.width;
// If the stage and the graphs are in different directions (such as the graph being wider that tall while the stage
// is taller than wide), we can stop here to have indeed nodes touching opposite sides:
if ((viewportRatio < 1 && graphRatio > 1) || (viewportRatio > 1 && graphRatio < 1)) {
return 1;
}
// Else, we need to fit the graph inside the stage:
// 1. If the graph is "squarer" (ie. with a ratio closer to 1), we need to make the largest sides touch;
// 2. If the stage is "squarer", we need to make the smallest sides touch.
return Math.min(Math.max(graphRatio, 1 / graphRatio), Math.max(1 / viewportRatio, viewportRatio));
}
exports.getCorrectionRatio = getCorrectionRatio;
/**
* Function returning a matrix from the current state of the camera.
*/
// TODO: it's possible to optimize this drastically!
function matrixFromCamera(state, viewportDimensions, graphDimensions, padding, inverse) {
var angle = state.angle, ratio = state.ratio, x = state.x, y = state.y;
var width = viewportDimensions.width, height = viewportDimensions.height;
var matrix = (0, matrices_1.identity)();
var smallestDimension = Math.min(width, height) - 2 * padding;
var correctionRatio = getCorrectionRatio(viewportDimensions, graphDimensions);
if (!inverse) {
(0, matrices_1.multiply)(matrix, (0, matrices_1.scale)((0, matrices_1.identity)(), 2 * (smallestDimension / width) * correctionRatio, 2 * (smallestDimension / height) * correctionRatio));
(0, matrices_1.multiply)(matrix, (0, matrices_1.rotate)((0, matrices_1.identity)(), -angle));
(0, matrices_1.multiply)(matrix, (0, matrices_1.scale)((0, matrices_1.identity)(), 1 / ratio));
(0, matrices_1.multiply)(matrix, (0, matrices_1.translate)((0, matrices_1.identity)(), -x, -y));
}
else {
(0, matrices_1.multiply)(matrix, (0, matrices_1.translate)((0, matrices_1.identity)(), x, y));
(0, matrices_1.multiply)(matrix, (0, matrices_1.scale)((0, matrices_1.identity)(), ratio));
(0, matrices_1.multiply)(matrix, (0, matrices_1.rotate)((0, matrices_1.identity)(), angle));
(0, matrices_1.multiply)(matrix, (0, matrices_1.scale)((0, matrices_1.identity)(), width / smallestDimension / 2 / correctionRatio, height / smallestDimension / 2 / correctionRatio));
}
return matrix;
}
exports.matrixFromCamera = matrixFromCamera;
/**
* All these transformations we apply on the matrix to get it rescale the graph
* as we want make it very hard to get pixel-perfect distances in WebGL. This
* function returns a factor that properly cancels the matrix effect on lengths.
*
* [jacomyal]
* To be fully honest, I can't really explain happens here... I notice that the
* following ratio works (ie. it correctly compensates the matrix impact on all
* camera states I could try):
* > `R = size(V) / size(M * V) / W`
* as long as `M * V` is in the direction of W (ie. parallel to (Ox)). It works
* as well with H and a vector that transforms into something parallel to (Oy).
*
* Also, note that we use `angle` and not `-angle` (that would seem logical,
* since we want to anticipate the rotation), because of the fact that in WebGL,
* the image is vertically swapped.
*/
function getMatrixImpact(matrix, cameraState, viewportDimensions) {
var _a = (0, matrices_1.multiplyVec2)(matrix, { x: Math.cos(cameraState.angle), y: Math.sin(cameraState.angle) }, 0), x = _a.x, y = _a.y;
return 1 / Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)) / viewportDimensions.width;
}
exports.getMatrixImpact = getMatrixImpact;
/**
* Function extracting the color at the given pixel.
*/
function extractPixel(gl, x, y, array) {
var data = array || new Uint8Array(4);
gl.readPixels(x, y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, data);
return data;
}
exports.extractPixel = extractPixel;
/**
* Function used to know whether given webgl context can use 32 bits indices.
*/
function canUse32BitsIndices(gl) {
var webgl2 = typeof WebGL2RenderingContext !== "undefined" && gl instanceof WebGL2RenderingContext;
return webgl2 || !!gl.getExtension("OES_element_index_uint");
}
exports.canUse32BitsIndices = canUse32BitsIndices;
/**
* Check if the graph variable is a valid graph, and if sigma can render it.
*/
function validateGraph(graph) {
// check if it's a valid graphology instance
if (!(0, is_graph_1.default)(graph))
throw new Error("Sigma: invalid graph instance.");
// check if nodes have x/y attributes
graph.forEachNode(function (key, attributes) {
if (!Number.isFinite(attributes.x) || !Number.isFinite(attributes.y)) {
throw new Error("Sigma: Coordinates of node ".concat(key, " are invalid. A node must have a numeric 'x' and 'y' attribute."));
}
});
}
exports.validateGraph = validateGraph;