From d3cafcc594d21ee4f484b968146b00098d44bf1d Mon Sep 17 00:00:00 2001 From: Dennis Collaris <d.a.c.collaris@uu.nl> Date: Tue, 9 Jul 2024 15:58:02 +0000 Subject: [PATCH] feat(Vis): implement edge labels for the node-link visualization --- .../nodelinkvis/components/NLPixi.tsx | 228 ++++++++++++++---- .../nodelinkvis/components/query2NL.tsx | 1 + .../vis/visualizations/nodelinkvis/types.ts | 1 + 3 files changed, 184 insertions(+), 46 deletions(-) diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx index ba9d39d82..0cd2b32b9 100644 --- a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx +++ b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx @@ -11,6 +11,7 @@ import { IPointData, Sprite, Assets, + Text, Texture, Resource, } from 'pixi.js'; @@ -56,14 +57,21 @@ export const NLPixi = (props: Props) => { antialias: true, autoDensity: true, eventMode: 'auto', - resolution: window.devicePixelRatio || 1, + resolution: window.devicePixelRatio || 2, }), [], ); const nodeLayer = useMemo(() => new Container(), []); + const labelLayer = useMemo(() => { + const container = new Container(); + container.alpha = 0; + container.renderable = false; + return container; + }, []); const nodeMap = useRef(new Map<string, Sprite>()); const linkGfx = new Graphics(); + const labelMap = useRef(new Map<string, Text>()); const viewport = useRef<Viewport>(); const layoutState = useRef<LayoutState>('reset'); const layoutStoppedCount = useRef(0); @@ -90,6 +98,8 @@ export const NLPixi = (props: Props) => { width: 1000, height: 1000, + LABEL_MAX_NODES: 1000, + LAYOUT_ALGORITHM: Layouts.FORCEATLAS2WEBWORKER, NODE_RADIUS: 5, @@ -208,6 +218,29 @@ export const NLPixi = (props: Props) => { } setPopups([...popups]); }, + onZoom(event: FederatedPointerEvent) { + const scale = viewport.current!.transform.scale.x; + + if (graph.current.nodes.length < config.LABEL_MAX_NODES) { + labelLayer.alpha = (scale > 2) ? Math.min(1, (scale - 2) * 3) : 0; + + if (labelLayer.alpha > 0) { + labelLayer.renderable = true; + + const scale = 1 / viewport.current!.scale.x; // starts from 0.5 down to 0. + + // Only change the fontSize for specific intervals, continuous change has too big of an impact on performance + const fontSize = (scale < 0.1) ? 30 : (scale < 0.2) ? 40 : (scale < 0.3) ? 50 : 60; + const strokeWidth = fontSize / 2; + labelMap.current.forEach((text) => { + text.style.fontSize = fontSize; + text.style.strokeThickness = strokeWidth; + }); + } else { + labelLayer.renderable = false; + } + } + } })); function resize() { @@ -300,6 +333,7 @@ export const NLPixi = (props: Props) => { const scale = (Math.max(nodeMeta.radius || 5, 5) / 70) * 2; sprite.scale.set(scale, scale); sprite.anchor.set(0.5, 0.5); + sprite.cullable = true; sprite.eventMode = 'static'; sprite.on('mousedown', (e) => imperative.current.onMouseDown(e)); @@ -316,13 +350,30 @@ export const NLPixi = (props: Props) => { return sprite; }; - // /** UpdateRadius works just like UpdateColors, but also applies radius*/ - // const UpdateRadius = (graph: GraphType, radius: number) => { - // // update for each node in graph - // graph.nodes.forEach((node: NodeType) => { - // createNode(node); - // }); - // }; + const createLinkLabel = (link: LinkTypeD3) => { + // check if link is already drawn, and if so, delete it + if (link && link?._id && labelMap.current.has(link._id)) { + labelMap.current.delete(link._id); + } + + const linkMeta = props.graph.links[link._id]; + + const text = new Text(linkMeta.name, { + fontSize: 60, + fill: config.LINE_COLOR_DEFAULT, + stroke: 0xffffff, + strokeThickness: 30, + }); + text.cullable = true; + text.anchor.set(0.5, 0.5); + text.scale.set(0.1, .1); + labelMap.current.set(link._id, text); + labelLayer.addChild(text); + + updateLinkLabel(link); + + return text; + }; const updateLink = (link: LinkTypeD3) => { if (!props.graph || nodeMap.current.size === 0) return; @@ -356,52 +407,110 @@ export const NLPixi = (props: Props) => { return; } - if (linkGfx) { - // let color = link.color || 0x000000; - let color = config.LINE_COLOR_DEFAULT; - let style = config.LINE_WIDTH_DEFAULT; - let alpha = linkMeta.alpha || 1; - if (linkMeta.mlEdge) { - color = config.LINE_COLOR_ML; + // let color = link.color || 0x000000; + let color = config.LINE_COLOR_DEFAULT; + let style = config.LINE_WIDTH_DEFAULT; + let alpha = linkMeta.alpha || 1; + if (linkMeta.mlEdge) { + color = config.LINE_COLOR_ML; + if (linkMeta.value > ml.communityDetection.jaccard_threshold) { + style = linkMeta.value * 1.8; + } else { + style = 0; + alpha = 0.2; + } + } else if (props.highlightedLinks && props.highlightedLinks.includes(linkMeta)) { + if (linkMeta.mlEdge && ml.communityDetection.jaccard_threshold) { if (linkMeta.value > ml.communityDetection.jaccard_threshold) { + color = dataColors.magenta[50]; + // 0xaa00ff; style = linkMeta.value * 1.8; - } else { - style = 0; - alpha = 0.2; } - } else if (props.highlightedLinks && props.highlightedLinks.includes(linkMeta)) { - if (linkMeta.mlEdge && ml.communityDetection.jaccard_threshold) { - if (linkMeta.value > ml.communityDetection.jaccard_threshold) { - color = dataColors.magenta[50]; - // 0xaa00ff; - style = linkMeta.value * 1.8; - } - } else { - color = dataColors.red[70]; - // color = 0xff0000; - style = 1.0; - } - } else if (props.currentShortestPathEdges && props.currentShortestPathEdges.includes(linkMeta)) { - color = dataColors.green[50]; - // color = 0x00ff00; - style = 3.0; + } else { + color = dataColors.red[70]; + // color = 0xff0000; + style = 1.0; } + } else if (props.currentShortestPathEdges && props.currentShortestPathEdges.includes(linkMeta)) { + 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 === link._id); - alpha = isLinkInSearchResults ? 1 : 0.05; - } + // 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 === link._id); + alpha = isLinkInSearchResults ? 1 : 0.05; + } + + linkGfx + .lineStyle(style, hslStringToHex(color), alpha) + .moveTo(source.x || 0, source.y || 0) + .lineTo(target.x || 0, target.y || 0); + }; + + const updateLinkLabel = (link: LinkTypeD3) => { + const text = labelMap.current.get(link._id); + if (!text) return; + + const _source = link.source; + const _target = link.target; + + if (!_source || !_target) { + return; + } + + const source = nodeMap.current.get(link.source as string) as Sprite; + const target = nodeMap.current.get(link.target as string) as Sprite; + + text.x = (source.x + target.x) / 2; + text.y = (source.y + target.y) / 2; + + const length = Math.hypot(target.x - source.x, target.y - source.y); - linkGfx - .lineStyle(style, hslStringToHex(color), alpha) - .moveTo(source.x || 0, source.y || 0) - .lineTo(target.x || 0, target.y || 0); + // Skip rendering labels on very short edges + if (length < text.width + 10) { // 10 to account for size of node + text.alpha = 0; + return; } else { - throw Error('Link not found'); + text.alpha = 1; } - }; + + const rads = Math.atan2(target.y - source.y, target.x - source.x); + text.rotation = rads + + const degrees = Math.abs(text.angle % 360); + + // Rotate edge labels to always be legible + if (degrees > 90 && degrees < 270) { + text.rotation = rads + Math.PI; + } else { + text.rotation = rads; + } + } + + + // const text = labelMap.current.get(link._id); + // if (!text) return; + + // const source = link.source as NodeTypeD3; + // const target = link.target as NodeTypeD3; + + // if (source.x == null || source.y == null || target.x == null || target.y == null) return; + + // text.x = (source.x + target.x) / 2; + // text.y = (source.y + target.y) / 2; + + // const rads = Math.atan2(target.y - source.y, target.x - source.x); + // const degrees = Math.abs(text.angle % 360); + + // // Rotate edge labels to always be legible + // if (degrees > 90 && degrees < 270) { + // text.rotation = rads + Math.PI; + // } else { + // text.rotation = rads; + // } async function loadAssets() { if (!Assets.cache.has('texture')) { @@ -420,8 +529,10 @@ export const NLPixi = (props: Props) => { loadAssets(); return () => { nodeMap.current.clear(); + labelMap.current.clear(); linkGfx.clear(); nodeLayer.removeChildren(); + labelLayer.removeChildren(); }; }, []); @@ -496,6 +607,7 @@ export const NLPixi = (props: Props) => { linkGfx.beginFill(); graph.current.links.forEach((link: any) => { updateLink(link); + updateLinkLabel(link); }); linkGfx.endFill(); } @@ -509,6 +621,7 @@ export const NLPixi = (props: Props) => { nodeMap.current.clear(); linkGfx.clear(); nodeLayer.removeChildren(); + labelLayer.removeChildren(); } nodeMap.current.forEach((gfx, id) => { @@ -519,6 +632,14 @@ export const NLPixi = (props: Props) => { } }); + labelMap.current.forEach((text, id) => { + if (!graph.current.links.find((link) => link._id === id)) { + labelLayer.removeChild(text); + text.destroy(); + labelMap.current.delete(id); + } + }); + linkGfx.clear(); graph.current.nodes.forEach((node) => { @@ -533,6 +654,16 @@ export const NLPixi = (props: Props) => { } }); + if (graph.current.nodes.length < config.LABEL_MAX_NODES) { + graph.current.links.forEach((link) => { + if (!forceClear && labelMap.current.has(link._id)) { + updateLinkLabel(link); + } else { + createLinkLabel(link); + } + }); + } + // // update text colour (written after nodes so that text appears on top of nodes) // nodes.forEach((node: NodeType) => { // if (node.gfxAttributes !== undefined) { @@ -561,6 +692,7 @@ export const NLPixi = (props: Props) => { */ const setup = async () => { nodeLayer.removeChildren(); + labelLayer.removeChildren(); app.stage.removeChildren(); if (!props.graph) throw Error('Graph is undefined'); @@ -589,6 +721,7 @@ export const NLPixi = (props: Props) => { viewport.current.drag().pinch().wheel({ smooth: 2 }).animate({}).decelerate({ friction: 0.75 }); viewport.current.addChild(linkGfx); + viewport.current.addChild(labelLayer); viewport.current.addChild(nodeLayer); viewport.current.on('moved', (event) => { imperative.current.onMoved(event); @@ -596,6 +729,9 @@ export const NLPixi = (props: Props) => { viewport.current.on('drag-end', (event) => { setDragging(false); }); + viewport.current.on('zoomed', (event) => { + imperative.current.onZoom(event); + }); app.stage.eventMode = 'dynamic'; app.stage.on('mousedown', (e) => imperative.current.onMouseDown(e)); diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx b/libs/shared/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx index 952d6bc4e..ef0aba7b5 100644 --- a/libs/shared/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx +++ b/libs/shared/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx @@ -247,6 +247,7 @@ export function parseQueryResult(queryResult: GraphQueryResult, ml: ML, options: source: uniqueEdges[i].from, target: uniqueEdges[i].to, value: uniqueEdges[i].count, + name: uniqueEdges[i].attributes.Type, mlEdge: false, color: 0x000000, }; diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/types.ts b/libs/shared/lib/vis/visualizations/nodelinkvis/types.ts index 321f20198..4d039f916 100644 --- a/libs/shared/lib/vis/visualizations/nodelinkvis/types.ts +++ b/libs/shared/lib/vis/visualizations/nodelinkvis/types.ts @@ -57,6 +57,7 @@ export interface LinkType { // 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; color: number; -- GitLab