From aa8db19e49696fd7eeae5793104923e2be04d332 Mon Sep 17 00:00:00 2001 From: Leonardo Christino <lchristino@graphpolaris.com> Date: Mon, 31 Mar 2025 13:49:29 +0200 Subject: [PATCH 01/12] chore: refactor nl now the calculation logic lives outside NLPixi, so that it doesnt care about it anymore and do not duplicate data unecessarily --- .../nodelinkvis/components/NLPixi.tsx | 432 ++++++------------ .../nodelinkvis/components/query2NL.tsx | 293 ++++++++++-- .../nodelinkvis/components/utils.tsx | 41 +- .../nodelinkvis/nodelinkvis.tsx | 180 +------- .../vis/visualizations/nodelinkvis/types.ts | 102 +---- 5 files changed, 422 insertions(+), 626 deletions(-) diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx index 8227b72c7..a78ab9887 100644 --- a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx @@ -1,7 +1,5 @@ import { dataColors, visualizationColors } from '@/config'; import { canViewFeature } from '@/lib/components/featureFlags'; -import { NodeDetails } from '@/lib/components/nodeDetails'; -import { Popover, PopoverContent, PopoverTrigger } from '@/lib/components/popover'; import { useConfig } from '@/lib/data-access/store'; import { Theme } from '@/lib/data-access/store/configSlice'; import { useAsyncMemo } from '@/utils'; @@ -19,12 +17,12 @@ import { Texture, type StrokeStyle, } from 'pixi.js'; -import { forwardRef, RefObject, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { AllLayoutAlgorithms } from 'ts-common/src/model/layouts'; import { useML, useSearchResultData } from '../../../../data-access'; import { GraphologyForceAtlas2Webworker, LayoutFactory, Layouts, LayoutTypes } from '../../../../graph-layout'; import { NodelinkVisProps } from '../nodelinkvis'; -import { EdgeType, EdgeTypeD3, GraphType, GraphTypeD3, NodeType, NodeTypeD3 } from '../types'; +import { EdgeType, GraphType, NodeType } from '../types'; import { ForceEdgeBundling, type Point } from './edgeBundling'; import { NLPopUp } from './NLPopup'; import { nodeColor, nodeColorHex } from './utils'; @@ -32,7 +30,7 @@ import { nodeColor, nodeColorHex } from './utils'; const PERF_EDGE_THRESHOLD = 2500; type Props = { - onClick: (event?: { node: NodeTypeD3; pos: PointData }) => void; + onClick: (event?: { node: NodeType; pos: PointData }) => void; // onHover: (data: { node: NodeType; pos: PointData }) => void; // onUnHover: (data: { node: NodeType; pos: PointData }) => void; highlightNodes: NodeType[]; @@ -51,10 +49,9 @@ type LayoutState = 'reset' | 'running' | 'paused'; // MAIN COMPONENT ////////////////// -let metaEdges: Record<string, EdgeType> | null = null; export const NLPixi = forwardRef((props: Props, refExternal) => { const [quickPopup, setQuickPopup] = useState<{ node: NodeType; pos: PointData } | undefined>(); - const [popups, setPopups] = useState<{ node: NodeTypeD3; pos: PointData }[]>([]); + const [popups, setPopups] = useState<{ node: NodeType; pos: PointData }[]>([]); const globalConfig = useConfig(); @@ -114,7 +111,6 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { const isSetup = useRef(false); const ml = useML(); const searchResults = useSearchResultData(); - const graph = useRef<GraphTypeD3>({ nodes: [], edges: [] }); const layoutAlgorithm = useRef(new LayoutFactory().createLayout<AllLayoutAlgorithms>(Layouts.DAGRE)); @@ -157,21 +153,20 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { useEffect(() => { if (nodeMap.current.size === 0) return; - graph.current.nodes.forEach(node => { - const sprite = nodeMap.current.get(node._id) as Sprite; - const nodeMeta = props.graph.nodes[node._id]; + Object.values(props.graph.nodes).forEach(node => { + const sprite = nodeMap.current.get(node.id) as Sprite; sprite.texture = (sprite as any).selected ? selectedTexture : glyphTexture; // To calculate the scale, we: // 1) Determine the node radius, with a minimum of 5. If not available, we default to 5. // 2) Get the ratio with respect to the typical size of the node (divide by NODE_RADIUS). // 3) Scale this ratio by the current scale factor. - let scale = (Math.max(nodeMeta.radius || 5, 5) / config.NODE_RADIUS) * 2; + let scale = (Math.max(node.radius || 5, 5) / config.NODE_RADIUS) * 2; scale *= responsiveScale; sprite.scale.set(scale, scale); }); - if (graph.current.nodes.length > config.LABEL_MAX_NODES) return; + if (Object.keys(props.graph.nodes).length > config.LABEL_MAX_NODES) return; // Change font size at specific scale intervals const fontSize = @@ -189,7 +184,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { text.resolution = Math.ceil(1 / responsiveScale); }); - graph.current.nodes.forEach((node: any) => { + Object.values(props.graph.nodes).forEach((node: any) => { updateNodeLabel(node); }); }, [responsiveScale, props.configuration.nodes?.shape?.type]); @@ -233,27 +228,27 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { }); }, [props.layoutAlgorithm, props.configuration, props.configuration.edgeBundlingEnabled]); - useEffect(() => { - if (nodeMap.current.size == 0 || props.graph.edges == null) { - metaEdges = null; - return; - } + // useEffect(() => { + // if (nodeMap.current.size == 0 || props.graph.edges == null) { + // metaEdges = null; + // return; + // } - const edgesCopy = JSON.parse(JSON.stringify(props.graph.edges)) as Record<string, EdgeType>; - metaEdges = Object.fromEntries( - Object.entries(edgesCopy).map(([key, edge]) => { - const sourceId = edge.source as string; - const targetId = edge.target as string; - const source = nodeMap.current.get(sourceId) as NodeTypeD3 | undefined; - const target = nodeMap.current.get(targetId) as NodeTypeD3 | undefined; + // const edgesCopy = JSON.parse(JSON.stringify(props.graph.edges)) as Record<string, EdgeType>; + // metaEdges = Object.fromEntries( + // Object.entries(edgesCopy).map(([key, edge]) => { + // const sourceId = edge.source as string; + // const targetId = edge.target as string; + // const source = nodeMap.current.get(sourceId) as NodeType | undefined; + // const target = nodeMap.current.get(targetId) as NodeType | undefined; - edge._source = source; - edge._target = target; + // edge._source = source; + // edge._target = target; - return [key, edge]; - }), - ); - }, [props.graph.edges, nodeMap.current.size]); + // return [key, edge]; + // }), + // ); + // }, [props.graph.edges, nodeMap.current.size]); const imperative = useRef<any>(null); @@ -277,14 +272,14 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { } const sprite = event.target as Sprite; - const node = (sprite as any).node as NodeTypeD3; + const node = (sprite as any).node as NodeType; if (event.shiftKey) { setPopups([...popups, { node: node, pos: toGlobal(node) }]); } else { setPopups([{ node: node, pos: toGlobal(node) }]); for (const popup of popups) { - const sprite = nodeMap.current.get(popup.node._id) as Sprite; + const sprite = nodeMap.current.get(popup.node.id) as Sprite; sprite.texture = glyphTexture; (sprite as any).selected = false; } @@ -306,7 +301,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { const holdDownTime = event.timeStamp - (event as any).mouseDownTimeStamp; if (holdDownTime < mouseClickThreshold) { for (const popup of popups) { - const sprite = nodeMap.current.get(popup.node._id) as Sprite; + const sprite = nodeMap.current.get(popup.node.id) as Sprite; sprite.texture = glyphTexture; (sprite as any).selected = false; } @@ -319,15 +314,15 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { if (!props.configuration.showPopUpOnHover) return; const sprite = event.target as Sprite; - const node = (sprite as any).node as NodeTypeD3; + const node = (sprite as any).node as NodeType; if ( mouseInCanvas.current && viewport?.current && !viewport?.current?.pause && node && - popups.filter(p => p.node._id === node._id).length === 0 + popups.filter(p => p.node.id === node.id).length === 0 ) { - setQuickPopup({ node: props.graph.nodes[node._id], pos: toGlobal(node) }); + setQuickPopup({ node: props.graph.nodes[node.id], pos: toGlobal(node) }); } }, onUnHover() { @@ -355,7 +350,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { setResponsiveScale(1); } - if (graph.current.nodes.length < config.LABEL_MAX_NODES) { + if (Object.values(props.graph.nodes).length < config.LABEL_MAX_NODES) { edgeLabelLayer.alpha = scale > 2 ? Math.min(1, (scale - 2) * 3) : 0; if (edgeLabelLayer.alpha > 0) { @@ -485,7 +480,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { }; }, []); - function toGlobal(node: NodeTypeD3): PointData { + function toGlobal(node: NodeType): PointData { if (viewport?.current) { // const rect = ref.current?.getBoundingClientRect(); const rect = { x: 0, y: 0 }; @@ -495,119 +490,33 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { } else return { x: 0, y: 0 }; } - const updateNode = (node: NodeTypeD3) => { - const gfx = nodeMap.current.get(node._id); + const updateNode = (node: NodeType) => { + const gfx = nodeMap.current.get(node.id); if (!gfx) return; // Update texture when selected - const nodeMeta = props.graph.nodes[node._id]; + const nodeMeta = props.graph.nodes[node.id]; if (nodeMeta == null) return; const texture = (gfx as any).selected ? selectedTexture : glyphTexture; gfx.texture = texture; - - // Cluster colors - if (nodeMeta?.cluster) { - gfx.tint = nodeMeta.cluster >= 0 ? nodeColor(nodeMeta.cluster) : 0x000000; - } else { - gfx.tint = nodeColor(nodeMeta.type); - } - gfx.position.set(node.x, node.y); - - // if (!item.position) { - // item.position = new Point(node.x, node.y); - // } else { - // item.position.set(node.x, node.y); - // } - // Update attributes position if they exist - // if (node.gfxAttributes) { - // const x = node.x - node.gfxAttributes.width / 2; - // const y = node.y - node.gfxAttributes.height - 20; - // if (!node.gfxAttributes?.position) node.gfxAttributes.position = new Point(x, y); - // else { - // node.gfxAttributes.position.set(x, y); - // } - // } }; - const getNodeLabel = (nodeMeta: NodeType) => { - let attribute; - try { - attribute = imperative.current.getNodeAttributes()[nodeMeta.label]; - } catch (e) { - return nodeMeta.label ?? ''; - } - - if (attribute == 'Default' || attribute == null) { - return nodeMeta.label ?? ''; - } - - const value = nodeMeta.attributes[attribute]; - - if (Array.isArray(value)) { - return value.join(', '); - } - - if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') { - return String(value); - } - - if (typeof value === 'object' && Object.keys(value).length != 0) { - return JSON.stringify(value); - } - - return '-'; - }; - - const getEdgeLabel = (edgeMeta: EdgeType) => { - let attribute; - try { - attribute = imperative.current.getEdgeAttributes()[edgeMeta.attributes.type]; - } catch (e) { - return edgeMeta.attributes.type ?? ''; - } - - if (attribute == 'None') { - return ''; - } - - if (attribute == 'Default' || attribute == null) { - return edgeMeta.attributes.type ?? ''; - } - - const value = edgeMeta.attributes[attribute]; - - if (Array.isArray(value)) { - return value.join(', '); - } - - if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') { - return String(value); - } - - if (typeof value === 'object' && Object.keys(value).length != 0) { - return JSON.stringify(value); - } - - return ''; - }; - - const createNode = (node: NodeTypeD3, selected?: boolean) => { - const nodeMeta = props.graph.nodes[node._id]; - + const createNode = (node: NodeType, selected?: boolean) => { // check if node is already drawn, and if so, delete it - if (node && node?._id && nodeMap.current.has(node._id)) { - nodeMap.current.delete(node._id); + if (node && node?.id && nodeMap.current.has(node.id)) { + nodeMap.current.delete(node.id); } + // Do not draw node if it has no position if (node.x === undefined || node.y === undefined) return; const texture = glyphTexture; const sprite = new Sprite(texture); - sprite.tint = nodeColor(nodeMeta.type); - const scale = (Math.max(nodeMeta.radius || 5, 5) / config.NODE_RADIUS) * 2; + sprite.tint = node.color; + const scale = (Math.max(node.radius || 5, 5) / config.NODE_RADIUS) * 2; sprite.scale.set(scale, scale); sprite.anchor.set(0.5, 0.5); sprite.cullable = true; @@ -618,14 +527,14 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { sprite.on('mouseover', e => imperative.current?.onHover(e)); sprite.on('mouseout', e => imperative.current?.onUnHover(e)); - nodeMap.current.set(node._id, sprite); + nodeMap.current.set(node.id, sprite); nodeLayer.addChild(sprite); updateNode(node); (sprite as any).node = node; // Node label - const attribute = getNodeLabel(nodeMeta); + const attribute = node.label; const text = new Text({ text: attribute, style: { @@ -641,7 +550,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { text.cullable = true; text.anchor.set(0.5, 0.5); text.scale.set(0.1, 0.1); - nodeLabelMap.current.set(node._id, text); + nodeLabelMap.current.set(node.id, text); nodeLabelLayer.addChild(text); updateNodeLabel(node); @@ -649,16 +558,13 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { return sprite; }; - const createEdgeLabel = (edge: EdgeTypeD3) => { + const createEdgeLabel = (edge: EdgeType) => { // check if edge is already drawn, and if so, delete it - if (edge && edge?._id && edgeLabelMap.current.has(edge._id)) { - edgeLabelMap.current.delete(edge._id); + if (edge && edge?.id && edgeLabelMap.current.has(edge.id)) { + edgeLabelMap.current.delete(edge.id); } - const edgeMeta = metaEdges?.[edge._id]; - if (edgeMeta == null) return; - - const label = getEdgeLabel(edgeMeta); + const label = edge.label; const text = new Text({ text: label, style: { @@ -673,7 +579,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { text.cullable = true; text.anchor.set(0.5, 0.5); text.scale.set(0.1, 0.1); - edgeLabelMap.current.set(edge._id, text); + edgeLabelMap.current.set(edge.id, text); edgeLabelLayer.addChild(text); updateEdgeLabel(edge); @@ -681,23 +587,21 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { return text; }; - const updateEdge = (edge: EdgeTypeD3, edgeBundle?: Point[]) => { + const updateEdge = (edge: EdgeType, edgeBundle?: Point[]) => { const multiple = - imperative.current.getShowMultipleEdges() && graph.current.edges.length < PERF_EDGE_THRESHOLD - ? graph.current.edges.filter( + imperative.current.getShowMultipleEdges() && Object.values(props.graph.edges).length < PERF_EDGE_THRESHOLD + ? Object.values(props.graph.edges).filter( x => (x.source == edge.source && x.target == edge.target) || (x.source == edge.target && x.target == edge.source), ).length : 0; - const edgeMeta = metaEdges?.[edge._id]; - if (edgeMeta == null) return; - - const { style, color, alpha } = getEdgeStyle(edgeMeta); + const target = nodeMap.current.get(edge.target as string) as Sprite; + const source = nodeMap.current.get(edge.source as string) as Sprite; - const sx = edgeMeta._source!.x as number; - const sy = edgeMeta._source!.y as number; - let tx = edgeMeta._target!.x as number; - let ty = edgeMeta._target!.y as number; + const sx = source.x; + const sy = source.y; + let tx = target.x; + let ty = target.y; const arrow = imperative.current.getShowArrows(); let ax, ay; @@ -716,23 +620,23 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { // Draw the edge // - Self-loops - if (edge.source === edge.target && edgeMeta._target!.x != null && edgeMeta._target!.y != null) { + if (edge.source === edge.target && target.x != null && target.y != null) { const selfLoopSize = 30; edgeGfx - .moveTo(edgeMeta._source!.x || 0, edgeMeta._source!.y || 0) + .moveTo(source.x || 0, source.y || 0) .bezierCurveTo( - edgeMeta._target!.x - selfLoopSize, - edgeMeta._target!.y - selfLoopSize, - edgeMeta._target!.x + selfLoopSize, - edgeMeta._target!.y - selfLoopSize, - edgeMeta._target!.x, - edgeMeta._target!.y, + target.x - selfLoopSize, + target.y - selfLoopSize, + target.x + selfLoopSize, + target.y - selfLoopSize, + target.x, + target.y, 0.9, ) .stroke({ - width: style, - color: color, - alpha: alpha, + width: edge.style, + color: edge.color, + alpha: edge.alpha, }); return; } @@ -746,9 +650,9 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { }); edgeGfx.stroke({ - width: style, - color: color, - alpha: alpha, + width: edge.style, + color: edge.color, + alpha: edge.alpha, }); } else if (imperative.current.getShowMultipleEdges() && multiple > 1) { // Perpendicular vector @@ -772,16 +676,16 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { .moveTo(sx + ox, sy + oy) .lineTo(tx + ox, ty + oy) .stroke({ - width: style, - color: color, - alpha: alpha, + width: edge.style, + color: edge.color, + alpha: edge.alpha, }); } } else { edgeGfx.moveTo(sx, sy).lineTo(tx, ty).stroke({ - width: style, - color: color, - alpha: alpha, + width: edge.style, + color: edge.color, + alpha: edge.alpha, }); } @@ -804,9 +708,9 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { .moveTo(tx, ty) .lineTo(tx + arrow1_x * arrowSize, ty + arrow1_y * arrowSize) .stroke({ - width: style, - color: color, - alpha: alpha, + width: edge.style, + color: edge.color, + alpha: edge.alpha, }); // -- Arrow head line 2 @@ -817,59 +721,18 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { .moveTo(tx, ty) .lineTo(tx + arrow2_x * arrowSize, ty + arrow2_y * arrowSize) .stroke({ - width: style, - color: color, - alpha: alpha, + width: edge.style, + color: edge.color, + alpha: edge.alpha, }); } }; - const getEdgeStyle = (edgeMeta: EdgeType) => { - // let color = edge.color || 0x000000; - let color = config.LINE_COLOR_DEFAULT; - let style = imperative.current.getEdgeWidth(); - let alpha = edgeMeta.alpha || 1; - if (edgeMeta.mlEdge) { - color = config.LINE_COLOR_ML; - if (edgeMeta.value > ml.communityDetection.jaccard_threshold) { - style = edgeMeta.value * 1.8; - } else { - style = 0; - alpha = 0.2; - } - } else if (props.highlightedLinks && props.highlightedLinks.includes(edgeMeta)) { - if (edgeMeta.mlEdge && ml.communityDetection.jaccard_threshold) { - if (edgeMeta.value > ml.communityDetection.jaccard_threshold) { - color = dataColors.magenta[50]; - // 0xaa00ff; - style = edgeMeta.value * 1.8; - } - } else { - color = dataColors.red[70]; - // color = 0xff0000; - style = 1.0; - } - } else if (props.currentShortestPathEdges && props.currentShortestPathEdges.includes(edgeMeta)) { - color = dataColors.green[50]; - // color = 0x00ff00; - style = 3.0; - } - - // Conditional alpha for search results - if (searchResults.nodes.length > 0 || searchResults.edges.length > 0) { - // FIXME: searchResults.edges should be a hashmap to improve performance. - const isLinkInSearchResults = searchResults.edges.some(resultEdge => resultEdge.id === false); //FIXME: needs to match edge._id - alpha = isLinkInSearchResults ? 1 : 0.05; - } - - return { style, color, alpha }; - }; - - const updateEdgeLabel = (edge: EdgeTypeD3) => { - if (graph.current.nodes.length > config.LABEL_MAX_NODES || viewport.current!.scale.x < 2) return; + const updateEdgeLabel = (edge: EdgeType) => { + if (Object.keys(props.graph.nodes).length > config.LABEL_MAX_NODES || viewport.current!.scale.x < 2) return; - const text = edgeLabelMap.current.get(edge._id); - if (!text) return; + const text = edgeLabelMap.current.get(edge.id); + if (!text || edge.label == null) return; const _source = edge.source; const _target = edge.target; @@ -881,10 +744,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { const source = nodeMap.current.get(edge.source as string) as Sprite; const target = nodeMap.current.get(edge.target as string) as Sprite; - const edgeMeta = metaEdges?.[edge._id]; - if (edgeMeta == null) return; - - text.text = getEdgeLabel(edgeMeta); + text.text = edge.label; text.x = (source.x + target.x) / 2; text.y = (source.y + target.y) / 2; @@ -915,25 +775,22 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { (text.style.stroke as StrokeStyle).color = imperative.current.getBackgroundColor(); }; - const updateNodeLabel = (node: NodeTypeD3) => { - if (graph.current.nodes.length > config.LABEL_MAX_NODES || viewport.current!.scale.x < 2) return; - const text = nodeLabelMap.current.get(node._id) as Text | undefined; - if (text == null) return; + const updateNodeLabel = (node: NodeType) => { + if (Object.keys(props.graph.nodes).length > config.LABEL_MAX_NODES || viewport.current!.scale.x < 2) return; + const text = nodeLabelMap.current.get(node.id) as Text | undefined; + if (text == null || node.label == null) return; if (node.x) text.x = node.x; if (node.y) text.y = node.y; - const nodeMeta = props.graph.nodes[node._id]; - const originalText = getNodeLabel(nodeMeta); - - text.text = originalText; // This is required to ensure the text size check (next line) works + text.text = node.label; // This is required to ensure the text size check (next line) works if (text.width / text.scale.x <= 90 && text.height / text.scale.y <= 90) { - text.text = originalText; + text.text = node.label; } else { // Change character limit at specific scale intervals const charLimit = responsiveScale > 0.2 ? 15 : responsiveScale > 0.1 ? 30 : 75; - text.text = `${originalText.slice(0, charLimit)}…`; + text.text = `${node.label.slice(0, charLimit)}…`; } text.alpha = text.width / text.scale.x <= 90 && text.height / text.scale.y <= 90 ? 1 : 0; @@ -988,10 +845,10 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { useEffect(() => { if (props.graph) { - graph.current.nodes.forEach(node => { - const gfx = nodeMap.current.get(node._id); + Object.values(props.graph.nodes).forEach(node => { + const gfx = nodeMap.current.get(node.id); if (!gfx) return; - const isNodeInSearchResults = searchResults.nodes.some(resultNode => resultNode.id === node._id); + const isNodeInSearchResults = searchResults.nodes.some(resultNode => resultNode.id === node.id); gfx.alpha = isNodeInSearchResults || searchResults.nodes.length === 0 ? 1 : 0.05; }); @@ -1004,12 +861,12 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { if (edgeBundling == null && imperative.current?.getEdgeBundlingEnabled()) { edgeBundling = ForceEdgeBundling() .nodes( - graph.current.nodes.reduce((a, b) => { - return { ...a, [b._id]: { x: b.x, y: b.y } }; + Object.values(props.graph.nodes).reduce((a, b) => { + return { ...a, [b.id]: { x: b.x, y: b.y } }; }, {}), ) // @ts-expect-error - edgeBundling is not null - .edges(graph.current.edges)(); + .edges(Object.values(props.graph.edges))(); } else { return; } @@ -1023,17 +880,20 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { const widthHalf = app.renderer.width / 2; const heightHalf = app.renderer.height / 2; - graph.current.nodes.forEach((node, i) => { - const gfx = nodeMap.current.get(node._id); + Object.values(props.graph.nodes).forEach((node, i) => { + const gfx = nodeMap.current.get(node.id); if (!gfx || node.x === undefined || node.y === undefined) { stopped += 1; return; } - const position = layoutAlgorithm.current.getNodePosition(node._id); + const position = layoutAlgorithm.current.getNodePosition(node.id); if (!position || Math.abs(node.x - position.x - widthHalf) + Math.abs(node.y - position.y - heightHalf) < 5) { stopped += 1; + } else { + node.x = position.x; + node.y = position.y; } if (layoutAlgorithm.current.provider === 'Graphology') { @@ -1050,7 +910,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { updateNodeLabel(node); }); - if (stopped === graph.current.nodes.length) { + if (stopped === Object.keys(props.graph.nodes).length) { layoutStoppedCount.current = layoutStoppedCount.current + 1; if (layoutStoppedCount.current > 500) { layoutState.current = 'paused'; @@ -1067,22 +927,22 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { // Draw the edges edgeGfx.clear(); - if (props.graph != null && nodeMap.current.size !== 0 && metaEdges != null) { - if (graph.current.edges.length > PERF_EDGE_THRESHOLD) { + if (props.graph != null && nodeMap.current.size !== 0 && props.graph.edges != null) { + if (Object.keys(props.graph.edges).length > PERF_EDGE_THRESHOLD) { // If many many edges, only update edges roughy 1/3th the time, dont draw labels, dont do edge bundling. if (Math.random() > 0.3) { - for (const link of graph.current.edges) { - updateEdge(link); + for (const edge of Object.values(props.graph.edges)) { + updateEdge(edge); } } } else { - for (const [i, link] of graph.current.edges.entries()) { + for (const [i, edge] of Object.values(props.graph.edges).entries()) { if (edgeBundling != null && imperative.current.getEdgeBundlingEnabled()) { - updateEdge(link, edgeBundling[i]); // FIXME: edgeBundling omits self-loops, index may not always match exactly! + updateEdge(edge, edgeBundling[i]); // FIXME: edgeBundling omits self-loops, index may not always match exactly! } else { - updateEdge(link); + updateEdge(edge); } - updateEdgeLabel(link); + updateEdgeLabel(edge); } } } @@ -1107,7 +967,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { } nodeMap.current.forEach((gfx, id) => { - if (!graph.current.nodes.find(node => node._id === id)) { + if (!props.graph.nodes[id]) { nodeLayer.removeChild(gfx); gfx.destroy(); nodeMap.current.delete(id); @@ -1115,7 +975,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { }); edgeLabelMap.current.forEach((text, id) => { - if (!graph.current.edges.find(link => link._id === id)) { + if (!props.graph.edges[id]) { edgeLabelLayer.removeChild(text); text.destroy(); edgeLabelMap.current.delete(id); @@ -1124,9 +984,9 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { edgeGfx.clear(); - graph.current.nodes.forEach(node => { - if (!forceClear && nodeMap.current.has(node._id)) { - const old = nodeMap.current.get(node._id); + Object.values(props.graph.nodes).forEach(node => { + if (!forceClear && nodeMap.current.has(node.id)) { + const old = nodeMap.current.get(node.id); node.x = old?.x || node.x; node.y = old?.y || node.y; @@ -1137,12 +997,12 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { } }); - if (graph.current.nodes.length < config.LABEL_MAX_NODES) { - for (const link of graph.current.edges) { - if (!forceClear && edgeLabelMap.current.has(link._id)) { - updateEdgeLabel(link); + if (Object.keys(props.graph.nodes).length < config.LABEL_MAX_NODES) { + for (const edge of Object.values(props.graph.edges)) { + if (!forceClear && edgeLabelMap.current.has(edge.id)) { + updateEdgeLabel(edge); } else { - createEdgeLabel(link); + createEdgeLabel(edge); } } } @@ -1177,16 +1037,6 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { if (!props.graph) throw Error('Graph is undefined'); - //Setup d3 graph structure - graph.current = { - nodes: Object.values(props.graph.nodes).map(n => ({ _id: n._id, x: n.defaultX, y: n.defaultY })), - edges: Object.values(props.graph.edges).map(l => ({ - _id: l.id, - source: l.source, - target: l.target, - })), - }; - const size = ref.current?.getBoundingClientRect(); viewport.current = new Viewport({ screenWidth: size?.width || 1000, @@ -1239,18 +1089,18 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { if (!layoutAlgorithm) throw Error('LayoutAlgorithm is undefined'); const graphologyGraph = new MultiGraph(); - graph.current.nodes.forEach(node => { - if (forceClear) graphologyGraph.addNode(node._id, { size: props.graph.nodes[node._id].radius || 5 }); + Object.values(props.graph.nodes).forEach(node => { + if (forceClear) graphologyGraph.addNode(node.id, { size: node.radius || 5 }); else - graphologyGraph.addNode(node._id, { - size: props.graph.nodes[node._id].radius || 5, + graphologyGraph.addNode(node.id, { + size: node.radius || 5, x: node.x || 0, y: node.y || 0, }); }); - for (const link of graph.current.edges) { - graphologyGraph.addEdge(link.source, link.target); + for (const edge of Object.values(props.graph.edges)) { + graphologyGraph.addEdge(edge.source, edge.target); } const boundingBox = { x1: 0, x2: app!.renderer.screen.width, y1: 0, y2: app!.renderer.screen.height }; @@ -1265,12 +1115,12 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { return ( <> - {popups.map(popup => ( - <Popover key={popup.node._id} open={true} interactive={!dragging} boundaryElement={ref as RefObject<HTMLElement>} showArrow={true}> + {/* {popups.map(popup => ( + <Popover key={popup.node.id} open={true} interactive={!dragging} boundaryElement={ref as RefObject<HTMLElement>} showArrow={true}> <PopoverTrigger x={popup.pos.x} y={popup.pos.y} /> <PopoverContent> - <NodeDetails name={popup.node._id} colorHeader={nodeColorHex(props.graph.nodes[popup.node._id].type)}> - <NLPopUp data={props.graph.nodes[popup.node._id].attributes} /> + <NodeDetails name={popup.node.id} colorHeader={nodeColorHex(props.graph.nodes[popup.node.id].type)}> + <NLPopUp data={props.graph.nodes[popup.node.id].attributes} /> </NodeDetails> </PopoverContent> </Popover> @@ -1284,7 +1134,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { </NodeDetails> </PopoverContent> </Popover> - )} + )} */} <div className="h-full w-full overflow-hidden" ref={ref} diff --git a/src/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx b/src/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx index 20f958e82..821bbf77e 100644 --- a/src/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx @@ -3,10 +3,13 @@ * Utrecht University within the Software Project course. * © Copyright Utrecht University (Department of Information and Computing Sciences) */ -import { EdgeQueryResult, GraphQueryResultMetaFromBackend, ML, NodeQueryResult } from 'ts-common'; +import { VisualizationSettingsType } from '@/lib/vis/common'; +import { dataColors, EdgeQueryResult, GraphQueryResultMetaFromBackend, ML, NodeQueryResult, visualizationColors } from 'ts-common'; +import { v4 as uuidv4 } from 'uuid'; import { GraphQueryResult } from '../../../../data-access/store'; +import { NodelinkVisProps } from '../nodelinkvis'; import { EdgeType, GraphType, NodeType } from '../types'; -import { processML } from './NLMachineLearning'; +import { nodeColor } from './utils'; /** ResultNodeLinkParserUseCase implements methods to parse and translate websocket messages from the backend into a GraphType. */ /** @@ -84,17 +87,112 @@ type OptionsI = { defaultRadius?: number; }; +const getNodeLabel = (node: NodeQueryResult, d3node: NodeType, settings: NodelinkVisProps) => { + // let attribute; + // try { + // attribute = node.attributes[node.label]; + // } catch (e) { + // return node.label ?? ''; + // } + // if (attribute == 'Default' || attribute == null) { + // return node.label ?? ''; + // } + // const value = node.attributes[attribute]; + // if (Array.isArray(value)) { + // return value.join(', '); + // } + // if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') { + // return String(value); + // } + // if (typeof value === 'object' && Object.keys(value).length != 0) { + // return JSON.stringify(value); + // } + // return '-'; +}; + +const getEdgeLabel = (edge: EdgeQueryResult, d3edge: EdgeType, settings: NodelinkVisProps) => { + // let attribute; + // try { + // attribute = imperative.current.getEdgeAttributes()[edgeMeta.attributes.type]; + // } catch (e) { + // return edgeMeta.attributes.type ?? ''; + // } + // if (attribute == 'None') { + // return ''; + // } + // if (attribute == 'Default' || attribute == null) { + // return edgeMeta.attributes.type ?? ''; + // } + // const value = edgeMeta.attributes[attribute]; + // if (Array.isArray(value)) { + // return value.join(', '); + // } + // if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') { + // return String(value); + // } + // if (typeof value === 'object' && Object.keys(value).length != 0) { + // return JSON.stringify(value); + // } + // return ''; +}; + +const LINE_COLOR_DEFAULT = dataColors.neutral[40]; +const LINE_COLOR_SELECTED = visualizationColors.GPSelected.colors[1]; +const LINE_COLOR_ML = dataColors.blue[60]; +const LINE_WIDTH_DEFAULT = 0.8; + +const getEdgeStyle = (edge: EdgeQueryResult, ml: ML) => { + // let color = edge.color || 0x000000; + let color = LINE_COLOR_DEFAULT; + const thickness = (edge.attributes.jaccard_coefficient as number) || 1; + let style = thickness || 1; + let alpha = 1; + let mlEdge = false; + + // Parse ml edges + if (ml != undefined && ml.linkPrediction.result.find(link => link.from === edge.from && link.to === edge.to)) { + mlEdge = true; + } + + if (mlEdge) { + color = LINE_COLOR_ML; + if (thickness > ml.communityDetection.jaccard_threshold) { + style = thickness * 1.8; + } else { + style = 0; + alpha = 0.2; + } + } + + // TODO + // Conditional alpha for search results + // if (searchResults.nodes.length > 0 || searchResults.edges.length > 0) { + // // FIXME: searchResults.edges should be a hashmap to improve performance. + // const isLinkInSearchResults = searchResults.edges.some(resultEdge => resultEdge.id === false); //FIXME: needs to match edge._id + // alpha = isLinkInSearchResults ? 1 : 0.05; + // } + + return { style, color, alpha, thickness }; +}; + /** * Parse a websocket message containing a query result into a node edge GraphType. * @param {any} queryResult An incoming query result from the websocket. * @returns {GraphType} A node-link graph containing the nodes and edges for the diagram. */ -export function parseQueryResult(queryResult: GraphQueryResultMetaFromBackend, ml: ML, options: OptionsI = {}): GraphType { +export function parseQueryResult( + queryResult: GraphQueryResultMetaFromBackend, + ml: ML, + settings: NodelinkVisProps & VisualizationSettingsType, + options: OptionsI = {}, +): GraphType { const ret: GraphType = { nodes: {}, edges: {}, }; + const nodeMap: Record<string, string> = {}; + const typeDict: { [key: string]: number } = {}; // Counter for the types let counter = 1; @@ -106,7 +204,8 @@ export function parseQueryResult(queryResult: GraphQueryResultMetaFromBackend, m const linkPredictionInResult = false; for (let i = 0; i < queryResult.nodes.length; i++) { // Assigns a group to every entity type for color coding - const nodeId = queryResult.nodes[i]._id; + const nodeId = uuidv4(); + nodeMap[queryResult.nodes[i]._id] = nodeId; // for datasets without label, label is included in id. eg. "kamerleden/112" //const entityType = queryResult.nodes[i].label; const node = queryResult.nodes[i]; @@ -125,21 +224,18 @@ export function parseQueryResult(queryResult: GraphQueryResultMetaFromBackend, m } // TODO: this should be a setting - // Check to see if node has a "naam" attribute and set prefText to it if (queryResult.nodes[i].attributes.name !== undefined) preferredText = queryResult.nodes[i].attributes.name as string; if (queryResult.nodes[i].attributes.label !== undefined) preferredText = queryResult.nodes[i].attributes.label as string; - if (queryResult.nodes[i].attributes.naam !== undefined) preferredText = queryResult.nodes[i].attributes.naam as string; const radius = options.defaultRadius || 5; const data: NodeType = { - _id: queryResult.nodes[i]._id, - label: entityType, - attributes: queryResult.nodes[i].attributes, - type: typeNumber, - displayInfo: preferredText, + id: nodeId, + ids: [queryResult.nodes[i]._id], + color: nodeColor(typeNumber), + label: entityType, // TODO radius: radius, - defaultX: (options.defaultX || 0) + Math.random() * radius * 20 - radius * 10, - defaultY: (options.defaultY || 0) + Math.random() * radius * 20 - radius * 10, + x: options.defaultX || 0, + y: options.defaultY || 0, }; // let mlExtra = {}; @@ -163,42 +259,23 @@ export function parseQueryResult(queryResult: GraphQueryResultMetaFromBackend, m // Add mlExtra to the node if necessary // data = { ...data, ...mlExtra }; - ret.nodes[data._id] = data; + ret.nodes[nodeId] = data; } // Filter unique edges and transform to LinkTypes // List for all links - // Parse ml edges - // if (ml != undefined) { - // ml?.linkPrediction?.forEach((link) => { - // if (allNodeIds.has(link.from) && allNodeIds.has(link.to)) { - // const toAdd: LinkType = { - // source: link.from, - // target: link.to, - // value: link.attributes.jaccard_coefficient as number, - // mlEdge: true, - // color: 0x000000, - // }; - // links.push(toAdd); - // } - // linkPredictionInResult = true; - // }); - // } - // Parse normal edges ret.edges = queryResult.edges .map(e => { return { - id: e.from + ':' + e.to + ':' + e.label, - source: e.from, - target: e.to, - value: (e.attributes.jaccard_coefficient as number) || 1, - name: e.label, - mlEdge: false, - color: 0x000000, - attributes: e.attributes, - } as EdgeType; + id: uuidv4(), + ids: [e._id], + source: nodeMap[e.from], + target: nodeMap[e.to], + label: e.label, // TODO + ...getEdgeStyle(e, ml), + }; }) .reduce((a, b) => { return { ...a, [b.id]: b }; @@ -222,5 +299,139 @@ export function parseQueryResult(queryResult: GraphQueryResultMetaFromBackend, m // } // return toBeReturned; - return processML(ml, ret); + // return processML(ml, ret); + return ret; } + +// export function parseLabelAggregationQueryResult( +// queryResult: GraphQueryResultMetaFromBackend, +// ml: ML, +// settings: NodelinkVisProps & VisualizationSettingsType, +// options: OptionsI = {}, +// ): GraphType { +// const labels = queryResult.nodes.map(node => node.label); +// const uniqueLabels = [...new Set(labels)]; + +// const ret: GraphType = { +// nodes: {}, +// edges: {}, +// }; + +// for (let i = 0; i < queryResult.nodes.length; i++) { +// // Assigns a group to every entity type for color coding +// const nodeId = queryResult.nodes[i]._id; +// // for datasets without label, label is included in id. eg. "kamerleden/112" +// //const entityType = queryResult.nodes[i].label; +// const node = queryResult.nodes[i]; +// const entityType: string = node.label; + +// // The preferred text to be shown on top of the node +// let preferredText = nodeId; +// let typeNumber = 1; + +// // Check if entity is already seen by the dictionary +// if (entityType in typeDict) typeNumber = typeDict[entityType]; +// else { +// typeDict[entityType] = counter; +// typeNumber = counter; +// counter++; +// } + +// // TODO: this should be a setting +// if (queryResult.nodes[i].attributes.name !== undefined) preferredText = queryResult.nodes[i].attributes.name as string; +// if (queryResult.nodes[i].attributes.label !== undefined) preferredText = queryResult.nodes[i].attributes.label as string; + +// const radius = options.defaultRadius || 5; +// const data: NodeType = { +// _id: queryResult.nodes[i]._id, +// label: entityType, +// attributes: queryResult.nodes[i].attributes, +// type: typeNumber, +// displayInfo: preferredText, +// radius: radius, +// defaultX: 0, // seems not to be used +// defaultY: 0, // seems not to be used +// }; + +// // let mlExtra = {}; +// // if (queryResult.nodes[i].mldata && typeof queryResult.nodes[i].mldata != 'number') { // TODO FIXME: this is somewhere else now +// // mlExtra = { +// // shortestPathData: queryResult.nodes[i].mldata as Record<string, string[]>, +// // }; +// // shortestPathInResult = true; +// // } else if (typeof queryResult.nodes[i].mldata == 'number') { +// // // mldata + 1 so you dont get 0, which is interpreted as 'undefined' +// // const numberOfCluster = (queryResult.nodes[i].mldata as number) + 1; +// // mlExtra = { +// // cluster: numberOfCluster, +// // clusterAccoringToMLData: numberOfCluster, +// // }; +// // communityDetectionInResult = true; +// // if (numberOfCluster > numberOfMlClusters) { +// // numberOfMlClusters = numberOfCluster; +// // } +// // } + +// // Add mlExtra to the node if necessary +// // data = { ...data, ...mlExtra }; +// ret.nodes[data._id] = data; +// } + +// // Filter unique edges and transform to LinkTypes +// // List for all links + +// // Parse ml edges +// // if (ml != undefined) { +// // ml?.linkPrediction?.forEach((link) => { +// // if (allNodeIds.has(link.from) && allNodeIds.has(link.to)) { +// // const toAdd: LinkType = { +// // source: link.from, +// // target: link.to, +// // value: link.attributes.jaccard_coefficient as number, +// // mlEdge: true, +// // color: 0x000000, +// // }; +// // links.push(toAdd); +// // } +// // linkPredictionInResult = true; +// // }); +// // } + +// // Parse normal edges +// ret.edges = queryResult.edges +// .map(e => { +// return { +// id: e.from + ':' + e.to + ':' + e.label, +// source: e.from, +// target: e.to, +// value: (e.attributes.jaccard_coefficient as number) || 1, +// name: e.label, +// mlEdge: false, +// color: 0x000000, +// attributes: e.attributes, +// } as EdgeType; +// }) +// .reduce((a, b) => { +// return { ...a, [b.id]: b }; +// }, {}); + +// // Graph to be returned +// // let toBeReturned: GraphType = { +// // nodes: nodes, +// // links: links, +// // linkPrediction: linkPredictionInResult, +// // shortestPath: shortestPathInResult, +// // communityDetection: communityDetectionInResult, +// // }; + +// // If query with community detection; add number of clusters to the graph +// // const numberOfClusters = { +// // numberOfMlClusters: numberOfMlClusters, +// // }; +// // if (communityDetectionInResult) { +// // toBeReturned = { ...toBeReturned, ...numberOfClusters }; +// // } + +// // return toBeReturned; +// return processML(ml, ret); +// } diff --git a/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx b/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx index 7ad7507c3..2aaa54340 100644 --- a/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx @@ -1,12 +1,11 @@ import { visualizationColors } from '@/config'; -import { EdgeType, GraphType, NodeType } from '../types'; /** * Colour is a function that takes a string of a number and returns a number of a color out of the d3 color scheme. * @param num Num is the input string representing a number of a colorgroup. * @returns {number} A number corresponding to a color in the d3 color scheme. */ -export function nodeColor(num: number) { +export function nodeColor(num: number): number { // num = num % 4; // const col = '#000000'; //let entityColors = Object.values(visualizationColors.GPSeq.colors[9]); @@ -60,41 +59,3 @@ export function hslStringToHex(hsl: string) { }; return `#${f(0)}${f(8)}${f(4)}`; } - -/** - * Used when you select nodes. - * The highlight is drawn in ticked. - * @param nodes The nodes you want to related edges to. - * @returns {EdgeType[]} All the links related to all the nodes - */ -export const getRelatedLinks = (graph: GraphType, nodes: NodeType[], jaccardThreshold: number): EdgeType[] => { - const relatedLinks: EdgeType[] = []; - Object.keys(graph.edges).forEach(id => { - const link = graph.edges[id]; - const { source, target } = link; - if (isLinkVisible(link, jaccardThreshold)) { - nodes.forEach((node: NodeType) => { - if (source == node._id || target == node._id || source == node._id || target == node._id) { - relatedLinks.push(link); - } - }); - } - }); - return relatedLinks; -}; - -/** - * Checks wheter a link is visible. - * This is used for highlighting nodes. - * @param link The link you want to check wheter it's visable or not - * @returns {boolean} - */ -export function isLinkVisible(link: EdgeType, jaccardThreshold: number): boolean { - //About the next line, If you don't do this here but lets say in the constructor it errors. So far no performance issues where noticed. - if (link.mlEdge) { - if (link.value > jaccardThreshold) { - return true; - } - } else return true; - return false; -} diff --git a/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx b/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx index 0e093296f..d1c3459c1 100644 --- a/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx @@ -1,18 +1,12 @@ -import { canViewFeature } from '@/lib/components/featureFlags'; -import { Input } from '@/lib/components/inputs'; -import { EntityPill } from '@/lib/components/pills/Pill'; -import { SettingsContainer } from '@/lib/vis/components/config'; import { type PointData } from 'pixi.js'; import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'; import { ML, NodeQueryResult } from 'ts-common'; import { useImmer } from 'use-immer'; -import { setShortestPathSource, setShortestPathTarget } from '../../../data-access/store/mlSlice'; import { Layouts, LayoutTypes } from '../../../graph-layout/types'; import { VISComponentType, VisualizationPropTypes, VisualizationSettingsPropTypes } from '../../common'; import { NLPixi } from './components/NLPixi'; import { parseQueryResult } from './components/query2NL'; -import { nodeColorHex } from './components/utils'; -import { EdgeType, GraphType, NodeType, NodeTypeD3 } from './types'; +import { EdgeType, GraphType, NodeType } from './types'; // For backwards compatibility with older saveStates, we migrate information from settings.nodes to settings.location // FIXME: this can be removed once all systems have updated their saveStates. @@ -37,10 +31,15 @@ export interface NodeLinkVisHandle { exportImageInternal: () => void; } +export const NLAggregationTypeArray = ['none', 'entity', 'attribute'] as const; +export type NLAggregationType = (typeof NLAggregationTypeArray)[number]; + export type NodelinkVisProps = { id: string; name: string; layout: LayoutTypes; + aggregation: NLAggregationType; + aggregationAttribute?: string; showPopUpOnHover: boolean; nodes: { shape: { @@ -66,6 +65,7 @@ export type NodelinkVisProps = { const settings: NodelinkVisProps = { id: 'NodeLinkVis', name: 'NodeLinkVis', + aggregation: 'none', layout: Layouts.FORCEATLAS2WEBWORKER, showPopUpOnHover: false, nodes: { @@ -98,11 +98,11 @@ const NodeLinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin useEffect(() => { if (data) { - setGraph(parseQueryResult(data.graph, ml)); + setGraph(parseQueryResult(data.graph, ml, settings)); } }, [data, ml]); - const onClickedNode = (event?: { node: NodeTypeD3; pos: PointData }, ml?: ML) => { + const onClickedNode = (event?: { node: NodeType; pos: PointData }, ml?: ML) => { if (graph) { if (!event?.node) { if (handleSelect) handleSelect(); @@ -110,35 +110,9 @@ const NodeLinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin } const node = event.node; - const nodeMeta = graph.nodes[node._id]; - if (handleSelect) handleSelect({ nodes: [nodeMeta as NodeQueryResult] }); - - if (ml && ml.shortestPath?.enabled) { - setGraph(draft => { - const _node = draft?.nodes[node._id]; - if (!_node) return draft; - - if (!ml.shortestPath.srcNode) { - _node.isShortestPathSource = true; - dispatch(setShortestPathSource(node._id)); - } else if (ml.shortestPath.srcNode === node._id) { - _node.isShortestPathSource = false; - dispatch(setShortestPathSource(undefined)); - } else if (!ml.shortestPath.trtNode) { - _node.isShortestPathTarget = true; - dispatch(setShortestPathTarget(node._id)); - } else if (ml.shortestPath.trtNode === node._id) { - _node.isShortestPathTarget = false; - dispatch(setShortestPathTarget(undefined)); - } else { - _node.isShortestPathSource = true; - _node.isShortestPathTarget = false; - dispatch(setShortestPathSource(node._id)); - dispatch(setShortestPathTarget(undefined)); - } - return draft; - }); - } + const nodeMeta: NodeQueryResult[] = data?.graph.nodes.filter(n => node.ids.includes(n._id)); + if (!nodeMeta) return; + if (handleSelect) handleSelect({ nodes: nodeMeta }); } }; @@ -170,135 +144,7 @@ const NodeLinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin }, ); -const NodelinkSettings = ({ settings, graphMetadata, updateSettings }: VisualizationSettingsPropTypes<NodelinkVisProps>) => { - useEffect(() => { - if (graphMetadata && graphMetadata.nodes && graphMetadata.nodes.labels.length > 0) { - updateSettings({ nodeList: graphMetadata.nodes.labels }); - } - }, [graphMetadata]); - - if (!settings.nodeList) return null; - - return ( - <SettingsContainer> - <div className="mb-4 text-xs"> - <h1 className="font-bold">General</h1> - <div className="m-1 flex flex-col space-y-2 mb-2"> - <h4 className="font-semibold">Nodes Labels:</h4> - {settings.nodeList.map((item, index) => ( - <div className="flex m-1 items-center" key={item}> - <div className="w-3/4 mr-6"> - <EntityPill title={item} /> - </div> - <div className="w-1/2"> - <div className={`h-5 w-5 border-2 border-sec-300`} style={{ backgroundColor: nodeColorHex(index + 1) }}></div> - </div> - </div> - ))} - </div> - <Input - type="dropdown" - label="Layout" - size="sm" - inline={false} - value={settings.layout} - options={Object.values(Layouts) as string[]} - onChange={val => updateSettings({ layout: val as LayoutTypes })} - /> - <Input - type="boolean" - label="Show pop-up on hover" - value={settings.showPopUpOnHover} - onChange={val => updateSettings({ showPopUpOnHover: val })} - /> - </div> - - <div className="mb-4"> - <h1 className="font-bold">Nodes</h1> - <div> - <span className="text-xs font-semibold">Shape</span> - <Input - type="dropdown" - label="Shape" - value={settings.nodes.shape.type} - options={[{ circle: 'Circle' }, { rectangle: 'Square' }]} - onChange={val => - updateSettings({ - nodes: { - ...settings.nodes, - shape: { - ...settings.nodes.shape, - type: val as 'circle' | 'rectangle', - }, - }, - }) - } - /> - </div> - </div> - - <div> - <h1 className="font-bold">Edges</h1> - <div> - <span className="text-xs font-semibold">Edge width</span> - <Input - type="slider" - label="Width" - size="sm" - className="my-1" - value={settings.edges.width.width} - onChangeConfirmed={val => updateSettings({ edges: { ...settings.edges, width: { ...settings.edges.width, width: val } } })} - min={0.1} - max={4} - step={0.1} - /> - </div> - </div> - <div> - <h1 className="font-bold">Labels</h1> - {Object.entries(graphMetadata.edges.types).map(([label, type]) => ( - <Input - type="dropdown" - size="sm" - key={label} - label={label} - value={settings.edges.labelAttributes ? settings.edges.labelAttributes[label] || 'Default' : undefined} - options={['Default', 'None', ...Object.keys(type.attributes).filter(x => x != 'Type')]} - onChange={val => - updateSettings({ - edges: { - ...settings.edges, - labelAttributes: { ...settings.edges.labelAttributes, [label]: val as string }, - }, - }) - } - /> - ))} - </div> - <div> - <Input type="boolean" label="Show arrows" value={settings.showArrows} onChange={val => updateSettings({ showArrows: val })} /> - </div> - <div> - <Input - type="boolean" - label="Show multiple edges" - value={settings.showMultipleEdges} - onChange={val => updateSettings({ showMultipleEdges: val })} - /> - </div> - {canViewFeature('EDGE_BUNDLING') ? ( - <div> - <Input - type="boolean" - label="Edge bundling" - value={settings.edgeBundlingEnabled} - onChange={val => updateSettings({ edgeBundlingEnabled: val })} - /> - </div> - ) : null} - </SettingsContainer> - ); -}; +const NodelinkSettings = ({ settings, graphMetadata, updateSettings }: VisualizationSettingsPropTypes<NodelinkVisProps>) => {}; const nodeLinkVisRef = React.createRef<{ exportImageInternal: () => void }>(); export const NodeLinkComponent: VISComponentType<NodelinkVisProps> = { diff --git a/src/lib/vis/visualizations/nodelinkvis/types.ts b/src/lib/vis/visualizations/nodelinkvis/types.ts index 7c980e1cc..d4f8a59ec 100644 --- a/src/lib/vis/visualizations/nodelinkvis/types.ts +++ b/src/lib/vis/visualizations/nodelinkvis/types.ts @@ -4,99 +4,27 @@ * © Copyright Utrecht University (Department of Information and Computing Sciences) */ -import * as PIXI from 'pixi.js'; -import { NodeQueryResult } from 'ts-common'; - /** Types for the nodes and links in the node-link diagram. */ export type GraphType = { - nodes: Record<string, NodeType>; // _id -> node - edges: Record<string, EdgeType>; // _id -> link - // linkPrediction?: boolean; - // shortestPath?: boolean; - // communityDetection?: boolean; - // numberOfMlClusters?: number; -}; - -export type GraphTypeD3 = { - nodes: NodeTypeD3[]; - edges: EdgeTypeD3[]; + nodes: Record<string, NodeType>; + edges: Record<string, EdgeType>; }; -/** The interface for a node in the node-link diagram */ -export interface NodeType extends NodeQueryResult { - _id: string; - - // Number to determine the color of the node - label: string; - type: number; - attributes: Record<string, any>; - cluster?: number; - clusterAccoringToMLData?: number; - shortestPathData?: Record<string, string[]>; - - // Node that is drawn. - radius: number; - // Text to be displayed on top of the node. - gfxtext?: PIXI.Text; - gfxAttributes?: PIXI.Graphics; - selected?: boolean; - isShortestPathSource?: boolean; - isShortestPathTarget?: boolean; - index?: number; - - // The text that will be shown on top of the node if selected. - displayInfo?: string; - defaultX?: number; - defaultY?: number; -} - -export type NodeTypeD3 = d3.SimulationNodeDatum & { _id: string }; - -/** The interface for a link in the node-link diagram */ -export type EdgeType = { - // The thickness of a line - id: string; - value: number; - name: string; - // To check if an edge is calculated based on a ML algorithm - mlEdge: boolean; +export type NodeType = d3.SimulationNodeDatum & { + id: string; // uuid + ids: string[]; // reverse mapping to original _id color: number; + label?: string; + radius?: number; alpha?: number; - source: string; - target: string; - _source?: NodeTypeD3; - _target?: NodeTypeD3; - attributes: Record<string, any>; }; -export type EdgeTypeD3 = d3.SimulationLinkDatum<NodeTypeD3> & { _id: string }; - -/**collectionNode holds 1 entry per node kind (so for example a MockNode with name "parties" and all associated attributes,) */ -export type TypeNode = { - name: string; //Collection name - attributes: string[]; //attributes. This includes all attributes found in the collection - type: number | undefined; //number that represents collection of node, for colorscheme - visualizations: Visualization[]; //The way to visualize attributes of this Node kind -}; - -export type CommunityDetectionNode = { - cluster: number; //group as used by colouring scheme -}; - -/**Visualization holds the visualization method for an attribute */ -export type Visualization = { - attribute: string; //attribute type (e.g. 'age') - vis: string; //visualization type (e.g. 'radius') -}; - -/** possible colors to pick from*/ -export type Colors = { - name: string; -}; - -/**AssignedColors is a simple holder for color selection */ -export type AssignedColors = { - collection: number | undefined; //number of the collection (type or group) - color: string; //color in hex - default: string; //default color, for easy switching back +export type EdgeType = d3.SimulationLinkDatum<NodeType> & { + id: string; // uuid + ids: string[]; // reverse mapping to original _id + label?: string; + thickness: number; + color: number; + alpha?: number; + style: number; }; -- GitLab From 281ee4582a2f42b50c22d2e3dcf5e00944d78817 Mon Sep 17 00:00:00 2001 From: Leonardo Christino <lchristino@graphpolaris.com> Date: Mon, 31 Mar 2025 14:26:26 +0200 Subject: [PATCH 02/12] fix: recover settings --- .../nodelinkvis/nodelinkvis.tsx | 134 +++++++++++++++++- 1 file changed, 133 insertions(+), 1 deletion(-) diff --git a/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx b/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx index d1c3459c1..b3c5ee195 100644 --- a/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx @@ -1,11 +1,15 @@ +import { EntityPill, Input } from '@/lib/components'; +import { canViewFeature } from '@/lib/components/featureFlags'; import { type PointData } from 'pixi.js'; import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'; import { ML, NodeQueryResult } from 'ts-common'; import { useImmer } from 'use-immer'; import { Layouts, LayoutTypes } from '../../../graph-layout/types'; import { VISComponentType, VisualizationPropTypes, VisualizationSettingsPropTypes } from '../../common'; +import { SettingsContainer } from '../../components/config'; import { NLPixi } from './components/NLPixi'; import { parseQueryResult } from './components/query2NL'; +import { nodeColorHex } from './components/utils'; import { EdgeType, GraphType, NodeType } from './types'; // For backwards compatibility with older saveStates, we migrate information from settings.nodes to settings.location @@ -144,7 +148,135 @@ const NodeLinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin }, ); -const NodelinkSettings = ({ settings, graphMetadata, updateSettings }: VisualizationSettingsPropTypes<NodelinkVisProps>) => {}; +const NodelinkSettings = ({ settings, graphMetadata, updateSettings }: VisualizationSettingsPropTypes<NodelinkVisProps>) => { + useEffect(() => { + if (graphMetadata && graphMetadata.nodes && graphMetadata.nodes.labels.length > 0) { + updateSettings({ nodeList: graphMetadata.nodes.labels }); + } + }, [graphMetadata]); + + if (!settings.nodeList) return null; + + return ( + <SettingsContainer> + <div className="mb-4 text-xs"> + <h1 className="font-bold">General</h1> + <div className="m-1 flex flex-col space-y-2 mb-2"> + <h4 className="font-semibold">Nodes Labels:</h4> + {settings.nodeList.map((item, index) => ( + <div className="flex m-1 items-center" key={item}> + <div className="w-3/4 mr-6"> + <EntityPill title={item} /> + </div> + <div className="w-1/2"> + <div className={`h-5 w-5 border-2 border-sec-300`} style={{ backgroundColor: nodeColorHex(index + 1) }}></div> + </div> + </div> + ))} + </div> + <Input + type="dropdown" + label="Layout" + size="sm" + inline={false} + value={settings.layout} + options={Object.values(Layouts) as string[]} + onChange={val => updateSettings({ layout: val as LayoutTypes })} + /> + <Input + type="boolean" + label="Show pop-up on hover" + value={settings.showPopUpOnHover} + onChange={val => updateSettings({ showPopUpOnHover: val })} + /> + </div> + + <div className="mb-4"> + <h1 className="font-bold">Nodes</h1> + <div> + <span className="text-xs font-semibold">Shape</span> + <Input + type="dropdown" + label="Shape" + value={settings.nodes.shape.type} + options={[{ circle: 'Circle' }, { rectangle: 'Square' }]} + onChange={val => + updateSettings({ + nodes: { + ...settings.nodes, + shape: { + ...settings.nodes.shape, + type: val as 'circle' | 'rectangle', + }, + }, + }) + } + /> + </div> + </div> + + <div> + <h1 className="font-bold">Edges</h1> + <div> + <span className="text-xs font-semibold">Edge width</span> + <Input + type="slider" + label="Width" + size="sm" + className="my-1" + value={settings.edges.width.width} + onChangeConfirmed={val => updateSettings({ edges: { ...settings.edges, width: { ...settings.edges.width, width: val } } })} + min={0.1} + max={4} + step={0.1} + /> + </div> + </div> + <div> + <h1 className="font-bold">Labels</h1> + {Object.entries(graphMetadata.edges.types).map(([label, type]) => ( + <Input + type="dropdown" + size="sm" + key={label} + label={label} + value={settings.edges.labelAttributes ? settings.edges.labelAttributes[label] || 'Default' : undefined} + options={['Default', 'None', ...Object.keys(type.attributes).filter(x => x != 'Type')]} + onChange={val => + updateSettings({ + edges: { + ...settings.edges, + labelAttributes: { ...settings.edges.labelAttributes, [label]: val as string }, + }, + }) + } + /> + ))} + </div> + <div> + <Input type="boolean" label="Show arrows" value={settings.showArrows} onChange={val => updateSettings({ showArrows: val })} /> + </div> + <div> + <Input + type="boolean" + label="Show multiple edges" + value={settings.showMultipleEdges} + onChange={val => updateSettings({ showMultipleEdges: val })} + /> + </div> + {canViewFeature('EDGE_BUNDLING') ? ( + <div> + <Input + type="boolean" + label="Edge bundling" + value={settings.edgeBundlingEnabled} + onChange={val => updateSettings({ edgeBundlingEnabled: val })} + /> + </div> + ) : null} + </SettingsContainer> + ); +}; const nodeLinkVisRef = React.createRef<{ exportImageInternal: () => void }>(); export const NodeLinkComponent: VISComponentType<NodelinkVisProps> = { -- GitLab From 7954a8e888789ac2f04a0b1e8bbb65d08d71d691 Mon Sep 17 00:00:00 2001 From: Leonardo Christino <lchristino@graphpolaris.com> Date: Mon, 31 Mar 2025 14:26:39 +0200 Subject: [PATCH 03/12] optm: optmize multi links in NL --- .../nodelinkvis/components/NLPixi.tsx | 19 +++++++++++++------ .../vis/visualizations/nodelinkvis/types.ts | 4 ++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx index a78ab9887..510e21286 100644 --- a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx @@ -98,6 +98,18 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { container.renderable = false; return container; }, []); + const multipleEdgesMap = useMemo<Record<string, number>>(() => { + return Object.fromEntries( + Object.values(props.graph.edges).map(edge => [ + edge.id, + props.configuration.showMultipleEdges + ? Object.values(props.graph.edges).filter( + x => (x.source === edge.source && x.target === edge.target) || (x.source === edge.target && x.target === edge.source), + ).length + : 0, + ]), + ); + }, [props.configuration.showMultipleEdges, props.graph.edges]); const nodeMap = useRef(new Map<string, Sprite>()); const edgeGfx = new Graphics(); @@ -588,12 +600,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { }; const updateEdge = (edge: EdgeType, edgeBundle?: Point[]) => { - const multiple = - imperative.current.getShowMultipleEdges() && Object.values(props.graph.edges).length < PERF_EDGE_THRESHOLD - ? Object.values(props.graph.edges).filter( - x => (x.source == edge.source && x.target == edge.target) || (x.source == edge.target && x.target == edge.source), - ).length - : 0; + const multiple = multipleEdgesMap[edge.id]; const target = nodeMap.current.get(edge.target as string) as Sprite; const source = nodeMap.current.get(edge.source as string) as Sprite; diff --git a/src/lib/vis/visualizations/nodelinkvis/types.ts b/src/lib/vis/visualizations/nodelinkvis/types.ts index d4f8a59ec..fccae23b0 100644 --- a/src/lib/vis/visualizations/nodelinkvis/types.ts +++ b/src/lib/vis/visualizations/nodelinkvis/types.ts @@ -15,7 +15,7 @@ export type NodeType = d3.SimulationNodeDatum & { ids: string[]; // reverse mapping to original _id color: number; label?: string; - radius?: number; + radius: number; alpha?: number; }; @@ -24,7 +24,7 @@ export type EdgeType = d3.SimulationLinkDatum<NodeType> & { ids: string[]; // reverse mapping to original _id label?: string; thickness: number; - color: number; + color: string; alpha?: number; style: number; }; -- GitLab From 2e714594228bce88ce5874967b05fcfabcaa5339 Mon Sep 17 00:00:00 2001 From: Leonardo Christino <lchristino@graphpolaris.com> Date: Wed, 2 Apr 2025 13:21:56 +0200 Subject: [PATCH 04/12] fix: finalize optimization and slight refactor of logic into a separate folder --- src/lib/vis/components/VisualizationPanel.tsx | 2 +- src/lib/vis/visualizations/index.tsx | 2 +- .../{nodelinkvis.tsx => NodelinkVis.tsx} | 65 ++++-- .../components/NLMachineLearning.tsx | 193 ------------------ .../nodelinkvis/components/NLPixi.tsx | 101 ++++----- .../components/query2NL/NLMachineLearning.ts | 62 ++++++ .../{ => query2NL}/edgeBundling.tsx | 0 .../components/query2NL/edgeStyle.ts | 40 ++++ .../nodelinkvis/components/query2NL/index.ts | 0 .../components/{ => query2NL}/query2NL.tsx | 74 ++----- .../nodelinkvis/components/utils.tsx | 15 +- .../nodelinkvis/nodelinkvis.stories.tsx | 2 +- .../vis/visualizations/nodelinkvis/types.ts | 6 +- 13 files changed, 221 insertions(+), 341 deletions(-) rename src/lib/vis/visualizations/nodelinkvis/{nodelinkvis.tsx => NodelinkVis.tsx} (81%) delete mode 100644 src/lib/vis/visualizations/nodelinkvis/components/NLMachineLearning.tsx create mode 100644 src/lib/vis/visualizations/nodelinkvis/components/query2NL/NLMachineLearning.ts rename src/lib/vis/visualizations/nodelinkvis/components/{ => query2NL}/edgeBundling.tsx (100%) create mode 100644 src/lib/vis/visualizations/nodelinkvis/components/query2NL/edgeStyle.ts create mode 100644 src/lib/vis/visualizations/nodelinkvis/components/query2NL/index.ts rename src/lib/vis/visualizations/nodelinkvis/components/{ => query2NL}/query2NL.tsx (87%) diff --git a/src/lib/vis/components/VisualizationPanel.tsx b/src/lib/vis/components/VisualizationPanel.tsx index d5b0700df..b7a27aa16 100644 --- a/src/lib/vis/components/VisualizationPanel.tsx +++ b/src/lib/vis/components/VisualizationPanel.tsx @@ -16,7 +16,7 @@ export const Visualizations: Record<string, PromiseFunc> = { ...(canViewFeature('TABLEVIS') && { TableVis: () => import('../visualizations/tablevis/tablevis') }), ...(canViewFeature('PAOHVIS') && { PaohVis: () => import('../visualizations/paohvis/paohvis') }), ...(canViewFeature('RAWJSONVIS') && { RawJSONVis: () => import('../visualizations/rawjsonvis/rawjsonvis') }), - ...(canViewFeature('NODELINKVIS') && { NodeLinkVis: () => import('../visualizations/nodelinkvis/nodelinkvis') }), + ...(canViewFeature('NODELINKVIS') && { NodeLinkVis: () => import('../visualizations/nodelinkvis/NodelinkVis') }), ...(canViewFeature('MATRIXVIS') && { MatrixVis: () => import('../visualizations/matrixvis/matrixvis') }), ...(canViewFeature('SEMANTICSUBSTRATESVIS') && { SemanticSubstratesVis: () => import('../visualizations/semanticsubstratesvis/semanticsubstratesvis'), diff --git a/src/lib/vis/visualizations/index.tsx b/src/lib/vis/visualizations/index.tsx index e4aea884e..d3e1cc666 100644 --- a/src/lib/vis/visualizations/index.tsx +++ b/src/lib/vis/visualizations/index.tsx @@ -1,5 +1,5 @@ export * from './matrixvis/matrixvis'; -export * from './nodelinkvis/nodelinkvis'; +export * from './nodelinkvis/NodelinkVis'; export * from './paohvis/paohvis'; export * from './rawjsonvis'; export * from './semanticsubstratesvis/semanticsubstratesvis'; diff --git a/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx b/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx similarity index 81% rename from src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx rename to src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx index b3c5ee195..715a973a0 100644 --- a/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx @@ -1,15 +1,16 @@ -import { EntityPill, Input } from '@/lib/components'; +import { EntityPill, Input, NodeDetails, Popover, PopoverContent, PopoverTrigger } from '@/lib/components'; import { canViewFeature } from '@/lib/components/featureFlags'; import { type PointData } from 'pixi.js'; -import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'; +import React, { forwardRef, RefObject, useEffect, useImperativeHandle, useRef, useState } from 'react'; import { ML, NodeQueryResult } from 'ts-common'; import { useImmer } from 'use-immer'; import { Layouts, LayoutTypes } from '../../../graph-layout/types'; import { VISComponentType, VisualizationPropTypes, VisualizationSettingsPropTypes } from '../../common'; import { SettingsContainer } from '../../components/config'; -import { NLPixi } from './components/NLPixi'; -import { parseQueryResult } from './components/query2NL'; -import { nodeColorHex } from './components/utils'; +import { NLPixi, SelectedNodeType } from './components/NLPixi'; +import { NLPopUp } from './components/NLPopup'; +import { parseQueryResult } from './components/query2NL/query2NL'; +import { hexColorFromBinary, nodeColorHex } from './components/utils'; import { EdgeType, GraphType, NodeType } from './types'; // For backwards compatibility with older saveStates, we migrate information from settings.nodes to settings.location @@ -90,13 +91,15 @@ const settings: NodelinkVisProps = { edgeBundlingEnabled: false, }; -const NodeLinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<NodelinkVisProps>>( +const NodelinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<NodelinkVisProps>>( ({ data, ml, dispatch, settings, handleSelect }, refExternal) => { const ref = useRef<HTMLDivElement>(null); const nlPixiRef = useRef<any>(null); const [graph, setGraph] = useImmer<GraphType | undefined>(undefined); const [highlightNodes, setHighlightNodes] = useState<NodeType[]>([]); const [highlightedLinks, setHighlightedLinks] = useState<EdgeType[]>([]); + const [selectedNodes, setSelectedNodes] = useState<SelectedNodeType[]>([]); + const [dragging, setDragging] = useState<boolean>(false); settings = patchLegacySettings(settings); @@ -114,7 +117,7 @@ const NodeLinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin } const node = event.node; - const nodeMeta: NodeQueryResult[] = data?.graph.nodes.filter(n => node.ids.includes(n._id)); + const nodeMeta: NodeQueryResult[] = data?.graph.nodes.filter(n => node.ids.map(n => n._id).includes(n._id)); if (!nodeMeta) return; if (handleSelect) handleSelect({ nodes: nodeMeta }); } @@ -131,19 +134,39 @@ const NodeLinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin if (!graph) return null; return ( - <NLPixi - ref={nlPixiRef} - graph={graph} - configuration={settings} - highlightNodes={highlightNodes} - highlightedLinks={highlightedLinks} - onClick={event => { - onClickedNode(event, ml); - }} - layoutAlgorithm={settings.layout} - showPopupsOnHover={settings.showPopUpOnHover} - edgeBundlingEnabled={settings.edgeBundlingEnabled} - /> + <> + {selectedNodes.map(selectedNode => ( + <Popover + key={selectedNode.id} + open={true} + interactive={!dragging} + boundaryElement={ref as RefObject<HTMLElement>} + showArrow={true} + > + <PopoverTrigger x={selectedNode.pos.x} y={selectedNode.pos.y} /> + <PopoverContent> + <NodeDetails name={selectedNode.id} colorHeader={hexColorFromBinary(graph.nodes[selectedNode.id].color)}> + <NLPopUp data={data.graph.nodes[graph.nodes[selectedNode.id].ids[0].idx].attributes} /> + </NodeDetails> + </PopoverContent> + </Popover> + ))} + <NLPixi + ref={nlPixiRef} + graph={graph} + setDragging={setDragging} + onSelectedNodes={setSelectedNodes} + configuration={settings} + highlightNodes={highlightNodes} + highlightedLinks={highlightedLinks} + onClick={event => { + onClickedNode(event, ml); + }} + layoutAlgorithm={settings.layout} + showPopupsOnHover={settings.showPopUpOnHover} + edgeBundlingEnabled={settings.edgeBundlingEnabled} + /> + </> ); }, ); @@ -280,7 +303,7 @@ const NodelinkSettings = ({ settings, graphMetadata, updateSettings }: Visualiza const nodeLinkVisRef = React.createRef<{ exportImageInternal: () => void }>(); export const NodeLinkComponent: VISComponentType<NodelinkVisProps> = { - component: React.forwardRef((props: VisualizationPropTypes<NodelinkVisProps>, ref) => <NodeLinkVis {...props} ref={nodeLinkVisRef} />), + component: React.forwardRef((props: VisualizationPropTypes<NodelinkVisProps>, ref) => <NodelinkVis {...props} ref={nodeLinkVisRef} />), settingsComponent: NodelinkSettings, settings: patchLegacySettings(settings), exportImage: () => { diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLMachineLearning.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLMachineLearning.tsx deleted file mode 100644 index d517168ed..000000000 --- a/src/lib/vis/visualizations/nodelinkvis/components/NLMachineLearning.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { useState } from 'react'; -import { ML } from 'ts-common'; -import { EdgeType, GraphType, NodeType } from '../types'; - -export function processLinkPrediction(ml: ML, graph: GraphType): GraphType { - if (ml === undefined || ml.linkPrediction === undefined) return graph; - - if (ml.linkPrediction.enabled) { - const allNodeIds = new Set(Object.keys(graph.nodes)); - ml.linkPrediction.result.forEach(link => { - if (allNodeIds.has(link.from) && allNodeIds.has(link.to)) { - const toAdd: EdgeType = { - id: link.from + ':LP:' + link.to, // TODO: this only supports one link between two nodes - name: 'Link Prediction', - source: link.from, - target: link.to, - value: link.attributes.jaccard_coefficient as number, - mlEdge: true, - color: 0x000000, - attributes: {}, - }; - graph.edges[toAdd.id] = toAdd; - } - }); - } - return graph; -} - -export function processCommunityDetection(ml: ML, graph: GraphType): GraphType { - if (ml === undefined || ml.communityDetection === undefined) return graph; - - if (ml.communityDetection.enabled) { - const allNodeIdMap = new Map<string, number>(); - ml.communityDetection.result.forEach((idSet, i) => { - idSet.forEach(id => { - allNodeIdMap.set(id, i); - }); - }); - - Object.keys(graph.nodes).forEach(nodeId => { - if (allNodeIdMap.has(nodeId)) { - graph.nodes[nodeId].cluster = allNodeIdMap.get(nodeId); - } else { - graph.nodes[nodeId].cluster = -1; - } - }); - } else { - Object.keys(graph.nodes).forEach(nodeId => { - graph.nodes[nodeId].cluster = undefined; - }); - } - return graph; -} - -export function processML(ml: ML, graph: GraphType): GraphType { - let ret = processLinkPrediction(ml, graph); - ret = processCommunityDetection(ml, ret); - return ret; -} - -export const useNLMachineLearning = (props: { - graph: GraphType; - highlightedNodes: NodeType[]; - jaccardThreshold: number; - numberOfMlClusters: number; -}) => { - const [shortestPathEdges, setShortestPathEdges] = useState<EdgeType[]>([]); - - /** - * The actual drawing of the shortest path is done in the ticked method - * This recalculates what should be shown and adds it to a list currentShortestPathEdges - * Small note; the order in which nodes are clicked matters. - * Also turns off highlightLinks - * */ - function showShortestPath(): void { - const shortestPathNodes: NodeType[] = []; - props.highlightedNodes.forEach(node => { - if (node.shortestPathData != undefined) { - shortestPathNodes.push(node); - } - }); - if (shortestPathNodes.length < 2) { - setShortestPathEdges([]); - } - let index = 0; - let allPaths: EdgeType[] = []; - while (index < shortestPathNodes.length - 1) { - const shortestPathData = shortestPathNodes[index].shortestPathData; - if (shortestPathData === undefined) { - console.warn('Something went wrong with shortest path calculation'); - } else { - const path: string[] = shortestPathData[shortestPathNodes[index + 1]._id]; - allPaths = allPaths.concat(getShortestPathEdges(path)); - } - index++; - } - setShortestPathEdges(allPaths); - } - - /** - * Gets the edges corresponding to the shortestPath. - * @param pathString The path as a string. - * @returns The path as a LinkType[] - * @deprecated This function is not working anymore - */ - function getShortestPathEdges(pathString: string[]): EdgeType[] { - try { - const newPath: EdgeType[] = []; - let index = 0; - while (index < pathString.length) { - if (pathString[index + 1] == undefined) { - index++; - continue; - } - const edgeFound = false; - Object.keys(props.graph.edges).forEach(key => { - const link = props.graph.edges[key]; - // if ( - // false // FIXME: This is not working anymore - // // (pathString[index] == source.id && pathString[index + 1] == target.id) || - // // (pathString[index] == source && pathString[index + 1] == target) || - // // (pathString[index + 1] == source.id && pathString[index] == target.id) || - // // (pathString[index + 1] == source && pathString[index] == target) - // ) { - // newPath.push(link); - // edgeFound = true; - // } - }); - if (!edgeFound) { - console.warn('skipped path: ' + pathString[index] + ' ' + pathString[index + 1]); - } - index++; - } - return newPath; - } catch { - return []; - } - } - - //MACHINE LEARNING-------------------------------------------------------------------------------------------------- - // /** - // * updates the JacccardThresh value. - // * This is called in the component - // * This makes testing purposes easier and makes sure you dont have to read out the value 2000 times, - // * but only when you change the value. - // */ - // function updateJaccardThreshHold(): void { - // const slider = document.getElementById('Slider'); - // props.jaccardThreshold = Number(slider?.innerText); - // } - - // /** initializeUniqueAttributes fills the uniqueAttributeValues with data from graph scheme analytics. - // * @param attributeData NodeAttributeData returned by graph scheme analytics. - // * @param attributeDataType Routing key. - // */ - // function initializeUniqueAttributes(attributeData: AttributeData, attributeDataType: string): void { - // if (attributeDataType === 'gsa_node_result') { - // const entity = attributeData as NodeAttributeData; - // entity.attributes.forEach((attribute) => { - // if (attribute.type === AttributeCategory.categorical) { - // const nameAttribute = attribute.name; - // const valuesAttribute = attribute.uniqueCategoricalValues; - // // check if not null - // if (valuesAttribute) { - // this.uniqueAttributeValues[nameAttribute] = valuesAttribute; - // } - // } - // }); - // } - // } - - /** - * resetClusterOfNodes is a function that resets the cluster of the nodes that are being customised by the user, - * after a community detection algorithm, where the cluster of these nodes could have been changed. - */ - const resetClusterOfNodes = (type: number): void => { - Object.keys(props.graph.nodes).forEach(key => { - const node = props.graph.nodes[key]; - if (node.cluster == type) { - node.cluster = props.numberOfMlClusters; - } - if (node.type == type) { - node.cluster = node.type; - } - }); - }; - - return { - shortestPathEdges, - showShortestPath, - resetClusterOfNodes, - }; -}; diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx index 510e21286..ccd1ed007 100644 --- a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx @@ -21,14 +21,15 @@ import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } import { AllLayoutAlgorithms } from 'ts-common/src/model/layouts'; import { useML, useSearchResultData } from '../../../../data-access'; import { GraphologyForceAtlas2Webworker, LayoutFactory, Layouts, LayoutTypes } from '../../../../graph-layout'; -import { NodelinkVisProps } from '../nodelinkvis'; +import { NodelinkVisProps } from '../NodelinkVis'; import { EdgeType, GraphType, NodeType } from '../types'; -import { ForceEdgeBundling, type Point } from './edgeBundling'; +import { ForceEdgeBundling, type Point } from './query2NL/edgeBundling'; import { NLPopUp } from './NLPopup'; import { nodeColor, nodeColorHex } from './utils'; const PERF_EDGE_THRESHOLD = 2500; +export type SelectedNodeType = { id: string; pos: PointData }; type Props = { onClick: (event?: { node: NodeType; pos: PointData }) => void; // onHover: (data: { node: NodeType; pos: PointData }) => void; @@ -41,6 +42,8 @@ type Props = { layoutAlgorithm: LayoutTypes; showPopupsOnHover: boolean; edgeBundlingEnabled: boolean; + setDragging: (dragging: boolean) => void; + onSelectedNodes: (selectedNodes: SelectedNodeType[]) => void; }; type LayoutState = 'reset' | 'running' | 'paused'; @@ -50,9 +53,6 @@ type LayoutState = 'reset' | 'running' | 'paused'; ////////////////// export const NLPixi = forwardRef((props: Props, refExternal) => { - const [quickPopup, setQuickPopup] = useState<{ node: NodeType; pos: PointData } | undefined>(); - const [popups, setPopups] = useState<{ node: NodeType; pos: PointData }[]>([]); - const globalConfig = useConfig(); useEffect(() => { @@ -119,13 +119,13 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { const layoutState = useRef<LayoutState>('reset'); const layoutStoppedCount = useRef(0); const mouseInCanvas = useRef<boolean>(false); - const [dragging, setDragging] = useState<boolean>(false); const isSetup = useRef(false); const ml = useML(); const searchResults = useSearchResultData(); - const layoutAlgorithm = useRef(new LayoutFactory().createLayout<AllLayoutAlgorithms>(Layouts.DAGRE)); + const [selectedNodes, setSelectedNodes] = useState<{ id: string; pos: PointData; onlyHovered?: boolean }[]>([]); + // const cull = new Cull(); // let cullDirty = useRef(true); @@ -240,6 +240,10 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { }); }, [props.layoutAlgorithm, props.configuration, props.configuration.edgeBundlingEnabled]); + useEffect(() => { + props.onSelectedNodes(selectedNodes); + }, [selectedNodes]); + // useEffect(() => { // if (nodeMap.current.size == 0 || props.graph.edges == null) { // metaEdges = null; @@ -271,7 +275,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { if (props.configuration.showPopUpOnHover) return; (event as any).mouseDownTimeStamp = event.timeStamp; - setDragging(true); + props.setDragging(true); }, onMouseUpNode(event: FederatedPointerEvent) { @@ -287,11 +291,11 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { const node = (sprite as any).node as NodeType; if (event.shiftKey) { - setPopups([...popups, { node: node, pos: toGlobal(node) }]); + setSelectedNodes([...selectedNodes, { id: node.id, pos: toGlobal(node) }]); } else { - setPopups([{ node: node, pos: toGlobal(node) }]); - for (const popup of popups) { - const sprite = nodeMap.current.get(popup.node.id) as Sprite; + setSelectedNodes([{ id: node.id, pos: toGlobal(node) }]); + for (const n of selectedNodes) { + const sprite = nodeMap.current.get(n.id) as Sprite; sprite.texture = glyphTexture; (sprite as any).selected = false; } @@ -299,7 +303,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { sprite.texture = selectedTexture; (sprite as any).selected = true; - setDragging(false); + props.setDragging(false); props.onClick({ node: node, pos: toGlobal(node) }); @@ -312,12 +316,12 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { // If its a short click (not a drag) on the stage but not on a node: clear the selection and remove all popups. const holdDownTime = event.timeStamp - (event as any).mouseDownTimeStamp; if (holdDownTime < mouseClickThreshold) { - for (const popup of popups) { - const sprite = nodeMap.current.get(popup.node.id) as Sprite; + for (const n of selectedNodes) { + const sprite = nodeMap.current.get(n.id) as Sprite; sprite.texture = glyphTexture; (sprite as any).selected = false; } - setPopups([]); + setSelectedNodes([]); props.onClick(); } }, @@ -332,25 +336,24 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { viewport?.current && !viewport?.current?.pause && node && - popups.filter(p => p.node.id === node.id).length === 0 + selectedNodes.filter(p => p.id === node.id).length === 0 ) { - setQuickPopup({ node: props.graph.nodes[node.id], pos: toGlobal(node) }); + setSelectedNodes([...selectedNodes, { id: node.id, pos: toGlobal(node), onlyHovered: true }]); } }, onUnHover() { if (!props.configuration.showPopUpOnHover) return; - - setQuickPopup(undefined); + setSelectedNodes(selectedNodes.filter(p => p.onlyHovered !== true)); }, onMoved(viewport: Viewport) { if (props.configuration.showPopUpOnHover) return; - for (const popup of popups) { - if (popup.node.x == null || popup.node.y == null) continue; - popup.pos.x = viewport.position.x + popup.node.x * viewport.scale.x; - popup.pos.y = viewport.position.y + popup.node.y * viewport.scale.y; + for (const n of selectedNodes) { + if (n.pos.x == null || n.pos.y == null) continue; + n.pos.x = viewport.position.x + (props.graph.nodes[n.id].x ?? 0) * viewport.scale.x; + n.pos.y = viewport.position.y + (props.graph.nodes[n.id].y ?? 0) * viewport.scale.y; } - setPopups([...popups]); + setSelectedNodes([...selectedNodes]); }, onZoom() { const scale = viewport.current!.scale.x; @@ -1065,7 +1068,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { imperative.current.onMoved(event.viewport); }); viewport.current.on('drag-end', _ => { - setDragging(false); + props.setDragging(false); }); viewport.current.on('zoomed', _ => { imperative.current.onZoom(); @@ -1121,39 +1124,17 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { // export image return ( - <> - {/* {popups.map(popup => ( - <Popover key={popup.node.id} open={true} interactive={!dragging} boundaryElement={ref as RefObject<HTMLElement>} showArrow={true}> - <PopoverTrigger x={popup.pos.x} y={popup.pos.y} /> - <PopoverContent> - <NodeDetails name={popup.node.id} colorHeader={nodeColorHex(props.graph.nodes[popup.node.id].type)}> - <NLPopUp data={props.graph.nodes[popup.node.id].attributes} /> - </NodeDetails> - </PopoverContent> - </Popover> - ))} - {quickPopup != null && ( - <Popover key={quickPopup.node._id} open={true} boundaryElement={ref as RefObject<HTMLElement>} showArrow={true}> - <PopoverTrigger x={quickPopup.pos.x} y={quickPopup.pos.y} /> - <PopoverContent> - <NodeDetails name={quickPopup.node._id} colorHeader={nodeColorHex(props.graph.nodes[quickPopup.node._id].type)}> - <NLPopUp data={props.graph.nodes[quickPopup.node._id].attributes} /> - </NodeDetails> - </PopoverContent> - </Popover> - )} */} - <div - className="h-full w-full overflow-hidden" - ref={ref} - onMouseEnter={e => { - mouseInCanvas.current = true; - }} - onMouseOut={e => { - mouseInCanvas.current = false; - }} - > - <canvas ref={canvas} /> - </div> - </> + <div + className="h-full w-full overflow-hidden" + ref={ref} + onMouseEnter={e => { + mouseInCanvas.current = true; + }} + onMouseOut={e => { + mouseInCanvas.current = false; + }} + > + <canvas ref={canvas} /> + </div> ); }); diff --git a/src/lib/vis/visualizations/nodelinkvis/components/query2NL/NLMachineLearning.ts b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/NLMachineLearning.ts new file mode 100644 index 000000000..c88846df9 --- /dev/null +++ b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/NLMachineLearning.ts @@ -0,0 +1,62 @@ +import { ML } from 'ts-common'; +import { EdgeType, GraphType } from '../../types'; +import { nodeColor } from '../utils'; +import { LINE_COLOR_ML } from './edgeStyle'; + +export function processLinkPrediction(ml: ML, graph: GraphType): GraphType { + if (ml === undefined || ml.linkPrediction === undefined || !ml.linkPrediction.enabled) return graph; + + if (ml.linkPrediction.enabled) { + const nodeIds = new Set(Object.keys(graph.nodeIdMap)); + ml.linkPrediction.result.forEach(edge => { + if (nodeIds.has(edge.from) && nodeIds.has(edge.to)) { + const toAdd: EdgeType = { + id: edge.from + ':LP:' + edge.to, // TODO: this only supports one link between two nodes + ids: [], + source: graph.nodeIdMap[edge.from], + target: graph.nodeIdMap[edge.to], + label: 'prediction', + color: LINE_COLOR_ML, + style: 1, + thickness: edge.attributes.jaccard_coefficient, + alpha: 1, + }; + graph.edges[toAdd.id] = toAdd; + } + }); + } + return graph; +} + +export function processCommunityDetection(ml: ML, graph: GraphType): GraphType { + if (ml === undefined || ml.communityDetection === undefined || !ml.communityDetection.enabled) return graph; + + console.log('processCommunityDetection', ml.communityDetection); + if (ml.communityDetection.enabled) { + const nodeToColorId = new Map<string, number>(); + ml.communityDetection.result.forEach((idSet, i) => { + idSet.forEach(id => { + nodeToColorId.set(id, i); + }); + }); + + Object.keys(graph.nodes).forEach(nodeId => { + if (nodeToColorId.has(nodeId)) { + graph.nodes[nodeId].color = nodeColor(nodeToColorId.get(nodeId) ?? 0); + } else { + graph.nodes[nodeId].color = nodeColor(-1); + } + }); + } else { + Object.keys(graph.nodes).forEach(nodeId => { + graph.nodes[nodeId].color = nodeColor(-1); + }); + } + return graph; +} + +export function processML(ml: ML, graph: GraphType): GraphType { + let ret = processLinkPrediction(ml, graph); + ret = processCommunityDetection(ml, ret); + return ret; +} diff --git a/src/lib/vis/visualizations/nodelinkvis/components/edgeBundling.tsx b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/edgeBundling.tsx similarity index 100% rename from src/lib/vis/visualizations/nodelinkvis/components/edgeBundling.tsx rename to src/lib/vis/visualizations/nodelinkvis/components/query2NL/edgeBundling.tsx diff --git a/src/lib/vis/visualizations/nodelinkvis/components/query2NL/edgeStyle.ts b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/edgeStyle.ts new file mode 100644 index 000000000..5e0dac48e --- /dev/null +++ b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/edgeStyle.ts @@ -0,0 +1,40 @@ +import { dataColors, EdgeQueryResult, ML, visualizationColors } from 'ts-common'; + +export const LINE_COLOR_DEFAULT = dataColors.neutral[40]; +export const LINE_COLOR_SELECTED = visualizationColors.GPSelected.colors[1]; +export const LINE_COLOR_ML = dataColors.blue[60]; +export const LINE_WIDTH_DEFAULT = 0.8; + +export const getEdgeStyle = (edge: EdgeQueryResult, ml: ML) => { + // let color = edge.color || 0x000000; + let color = LINE_COLOR_DEFAULT; + const thickness = (edge.attributes.jaccard_coefficient as number) || 1; + let style = thickness || 1; + let alpha = 1; + let mlEdge = false; + + // Parse ml edges + if (ml != undefined && ml.linkPrediction.result.find(edge => edge.from === edge.from && edge.to === edge.to)) { + mlEdge = true; + } + + if (mlEdge) { + color = LINE_COLOR_ML; + if (thickness > ml.communityDetection.jaccard_threshold) { + style = thickness * 1.8; + } else { + style = 0; + alpha = 0.2; + } + } + + // TODO + // Conditional alpha for search results + // if (searchResults.nodes.length > 0 || searchResults.edges.length > 0) { + // // FIXME: searchResults.edges should be a hashmap to improve performance. + // const isLinkInSearchResults = searchResults.edges.some(resultEdge => resultEdge.id === false); //FIXME: needs to match edge._id + // alpha = isLinkInSearchResults ? 1 : 0.05; + // } + + return { style, color, alpha, thickness }; +}; diff --git a/src/lib/vis/visualizations/nodelinkvis/components/query2NL/index.ts b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/query2NL.tsx similarity index 87% rename from src/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx rename to src/lib/vis/visualizations/nodelinkvis/components/query2NL/query2NL.tsx index 821bbf77e..bb33dd8b9 100644 --- a/src/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/query2NL.tsx @@ -4,12 +4,14 @@ * © Copyright Utrecht University (Department of Information and Computing Sciences) */ import { VisualizationSettingsType } from '@/lib/vis/common'; -import { dataColors, EdgeQueryResult, GraphQueryResultMetaFromBackend, ML, NodeQueryResult, visualizationColors } from 'ts-common'; +import { EdgeQueryResult, GraphQueryResultMetaFromBackend, ML, NodeQueryResult } from 'ts-common'; import { v4 as uuidv4 } from 'uuid'; -import { GraphQueryResult } from '../../../../data-access/store'; -import { NodelinkVisProps } from '../nodelinkvis'; -import { EdgeType, GraphType, NodeType } from '../types'; -import { nodeColor } from './utils'; +import { GraphQueryResult } from '../../../../../data-access/store'; +import { NodelinkVisProps } from '../../NodelinkVis'; +import { EdgeType, GraphType, NodeType } from '../../types'; +import { nodeColor } from '../utils'; +import { processML } from './NLMachineLearning'; +import { getEdgeStyle } from './edgeStyle'; /** ResultNodeLinkParserUseCase implements methods to parse and translate websocket messages from the backend into a GraphType. */ /** @@ -136,45 +138,6 @@ const getEdgeLabel = (edge: EdgeQueryResult, d3edge: EdgeType, settings: Nodelin // return ''; }; -const LINE_COLOR_DEFAULT = dataColors.neutral[40]; -const LINE_COLOR_SELECTED = visualizationColors.GPSelected.colors[1]; -const LINE_COLOR_ML = dataColors.blue[60]; -const LINE_WIDTH_DEFAULT = 0.8; - -const getEdgeStyle = (edge: EdgeQueryResult, ml: ML) => { - // let color = edge.color || 0x000000; - let color = LINE_COLOR_DEFAULT; - const thickness = (edge.attributes.jaccard_coefficient as number) || 1; - let style = thickness || 1; - let alpha = 1; - let mlEdge = false; - - // Parse ml edges - if (ml != undefined && ml.linkPrediction.result.find(link => link.from === edge.from && link.to === edge.to)) { - mlEdge = true; - } - - if (mlEdge) { - color = LINE_COLOR_ML; - if (thickness > ml.communityDetection.jaccard_threshold) { - style = thickness * 1.8; - } else { - style = 0; - alpha = 0.2; - } - } - - // TODO - // Conditional alpha for search results - // if (searchResults.nodes.length > 0 || searchResults.edges.length > 0) { - // // FIXME: searchResults.edges should be a hashmap to improve performance. - // const isLinkInSearchResults = searchResults.edges.some(resultEdge => resultEdge.id === false); //FIXME: needs to match edge._id - // alpha = isLinkInSearchResults ? 1 : 0.05; - // } - - return { style, color, alpha, thickness }; -}; - /** * Parse a websocket message containing a query result into a node edge GraphType. * @param {any} queryResult An incoming query result from the websocket. @@ -189,10 +152,10 @@ export function parseQueryResult( const ret: GraphType = { nodes: {}, edges: {}, + nodeIdMap: {}, + edgeIdMap: {}, }; - const nodeMap: Record<string, string> = {}; - const typeDict: { [key: string]: number } = {}; // Counter for the types let counter = 1; @@ -205,7 +168,7 @@ export function parseQueryResult( for (let i = 0; i < queryResult.nodes.length; i++) { // Assigns a group to every entity type for color coding const nodeId = uuidv4(); - nodeMap[queryResult.nodes[i]._id] = nodeId; + ret.nodeIdMap[queryResult.nodes[i]._id] = nodeId; // for datasets without label, label is included in id. eg. "kamerleden/112" //const entityType = queryResult.nodes[i].label; const node = queryResult.nodes[i]; @@ -230,7 +193,7 @@ export function parseQueryResult( const radius = options.defaultRadius || 5; const data: NodeType = { id: nodeId, - ids: [queryResult.nodes[i]._id], + ids: [{ _id: queryResult.nodes[i]._id, idx: i }], color: nodeColor(typeNumber), label: entityType, // TODO radius: radius, @@ -267,12 +230,13 @@ export function parseQueryResult( // Parse normal edges ret.edges = queryResult.edges - .map(e => { + .map((e, i) => { + ret.edgeIdMap[e._id] = uuidv4(); return { - id: uuidv4(), - ids: [e._id], - source: nodeMap[e.from], - target: nodeMap[e.to], + id: ret.edgeIdMap[e._id], + ids: [{ _id: e._id, idx: i }], + source: ret.nodeIdMap[e.from], + target: ret.nodeIdMap[e.to], label: e.label, // TODO ...getEdgeStyle(e, ml), }; @@ -298,9 +262,7 @@ export function parseQueryResult( // toBeReturned = { ...toBeReturned, ...numberOfClusters }; // } - // return toBeReturned; - // return processML(ml, ret); - return ret; + return processML(ml, ret); } // export function parseLabelAggregationQueryResult( diff --git a/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx b/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx index 2aaa54340..348f56c1b 100644 --- a/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx @@ -6,11 +6,7 @@ import { visualizationColors } from '@/config'; * @returns {number} A number corresponding to a color in the d3 color scheme. */ export function nodeColor(num: number): number { - // num = num % 4; - // const col = '#000000'; - //let entityColors = Object.values(visualizationColors.GPSeq.colors[9]); - const col = visualizationColors.GPCat.colors[14][(num - 1) % visualizationColors.GPCat.colors[14].length]; - return binaryColor(col); + return binaryColor(nodeColorHex(num)); } export function nodeColorHex(num: number) { @@ -18,7 +14,9 @@ export function nodeColorHex(num: number) { // const col = '#000000'; //let entityColors = Object.values(visualizationColors.GPSeq.colors[9]); - const col = visualizationColors.GPCat.colors[14][(num - 1) % visualizationColors.GPCat.colors[14].length]; + const colors = visualizationColors.GPCat.colors[14]; + const index = (((num - 1) % colors.length) + colors.length) % colors.length; // wrap around numbers + const col = colors[index]; return col; } @@ -26,6 +24,11 @@ export function binaryColor(color: string) { return Number('0x' + color.replace('#', '')); } +export function hexColorFromBinary(num: number) { + const hex = num.toString(16); + return '#' + hex.padStart(6, '0'); +} + export function uniq<T>(items: T[]): T[] { return Array.from(new Set<T>(items)); } diff --git a/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.stories.tsx b/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.stories.tsx index 87bd1d615..fa6ec8667 100644 --- a/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.stories.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.stories.tsx @@ -4,7 +4,7 @@ import { Meta } from '@storybook/react'; import { Provider } from 'react-redux'; import { graphQueryResultSlice, schemaSlice, searchResultSlice, visualizationSlice } from '../../../data-access/store'; import { mockData } from '../../../mock-data'; -import { NodeLinkComponent } from './nodelinkvis'; +import { NodeLinkComponent } from './NodelinkVis'; const Mockstore = configureStore({ reducer: { diff --git a/src/lib/vis/visualizations/nodelinkvis/types.ts b/src/lib/vis/visualizations/nodelinkvis/types.ts index fccae23b0..9a7766fb5 100644 --- a/src/lib/vis/visualizations/nodelinkvis/types.ts +++ b/src/lib/vis/visualizations/nodelinkvis/types.ts @@ -8,11 +8,13 @@ export type GraphType = { nodes: Record<string, NodeType>; edges: Record<string, EdgeType>; + nodeIdMap: Record<string, string>; // maps the original _id of the graph result to generated ids of this struct + edgeIdMap: Record<string, string>; }; export type NodeType = d3.SimulationNodeDatum & { id: string; // uuid - ids: string[]; // reverse mapping to original _id + ids: { _id: string; idx: number }[]; // reverse mapping to original _id and index in original graph color: number; label?: string; radius: number; @@ -21,7 +23,7 @@ export type NodeType = d3.SimulationNodeDatum & { export type EdgeType = d3.SimulationLinkDatum<NodeType> & { id: string; // uuid - ids: string[]; // reverse mapping to original _id + ids: { _id: string; idx: number }[]; // reverse mapping to original _id and index in original graph label?: string; thickness: number; color: string; -- GitLab From 3b6efd2b603a8bf91e60aa61504b888e5db17a63 Mon Sep 17 00:00:00 2001 From: Dennis Collaris <d.collaris@me.com> Date: Wed, 2 Apr 2025 14:51:51 +0200 Subject: [PATCH 05/12] perf: prevent expensive Object.values or keys if not strictly necessary --- .../nodelinkvis/components/NLPixi.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx index ccd1ed007..e290d43a5 100644 --- a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx @@ -178,7 +178,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { sprite.scale.set(scale, scale); }); - if (Object.keys(props.graph.nodes).length > config.LABEL_MAX_NODES) return; + if (nodeMap.current.size > config.LABEL_MAX_NODES) return; // Change font size at specific scale intervals const fontSize = @@ -365,7 +365,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { setResponsiveScale(1); } - if (Object.values(props.graph.nodes).length < config.LABEL_MAX_NODES) { + if (nodeMap.current.size < config.LABEL_MAX_NODES) { edgeLabelLayer.alpha = scale > 2 ? Math.min(1, (scale - 2) * 3) : 0; if (edgeLabelLayer.alpha > 0) { @@ -739,7 +739,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { }; const updateEdgeLabel = (edge: EdgeType) => { - if (Object.keys(props.graph.nodes).length > config.LABEL_MAX_NODES || viewport.current!.scale.x < 2) return; + if (edgeLabelMap.current.size > config.LABEL_MAX_NODES || viewport.current!.scale.x < 2) return; const text = edgeLabelMap.current.get(edge.id); if (!text || edge.label == null) return; @@ -786,7 +786,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { }; const updateNodeLabel = (node: NodeType) => { - if (Object.keys(props.graph.nodes).length > config.LABEL_MAX_NODES || viewport.current!.scale.x < 2) return; + if (nodeMap.current.size > config.LABEL_MAX_NODES || viewport.current!.scale.x < 2) return; const text = nodeLabelMap.current.get(node.id) as Text | undefined; if (text == null || node.label == null) return; @@ -920,7 +920,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { updateNodeLabel(node); }); - if (stopped === Object.keys(props.graph.nodes).length) { + if (stopped === nodeMap.current.size) { layoutStoppedCount.current = layoutStoppedCount.current + 1; if (layoutStoppedCount.current > 500) { layoutState.current = 'paused'; @@ -938,7 +938,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { edgeGfx.clear(); if (props.graph != null && nodeMap.current.size !== 0 && props.graph.edges != null) { - if (Object.keys(props.graph.edges).length > PERF_EDGE_THRESHOLD) { + if (edgeLabelMap.current.size > PERF_EDGE_THRESHOLD) { // If many many edges, only update edges roughy 1/3th the time, dont draw labels, dont do edge bundling. if (Math.random() > 0.3) { for (const edge of Object.values(props.graph.edges)) { @@ -1007,7 +1007,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { } }); - if (Object.keys(props.graph.nodes).length < config.LABEL_MAX_NODES) { + if (edgeLabelMap.current.size < config.LABEL_MAX_NODES) { for (const edge of Object.values(props.graph.edges)) { if (!forceClear && edgeLabelMap.current.has(edge.id)) { updateEdgeLabel(edge); -- GitLab From 0a17a8391493f439d7f9177bb3493c4ddee02c95 Mon Sep 17 00:00:00 2001 From: Dennis Collaris <d.collaris@me.com> Date: Wed, 2 Apr 2025 14:53:17 +0200 Subject: [PATCH 06/12] fix: random initialisation for large datasets was way too large --- src/lib/graph-layout/graphologyLayouts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/graph-layout/graphologyLayouts.ts b/src/lib/graph-layout/graphologyLayouts.ts index 8867f29ee..6afa23e73 100644 --- a/src/lib/graph-layout/graphologyLayouts.ts +++ b/src/lib/graph-layout/graphologyLayouts.ts @@ -105,7 +105,7 @@ export class GraphologyRandom extends GraphologyLayout { // To directly assign the positions to the nodes: random.assign(graph, { - scale: (graph.order * graph.order) / 10, + scale: Math.sqrt(graph.order) * 40, ...this.defaultLayoutSettings, center: 0, }); -- GitLab From 6c7eee129bffe36a32f6ea0140a764cf79492434 Mon Sep 17 00:00:00 2001 From: Dennis Collaris <d.collaris@me.com> Date: Wed, 2 Apr 2025 15:03:42 +0200 Subject: [PATCH 07/12] perf: reduce is very slow, just setting in a for of loop is much faster --- .../components/query2NL/query2NL.tsx | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/lib/vis/visualizations/nodelinkvis/components/query2NL/query2NL.tsx b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/query2NL.tsx index bb33dd8b9..93ff757bd 100644 --- a/src/lib/vis/visualizations/nodelinkvis/components/query2NL/query2NL.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/query2NL.tsx @@ -229,21 +229,22 @@ export function parseQueryResult( // List for all links // Parse normal edges - ret.edges = queryResult.edges - .map((e, i) => { - ret.edgeIdMap[e._id] = uuidv4(); - return { - id: ret.edgeIdMap[e._id], - ids: [{ _id: e._id, idx: i }], - source: ret.nodeIdMap[e.from], - target: ret.nodeIdMap[e.to], - label: e.label, // TODO - ...getEdgeStyle(e, ml), - }; - }) - .reduce((a, b) => { - return { ...a, [b.id]: b }; - }, {}); + const partialEdges = queryResult.edges.map((e, i) => { + ret.edgeIdMap[e._id] = uuidv4(); + return { + id: ret.edgeIdMap[e._id], + ids: [{ _id: e._id, idx: i }], + source: ret.nodeIdMap[e.from], + target: ret.nodeIdMap[e.to], + label: e.label, // TODO + ...getEdgeStyle(e, ml), + }; + }); + + ret.edges = {}; + for (const edge of partialEdges) { + ret.edges[edge.id] = edge; + } // Graph to be returned // let toBeReturned: GraphType = { -- GitLab From 6168c057e7ba8b11d3e844cbf5ea51c03bd8afc6 Mon Sep 17 00:00:00 2001 From: Dennis Collaris <d.collaris@me.com> Date: Wed, 2 Apr 2025 15:19:13 +0200 Subject: [PATCH 08/12] perf: eliminate more Object.values and keys calls in favor of for in loops --- .../nodelinkvis/components/NLPixi.tsx | 47 +++++++++++-------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx index e290d43a5..21442b263 100644 --- a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx @@ -165,7 +165,8 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { useEffect(() => { if (nodeMap.current.size === 0) return; - Object.values(props.graph.nodes).forEach(node => { + for (const id in props.graph.nodes) { + const node = props.graph.nodes[id]; const sprite = nodeMap.current.get(node.id) as Sprite; sprite.texture = (sprite as any).selected ? selectedTexture : glyphTexture; @@ -176,7 +177,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { let scale = (Math.max(node.radius || 5, 5) / config.NODE_RADIUS) * 2; scale *= responsiveScale; sprite.scale.set(scale, scale); - }); + } if (nodeMap.current.size > config.LABEL_MAX_NODES) return; @@ -196,9 +197,10 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { text.resolution = Math.ceil(1 / responsiveScale); }); - Object.values(props.graph.nodes).forEach((node: any) => { + for (const id in props.graph.nodes) { + const node = props.graph.nodes[id]; updateNodeLabel(node); - }); + } }, [responsiveScale, props.configuration.nodes?.shape?.type]); const [config, setConfig] = useState({ @@ -855,13 +857,14 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { useEffect(() => { if (props.graph) { - Object.values(props.graph.nodes).forEach(node => { + for (const id in props.graph.nodes) { + const node = props.graph.nodes[id]; const gfx = nodeMap.current.get(node.id); if (!gfx) return; const isNodeInSearchResults = searchResults.nodes.some(resultNode => resultNode.id === node.id); gfx.alpha = isNodeInSearchResults || searchResults.nodes.length === 0 ? 1 : 0.05; - }); + } } }, [searchResults]); @@ -890,7 +893,8 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { const widthHalf = app.renderer.width / 2; const heightHalf = app.renderer.height / 2; - Object.values(props.graph.nodes).forEach((node, i) => { + for (const id in props.graph.nodes) { + const node = props.graph.nodes[id]; const gfx = nodeMap.current.get(node.id); if (!gfx || node.x === undefined || node.y === undefined) { stopped += 1; @@ -918,7 +922,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { gfx.position.copyFrom(node as PointData); updateNodeLabel(node); - }); + } if (stopped === nodeMap.current.size) { layoutStoppedCount.current = layoutStoppedCount.current + 1; @@ -940,11 +944,12 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { if (props.graph != null && nodeMap.current.size !== 0 && props.graph.edges != null) { if (edgeLabelMap.current.size > PERF_EDGE_THRESHOLD) { // If many many edges, only update edges roughy 1/3th the time, dont draw labels, dont do edge bundling. - if (Math.random() > 0.3) { - for (const edge of Object.values(props.graph.edges)) { + // if (Math.random() > 0.3) { // TODO it is actually working pretty nice without this now. Let's test and see if/when we reinstate. + for (const id in props.graph.edges) { + const edge = props.graph.edges[id]; updateEdge(edge); } - } + // } } else { for (const [i, edge] of Object.values(props.graph.edges).entries()) { if (edgeBundling != null && imperative.current.getEdgeBundlingEnabled()) { @@ -994,7 +999,8 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { edgeGfx.clear(); - Object.values(props.graph.nodes).forEach(node => { + for (const id in props.graph.nodes) { + const node = props.graph.nodes[id]; if (!forceClear && nodeMap.current.has(node.id)) { const old = nodeMap.current.get(node.id); @@ -1005,11 +1011,12 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { } else { createNode(node); } - }); + } - if (edgeLabelMap.current.size < config.LABEL_MAX_NODES) { - for (const edge of Object.values(props.graph.edges)) { - if (!forceClear && edgeLabelMap.current.has(edge.id)) { + if (nodeMap.current.size < config.LABEL_MAX_NODES) { + for (const id in props.graph.edges) { + const edge = props.graph.edges[id]; + if (!forceClear && edgeLabelMap.current.has(edge.ids[0]._id)) { updateEdgeLabel(edge); } else { createEdgeLabel(edge); @@ -1099,7 +1106,8 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { if (!layoutAlgorithm) throw Error('LayoutAlgorithm is undefined'); const graphologyGraph = new MultiGraph(); - Object.values(props.graph.nodes).forEach(node => { + for (const id in props.graph.nodes) { + const node = props.graph.nodes[id]; if (forceClear) graphologyGraph.addNode(node.id, { size: node.radius || 5 }); else graphologyGraph.addNode(node.id, { @@ -1107,9 +1115,10 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { x: node.x || 0, y: node.y || 0, }); - }); + } - for (const edge of Object.values(props.graph.edges)) { + for (const id in props.graph.edges) { + const edge = props.graph.edges[id]; graphologyGraph.addEdge(edge.source, edge.target); } const boundingBox = { x1: 0, x2: app!.renderer.screen.width, y1: 0, y2: app!.renderer.screen.height }; -- GitLab From 3236dc6071bc0176acfa85529186fbc56b147764 Mon Sep 17 00:00:00 2001 From: Leonardo Christino <lchristino@graphpolaris.com> Date: Mon, 14 Apr 2025 10:44:34 +0200 Subject: [PATCH 09/12] fix: better popup and fix to only rerun nl parser on new graph --- .../nodelinkvis/NodelinkVis.tsx | 37 +++++----- .../nodelinkvis/components/NLPixi.tsx | 20 +++--- .../nodelinkvis/components/NLPopup.tsx | 72 ++++++++++--------- .../nodelinkvis/components/utils.tsx | 3 +- 4 files changed, 73 insertions(+), 59 deletions(-) diff --git a/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx b/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx index 715a973a0..0990169a8 100644 --- a/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx @@ -105,9 +105,10 @@ const NodelinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin useEffect(() => { if (data) { + console.log('parseQueryResult!!!', data); setGraph(parseQueryResult(data.graph, ml, settings)); } - }, [data, ml]); + }, [data.graph, ml]); const onClickedNode = (event?: { node: NodeType; pos: PointData }, ml?: ML) => { if (graph) { @@ -135,22 +136,24 @@ const NodelinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin return ( <> - {selectedNodes.map(selectedNode => ( - <Popover - key={selectedNode.id} - open={true} - interactive={!dragging} - boundaryElement={ref as RefObject<HTMLElement>} - showArrow={true} - > - <PopoverTrigger x={selectedNode.pos.x} y={selectedNode.pos.y} /> - <PopoverContent> - <NodeDetails name={selectedNode.id} colorHeader={hexColorFromBinary(graph.nodes[selectedNode.id].color)}> - <NLPopUp data={data.graph.nodes[graph.nodes[selectedNode.id].ids[0].idx].attributes} /> - </NodeDetails> - </PopoverContent> - </Popover> - ))} + {selectedNodes.map(selectedNode => { + return ( + <Popover + key={selectedNode.id} + open={true} + interactive={!dragging} + boundaryElement={ref as RefObject<HTMLElement>} + showArrow={true} + > + <PopoverTrigger x={selectedNode.pos.x} y={selectedNode.pos.y} /> + <PopoverContent> + <NodeDetails name={selectedNode.id} colorHeader={hexColorFromBinary(graph.nodes[selectedNode.id].color)}> + <NLPopUp data={data.graph.nodes[graph.nodes[selectedNode.id].ids[0].idx].attributes} /> + </NodeDetails> + </PopoverContent> + </Popover> + ); + })} <NLPixi ref={nlPixiRef} graph={graph} diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx index 21442b263..390149220 100644 --- a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx @@ -24,8 +24,6 @@ import { GraphologyForceAtlas2Webworker, LayoutFactory, Layouts, LayoutTypes } f import { NodelinkVisProps } from '../NodelinkVis'; import { EdgeType, GraphType, NodeType } from '../types'; import { ForceEdgeBundling, type Point } from './query2NL/edgeBundling'; -import { NLPopUp } from './NLPopup'; -import { nodeColor, nodeColorHex } from './utils'; const PERF_EDGE_THRESHOLD = 2500; @@ -167,7 +165,9 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { for (const id in props.graph.nodes) { const node = props.graph.nodes[id]; - const sprite = nodeMap.current.get(node.id) as Sprite; + const sprite = nodeMap.current.get(node.id); + if (!sprite) continue; + sprite.texture = (sprite as any).selected ? selectedTexture : glyphTexture; // To calculate the scale, we: @@ -290,7 +290,8 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { } const sprite = event.target as Sprite; - const node = (sprite as any).node as NodeType; + const nodeId = (sprite as any).node as number; + const node = props.graph.nodes[nodeId]; if (event.shiftKey) { setSelectedNodes([...selectedNodes, { id: node.id, pos: toGlobal(node) }]); @@ -332,7 +333,8 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { if (!props.configuration.showPopUpOnHover) return; const sprite = event.target as Sprite; - const node = (sprite as any).node as NodeType; + const nodeId = (sprite as any).node as number; + const node = props.graph.nodes[nodeId]; if ( mouseInCanvas.current && viewport?.current && @@ -548,7 +550,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { nodeLayer.addChild(sprite); updateNode(node); - (sprite as any).node = node; + (sprite as any).node = node.id; // Node label const attribute = node.label; @@ -946,9 +948,9 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { // If many many edges, only update edges roughy 1/3th the time, dont draw labels, dont do edge bundling. // if (Math.random() > 0.3) { // TODO it is actually working pretty nice without this now. Let's test and see if/when we reinstate. for (const id in props.graph.edges) { - const edge = props.graph.edges[id]; - updateEdge(edge); - } + const edge = props.graph.edges[id]; + updateEdge(edge); + } // } } else { for (const [i, edge] of Object.values(props.graph.edges).entries()) { diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx index 891e458d7..731bdcebb 100644 --- a/src/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx @@ -15,7 +15,7 @@ export const NLPopUp: React.FC<NLPopUpProps> = ({ data }) => { const [didCopy, setDidCopy] = useState<string | null>(null); return ( <TooltipProvider delay={100}> - <div className={`px-2`}> + <div className={`px-2 overflow-auto max-h-96`}> {Object.keys(data).length === 0 ? ( <div className="flex justify-center items-center h-full"> <span>No attributes</span> @@ -24,38 +24,46 @@ export const NLPopUp: React.FC<NLPopUpProps> = ({ data }) => { Object.entries(data).map(([k, v]) => { if (v?.toString().length > ATTRIBUTE_MAX_CHARACTERS) return; return ( - <div className="flex flex-row gap-1 items-center min-h-5" key={k}> - <span className={`font-semibold truncate min-w-[40%]`}>{k}</span> - <span className="ml-auto text-right truncate grow-1 flex items-center"> - {v !== undefined && (typeof v !== 'object' || Array.isArray(v)) && v !== null ? ( - <Tooltip open={didCopy === k} placement="top"> - <TooltipContent>Copied!</TooltipContent> - <TooltipTrigger> - <span - className="ml-auto text-right truncate" - onDoubleClick={e => { - const value = typeof v === 'number' ? formatNumber(v) : v.toString(); - navigator.clipboard.writeText(value); - setDidCopy(k); - window.getSelection()?.empty(); - setTimeout(() => setDidCopy(null), 1000); + <Tooltip> + <TooltipTrigger> + <div className="flex flex-row gap-1 items-center min-h-5" key={k}> + <span className={`font-semibold truncate min-w-[40%]`}>{k}</span> + <span className="ml-auto text-right truncate grow-1 flex items-center"> + {v !== undefined && (typeof v !== 'object' || Array.isArray(v)) && v !== null ? ( + <Tooltip open={didCopy === k} placement="top"> + <TooltipContent>Copied!</TooltipContent> + <TooltipTrigger> + <span + className="ml-auto text-right truncate" + onDoubleClick={e => { + const value = typeof v === 'number' ? formatNumber(v) : v.toString(); + navigator.clipboard.writeText(value); + setDidCopy(k); + window.getSelection()?.empty(); + setTimeout(() => setDidCopy(null), 1000); + }} + > + {typeof v === 'number' ? formatNumber(v) : v.toString()} + </span> + </TooltipTrigger> + </Tooltip> + ) : ( + <div + className={`ml-auto mt-auto h-4 w-12 border-[1px] solid border-gray`} + style={{ + background: + 'repeating-linear-gradient(-45deg, transparent, transparent 6px, #eaeaea 6px, #eaeaea 8px), linear-gradient(to bottom, transparent, transparent)', }} - > - {typeof v === 'number' ? formatNumber(v) : v.toString()} - </span> - </TooltipTrigger> - </Tooltip> - ) : ( - <div - className={`ml-auto mt-auto h-4 w-12 border-[1px] solid border-gray`} - style={{ - background: - 'repeating-linear-gradient(-45deg, transparent, transparent 6px, #eaeaea 6px, #eaeaea 8px), linear-gradient(to bottom, transparent, transparent)', - }} - ></div> - )} - </span> - </div> + ></div> + )} + </span> + </div> + </TooltipTrigger> + <TooltipContent className="w-fit max-w-fit"> + <span className="font-semibold">{k}: </span> + <span className="text-sm">{v == null || v == '' ? 'empty' : v?.toString()}</span> + </TooltipContent> + </Tooltip> ); }) )} diff --git a/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx b/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx index 348f56c1b..8d29d4698 100644 --- a/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx @@ -24,7 +24,8 @@ export function binaryColor(color: string) { return Number('0x' + color.replace('#', '')); } -export function hexColorFromBinary(num: number) { +export function hexColorFromBinary(num?: number) { + if (num == null) return '#000000'; const hex = num.toString(16); return '#' + hex.padStart(6, '0'); } -- GitLab From 11c0bbefc136ce838f96caecd314bd9bc40d628c Mon Sep 17 00:00:00 2001 From: Leonardo Christino <lchristino@graphpolaris.com> Date: Mon, 14 Apr 2025 11:56:24 +0200 Subject: [PATCH 10/12] fix: nl edge label and width --- src/lib/graph-layout/cytoscapeLayouts.ts | 4 ++ src/lib/graph-layout/dagreLayout.ts | 4 ++ src/lib/graph-layout/graphologyLayouts.ts | 10 +++- src/lib/graph-layout/layout.ts | 4 ++ .../nodelinkvis/NodelinkVis.tsx | 8 ++- .../nodelinkvis/components/NLPixi.tsx | 40 +++++++------- .../components/query2NL/edgeStyle.ts | 6 ++- .../components/query2NL/query2NL.tsx | 53 +++++++++---------- 8 files changed, 74 insertions(+), 55 deletions(-) diff --git a/src/lib/graph-layout/cytoscapeLayouts.ts b/src/lib/graph-layout/cytoscapeLayouts.ts index d52062333..251163268 100644 --- a/src/lib/graph-layout/cytoscapeLayouts.ts +++ b/src/lib/graph-layout/cytoscapeLayouts.ts @@ -116,6 +116,10 @@ export abstract class CytoscapeLayout extends Layout<CytoscapeProvider> { super('Cytoscape', algorithm); } + public override cleanup() { + // Cleanup logic if needed + } + public override async layout( graph: Graph<Attributes, Attributes, Attributes>, boundingBox?: { x1: number; x2: number; y1: number; y2: number }, diff --git a/src/lib/graph-layout/dagreLayout.ts b/src/lib/graph-layout/dagreLayout.ts index d15840d9b..37a03dd9f 100644 --- a/src/lib/graph-layout/dagreLayout.ts +++ b/src/lib/graph-layout/dagreLayout.ts @@ -21,6 +21,10 @@ export class DagreLayout extends Layout<DagreProvider> { super('Dagre', 'Dagre_Dagre'); } + public override cleanup() { + // Cleanup logic if needed + } + public override async layout( graph: Graph<Attributes, Attributes, Attributes>, boundingBox?: { x1: number; x2: number; y1: number; y2: number }, diff --git a/src/lib/graph-layout/graphologyLayouts.ts b/src/lib/graph-layout/graphologyLayouts.ts index 6afa23e73..1d1ed3246 100644 --- a/src/lib/graph-layout/graphologyLayouts.ts +++ b/src/lib/graph-layout/graphologyLayouts.ts @@ -52,6 +52,10 @@ export abstract class GraphologyLayout extends Layout<GraphologyProvider> { super('Graphology', algorithm); } + public override cleanup() { + // Cleanup logic if needed + } + /** * Retrieves the position of a node in the graph layout. * @param nodeId - The ID of the node. @@ -199,7 +203,9 @@ export class GraphologyForceAtlas2Webworker extends GraphologyLayout { super('Graphology_forceAtlas2_webworker'); } - public cleanup() { + public override cleanup() { + console.log('Cleaning up layout webworker'); + this._layout?.stop(); this._layout?.kill(); } @@ -231,7 +237,7 @@ export class GraphologyForceAtlas2Webworker extends GraphologyLayout { // stop the layout after 60 seconds setTimeout(() => { - console.log('Stopping layout after set threshold'); + console.log('Stopping nl webworker layout after set threshold'); this._layout?.stop(); }, 60000); } diff --git a/src/lib/graph-layout/layout.ts b/src/lib/graph-layout/layout.ts index 0f9cdf371..98e64d48c 100644 --- a/src/lib/graph-layout/layout.ts +++ b/src/lib/graph-layout/layout.ts @@ -14,6 +14,10 @@ export abstract class Layout<provider extends Providers> { // console.info(`Created the following Layout: ${provider} - ${this.algorithm}`); } + public cleanup() { + // Cleanup logic if needed + } + public setVerbose(verbose: boolean) { this.verbose = verbose; } diff --git a/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx b/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx index 0990169a8..b5c4b9149 100644 --- a/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx @@ -101,14 +101,12 @@ const NodelinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin const [selectedNodes, setSelectedNodes] = useState<SelectedNodeType[]>([]); const [dragging, setDragging] = useState<boolean>(false); - settings = patchLegacySettings(settings); - useEffect(() => { if (data) { - console.log('parseQueryResult!!!', data); - setGraph(parseQueryResult(data.graph, ml, settings)); + const graph = parseQueryResult(data.graph, ml, settings); + setGraph(graph); } - }, [data.graph, ml]); + }, [data.graph, ml, settings.edges, settings.nodes, settings.layout]); const onClickedNode = (event?: { node: NodeType; pos: PointData }, ml?: ML) => { if (graph) { diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx index 390149220..1f21c4968 100644 --- a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx @@ -1,4 +1,4 @@ -import { dataColors, visualizationColors } from '@/config'; +import { dataColors } from '@/config'; import { canViewFeature } from '@/lib/components/featureFlags'; import { useConfig } from '@/lib/data-access/store'; import { Theme } from '@/lib/data-access/store/configSlice'; @@ -25,6 +25,7 @@ import { NodelinkVisProps } from '../NodelinkVis'; import { EdgeType, GraphType, NodeType } from '../types'; import { ForceEdgeBundling, type Point } from './query2NL/edgeBundling'; +const layoutFactory = new LayoutFactory(); const PERF_EDGE_THRESHOLD = 2500; export type SelectedNodeType = { id: string; pos: PointData }; @@ -78,10 +79,10 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { }, [canvas]); useEffect(() => { - if (app == null) return; + if (app == null || props.graph == null) return; - setup(); - }, [app]); + setup(true); + }, [app, props.graph]); const nodeLayer = useMemo(() => new Container(), []); const edgeLabelLayer = useMemo(() => { @@ -218,9 +219,6 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { // NODE_BORDER_COLOR_SELECTED: dataColors.orange[60], LINE_COLOR_DEFAULT: dataColors.neutral[40], - LINE_COLOR_SELECTED: visualizationColors.GPSelected.colors[1], - LINE_COLOR_ML: dataColors.blue[60], - LINE_WIDTH_DEFAULT: 0.8, }); const glyphTexture = useMemo(() => { @@ -387,10 +385,6 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { } }, - getEdgeWidth() { - return props.configuration.edges.width.width || config.LINE_WIDTH_DEFAULT; - }, - getBackgroundColor() { // Colors corresponding to .bg-light class return globalConfig.currentTheme === Theme.dark ? 0x121621 : 0xffffff; @@ -847,7 +841,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { }); const layout = layoutAlgorithm.current as GraphologyForceAtlas2Webworker; - if (layout?.cleanup != null) layout.cleanup(); + layout.cleanup(); }; }, []); @@ -1048,8 +1042,15 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { * It creates graphic objects and adds these to the PIXI containers. It also clears both of these of previous nodes and links. * @param graph The graph returned from the database and that is parsed into a nodelist and edgelist. */ - const setup = () => { - if (app == null || isSetup.current) return; + const setup = (restart = false) => { + if (app == null) return; + if (!restart && isSetup.current) return; + + // Ensure any previous PIXI application is completely cleaned up. + app.stage.removeChildren(); + app.ticker.stop(); + app.renderer.clear(); + nodeLayer.removeChildren(); edgeLabelLayer.removeChildren(); app.stage.removeChildren(); @@ -1090,7 +1091,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { nodeMap.current.clear(); edgeGfx.clear(); - app.ticker.add(tick); + app.ticker.add(tick.bind(this)); // NOTE: this fixes a weird bug that every once in a while results in a white screen in Chrome app.ticker.start(); @@ -1102,10 +1103,13 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { }; const setupLayout = (forceClear: boolean) => { - const layoutFactory = new LayoutFactory(); - layoutAlgorithm.current = layoutFactory.createLayout(config.LAYOUT_ALGORITHM); + layoutAlgorithm.current.cleanup(); + + if (layoutAlgorithm.current.algorithm !== config.LAYOUT_ALGORITHM) { + layoutAlgorithm.current = layoutFactory.createLayout(config.LAYOUT_ALGORITHM); + } - if (!layoutAlgorithm) throw Error('LayoutAlgorithm is undefined'); + if (!layoutAlgorithm?.current) throw Error('LayoutAlgorithm is undefined'); const graphologyGraph = new MultiGraph(); for (const id in props.graph.nodes) { diff --git a/src/lib/vis/visualizations/nodelinkvis/components/query2NL/edgeStyle.ts b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/edgeStyle.ts index 5e0dac48e..cd772edae 100644 --- a/src/lib/vis/visualizations/nodelinkvis/components/query2NL/edgeStyle.ts +++ b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/edgeStyle.ts @@ -1,14 +1,16 @@ +import { VisualizationSettingsType } from '@/lib/vis/common'; import { dataColors, EdgeQueryResult, ML, visualizationColors } from 'ts-common'; +import { NodelinkVisProps } from '../../NodelinkVis'; export const LINE_COLOR_DEFAULT = dataColors.neutral[40]; export const LINE_COLOR_SELECTED = visualizationColors.GPSelected.colors[1]; export const LINE_COLOR_ML = dataColors.blue[60]; export const LINE_WIDTH_DEFAULT = 0.8; -export const getEdgeStyle = (edge: EdgeQueryResult, ml: ML) => { +export const getEdgeStyle = (edge: EdgeQueryResult, ml: ML, settings: NodelinkVisProps & VisualizationSettingsType) => { // let color = edge.color || 0x000000; let color = LINE_COLOR_DEFAULT; - const thickness = (edge.attributes.jaccard_coefficient as number) || 1; + const thickness = ((edge.attributes.jaccard_coefficient as number) || 1) * settings.edges.width.width; let style = thickness || 1; let alpha = 1; let mlEdge = false; diff --git a/src/lib/vis/visualizations/nodelinkvis/components/query2NL/query2NL.tsx b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/query2NL.tsx index 93ff757bd..d0002b883 100644 --- a/src/lib/vis/visualizations/nodelinkvis/components/query2NL/query2NL.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/query2NL.tsx @@ -8,7 +8,7 @@ import { EdgeQueryResult, GraphQueryResultMetaFromBackend, ML, NodeQueryResult } import { v4 as uuidv4 } from 'uuid'; import { GraphQueryResult } from '../../../../../data-access/store'; import { NodelinkVisProps } from '../../NodelinkVis'; -import { EdgeType, GraphType, NodeType } from '../../types'; +import { GraphType, NodeType } from '../../types'; import { nodeColor } from '../utils'; import { processML } from './NLMachineLearning'; import { getEdgeStyle } from './edgeStyle'; @@ -112,30 +112,26 @@ const getNodeLabel = (node: NodeQueryResult, d3node: NodeType, settings: Nodelin // return '-'; }; -const getEdgeLabel = (edge: EdgeQueryResult, d3edge: EdgeType, settings: NodelinkVisProps) => { - // let attribute; - // try { - // attribute = imperative.current.getEdgeAttributes()[edgeMeta.attributes.type]; - // } catch (e) { - // return edgeMeta.attributes.type ?? ''; - // } - // if (attribute == 'None') { - // return ''; - // } - // if (attribute == 'Default' || attribute == null) { - // return edgeMeta.attributes.type ?? ''; - // } - // const value = edgeMeta.attributes[attribute]; - // if (Array.isArray(value)) { - // return value.join(', '); - // } - // if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') { - // return String(value); - // } - // if (typeof value === 'object' && Object.keys(value).length != 0) { - // return JSON.stringify(value); - // } - // return ''; +const getEdgeLabel = (edge: EdgeQueryResult, settings: NodelinkVisProps): string => { + const attribute = settings.edges.labelAttributes[edge.label]; + + if (attribute == 'None') { + return ''; + } + if (attribute == 'Default' || attribute == null) { + return String(edge.attributes.type ?? ''); + } + const value = edge.attributes[attribute]; + if (Array.isArray(value)) { + return value.join(', '); + } + if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') { + return String(value); + } + if (typeof value === 'object' && Object.keys(value as object).length != 0) { + return JSON.stringify(value); + } + return ''; }; /** @@ -167,7 +163,7 @@ export function parseQueryResult( const linkPredictionInResult = false; for (let i = 0; i < queryResult.nodes.length; i++) { // Assigns a group to every entity type for color coding - const nodeId = uuidv4(); + const nodeId = queryResult.nodes[i]._id; ret.nodeIdMap[queryResult.nodes[i]._id] = nodeId; // for datasets without label, label is included in id. eg. "kamerleden/112" //const entityType = queryResult.nodes[i].label; @@ -231,13 +227,14 @@ export function parseQueryResult( // Parse normal edges const partialEdges = queryResult.edges.map((e, i) => { ret.edgeIdMap[e._id] = uuidv4(); + const label = getEdgeLabel(e, settings); return { id: ret.edgeIdMap[e._id], ids: [{ _id: e._id, idx: i }], source: ret.nodeIdMap[e.from], target: ret.nodeIdMap[e.to], - label: e.label, // TODO - ...getEdgeStyle(e, ml), + label: label, + ...getEdgeStyle(e, ml, settings), }; }); -- GitLab From 3df22900efd6c83435d6a2353ecfa5a56f47c371 Mon Sep 17 00:00:00 2001 From: Leonardo Christino <lchristino@graphpolaris.com> Date: Mon, 14 Apr 2025 12:15:14 +0200 Subject: [PATCH 11/12] fix: popup only shows inside nl --- src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx | 6 ++++-- .../vis/visualizations/nodelinkvis/components/NLPixi.tsx | 3 +-- .../vis/visualizations/nodelinkvis/components/NLPopup.tsx | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx b/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx index b5c4b9149..889b60eb9 100644 --- a/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx @@ -133,8 +133,10 @@ const NodelinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin if (!graph) return null; return ( - <> + <div ref={ref} className="relative h-full w-full"> {selectedNodes.map(selectedNode => { + if (selectedNode.pos.x === undefined || selectedNode.pos.y === undefined) return null; + return ( <Popover key={selectedNode.id} @@ -167,7 +169,7 @@ const NodelinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin showPopupsOnHover={settings.showPopUpOnHover} edgeBundlingEnabled={settings.edgeBundlingEnabled} /> - </> + </div> ); }, ); diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx index 1f21c4968..2d78d2345 100644 --- a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx @@ -19,7 +19,7 @@ import { } from 'pixi.js'; import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { AllLayoutAlgorithms } from 'ts-common/src/model/layouts'; -import { useML, useSearchResultData } from '../../../../data-access'; +import { useSearchResultData } from '../../../../data-access'; import { GraphologyForceAtlas2Webworker, LayoutFactory, Layouts, LayoutTypes } from '../../../../graph-layout'; import { NodelinkVisProps } from '../NodelinkVis'; import { EdgeType, GraphType, NodeType } from '../types'; @@ -119,7 +119,6 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { const layoutStoppedCount = useRef(0); const mouseInCanvas = useRef<boolean>(false); const isSetup = useRef(false); - const ml = useML(); const searchResults = useSearchResultData(); const layoutAlgorithm = useRef(new LayoutFactory().createLayout<AllLayoutAlgorithms>(Layouts.DAGRE)); diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx index 731bdcebb..33bebaaaf 100644 --- a/src/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx @@ -24,7 +24,7 @@ export const NLPopUp: React.FC<NLPopUpProps> = ({ data }) => { Object.entries(data).map(([k, v]) => { if (v?.toString().length > ATTRIBUTE_MAX_CHARACTERS) return; return ( - <Tooltip> + <Tooltip key={k}> <TooltipTrigger> <div className="flex flex-row gap-1 items-center min-h-5" key={k}> <span className={`font-semibold truncate min-w-[40%]`}>{k}</span> -- GitLab From 9d4c4950393235b747d63ae0ffc957d4dea847b0 Mon Sep 17 00:00:00 2001 From: Leonardo Christino <lchristino@graphpolaris.com> Date: Mon, 14 Apr 2025 12:27:58 +0200 Subject: [PATCH 12/12] fix: reset responsive scale on new data --- src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx index 2d78d2345..8512dfd05 100644 --- a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx @@ -1095,6 +1095,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { // NOTE: this fixes a weird bug that every once in a while results in a white screen in Chrome app.ticker.start(); + setResponsiveScale(1); imperative.current?.resize(); isSetup.current = true; -- GitLab