361 lines
9.7 KiB
TypeScript

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')) ?? [];
}
}