361 lines
9.7 KiB
TypeScript
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')) ?? [];
|
|
}
|
|
}
|