From 9673c1dadd51df4c1f617d8c2dc664cd8aa6cb05 Mon Sep 17 00:00:00 2001 From: James McLaughlin Date: Sun, 8 Dec 2024 03:52:48 +0000 Subject: [PATCH] graph view fixes and datasource highlighting --- .../src/components/DatasourceSelector.tsx | 10 +- .../components/node_graph_view/CyWrapper.tsx | 111 ++++++ .../node_graph_view/GraphViewCtx.tsx | 329 +++++++++++++----- 3 files changed, 357 insertions(+), 93 deletions(-) create mode 100644 webapp/grebi_ui/src/components/node_graph_view/CyWrapper.tsx diff --git a/webapp/grebi_ui/src/components/DatasourceSelector.tsx b/webapp/grebi_ui/src/components/DatasourceSelector.tsx index 859b39b..b3b8299 100644 --- a/webapp/grebi_ui/src/components/DatasourceSelector.tsx +++ b/webapp/grebi_ui/src/components/DatasourceSelector.tsx @@ -3,10 +3,12 @@ import React from "react"; export default function DatasourceSelector({ datasources, - dsEnabled, setDsEnabled + dsEnabled, setDsEnabled, + onMouseoverDs, onMouseoutDs }:{ datasources:string[], - dsEnabled:string[], setDsEnabled:(ds:string[])=>void + dsEnabled:string[], setDsEnabled:(ds:string[])=>void, + onMouseoverDs?:undefined|((ds:string)=>void), onMouseoutDs?:undefined|((ds:string)=>void) }) { datasources.sort((a, b) => a.localeCompare(b) + (a.startsWith("OLS.") ? 10000 : 0) + (b.startsWith("OLS.") ? -10000 : 0)) @@ -22,13 +24,13 @@ export default function DatasourceSelector({ return
{datasources.map((ds, i) => { if (ds.startsWith("OLS.")) { - return + return onMouseoverDs && onMouseoverDs(ds)} onMouseOut={() => onMouseoutDs && onMouseoutDs(ds)}> { datasources.length > 1 && toggleDsEnabled(ds)} />} {ds.split('.')[1]} } else { - return + return onMouseoverDs && onMouseoverDs(ds)} onMouseOut={() => onMouseoutDs && onMouseoutDs(ds)}> { datasources.length > 1 && toggleDsEnabled(ds)} />} = new Map() + + locked:boolean = false + + constructor(container:HTMLDivElement, elements:any[], style:any, layout:any) { + + layout.animate = false + this.cy = cytoscape({ container, elements, style, layout }); + + for(let elem of elements) { + this.elementSnapshots.set(elem.data.id, JSON.stringify(elem)) + } + } + + exclusiveBatch(op:()=>void) { + + if(this.locked) { + throw new Error('graph is already updating') + } + this.locked = true + + this.cy.startBatch() + op() + this.cy.endBatch() + + this.locked = false + + } + + updateElements(elems:any[]) { + + this.exclusiveBatch(() => { + + + let curElemIdSet = new Set() + for(let elem of elems) { + curElemIdSet.add(elem.data.id) + } + + // remove elems that are gone now + // + for(let elemId of this.elementSnapshots.keys()) { + if(!curElemIdSet.has(elemId)) { + let elem = this.cy.getElementById(elemId) + elem.remove() + this.elementSnapshots.delete(elemId) + } + } + + let adds:any[] = [] + + for(let elem of elems) { + curElemIdSet.add(elem.data.id) + + let oldSnapshot = this.elementSnapshots.get(elem.data.id) + let curSnapshot = JSON.stringify(elem) + this.elementSnapshots.set(elem.data.id, curSnapshot) + + if(oldSnapshot) { + if(oldSnapshot === curSnapshot) { + // element has not changed, nothing to do + continue + } + // element was in the graph but has changed + let exist = this.cy.getElementById(elem.data.id) + exist.data(elem.data) + } else { + // element is new + adds.push(elem) + } + } + + this.cy.add(adds) + }) + + } + + async applyLayout(layout:any):Promise { + + return new Promise((resolve, reject) => { + + layout.animationDuration = 500 + layout.stop = () => { + resolve() + } + + this.cy.layout(layout).run(); + }); + } + + destroy() { + this.cy.destroy() + } + + getElements():any[] { + return this.cy.elements() + } + + +} + diff --git a/webapp/grebi_ui/src/components/node_graph_view/GraphViewCtx.tsx b/webapp/grebi_ui/src/components/node_graph_view/GraphViewCtx.tsx index 4eb8594..f21eb24 100644 --- a/webapp/grebi_ui/src/components/node_graph_view/GraphViewCtx.tsx +++ b/webapp/grebi_ui/src/components/node_graph_view/GraphViewCtx.tsx @@ -1,26 +1,27 @@ -import cytoscape from "cytoscape" -import fcose from "cytoscape-fcose" -import ReactDOM from "react-dom" +import ReactDOM, { render } from "react-dom" import { get, getPaginated } from "../../app/api"; import GraphEdge from "../../model/GraphEdge"; import GraphNodeRef from "../../model/GraphNodeRef"; import DatasourceSelector from "../DatasourceSelector"; import LoadingOverlay from "../LoadingOverlay"; - -cytoscape.use(fcose) +import CyWrapper from "./CyWrapper"; let formatter = Intl.NumberFormat('en', { notation: 'compact' }); let MIN_COUNT_NODE_SIZE = 30 let MAX_COUNT_NODE_SIZE = 120 +let MAX_CLICKABLE_COUNT = 500 + export default class GraphViewCtx { public dsSelectorDiv:HTMLDivElement public graphDiv:HTMLDivElement - public cy:any = null + public cy:CyWrapper|null = null public subgraph:string + + public loadingDepth:number = 0 public loadingOverlay:HTMLDivElement public allDatasources:Set = new Set() @@ -39,23 +40,24 @@ export default class GraphViewCtx { public root:GraphNodeRef|undefined - getTotalIncomingEdgeCountByType(nodeId:string) { + getTotalIncomingEdgeCountByType(nodeId:string):Map { return this.getTotalEdgeCountByType(this.incoming_nodeIdToEdgeCountByTypeAndDs.get(nodeId)); } - getTotalOutgoingEdgeCountByType(nodeId:string) { + getTotalOutgoingEdgeCountByType(nodeId:string):Map { return this.getTotalEdgeCountByType(this.outgoing_nodeIdToEdgeCountByTypeAndDs.get(nodeId)); } - getTotalEdgeCountByType(edgeCountByTypeAndDs:any) { - let res = new Map() + getTotalEdgeCountByType(edgeCountByTypeAndDs:any):Map { + let res = new Map() for(let type of Object.keys(edgeCountByTypeAndDs)) { let edgeCountByDs = edgeCountByTypeAndDs[type] let count = 0 - for(let ds of Object.keys(edgeCountByDs)) { + let dss = Object.keys(edgeCountByDs) + for(let ds of dss) { if(this.dsExclude.has(ds)) continue count += edgeCountByDs[ds]; } - res.set(type, count) + res.set(type, {datasources:dss,count,dsToCount:edgeCountByDs}) } return res } @@ -77,26 +79,11 @@ export default class GraphViewCtx { ReactDOM.render(, this.loadingOverlay) } - async reload(root:GraphNodeRef) { - - this.root = root - - this.allDatasources = new Set() - this.incoming_nodeIdToEdgeCountByTypeAndDs = new Map() - this.incoming_nodeIdToEdgeIds = new Map() - this.outgoing_nodeIdToEdgeCountByTypeAndDs = new Map() - this.outgoing_nodeIdToEdgeIds = new Map() - this.nodes = new Map() - this.edges = new Map() - this.nodes.set(root.getNodeId(), root) - - this.showLoadingOverlay() - - await this.loadAll() + renderToCytoscapeJson():{elements:any,style:any,layout:any} { let elements = [ ...Array.from(this.nodes.values()).map((node:GraphNodeRef) => ({ - classes: 'node' + (node.getNodeId() === root.getNodeId() ? ' root' : ''), + classes: 'node' + (node.getNodeId() === this.root!.getNodeId() ? ' root' : ''), data: { id: node.getNodeId(), label: node.getName() @@ -119,74 +106,85 @@ export default class GraphViewCtx { let nodeId = node.getNodeId() let outgoing_edgeCountByType = this.getTotalOutgoingEdgeCountByType(nodeId) let incoming_edgeCountByType = this.getTotalIncomingEdgeCountByType(nodeId) - for(let [edgeType,count] of outgoing_edgeCountByType!.entries()) { + + for(let [edgeType,{datasources,count}] of outgoing_edgeCountByType!.entries()) { + max_count = Math.max(count, max_count) + } + for(let [edgeType,{datasources,count}] of incoming_edgeCountByType!.entries()) { + max_count = Math.max(count, max_count) + } + + + for(let [edgeType,{datasources,count,dsToCount}] of outgoing_edgeCountByType!.entries()) { if(count === 0) continue - max_count = Math.max(count, max_count) let countNodeId = 'count_outgoing_' + nodeId + '_' + edgeType elements.push({ - classes: 'count', + classes: ['count', ...(count <= MAX_CLICKABLE_COUNT ? ['small_count'] : [])], data: { + group: 'nodes', id: countNodeId, - label: formatter.format(count), - count + count, + size: (MIN_COUNT_NODE_SIZE + (count / max_count) * (MAX_COUNT_NODE_SIZE-MIN_COUNT_NODE_SIZE)), + datasources, + dsToCount } - }) + } as any) elements.push({ - classes: 'count_edge', + classes: ['count_edge', ...(count <= MAX_CLICKABLE_COUNT ? ['small_count'] : [])], data: { + group: 'edges', id: 'to_' + countNodeId, source: nodeId, target: countNodeId, - label: edgeType + label: edgeType, + datasources } - }) + } as any) constraints.push({ left: nodeId, right: countNodeId }) } - for(let [edgeType,count] of incoming_edgeCountByType!.entries()) { + for(let [edgeType,{datasources,count,dsToCount}] of incoming_edgeCountByType!.entries()) { if(count === 0) continue - max_count = Math.max(count, max_count) let countNodeId = 'count_incoming_' + nodeId + '_' + edgeType elements.push({ - classes: 'count', + classes: ['count', ...(count <= MAX_CLICKABLE_COUNT ? ['small_count'] : [])], data: { + group: 'nodes', id: countNodeId, - label: formatter.format(count), - count - } - }) + count, + size: (MIN_COUNT_NODE_SIZE + (count / max_count) * (MAX_COUNT_NODE_SIZE-MIN_COUNT_NODE_SIZE)), + datasources, + dsToCount + }, + } as any) elements.push({ - classes: 'count_edge', + classes: ['count_edge', ...(count <= MAX_CLICKABLE_COUNT ? ['small_count'] : [])], data: { + group: 'edges', id: 'from_' + countNodeId, source: countNodeId, target: nodeId, - label: edgeType + label: edgeType, + datasources } - }) + } as any) constraints.push({ right: nodeId, left: countNodeId }) } } - - console.dir(elements) - - if(this.cy) - this.cy.destroy() - - this.cy = cytoscape({ - container: this.graphDiv, - elements, - style: [ // the stylesheet for the graph - { + let style = [{ selector: '.root', style: { 'background-color': '#DDD', 'label': 'data(label)', "text-valign" : "center", "text-halign": "center", - padding: '16px' + padding: '16px', +width: 'label', +shape:'ellipse' + + } as any }, { @@ -196,20 +194,24 @@ export default class GraphViewCtx { 'label': 'data(label)', "text-valign" : "center", "text-halign" : "center", -padding: '8px' +padding: '8px', +width: 'label', +shape:'ellipse', } as any }, { selector: '.count', style: { 'background-color': '#EEE', - 'label': 'data(label)', + 'label': (node) => { + return formatter.format(node.data('count')) + }, "text-valign" : "center", "text-halign" : "center", padding: '8px', - width: (node) => (MIN_COUNT_NODE_SIZE + (node.data('count') / max_count) * (MAX_COUNT_NODE_SIZE-MIN_COUNT_NODE_SIZE)) + 'px', - height: (node) => (MIN_COUNT_NODE_SIZE + (node.data('count') / max_count) * (MAX_COUNT_NODE_SIZE-MIN_COUNT_NODE_SIZE)) + 'px' - +color:'gray', + width: (node) => node.data('size')+'px', + height: (node) =>node.data('size')+'px' } as any }, { @@ -223,11 +225,65 @@ padding: '8px', 'target-arrow-shape': 'triangle', 'arrow-scale': 2, 'curve-style': 'bezier', - "text-rotation": "autorotate" + "text-rotation": "autorotate", + // 'font-weight': 'bold', + // 'text-background-opacity': 1, + // 'text-background-color': 'white', + // 'text-border-width': 1, + // 'text-border-color': 'black', + // 'text-background-padding': '8px', + // 'font-size': '30px' + + } + }, + { + selector: '.small_count', + style: { + // 'background-color': '#A7C7E7', + // 'line-color': '#A7C7E7', + // 'target-arrow-color': '#A7C7E7' + 'color':'black' + } as any + }, + { + selector: 'node.ds_highlight', + style:{ + 'background-color': '#7323b7', + 'color': 'white', + 'label': (node) => { + let ds_highlight = node.data('ds_highlight') + return formatter.format(node.data('dsToCount')[ds_highlight]) + }, + } + }, + { + selector: 'node.ds_onto_highlight', + style:{ + 'background-color': '#00827c', + 'color': 'white', + 'label': (node) => { + let ds_highlight = node.data('ds_highlight') + return formatter.format(node.data('dsToCount')[ds_highlight]) + }, + } + }, + { + selector: 'edge.ds_highlight', + style:{ + 'line-color': '#7323b7', + 'target-arrow-color': '#7323b7', + } + }, + { + selector: 'edge.ds_onto_highlight', + style:{ + 'line-color': '#00827c', + 'target-arrow-color': '#00827c' } } - ], - layout: { + ]; + + let layout = { name: 'fcose', avoidOverlap: true, nodeDimensionsIncludeLabels: true, @@ -235,33 +291,128 @@ padding: '8px', numIter: 500, amimate: false, relativePlacementConstraint: constraints - } as any + }; + + return {elements,style,layout} + } + + + async reload(root:GraphNodeRef) { + + this.root = root + + this.allDatasources = new Set() + this.incoming_nodeIdToEdgeCountByTypeAndDs = new Map() + this.incoming_nodeIdToEdgeIds = new Map() + this.outgoing_nodeIdToEdgeCountByTypeAndDs = new Map() + this.outgoing_nodeIdToEdgeIds = new Map() + this.nodes = new Map() + this.edges = new Map() + this.nodes.set(root.getNodeId(), root) + + this.startLoading() + + await this.loadAll() + + let {elements,style,layout} = this.renderToCytoscapeJson() + + console.dir(elements) + + if(this.cy) + this.cy.destroy() + + this.cy = new CyWrapper(this.graphDiv, elements, style, layout) + + //this.dsSelectorDiv.innerHTML = '' + + let renderDsSelector = () => { + ReactDOM.render( + !this.dsExclude.has(el))} + setDsEnabled={async (dss:string[]) => { + if(this.isLoading()) + return; + let newExclude = new Set(this.allDatasources) + for(let ds of dss) { + newExclude.delete(ds) + } + // if(newExclude.size < this.dsExclude.size) { + // this.clearHighlightedDatasource() + // } + this.dsExclude = newExclude + this.loadSingulars() + this.startLoading(false) + renderDsSelector() + await this.incrementalUpdate() + this.stopLoading() + }} + onMouseoverDs={(ds:string) => { + if(!this.dsExclude.has(ds)) { + this.highlightDatasource(ds) + } + }} + onMouseoutDs={(ds:string) => this.clearHighlightedDatasource()} + />, + this.dsSelectorDiv + ) + } + + renderDsSelector() + + this.stopLoading() + } + + highlightDatasource(ds:string) { + let cl = ds.startsWith('OLS.') ? 'ds_onto_highlight' : 'ds_highlight' + this.cy?.exclusiveBatch(() => { + for(let element of this.cy!.getElements()) { + if(element.data('datasources') && element.data('datasources').indexOf(ds) !== -1) { + element.data('ds_highlight', ds) + element.addClass(cl) + } else { + element.removeClass('ds_highlight') + element.removeClass('ds_onto_highlight') + } + } }) + } - this.dsSelectorDiv.innerHTML = '' - ReactDOM.render( - { - this.dsExclude = new Set(this.allDatasources) - for(let ds of dss) { - this.dsExclude.delete(ds) - } - this.reload(root) - }} - />, - this.dsSelectorDiv - ) + clearHighlightedDatasource() { + this.cy?.exclusiveBatch(() => { + for(let element of this.cy!.getElements()) { + element.data('ds_highlight', undefined) + element.removeClass('ds_highlight') + element.removeClass('ds_onto_highlight') + } + }) + } + + loadSingulars() { - this.hideLoadingOverlay() + + } + + async incrementalUpdate() { + + let {elements,style,layout} = this.renderToCytoscapeJson() + + this.cy!.updateElements(elements) + await this.cy!.applyLayout(layout) } - showLoadingOverlay() { - this.graphDiv.insertBefore(this.loadingOverlay, this.graphDiv.firstChild) + startLoading(showOverlay?:boolean|undefined) { + this.loadingDepth += 1 + if(showOverlay && this.loadingDepth === 1) + this.graphDiv.insertBefore(this.loadingOverlay, this.graphDiv.firstChild) + } + stopLoading() { + this.loadingDepth -= 1 + if(this.loadingDepth === 0 && this.loadingOverlay.parentNode) + this.graphDiv.removeChild(this.loadingOverlay) } - hideLoadingOverlay() { - this.graphDiv.removeChild(this.loadingOverlay) + isLoading() { + return this.loadingDepth > 0 } async loadAll() {