cytoscape
Version:
Graph theory (a.k.a. network) library for analysis and visualisation
547 lines (460 loc) • 16.5 kB
JavaScript
import { ElementDrawingWebGL } from './drawing-elements-webgl.mjs';
import { RENDER_TARGET, renderDefaults, atlasCollectionDefaults } from './defaults.mjs';
import { OverlayUnderlayRenderer } from './drawing-overlay.mjs';
import * as util from './webgl-util.mjs';
import * as eleTextureCache from '../ele-texture-cache.mjs';
import { debounce } from '../../../../util/index.mjs';
import { color2tuple } from '../../../../util/colors.mjs';
import { mat3 } from 'gl-matrix';
const CRp = {};
CRp.initWebgl = function(opts, fns) {
const r = this;
const gl = r.data.contexts[r.WEBGL];
opts.bgColor = getBGColor(r);
opts.webglTexSize = Math.min(opts.webglTexSize, gl.getParameter(gl.MAX_TEXTURE_SIZE));
opts.webglTexRows = Math.min(opts.webglTexRows, 54);
opts.webglTexRowsNodes = Math.min(opts.webglTexRowsNodes, 54);
opts.webglBatchSize = Math.min(opts.webglBatchSize, 16384);
opts.webglTexPerBatch = Math.min(opts.webglTexPerBatch, gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS));
r.webglDebug = opts.webglDebug;
r.webglDebugShowAtlases = opts.webglDebugShowAtlases;
// for offscreen rendering when render target is PICKING
r.pickingFrameBuffer = util.createPickingFrameBuffer(gl);
r.pickingFrameBuffer.needsDraw = true;
const getLabelRotation = (prop) => (ele) => r.getTextAngle(ele, prop);
const isLabelVisible = (prop) => (ele) => {
const label = ele.pstyle(prop);
return label && label.value;
};
r.drawing = new ElementDrawingWebGL(r, gl, opts);
const our = new OverlayUnderlayRenderer(r);
r.drawing.addAtlasCollection('node', atlasCollectionDefaults({
texRows: opts.webglTexRowsNodes
}));
r.drawing.addAtlasCollection('label', atlasCollectionDefaults({
texRows: opts.webglTexRows
}));
r.drawing.addAtlasRenderType('node-body', renderDefaults({
collection: 'node',
getKey: fns.getStyleKey,
getBoundingBox: fns.getElementBox,
drawElement: fns.drawElement,
}));
r.drawing.addAtlasRenderType('label', renderDefaults({ // node label or edge mid label
collection: 'label',
getKey: fns.getLabelKey,
getBoundingBox: fns.getLabelBox,
drawElement: fns.drawLabel,
getRotation: getLabelRotation(null),
getRotationPoint: fns.getLabelRotationPoint,
getRotationOffset: fns.getLabelRotationOffset,
isVisible: isLabelVisible('label'),
}));
r.drawing.addAtlasRenderType('node-overlay', renderDefaults({
collection: 'node',
getBoundingBox: fns.getElementBox,
getKey: ele => our.getStyleKey('overlay', ele),
drawElement: (ctx, ele, bb) => our.draw('overlay', ctx, ele, bb),
isVisible: ele => our.isVisible('overlay', ele),
getPadding: ele => our.getPadding('overlay', ele),
}));
r.drawing.addAtlasRenderType('node-underlay', renderDefaults({
collection: 'node',
getBoundingBox: fns.getElementBox,
getKey: ele => our.getStyleKey('underlay', ele),
drawElement: (ctx, ele, bb) => our.draw('underlay', ctx, ele, bb),
isVisible: ele => our.isVisible('underlay', ele),
getPadding: ele => our.getPadding('underlay', ele),
}));
r.drawing.addAtlasRenderType('edge-source-label', renderDefaults({
collection: 'label',
getKey: fns.getSourceLabelKey,
getBoundingBox: fns.getSourceLabelBox,
drawElement: fns.drawSourceLabel,
getRotation: getLabelRotation('source'),
getRotationPoint: fns.getSourceLabelRotationPoint,
getRotationOffset: fns.getSourceLabelRotationOffset,
isVisible: isLabelVisible('source-label'),
}));
r.drawing.addAtlasRenderType('edge-target-label', renderDefaults({
collection: 'label',
getKey: fns.getTargetLabelKey,
getBoundingBox: fns.getTargetLabelBox,
drawElement: fns.drawTargetLabel,
getRotation: getLabelRotation('target'),
getRotationPoint: fns.getTargetLabelRotationPoint,
getRotationOffset: fns.getTargetLabelRotationOffset,
isVisible: isLabelVisible('target-label'),
}));
// this is a very simplistic way of triggering garbage collection
const setGCFlag = debounce(() => {
console.log('garbage collect flag set');
r.data.gc = true;
}, 10000);
r.onUpdateEleCalcs((willDraw, eles) => {
let gcNeeded = false;
if(eles && eles.length > 0) {
gcNeeded |= r.drawing.invalidate(eles);
}
if(gcNeeded) {
setGCFlag();
}
});
// "Override" certain functions in canvas and base renderer
overrideCanvasRendererFunctions(r);
};
function getBGColor(r) {
const container = r.cy.container();
const cssColor = (container && container.style && container.style.backgroundColor) || 'white';
return color2tuple(cssColor);
}
/**
* Plug into the canvas renderer to use webgl for rendering.
*/
function overrideCanvasRendererFunctions(r) {
{ // Override the render function to call the webgl render function if the zoom level is appropriate
const renderCanvas = r.render;
r.render = function(options) {
options = options || {};
const cy = r.cy;
if(r.webgl) {
// if the zoom level is greater than the max zoom level, then disable webgl
if(cy.zoom() > eleTextureCache.maxZoom) {
clearWebgl(r);
renderCanvas.call(r, options);
} else {
clearCanvas(r);
renderWebgl(r, options, RENDER_TARGET.SCREEN);
}
}
};
}
{ // Override the matchCanvasSize function to update the picking frame buffer size
const baseFunc = r.matchCanvasSize;
r.matchCanvasSize = function(container) {
baseFunc.call(r, container);
r.pickingFrameBuffer.setFramebufferAttachmentSizes(r.canvasWidth, r.canvasHeight);
r.pickingFrameBuffer.needsDraw = true;
};
}
{ // Override function to call the webgl version
r.findNearestElements = function(x, y, interactiveElementsOnly, isTouch) {
// the canvas version of this function is very slow on large graphs
return findNearestElementsWebgl(r, x, y, interactiveElementsOnly, isTouch);
};
}
// Don't override the selction box picking, its not accurate enough with webgl
// { // Override function to call the webgl version
// r.getAllInBox = function(x1, y1, x2, y2) {
// return getAllInBoxWebgl(r, x1, y1, x2, y2);
// }
// }
{ // need to know when the cached elements have changed so we can invalidate our caches
const baseFunc = r.invalidateCachedZSortedEles;
r.invalidateCachedZSortedEles = function() {
baseFunc.call(r);
r.pickingFrameBuffer.needsDraw = true;
};
}
{ // need to know when the cached elements have changed so we can invalidate our caches
const baseFunc = r.notify;
r.notify = function(eventName, eles) {
baseFunc.call(r, eventName, eles);
if(eventName === 'viewport' || eventName === 'bounds') {
r.pickingFrameBuffer.needsDraw = true;
} else if(eventName === 'background') { // background image finished loading, need to redraw
r.drawing.invalidate(eles, { type: 'node-body' });
}
};
}
}
function clearWebgl(r) {
const gl = r.data.contexts[r.WEBGL];
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
}
function clearCanvas(r) {
// the CRp.clearCanvas() function doesn't take the transform into account
const clear = context => {
context.save();
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, r.canvasWidth, r.canvasHeight);
context.restore();
};
clear(r.data.contexts[r.NODE]);
clear(r.data.contexts[r.DRAG]);
}
function createPanZoomMatrix(r) {
const width = r.canvasWidth;
const height = r.canvasHeight;
const { pan, zoom } = util.getEffectivePanZoom(r);
const transform = mat3.create();
mat3.translate(transform, transform, [pan.x, pan.y]);
mat3.scale(transform, transform, [zoom, zoom]);
const projection = mat3.create();
mat3.projection(projection, width, height);
const product = mat3.create();
mat3.multiply(product, projection, transform);
return product;
}
function setContextTransform(r, context) {
const width = r.canvasWidth;
const height = r.canvasHeight;
const { pan, zoom } = util.getEffectivePanZoom(r);
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, width, height);
context.translate(pan.x, pan.y);
context.scale(zoom, zoom);
}
function drawSelectionRectangle(r, options) {
r.drawSelectionRectangle(options, context => setContextTransform(r, context));
}
// eslint-disable-next-line no-unused-vars
function drawAxes(r) { // for debgging
const context = r.data.contexts[r.NODE];
context.save();
setContextTransform(r, context);
context.strokeStyle='rgba(0, 0, 0, 0.3)';
context.beginPath();
context.moveTo(-1000, 0);
context.lineTo(1000, 0);
context.stroke();
context.beginPath();
context.moveTo(0, -1000);
context.lineTo(0, 1000);
context.stroke();
context.restore();
}
function drawAtlases(r) {
// For debugging the atlases
const draw = (drawing, name, row) => {
const collection = drawing.atlasManager.getAtlasCollection(name);
const context = r.data.contexts[r.NODE];
const scale = 0.125;
const atlases = collection.atlases;
for(let i = 0; i < atlases.length; i++) {
const atlas = atlases[i];
const canvas = atlas.canvas;
if(canvas) {
const w = canvas.width;
const h = canvas.height;
const x = w * i;
const y = canvas.height * row;
context.save();
context.scale(scale, scale);
context.drawImage(canvas, x, y);
context.strokeStyle = 'black';
context.rect(x, y, w, h);
context.stroke();
context.restore();
}
}
};
let i = 0;
draw(r.drawing, 'node', i++);
draw(r.drawing, 'label', i++);
}
/**
* Arguments are in model coordinates.
* (x1, y1) is top left corner
* (x2, y2) is bottom right corner (optional)
* Returns a Set of indexes.
*/
function getPickingIndexes(r, mX1, mY1, mX2, mY2) {
let x, y, w, h;
const { pan, zoom } = util.getEffectivePanZoom(r);
if(mX2 === undefined || mY2 === undefined) {
const [ cX1, cY1 ] = util.modelToRenderedPosition(r, pan, zoom, mX1, mY1);
const t = 6; // should be even
x = cX1 - (t / 2);
y = cY1 - (t / 2);
w = t;
h = t;
} else {
const [ cX1, cY1 ] = util.modelToRenderedPosition(r, pan, zoom, mX1, mY1);
const [ cX2, cY2 ] = util.modelToRenderedPosition(r, pan, zoom, mX2, mY2);
x = cX1; // (cX1, cY2) is the bottom left corner of the box
y = cY2;
w = Math.abs(cX2 - cX1);
h = Math.abs(cY2 - cY1);
}
if(w === 0 || h === 0) {
return [];
}
const gl = r.data.contexts[r.WEBGL];
gl.bindFramebuffer(gl.FRAMEBUFFER, r.pickingFrameBuffer);
if(r.pickingFrameBuffer.needsDraw) {
// Draw element z-indexes to the picking framebuffer
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
renderWebgl(r, null, RENDER_TARGET.PICKING);
r.pickingFrameBuffer.needsDraw = false;
}
const n = w * h; // number of pixels to read
// eslint-disable-next-line no-undef
const data = new Uint8Array(n * 4); // 4 bytes per pixel
gl.readPixels(x, y, w, h, gl.RGBA, gl.UNSIGNED_BYTE, data);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
const indexes = new Set();
for(let i = 0; i < n; i++) {
const pixel = data.slice(i*4, i*4 + 4);
const index = util.vec4ToIndex(pixel) - 1; // The framebuffer is cleared with 0s, so z-indexes are offset by 1
if(index >= 0) {
indexes.add(index);
}
}
return indexes;
}
/**
* Cy.js: model coordinate y axis goes down
*/
function findNearestElementsWebgl(r, x, y) { // model coordinates
const indexes = getPickingIndexes(r, x, y);
const eles = r.getCachedZSortedEles();
let node, edge;
for(const index of indexes) {
const ele = eles[index];
if(!node && ele.isNode()) {
node = ele;
}
if(!edge && ele.isEdge()) {
edge = ele;
}
if(node && edge) {
break;
}
}
return [ node, edge ].filter(Boolean);
}
// eslint-disable-next-line no-unused-vars
function getAllInBoxWebgl(r, x1, y1, x2, y2) { // model coordinates
let x1c = Math.min(x1, x2);
let x2c = Math.max(x1, x2);
let y1c = Math.min(y1, y2);
let y2c = Math.max(y1, y2);
x1 = x1c;
x2 = x2c;
y1 = y1c;
y2 = y2c;
const indexes = getPickingIndexes(r, x1, y1, x2, y2);
const eles = r.getCachedZSortedEles();
const box = new Set();
for(const index of indexes) {
if(index >= 0) {
box.add(eles[index]);
}
}
return Array.from(box);
}
// TODO: Is constantly checking this slower than just rendering a texture?
// Maybe this should be cached as a flag on each node.
function isSimpleRectangle(node) {
return (
node.pstyle('shape').value === 'rectangle' &&
node.pstyle('background-fill').value === 'solid' &&
node.pstyle('border-width').pfValue === 0 &&
node.pstyle('background-image').strValue === 'none'
);
}
function drawEle(r, index, ele) {
const { drawing } = r;
index += 1; // 0 is used to clear the background, need to offset all z-indexes by one
if(ele.isNode()) {
drawing.drawTexture(ele, index, 'node-underlay');
if(isSimpleRectangle(ele)) {
drawing.drawSimpleRectangle(ele, index, 'node-body');
} else {
drawing.drawTexture(ele, index, 'node-body');
}
drawing.drawTexture(ele, index, 'label');
drawing.drawTexture(ele, index, 'node-overlay');
} else {
drawing.drawEdgeLine(ele, index);
drawing.drawEdgeArrow(ele, index, 'source');
drawing.drawEdgeArrow(ele, index, 'target');
drawing.drawTexture(ele, index, 'label');
drawing.drawTexture(ele, index, 'edge-source-label');
drawing.drawTexture(ele, index, 'edge-target-label');
}
}
function renderWebgl(r, options, renderTarget) {
let start;
if(r.webglDebug) {
start = performance.now(); // eslint-disable-line no-undef
}
const { drawing } = r;
let eleCount = 0;
if(renderTarget.screen) {
if(r.data.canvasNeedsRedraw[r.SELECT_BOX]) {
drawSelectionRectangle(r, options);
}
}
// see drawing-elements.js drawCachedElement()
if(r.data.canvasNeedsRedraw[r.NODE] || renderTarget.picking) {
const gl = r.data.contexts[r.WEBGL];
if(renderTarget.screen) {
gl.clearColor(0, 0, 0, 0); // background color
gl.enable(gl.BLEND); // enable alpha blending of textures
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); // we are using premultiplied alpha
} else {
gl.disable(gl.BLEND);
}
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
const panZoomMatrix = createPanZoomMatrix(r);
const eles = r.getCachedZSortedEles();
eleCount = eles.length;
drawing.startFrame(panZoomMatrix, renderTarget);
if(renderTarget.screen) {
for(let i = 0; i < eles.nondrag.length; i++) {
drawEle(r, i, eles.nondrag[i]);
}
for(let i = 0; i < eles.drag.length; i++) {
drawEle(r, i, eles.drag[i]);
}
} else if(renderTarget.picking) {
for(let i = 0; i < eles.length; i++) {
drawEle(r, i, eles[i]);
}
}
drawing.endFrame();
if(renderTarget.screen && r.webglDebugShowAtlases) {
drawAxes(r);
drawAtlases(r);
}
r.data.canvasNeedsRedraw[r.NODE] = false;
r.data.canvasNeedsRedraw[r.DRAG] = false;
}
if(r.webglDebug) {
// eslint-disable-next-line no-undef
const end = performance.now();
const compact = false;
const time = Math.ceil(end - start);
const debugInfo = drawing.getDebugInfo();
const report = [
`${eleCount} elements`,
`${debugInfo.totalInstances} instances`,
`${debugInfo.batchCount} batches`,
`${debugInfo.totalAtlases} atlases`,
`${debugInfo.wrappedCount} wrapped textures`,
`${debugInfo.rectangleCount} simple rectangles`
].join(', ');
if(compact) {
console.log(`WebGL (${renderTarget.name}) - time ${time}ms, ${report}`);
} else {
console.log(`WebGL (${renderTarget.name}) - frame time ${time}ms`);
console.log('Totals:');
console.log(` ${report}`);
console.log('Texture Atlases Used:');
const atlasInfo = debugInfo.atlasInfo;
for(const info of atlasInfo) {
console.log(` ${info.type}: ${info.keyCount} keys, ${info.atlasCount} atlases`);
}
console.log('');
}
}
if(r.data.gc) {
console.log('Garbage Collect!');
r.data.gc = false;
drawing.gc();
}
}
export default CRp;