import cytoscape, { Collection, Core, EdgeDefinition, EdgeSingular, } from 'cytoscape'; import { edgeStyles, nodeStyles } from '../styles-graph'; import { GraphInteractionEvents } from '../graph-interaction-events'; import { VirtualElement } from '@floating-ui/react'; import { darkModeScratchKey, switchValueByDarkMode, } from '../styles-graph/dark-mode'; import { CytoscapeDagreConfig } from './cytoscape.models'; const cytoscapeDagreConfig = { name: 'dagre', nodeDimensionsIncludeLabels: true, rankSep: 75, rankDir: 'TB', edgeSep: 50, ranker: 'network-simplex', } as CytoscapeDagreConfig; export class RenderGraph { private cy?: Core; collapseEdges = false; private _theme: 'light' | 'dark'; private _rankDir: 'TB' | 'LR' = 'TB'; private listeners = new Map< number, (event: GraphInteractionEvents) => void >(); constructor( private container: string | HTMLElement, theme: 'light' | 'dark', private renderMode?: 'nx-console' | 'nx-docs', rankDir: 'TB' | 'LR' = 'TB' ) { this._theme = theme; this._rankDir = rankDir; } set theme(theme: 'light' | 'dark') { this._theme = theme; this.render(); } set rankDir(rankDir: 'LR' | 'TB') { this._rankDir = rankDir; this.render(); } get activeContainer() { return typeof this.container === 'string' ? document.getElementById(this.container) : this.container; } private broadcast(event: GraphInteractionEvents) { this.listeners.forEach((callback) => callback(event)); } listen(callback: (event: GraphInteractionEvents) => void) { const listenerId = this.listeners.size + 1; this.listeners.set(listenerId, callback); return () => { this.listeners.delete(listenerId); }; } setElements(elements: Collection) { let currentFocusedProjectName; if (this.cy) { currentFocusedProjectName = this.cy.nodes('.focused').first().id(); this.cy.destroy(); delete this.cy; } this.cy = cytoscape({ headless: this.activeContainer === null, container: this.activeContainer, boxSelectionEnabled: false, style: [...nodeStyles, ...edgeStyles], panningEnabled: true, userZoomingEnabled: this.renderMode !== 'nx-docs', }); this.cy.add(elements); if (!!currentFocusedProjectName) { this.cy.$id(currentFocusedProjectName).addClass('focused'); } this.cy.on('zoom pan', () => { this.broadcast({ type: 'GraphRegenerated' }); }); this.listenForProjectNodeClicks(); this.listenForEdgeNodeClicks(); this.listenForProjectNodeHovers(); this.listenForTaskNodeClicks(); this.listenForEmptyClicks(); } render(): { numEdges: number; numNodes: number } { if (this.cy) { const elements = this.cy.elements().sort((a, b) => { return a.id().localeCompare(b.id()); }); elements .layout({ ...cytoscapeDagreConfig, ...{ rankDir: this._rankDir }, }) .run(); if (this.collapseEdges) { this.cy.remove(this.cy.edges()); elements.edges().forEach((edge) => { const sourceNode = edge.source(); const targetNode = edge.target(); if ( sourceNode.parent().first().id() === targetNode.parent().first().id() ) { this.cy.add(edge); } else { let sourceAncestors, targetAncestors; const commonAncestors = edge.connectedNodes().commonAncestors(); if (commonAncestors.length > 0) { sourceAncestors = sourceNode .ancestors() .filter((anc) => !commonAncestors.contains(anc)); targetAncestors = targetNode .ancestors() .filter((anc) => !commonAncestors.contains(anc)); } else { sourceAncestors = sourceNode.ancestors(); targetAncestors = targetNode.ancestors(); } let sourceId, targetId; if (sourceAncestors.length > 0 && targetAncestors.length === 0) { sourceId = sourceAncestors.last().id(); targetId = targetNode.id(); } else if ( targetAncestors.length > 0 && sourceAncestors.length === 0 ) { sourceId = sourceNode.id(); targetId = targetAncestors.last().id(); } else { sourceId = sourceAncestors.last().id(); targetId = targetAncestors.last().id(); } if (sourceId !== undefined && targetId !== undefined) { const edgeId = `${sourceId}|${targetId}`; if (this.cy.$id(edgeId).length === 0) { const ancestorEdge: EdgeDefinition = { group: 'edges', data: { id: edgeId, source: sourceId, target: targetId, }, }; this.cy.add(ancestorEdge); } } else { console.log(`Couldn't figure out how to draw edge ${edge.id()}`); console.log( 'source ancestors', sourceAncestors.map((anc) => anc.id()) ); console.log( 'target ancestors', targetAncestors.map((anc) => anc.id()) ); } } }); } if (this.renderMode === 'nx-console') { // when in the nx-console environment, adjust graph width and position to be to right of floating panel // 175 is a magic number that represents the width of the floating panels divided in half plus some padding this.cy .fit(this.cy.elements(), 175) .center() .resize() .panBy({ x: 150, y: 0 }); } else { this.cy.fit(this.cy.elements(), 25).center().resize(); } this.cy.scratch(darkModeScratchKey, this._theme === 'dark'); this.cy.elements().scratch(darkModeScratchKey, this._theme === 'dark'); this.cy.mount(this.activeContainer); } return { numNodes: this.cy?.nodes().length ?? 0, numEdges: this.cy?.edges().length ?? 0, }; } private listenForProjectNodeClicks() { this.cy.$('node.projectNode').on('click', (event) => { const node = event.target; let ref: VirtualElement = node.popperRef(); // used only for positioning this.broadcast({ type: 'ProjectNodeClick', ref, id: node.id(), data: { id: node.id(), type: node.data('type'), tags: node.data('tags'), description: node.data('description'), }, }); }); } private listenForTaskNodeClicks() { this.cy.$('node.taskNode').on('click', (event) => { const node = event.target; let ref: VirtualElement = node.popperRef(); // used only for positioning this.broadcast({ type: 'TaskNodeClick', ref, id: node.id(), data: { id: node.id(), label: node.data('label'), executor: node.data('executor'), description: node.data('description'), }, }); }); } private listenForEdgeNodeClicks() { this.cy.$('edge.projectEdge').on('click', (event) => { const edge: EdgeSingular & { popperRef: () => VirtualElement } = event.target; let ref: VirtualElement = edge.popperRef(); // used only for positioning this.broadcast({ type: 'EdgeClick', ref, id: edge.id(), data: { id: edge.id(), type: edge.data('type'), source: edge.source().id(), target: edge.target().id(), sourceRoot: edge.source().data('root'), fileDependencies: edge .source() .data('files') ?.filter( (file) => file.deps && file.deps.find( (d) => (typeof d === 'string' ? d : d[0]) === edge.target().id() ) ) .map((file) => { return { fileName: file.file.replace( `${edge.source().data('root')}/`, '' ), target: edge.target().id(), }; }) || [], }, }); }); } private listenForProjectNodeHovers(): void { this.cy.on('mouseover', (event) => { const node = event.target; if (!node.isNode || !node.isNode() || node.isParent()) return; this.cy .elements() .difference(node.outgoers().union(node.incomers())) .not(node) .addClass('transparent'); node .addClass('highlight') .outgoers() .union(node.incomers()) .addClass('highlight'); }); this.cy.on('mouseout', (event) => { const node = event.target; if (!node.isNode || !node.isNode() || node.isParent()) return; this.cy.elements().removeClass('transparent'); node .removeClass('highlight') .outgoers() .union(node.incomers()) .removeClass('highlight'); }); } private listenForEmptyClicks(): void { this.cy.on('click', (event) => { if (event.target === this.cy) { this.broadcast({ type: 'BackgroundClick' }); } }); } getImage() { const bg = switchValueByDarkMode(this.cy, '#0F172A', '#FFFFFF'); return this.cy.png({ bg, full: true }); } setFocussedElement(id: string) { this.cy.$id(id).addClass('focused'); } clearFocussedElement() { this.cy?.nodes('.focused').removeClass('focused'); } getCurrentlyShownProjectIds(): string[] { return this.cy?.nodes().map((node) => node.data('id')) ?? []; } }