From efb8e55d1df65803dea0e99879dfad81b3fe484a Mon Sep 17 00:00:00 2001 From: Dennis Collaris <d.a.c.collaris@uu.nl> Date: Thu, 25 Jul 2024 16:11:29 +0000 Subject: [PATCH] feat: implement node labels for the node link visualization --- apps/web/public/assets/sprite.png | Bin 687 -> 0 bytes apps/web/public/assets/sprite_selected.png | Bin 697 -> 0 bytes .../public/assets/sprite_selected_square.png | Bin 96 -> 0 bytes apps/web/public/assets/sprite_square.png | Bin 344 -> 0 bytes .../nodelinkvis/components/NLPixi.tsx | 255 +++++++++++++----- 5 files changed, 184 insertions(+), 71 deletions(-) delete mode 100644 apps/web/public/assets/sprite.png delete mode 100644 apps/web/public/assets/sprite_selected.png delete mode 100644 apps/web/public/assets/sprite_selected_square.png delete mode 100644 apps/web/public/assets/sprite_square.png diff --git a/apps/web/public/assets/sprite.png b/apps/web/public/assets/sprite.png deleted file mode 100644 index a9d77b0520b6b4945bbe38e04553e4e349f1794e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 687 zcmeAS@N?(olHy`uVBq!ia0vp^ZXnFT3?e@WZ39vj0X`wFK-w=N%P%|&L?WS}=&YdV zY>-GKL>$P@_6yJUjm+^5&+zoj{r~?zP&PO=f6?-dKY#xE@#E+2{l^lss(%0e{ps_U zH*ep+eD&tWt$WvQ+&y>k+M%OoCQe_}HDOL=V_#uqM@B)znOKo*pxweHL4LsuZ!i5= zvogU$ivRwNLsQx!f_2!h<z3j$z`%If)5S3);_#fQoYR^N1YDdsG*)PoDJWQX-2315 zIn64ONy+;k<FC@trB*487kDMU>fYW~bBf7a<@d*!<Bx=Oj(<HN$Jci@edAo_o2RCJ zVtrA1TTOl6q0HLF%8#WJ+&7)@{cif|@fN+$I;T%t_5SuK|GmxNN!`V?5??0eNy{#) z#lDMowktkr?VHf1Qd@lV=I^A_0v8syuHVwHxasj5k@%CHtU3xws%PiVd>pvdi&Z-$ zOYv3hj@31lq8q$E2L1D?4U@EG&2g{^jhp=a&_oF{q2ecIo32mVeIV_L-LyR8$@@5N z^i23?u(sD|F;iK<i9P?SyXN+3-Eb96yx;!YXk+>Ev#<UIOi@WtXG*;|(OCD*va5Fs zH(X9&_;&ZFws72g5gw0^=l+MCJ~~6lK;htqJC4EZZY9lHZycH3Om1*EIr&cBxGEs( za<k%I6LpFG37e<O2rN6AD`Pp?cg4X8QQsK<Hw*D-e(ax6`8>fxPx!c2N`gmQ>5t5# zei;l6jS_7i({qK?3^L>OGo#dUwPXu-efaX{Qo2XHG3(LJwIYi&FI{f1e5}WNznO{C zhfhs4!<u7e!?y=t8~-kmb8qRJChKW)s)}XTi$9v;URMLVrpGY<4blotVK_ET^}&|e S-<5%}&*16m=d#Wzp$P!Bu{<>Z diff --git a/apps/web/public/assets/sprite_selected.png b/apps/web/public/assets/sprite_selected.png deleted file mode 100644 index 1717d0ce9b6570151ce5f68bacb12864bc542e00..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 697 zcmV;q0!ICbP)<h;3K|Lk000e1NJLTq002w?002w~0{{R3@JXQ=0001cP)t-s0000_ zS!zyLYfe~dO;>79S8GmKYED>bO<8GGRc20CYED>cPFQMASZYpJYED>cPFZSCS7}XH zYEM~ePgrYET4+^OW=>dY|NsBd)Y-4Jylr)aXK;Y__V@Dh^y}>I=;`d|>Fd+g+Qi4r zwYtHTo2GMnid$rLeua`MI{i`r000+sQchC<FZt%Rl6^iO2k-3P+0x9xtd3_<HV=D) z(C`2N0oF-GK~z}7oYe`AgD?yQ(6E<bDN9=$C_9wi|AZrTMk0X_;=Th!w*0@slzcDG z>^KZeGYDfl%RQfc>Fy>bY?z6AXJi*Qf&-CzVH8FRN~xj$lntkBz2_bdXmK9w)Xf4- z){Uw84iGrL@`hI+xhn2zSU_YQ-og6;vLEl@Efc<AdeR-Gb3Ny&=o-YPE2jA=olUao z#e$uxY*Emb73^5MEBO06z6O*SUi+JF6XNXl8}Y!n+t%6S9yUr;C}HC!^w9#rQlL_? zfWUejFIn|?qOT4YH9+vL@l^c=!I}?SoU29+wK$tCA8hGSOTS1*OMzFQrUH2BfA&?G z&`-!;4_qOuNFb!w{X7-{XGDS#IML^?75a!ILQn78Ee9kSwtFUg#E^&&Tf&%c%1=o@ zsOd8gI4u&j!0}kF$OSPSIm=)*BiRhAOepz;WRp<#SW!!2L1Pxeszb64Rv`;u)gV~| ztAOz_!!Phb`)$QOXaInLAcpa0Z4xF>srq$EeXM@77NE99ZQ43cb<pbys>^Y%?Rrq^ zshdZ*#)BGMW~8cNZ^qCXkY{wSP%&|5f>=ew39l2YC-6@dF(t{=H&ZM{<uzqo)`VYR fezp3gZ`IKNWhbEIJ}Luq00000NkvXXu0mjf;+Z>d diff --git a/apps/web/public/assets/sprite_selected_square.png b/apps/web/public/assets/sprite_selected_square.png deleted file mode 100644 index 8e6a75992f913fa454c18c4138c8a3883791f98f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 96 zcmeAS@N?(olHy`uVBq!ia0vp^P9V(43?y&PnzR~7u?6^qxcWt8{r~^}OmX@epopla ti(`n!`Q!!M8~*b%Enf5Rz#>})2E{iYa@)L}HGm2jJYD@<);T3K0RXyd9TNZm diff --git a/apps/web/public/assets/sprite_square.png b/apps/web/public/assets/sprite_square.png deleted file mode 100644 index 38a4cd208703a2e481ca4337430f631026c6bed0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 344 zcmeAS@N?(olHy`uVBq!ia0vp^wjj*G1SBUieA@@47>k44ofy`glX(f`xTHpSruq6Z zXaU(A42<oW3@jieKr98s3=GT*7#Wy>G$TlC0TW!-b^$Yj4N_<*$-N#(9rAQ>49Q@9 zdut=_AqNqbz@8QSL3fzewM%up6qwjMm;aIX<BYc{;qUdn6z#S5W1j!{z}fpJ-~M}h z(D&;;nHl%iKYw_7{+@f+dcBubZJ)jT#q&M2$!1S{dF`LSmENqFpY_8s|Nac#%HYH2 zD}Nu$IWb$R?%ZwjH0OI)J`~^EXDIz?S=;>2-+FIOFgN`(XM6scwmmB!;x}T(tG!?T mEA$7R4}R~t`|IkB+CLZ^8CYlcMn~QU`O4GP&t;ucLK6V{;DuBG diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx index 6e1277c66..d9c296688 100644 --- a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx +++ b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx @@ -3,17 +3,16 @@ import { dataColors, visualizationColors } from 'config'; import { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { Application, - AssetsBundle, Color, Container, FederatedPointerEvent, Graphics, IPointData, Sprite, - Assets, Text, Texture, Resource, + RenderTexture } from 'pixi.js'; import { useAppDispatch, useML, useSearchResultData } from '../../../../data-access'; import { NLPopup } from './NLPopup'; @@ -47,7 +46,6 @@ type LayoutState = 'reset' | 'running' | 'paused'; export const NLPixi = (props: Props) => { const [quickPopup, setQuickPopup] = useState<{ node: NodeType; pos: IPointData } | undefined>(); const [popups, setPopups] = useState<{ node: NodeTypeD3; pos: IPointData }[]>([]); - const [assetsLoaded, setAssetsLoaded] = useState(false); const app = useMemo( () => @@ -61,7 +59,13 @@ export const NLPixi = (props: Props) => { [], ); const nodeLayer = useMemo(() => new Container(), []); - const labelLayer = useMemo(() => { + const linkLabelLayer = useMemo(() => { + const container = new Container(); + container.alpha = 0; + container.renderable = false; + return container; + }, []); + const nodeLabelLayer = useMemo(() => { const container = new Container(); container.alpha = 0; container.renderable = false; @@ -70,7 +74,8 @@ export const NLPixi = (props: Props) => { const nodeMap = useRef(new Map<string, Sprite>()); const linkGfx = new Graphics(); - const labelMap = useRef(new Map<string, Text>()); + const linkLabelMap = useRef(new Map<string, Text>()); + const nodeLabelMap = useRef(new Map<string, Text>()); const viewport = useRef<Viewport>(); const layoutState = useRef<LayoutState>('reset'); const layoutStoppedCount = useRef(0); @@ -87,11 +92,71 @@ export const NLPixi = (props: Props) => { // const cull = new Cull(); // let cullDirty = useRef(true); - const textureId = (selected: boolean = false) => { - const selectionSuffix = selected ? '_selected' : ''; - const shapeSuffix = props.configuration.shapes.shape == 'rectangle' ? '_square' : ''; - return `texture${selectionSuffix}${shapeSuffix}`; - }; + const _glyphTexture = RenderTexture.create(); + const _selectedTexture = RenderTexture.create(); + + const getTexture = (renderTexture: RenderTexture, selected: boolean = false): RenderTexture => { + let size = config.NODE_RADIUS * (1 / responsiveScale); + let lineWidth = selected ? 12 : 6; + renderTexture.resize(size + lineWidth, size + lineWidth); + + const graphics = new Graphics(); + graphics.lineStyle(lineWidth, 0x4e586a); + graphics.beginFill(0xffffff, 1); + + if (props.configuration.shapes?.shape == "circle") { + graphics.drawCircle((size / 2) + (lineWidth / 2), (size / 2) + (lineWidth/2), (size / 2)); + } else { + graphics.drawRect(lineWidth, lineWidth, size - lineWidth, size - lineWidth); + } + graphics.endFill(); + + app.renderer.render(graphics, { renderTexture }); + + return renderTexture; + } + + // Pixi viewport zoom scale, but discretized to single decimal. + const [responsiveScale, setResponsiveScale] = useState(1); + + 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]; + 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; + scale *= responsiveScale; + sprite.scale.set(scale, scale); + }); + + if (graph.current.nodes.length > config.LABEL_MAX_NODES) return; + + // Change font size at specific scale intervals + const fontSize = (responsiveScale <= 0.1) ? 15 : (responsiveScale <= 0.2) ? 22.5 : (responsiveScale <= 0.4) ? 30 : (responsiveScale <= 0.6) ? 37.5 : 45; + + const strokeWidth = fontSize / 2; + linkLabelMap.current.forEach((text) => { + text.style.fontSize = fontSize; + text.style.strokeThickness = strokeWidth; + text.resolution = Math.ceil(0.5 / responsiveScale); + }); + + nodeLabelMap.current.forEach((text) => { + text.style.fontSize = fontSize * (2 / 3); + text.resolution = Math.ceil(1 / responsiveScale); + }); + + graph.current.nodes.forEach((node: any) => { + updateNodeLabel(node); + }); + }, [responsiveScale, props.configuration.shapes?.shape]); const [config, setConfig] = useState({ width: 1000, @@ -101,7 +166,7 @@ export const NLPixi = (props: Props) => { LAYOUT_ALGORITHM: Layouts.FORCEATLAS2WEBWORKER, - NODE_RADIUS: 5, + NODE_RADIUS: 70, // NODE_BORDER_LINE_WIDTH: 1.0, // NODE_BORDER_LINE_WIDTH_SELECTED: 5.0, // if selected and normal width are different the thicker line will be still in the gfx // NODE_BORDER_COLOR_DEFAULT: dataColors.neutral[70], @@ -113,6 +178,14 @@ export const NLPixi = (props: Props) => { LINE_WIDTH_DEFAULT: 0.8, }); + const glyphTexture = useMemo(() => { + return getTexture(_glyphTexture); + }, [responsiveScale, props.configuration.shapes?.shape]); + + const selectedTexture = useMemo(() => { + return getTexture(_selectedTexture, true); + }, [responsiveScale, props.configuration.shapes?.shape]); + useEffect(() => { setConfig((lastConfig) => { return { @@ -122,15 +195,6 @@ export const NLPixi = (props: Props) => { }); }, [props.layoutAlgorithm, props.configuration]); - useEffect(() => { - if (nodeMap.current.size === 0) return; - - const texture = Assets.get(textureId()); - for (const sprite of nodeMap.current.values()) { - sprite.texture = texture; - } - }, [props.configuration.shapes?.shape]); - const imperative = useRef<any>(null); const mouseClickThreshold = 200; // Time between mouse up and down events that is considered a click, and not a drag. @@ -161,11 +225,13 @@ export const NLPixi = (props: Props) => { setPopups([{ node: node, pos: toGlobal(node) }]); for (const popup of popups) { const sprite = nodeMap.current.get(popup.node._id) as Sprite; - sprite.texture = Assets.get(textureId(false)); + sprite.texture = glyphTexture; + (sprite as any).selected = false; } } - sprite.texture = Assets.get(textureId(true)); + sprite.texture = selectedTexture; + (sprite as any).selected = true; props.onClick({ node: node, pos: toGlobal(node) }); @@ -180,7 +246,8 @@ export const NLPixi = (props: Props) => { if (holdDownTime < mouseClickThreshold) { for (const popup of popups) { const sprite = nodeMap.current.get(popup.node._id) as Sprite; - sprite.texture = Assets.get(textureId(false)); + sprite.texture = glyphTexture; + (sprite as any).selected = false; } setPopups([]); props.onClick(); @@ -220,23 +287,27 @@ export const NLPixi = (props: Props) => { 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 (scale > 2) { + const scale = 1 / viewport.current!.scale.x; // starts from 0.5 down to 0. + setResponsiveScale((scale < 0.05) ? 0.1 : (scale < 0.1) ? 0.2 : (scale < 0.2) ? 0.4 : (scale < 0.3) ? 0.6 : 0.8); + } else { + setResponsiveScale(1); + } - if (labelLayer.alpha > 0) { - labelLayer.renderable = true; + if (graph.current.nodes.length < config.LABEL_MAX_NODES) { + linkLabelLayer.alpha = (scale > 2) ? Math.min(1, (scale - 2) * 3) : 0; - const scale = 1 / viewport.current!.scale.x; // starts from 0.5 down to 0. + if (linkLabelLayer.alpha > 0) { + linkLabelLayer.renderable = true; + } else { + linkLabelLayer.renderable = false; + } - // 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; - }); + nodeLabelLayer.alpha = (scale > 5) ? Math.min(1, (scale - 5) * 3) : 0; + if (nodeLabelLayer.alpha > 0) { + nodeLabelLayer.renderable = true; } else { - labelLayer.renderable = false; + nodeLabelLayer.renderable = false; } } }, @@ -286,8 +357,9 @@ export const NLPixi = (props: Props) => { // Update texture when selected const nodeMeta = props.graph.nodes[node._id]; - const texture = Assets.get(textureId(nodeMeta.selected)); + const texture = (gfx as any).selected ? selectedTexture : glyphTexture; gfx.texture = texture; + // Cluster colors if (nodeMeta?.cluster) { @@ -314,6 +386,10 @@ export const NLPixi = (props: Props) => { // } }; + const getNodeLabel = (nodeMeta: NodeType) => { + return nodeMeta.label + } + const createNode = (node: NodeTypeD3, selected?: boolean) => { const nodeMeta = props.graph.nodes[node._id]; @@ -325,11 +401,11 @@ export const NLPixi = (props: Props) => { if (node.x === undefined || node.y === undefined) return; let sprite: Sprite; - const texture = Assets.get(textureId()); + const texture = glyphTexture; sprite = new Sprite(texture); sprite.tint = nodeColor(nodeMeta.type); - const scale = (Math.max(nodeMeta.radius || 5, 5) / 70) * 2; + const scale = (Math.max(nodeMeta.radius || 5, 5) / config.NODE_RADIUS) * 2; sprite.scale.set(scale, scale); sprite.anchor.set(0.5, 0.5); sprite.cullable = true; @@ -346,13 +422,31 @@ export const NLPixi = (props: Props) => { updateNode(node); (sprite as any).node = node; + // Node label + const attribute = getNodeLabel(nodeMeta); + const text = new Text(attribute, { + fontSize: 20, + fill: 0xffffff, + wordWrap: true, + wordWrapWidth: 65, + align: 'center' + }); + text.eventMode = 'none'; + text.cullable = true; + text.anchor.set(0.5, 0.5); + text.scale.set(0.1, 0.1); + nodeLabelMap.current.set(node._id, text); + nodeLabelLayer.addChild(text); + + updateNodeLabel(node); + return sprite; }; 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); + if (link && link?._id && linkLabelMap.current.has(link._id)) { + linkLabelMap.current.delete(link._id); } const linkMeta = props.graph.links[link._id]; @@ -366,8 +460,8 @@ export const NLPixi = (props: Props) => { text.cullable = true; text.anchor.set(0.5, 0.5); text.scale.set(0.1, 0.1); - labelMap.current.set(link._id, text); - labelLayer.addChild(text); + linkLabelMap.current.set(link._id, text); + linkLabelLayer.addChild(text); updateLinkLabel(link); @@ -450,7 +544,9 @@ export const NLPixi = (props: Props) => { }; const updateLinkLabel = (link: LinkTypeD3) => { - const text = labelMap.current.get(link._id); + if (graph.current.nodes.length > config.LABEL_MAX_NODES) return; + + const text = linkLabelMap.current.get(link._id); if (!text) return; const _source = link.source; @@ -488,9 +584,34 @@ export const NLPixi = (props: Props) => { } else { text.rotation = rads; } - }; + } - // const text = labelMap.current.get(link._id); + const updateNodeLabel = (node: NodeTypeD3) => { + if (graph.current.nodes.length > config.LABEL_MAX_NODES) return; + const text = nodeLabelMap.current.get(node._id) as Text | undefined; + if (text == 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 + + if ((text.width/text.scale.x) <= 90 && (text.height/text.scale.y) <= 90) { + text.text = originalText; + } 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.alpha = ((text.width/text.scale.x) <= 90 && (text.height/text.scale.y) <= 90) ? 1 : 0; + } + + + // const text = linkLabelMap.current.get(link._id); // if (!text) return; // const source = link.source as NodeTypeD3; @@ -511,27 +632,14 @@ export const NLPixi = (props: Props) => { // text.rotation = rads; // } - async function loadAssets() { - if (!Assets.cache.has('texture')) { - Assets.addBundle('glyphs', { - texture: 'assets/sprite.png', - texture_square: 'assets/sprite_square.png', - texture_selected: 'assets/sprite_selected.png', - texture_selected_square: 'assets/sprite_selected_square.png', - }); - await Assets.loadBundle('glyphs'); - } - setAssetsLoaded(true); - } - useEffect(() => { - loadAssets(); return () => { nodeMap.current.clear(); - labelMap.current.clear(); + linkLabelMap.current.clear(); linkGfx.clear(); nodeLayer.removeChildren(); - labelLayer.removeChildren(); + linkLabelLayer.removeChildren(); + nodeLabelLayer.removeChildren(); const layout = layoutAlgorithm.current as GraphologyForceAtlas2Webworker; if(layout?.cleanup != null) layout.cleanup(); @@ -539,11 +647,11 @@ export const NLPixi = (props: Props) => { }, []); useEffect(() => { - if (assetsLoaded && props.graph && ref.current && ref.current.children.length > 0 && imperative.current) { + if (props.graph && ref.current && ref.current.children.length > 0 && imperative.current) { if (isSetup.current === false) setup(); else update(false); } - }, [config, assetsLoaded]); + }, [config]); useEffect(() => { if (props.graph) { @@ -589,6 +697,8 @@ export const NLPixi = (props: Props) => { } gfx.position.copyFrom(node as IPointData); + + updateNodeLabel(node); }); if (stopped === graph.current.nodes.length) { @@ -623,7 +733,8 @@ export const NLPixi = (props: Props) => { nodeMap.current.clear(); linkGfx.clear(); nodeLayer.removeChildren(); - labelLayer.removeChildren(); + linkLabelLayer.removeChildren(); + nodeLabelLayer.removeChildren(); } nodeMap.current.forEach((gfx, id) => { @@ -634,11 +745,11 @@ export const NLPixi = (props: Props) => { } }); - labelMap.current.forEach((text, id) => { + linkLabelMap.current.forEach((text, id) => { if (!graph.current.links.find((link) => link._id === id)) { - labelLayer.removeChild(text); + linkLabelLayer.removeChild(text); text.destroy(); - labelMap.current.delete(id); + linkLabelMap.current.delete(id); } }); @@ -651,6 +762,7 @@ export const NLPixi = (props: Props) => { node.x = old?.x || node.x; node.y = old?.y || node.y; updateNode(node); + updateNodeLabel(node); } else { createNode(node); } @@ -658,7 +770,7 @@ 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)) { + if (!forceClear && linkLabelMap.current.has(link._id)) { updateLinkLabel(link); } else { createLinkLabel(link); @@ -694,7 +806,7 @@ export const NLPixi = (props: Props) => { */ const setup = async () => { nodeLayer.removeChildren(); - labelLayer.removeChildren(); + linkLabelLayer.removeChildren(); app.stage.removeChildren(); if (!props.graph) throw Error('Graph is undefined'); @@ -723,8 +835,9 @@ 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(linkLabelLayer); viewport.current.addChild(nodeLayer); + viewport.current.addChild(nodeLabelLayer); viewport.current.on('moved', (event) => { imperative.current.onMoved(event); }); -- GitLab