import type cy from 'cytoscape'; import { HTMLLayer, ICytoscapeDragLayer, ICytoscapeNodeLayer, ICytoscapeSelectBoxLayer, ILayer, ISVGLayer, ICanvasLayer, IHTMLLayer, CytoscapeNodeLayer, CytoscapeDragLayer, CytoscapeSelectBoxLayer, ILayerImpl, ILayerElement, CanvasLayer, SVGLayer, IMoveAbleLayer, SVGStaticLayer, HTMLStaticLayer, ISVGStaticLayer, IHTMLStaticLayer, CanvasStaticLayer, ICanvasStaticLayer, IHTMLLayerOptions, ISVGLayerOptions, ICanvasLayerOptions, IPoint, CytoscapeBaseLayer, } from './layers'; import type { ILayerAdapter } from './layers/ABaseLayer'; import { renderPerEdge, renderPerNode } from './elements'; import { output } from './copied'; function isPoint(p: IPoint | cy.BoundingBox12): p is IPoint { return (p as IPoint).x != null; } export interface LayerExportOptions { ignoreUnsupportedLayers?: boolean; ignoreUnsupportedLayerOrder?: boolean; } export class LayerExportException extends Error {} export default class LayersPlugin { readonly cy: cy.Core; private bak: Record = {}; readonly nodeLayer: ICytoscapeNodeLayer; readonly dragLayer: ICytoscapeDragLayer; readonly selectBoxLayer: ICytoscapeSelectBoxLayer; private readonly adapter: ILayerAdapter; private readonly viewport: { width: number; height: number; tx: number; ty: number; zoom: number }; constructor(cy: cy.Core) { this.cy = cy; this.adapter = { cy: this.cy, insert: (where: 'before' | 'after', layer: IMoveAbleLayer, type) => this.insert(where, layer as ILayer & ILayerImpl, type), move: (layer: IMoveAbleLayer, offset) => this.move(layer as ILayer & ILayerImpl, offset), inVisibleBounds: (p: IPoint | cy.BoundingBox12) => { const v = this.viewport; const inX = (x: number) => { const xp = x * v.zoom + v.tx; return xp >= 0 && xp <= v.width; }; const inY = (y: number) => { const yp = y * v.zoom + v.ty; return yp >= 0 && yp <= v.height; }; if (isPoint(p)) { return inX(p.x) && inY(p.y); } // any of the four corners or the center are inside return ( (inX(p.x1) && inY(p.y1)) || (inX(p.x2) && inY(p.y1)) || (inX(p.x2) && inY(p.y2)) || (inX(p.x1) && inY(p.y2)) || (inX((p.x1 + p.x2) / 2) && inY((p.y1 + p.y2) / 2)) ); }, }; const container = cy.container()!; const nodeLayer = new CytoscapeNodeLayer( this.adapter, container.querySelector('[data-id="layer2-node"]')! ); this.nodeLayer = nodeLayer; const dragLayer = new CytoscapeDragLayer( this.adapter, container.querySelector('[data-id="layer1-drag"]')! ); this.dragLayer = dragLayer; const selectBoxLayer = new CytoscapeSelectBoxLayer( this.adapter, container.querySelector('[data-id="layer0-selectbox"]')! ); this.selectBoxLayer = selectBoxLayer; nodeLayer.root.style.zIndex = ''; dragLayer.root.style.zIndex = ''; selectBoxLayer.root.style.zIndex = ''; nodeLayer.root.insertAdjacentElement('afterend', dragLayer.root); dragLayer.root.insertAdjacentElement('afterend', selectBoxLayer.root); cy.on('viewport', this.zoomed); cy.on('resize', this.resize); cy.on('destroy', this.destroy); this.bak = { png: cy.png, jpg: cy.jpg, jpeg: cy.jpeg, }; cy.png = this.png.bind(this); cy.jpg = this.jpg.bind(this); cy.jpeg = this.jpeg.bind(this); this.viewport = { width: this.cy.width(), height: this.cy.height(), tx: this.cy.pan().x, ty: this.cy.pan().y, zoom: this.cy.zoom(), }; } private move(layer: ILayer & ILayerImpl, offset: number) { const l = this.layers; const index = l.indexOf(layer); const target = Math.max(Math.min(index + offset, l.length, 0)); if (target === index) { return; } if (index >= l.length - 1) { this.root.appendChild(layer.root); } else { this.root.insertBefore(layer.root, l[target].root); } } get document() { return this.cy.container()!.ownerDocument; } get root() { return this.nodeLayer.node.parentElement! as HTMLElement; } private get layers(): readonly (ILayer & ILayerImpl)[] { return Array.from(this.root.children) .map((d) => (d as unknown as ILayerElement).__cy_layer) .filter((d) => d != null); } getLayers(): readonly ILayer[] { return this.layers; } private readonly resize = () => { const width = this.cy.width(); const height = this.cy.height(); this.viewport.width = width; this.viewport.height = height; for (const layer of this.layers) { layer.resize(width, height); } }; private readonly destroy = () => { for (const layer of this.layers) { layer.remove(); } this.cy.off('destroy', this.destroy); this.cy.off('viewport', this.zoomed); this.cy.off('resize', this.resize); this.cy.scratch('_layers', undefined); this.cy.png = this.bak.png; this.cy.jpg = this.bak.jpg; this.cy.jpeg = this.bak.jpeg; this.bak = {}; }; private readonly zoomed = () => { const pan = this.cy.pan(); const zoom = this.cy.zoom(); this.viewport.tx = pan.x; this.viewport.ty = pan.y; this.viewport.zoom = zoom; for (const layer of this.layers) { layer.setViewport(pan.x, pan.y, zoom); } }; private init(layer: T): T { layer.resize(this.viewport.width, this.viewport.height); layer.setViewport(this.viewport.tx, this.viewport.ty, this.viewport.zoom); return layer; } update() { this.zoomed(); for (const layer of this.layers) { if (layer instanceof CanvasLayer) { layer.draw(); } } } private createLayer( type: 'svg' | 'html' | 'canvas' | 'svg-static' | 'html-static' | 'canvas-static', options?: IHTMLLayerOptions | ISVGLayerOptions | ICanvasLayerOptions ) { switch (type) { case 'svg': return this.init(new SVGLayer(this.adapter, this.document, options)); case 'html': return this.init(new HTMLLayer(this.adapter, this.document, options)); case 'canvas': return this.init(new CanvasLayer(this.adapter, this.document, options)); case 'html-static': return this.init(new HTMLStaticLayer(this.adapter, this.document, options)); case 'svg-static': return this.init(new SVGStaticLayer(this.adapter, this.document, options)); case 'canvas-static': return this.init(new CanvasStaticLayer(this.adapter, this.document, options)); } } append(type: 'svg', options?: ISVGLayerOptions): ISVGLayer; append(type: 'svg-static', options?: ISVGLayerOptions): ISVGStaticLayer; append(type: 'canvas', options?: ICanvasLayerOptions): ICanvasLayer; append(type: 'canvas-static', options?: ICanvasLayerOptions): ICanvasStaticLayer; append(type: 'html', options?: IHTMLLayerOptions): IHTMLLayer; append(type: 'html-static', options?: IHTMLLayerOptions): IHTMLStaticLayer; append( type: 'svg' | 'html' | 'canvas' | 'canvas-static' | 'svg-static' | 'html-static', options?: IHTMLLayerOptions | ISVGLayerOptions | ICanvasLayerOptions ) { const layer = this.createLayer(type, options); this.root.appendChild(layer.root); return layer as any; } insert(where: 'before' | 'after', layer: ILayer & ILayerImpl, type: 'svg', options?: ISVGLayerOptions): ISVGLayer; insert( where: 'before' | 'after', layer: ILayer & ILayerImpl, type: 'svg-static', options?: ISVGLayerOptions ): ISVGStaticLayer; insert( where: 'before' | 'after', layer: ILayer & ILayerImpl, type: 'canvas', options?: ICanvasLayerOptions ): ICanvasLayer; insert( where: 'before' | 'after', layer: ILayer & ILayerImpl, type: 'canvas-static', options?: ICanvasLayerOptions ): ICanvasStaticLayer; insert(where: 'before' | 'after', layer: ILayer & ILayerImpl, type: 'html', options?: IHTMLLayerOptions): IHTMLLayer; insert( where: 'before' | 'after', layer: ILayer & ILayerImpl, type: 'html-static', options?: IHTMLLayerOptions ): IHTMLStaticLayer; insert( where: 'before' | 'after', layer: ILayer & ILayerImpl, type: 'svg' | 'html' | 'canvas' | 'svg-static' | 'html-static' | 'canvas-static', options?: IHTMLLayerOptions | ISVGLayerOptions | ICanvasLayerOptions ): ISVGLayer | ICanvasLayer | IHTMLLayer; insert( where: 'before' | 'after', ref: ILayer & ILayerImpl, type: 'svg' | 'html' | 'canvas' | 'svg-static' | 'html-static' | 'canvas-static', options?: IHTMLLayerOptions | ISVGLayerOptions | ICanvasLayerOptions ) { const layer = this.createLayer(type, options); ref.root.insertAdjacentElement(where === 'before' ? 'beforebegin' : 'afterend', layer.root); return layer as any; } getLast(): ILayer | null { const layers = this.layers; return layers[layers.length - 1] ?? null; } getFirst(): ILayer | null { const layers = this.layers; return layers[0] ?? null; } private hasCustomLayer(): boolean { return this.layers.some((d) => !(d instanceof CytoscapeBaseLayer)); } private toCanvas( options: (cy.ExportJpgStringOptions | cy.ExportJpgBlobOptions | cy.ExportJpgBlobPromiseOptions) & LayerExportOptions ): HTMLCanvasElement { const layers = this.layers; if (layers.some((d) => !d.supportsRender) && !options.ignoreUnsupportedLayers) { throw new LayerExportException( `some layer doesn't support exporting, use {ignoreUnsupportedLayers: true} option to ignore` ); } const nodeIndex = layers.indexOf(this.nodeLayer as ILayer & ILayerImpl); const dragIndex = layers.indexOf(this.dragLayer as ILayer & ILayerImpl); const selectBoxIndex = layers.indexOf(this.selectBoxLayer as ILayer & ILayerImpl); if ((nodeIndex !== dragIndex - 1 || dragIndex !== selectBoxIndex - 1) && !options.ignoreUnsupportedLayerOrder) { throw new LayerExportException( `cytoscape layers are not in order, use {ignoreUnsupportedLayerOrder: true} option to ignore` ); } const before = layers .slice(0, nodeIndex) .reverse() .filter((d) => d.supportsRender() && d !== this.dragLayer && d !== this.selectBoxLayer); const after = layers .slice(nodeIndex + 1) .filter((d) => d.supportsRender() && d !== this.dragLayer && d !== this.selectBoxLayer); const renderer = ( this.cy as unknown as { renderer(): { bufferCanvasImage( o: cy.ExportJpgStringOptions | cy.ExportJpgBlobOptions | cy.ExportJpgBlobPromiseOptions ): HTMLCanvasElement; drawElements(ctx: CanvasRenderingContext2D, elems: cy.Collection): void; }; } ).renderer(); const drawElements = renderer.drawElements; // patch with all levels renderer.drawElements = function (ctx, elems) { for (const l of before) { l.renderInto(ctx); } drawElements.call(this, ctx, elems); for (const l of after) { l.renderInto(ctx); } }; const canvas = renderer.bufferCanvasImage(options); renderer.drawElements = drawElements; return canvas; } /** * Export the current graph view as a PNG image in Base64 representation. */ png(options?: cy.ExportStringOptions & LayerExportOptions): string; png(options?: cy.ExportBlobOptions & LayerExportOptions): Blob; png(options?: cy.ExportBlobPromiseOptions & LayerExportOptions): Promise; png( options?: (cy.ExportStringOptions | cy.ExportBlobOptions | cy.ExportBlobPromiseOptions) & LayerExportOptions ): any { if (!this.hasCustomLayer()) { return this.bak.png.call(this.cy, options); } return output(options ?? {}, this.toCanvas(options ?? {}), 'image/png'); } /** * Export the current graph view as a JPG image in Base64 representation. */ jpg(options?: cy.ExportJpgStringOptions & LayerExportOptions): string; jpg(options?: cy.ExportJpgBlobOptions & LayerExportOptions): Blob; jpg(options?: cy.ExportJpgBlobPromiseOptions & LayerExportOptions): Promise; jpg( options?: (cy.ExportJpgStringOptions | cy.ExportJpgBlobOptions | cy.ExportJpgBlobPromiseOptions) & LayerExportOptions ): any { if (!this.hasCustomLayer()) { return this.bak.jpg.call(this.cy, options); } const o = { bg: '#fff', ...(options ?? {}), }; return output(o, this.toCanvas(o), 'image/png'); } /** * Export the current graph view as a JPG image in Base64 representation. */ jpeg(options?: cy.ExportJpgStringOptions): string; jpeg(options?: cy.ExportJpgBlobOptions): Blob; jpeg(options?: cy.ExportJpgBlobPromiseOptions): Promise; jpeg(options?: cy.ExportJpgStringOptions | cy.ExportJpgBlobOptions | cy.ExportJpgBlobPromiseOptions): any { if (!this.hasCustomLayer()) { return this.bak.jpeg.call(this.cy, options); } return this.jpg(options as unknown as any); } readonly renderPerEdge = renderPerEdge; readonly renderPerNode = renderPerNode; } export function layers(this: cy.Core): LayersPlugin; export function layers(cy: cy.Core): LayersPlugin; export function layers(this: cy.Core, cy: cy.Core = this) { if (!cy.container()) { throw new Error('layers plugin does not work in headless environments'); } // ensure just one instance exists const singleton = cy.scratch('_layers') as LayersPlugin; if (singleton) { return singleton; } const plugin = new LayersPlugin(cy); cy.scratch('_layers', plugin); return plugin; }