From 54e7ac1c0fdabfd27c378bce534f538e5adb045e Mon Sep 17 00:00:00 2001 From: Dennis Collaris <d.a.c.collaris@uu.nl> Date: Tue, 17 Sep 2024 15:08:03 +0000 Subject: [PATCH] feat: inspector panel configuration for node and edge labels --- .../components/NLMachineLearning.tsx | 1 + .../nodelinkvis/components/NLPixi.tsx | 78 ++++++++++++++-- .../nodelinkvis/components/query2NL.tsx | 1 + .../nodelinkvis/nodelinkvis.tsx | 88 +++++++++++++++---- .../vis/visualizations/nodelinkvis/types.ts | 1 + 5 files changed, 144 insertions(+), 25 deletions(-) diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLMachineLearning.tsx b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLMachineLearning.tsx index 7ffc49974..b97e11e61 100644 --- a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLMachineLearning.tsx +++ b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLMachineLearning.tsx @@ -17,6 +17,7 @@ export function processLinkPrediction(ml: ML, graph: GraphType): GraphType { value: link.attributes.jaccard_coefficient as number, mlEdge: true, color: 0x000000, + attributes: {}, }; graph.links[toAdd.id] = toAdd; } diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx index 9d45b688d..8dd8460eb 100644 --- a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx +++ b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx @@ -102,7 +102,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { graphics.lineStyle(lineWidth, 0x4e586a); graphics.beginFill(0xffffff, 1); - if (props.configuration.shapes?.shape == 'circle') { + if (props.configuration.nodes?.shape.type == "circle") { graphics.drawCircle(size / 2 + lineWidth / 2, size / 2 + lineWidth / 2, size / 2); } else { graphics.drawRect(lineWidth, lineWidth, size - lineWidth, size - lineWidth); @@ -155,7 +155,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { graph.current.nodes.forEach((node: any) => { updateNodeLabel(node); }); - }, [responsiveScale, props.configuration.shapes?.shape]); + }, [responsiveScale, props.configuration.nodes?.shape?.type]); const [config, setConfig] = useState({ width: 1000, @@ -179,11 +179,11 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { const glyphTexture = useMemo(() => { return getTexture(_glyphTexture); - }, [responsiveScale, props.configuration.shapes?.shape]); + }, [responsiveScale, props.configuration.nodes?.shape?.type]); const selectedTexture = useMemo(() => { return getTexture(_selectedTexture, true); - }, [responsiveScale, props.configuration.shapes?.shape]); + }, [responsiveScale, props.configuration.nodes?.shape?.type]); useEffect(() => { setConfig((lastConfig) => { @@ -427,6 +427,8 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { // Update texture when selected const nodeMeta = props.graph.nodes[node._id]; + if (nodeMeta == null) return; + const texture = (gfx as any).selected ? selectedTexture : glyphTexture; gfx.texture = texture; @@ -456,8 +458,61 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { }; const getNodeLabel = (nodeMeta: NodeType) => { - return nodeMeta.label; - }; + try { + var attribute = props.configuration.nodes.labelAttributes[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') { + return String(value); + } + + if (typeof value === 'object' && Object.keys(value).length != 0) { + return JSON.stringify(value); + } + + return '-'; + } + + const getLinkLabel = (linkMeta: LinkType) => { + try { + var attribute = props.configuration.edges.labelAttributes[linkMeta.name]; + } catch(e) { + return linkMeta.name; + } + + if (attribute == 'Default' || attribute == null) { + return linkMeta.name; + } + + const value = linkMeta.attributes[attribute]; + + if (Array.isArray(value)) { + return value.join(', '); + } + + if (typeof value === 'number' || typeof value === 'string') { + 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]; @@ -497,8 +552,9 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { fontSize: 20, fill: 0xffffff, wordWrap: true, - wordWrapWidth: 65, - align: 'center', + breakWords: true, + wordWrapWidth: config.NODE_RADIUS, + align: 'center' }); text.eventMode = 'none'; text.cullable = true; @@ -520,7 +576,8 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { const linkMeta = props.graph.links[link._id]; - const text = new Text(linkMeta.name, { + const label = getLinkLabel(linkMeta); + const text = new Text(label, { fontSize: 60, fill: config.LINE_COLOR_DEFAULT, stroke: imperative.current.getBackgroundColor(), @@ -628,6 +685,9 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { const source = nodeMap.current.get(link.source as string) as Sprite; const target = nodeMap.current.get(link.target as string) as Sprite; + const linkMeta = props.graph.links[link._id]; + text.text = getLinkLabel(linkMeta); + text.x = (source.x + target.x) / 2; text.y = (source.y + target.y) / 2; diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx b/libs/shared/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx index 2c6d73ce6..3e649a991 100644 --- a/libs/shared/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx +++ b/libs/shared/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx @@ -264,6 +264,7 @@ export function parseQueryResult(queryResult: GraphQueryResult, ml: ML, options: name: uniqueEdges[i].attributes.Type, mlEdge: false, color: 0x000000, + attributes: uniqueEdges[i].attributes }; ret.links[toAdd.id] = toAdd; } diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx b/libs/shared/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx index 8a4857f6d..a2d1c9313 100644 --- a/libs/shared/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx +++ b/libs/shared/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx @@ -13,6 +13,26 @@ import { Node } from '@graphpolaris/shared/lib/data-access/store/graphQueryResul import { IPointData } from 'pixi.js'; import { VisualizationPropTypes, VISComponentType, VisualizationSettingsPropTypes } from '../../common'; + +// 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. +function patchLegacySettings(settings: NodelinkVisProps): NodelinkVisProps { + if (!('nodes' in settings)) { + settings = JSON.parse(JSON.stringify(settings)); // Undo Object.preventExtensions() + + settings.nodes = { + shape: { + type: (settings as any).shapes.shape, + similar: (settings as any).shapes.similar, + shapeMap: undefined + }, + labelAttributes: {} + }; + settings.edges.labelAttributes = {}; + } + return settings; +} + export interface NodeLinkVisHandle { exportImageInternal: () => void; } @@ -22,16 +42,20 @@ export interface NodelinkVisProps { name: string; layout: string; showPopUpOnHover: boolean; - shapes: { - similar: boolean; - shape: 'circle' | 'rectangle'; - shapeMap: { [id: string]: 'circle' | 'rectangle' } | undefined; - }; + nodes: { + shape: { + similar: boolean; + type: 'circle' | 'rectangle'; + shapeMap: { [id: string]: 'circle' | 'rectangle' } | undefined; + }; + labelAttributes: { [nodeType: string]: string }; + }, edges: { width: { similar: boolean; width: number; }; + labelAttributes: { [nodeType: string]: string }; }; nodeList: string[]; } @@ -41,13 +65,17 @@ const settings: NodelinkVisProps = { name: 'NodeLinkVis', layout: Layouts.FORCEATLAS2WEBWORKER as string, showPopUpOnHover: false, - shapes: { - similar: true, - shape: 'circle', - shapeMap: undefined, + nodes: { + shape: { + similar: true, + type: 'circle', + shapeMap: undefined, + }, + labelAttributes: {}, }, edges: { width: { similar: true, width: 0.8 }, + labelAttributes: {}, }, nodeList: [], }; @@ -60,6 +88,8 @@ const NodeLinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin const [highlightNodes, setHighlightNodes] = useState<NodeType[]>([]); const [highlightedLinks, setHighlightedLinks] = useState<LinkType[]>([]); + settings = patchLegacySettings(settings); + useEffect(() => { if (data) { setGraph( @@ -147,6 +177,8 @@ const NodelinkSettings = ({ settings, graphMetadata, updateSettings }: Visualiza if (!settings.nodeList) return null; + settings = patchLegacySettings(settings); + return ( <SettingsContainer> <div className="mb-4 text-xs"> @@ -187,16 +219,16 @@ const NodelinkSettings = ({ settings, graphMetadata, updateSettings }: Visualiza <Input type="boolean" label="Common shape?" - value={settings.shapes.similar} - onChange={(val) => updateSettings({ shapes: { ...settings.shapes, similar: val } })} + value={settings.nodes?.shape.similar} + onChange={(val) => updateSettings({ nodes: { ...settings.nodes, shape: { ...settings.nodes.shape, similar: val } } })} /> - {settings.shapes.similar ? ( + {settings.nodes?.shape?.similar ? ( <Input type="dropdown" label="Shape" - value={settings.shapes.shape} + value={settings.nodes?.shape.type} options={[{ circle: 'Circle' }, { rectangle: 'Square' }]} - onChange={(val) => updateSettings({ shapes: { ...settings.shapes, shape: val as any } })} + onChange={(val) => updateSettings({ nodes: { ...settings.nodes, shape: { ...settings.nodes.shape, type: val as any } } })} /> ) : ( <span>Map shapes to labels (to be implemented)</span> @@ -204,7 +236,17 @@ const NodelinkSettings = ({ settings, graphMetadata, updateSettings }: Visualiza </div> <div> - <span className="text-xs font-semibold">Color</span> + <span className="text-xs font-semibold">Labels</span> + { Object.entries(graphMetadata.nodes.types).map(([label, type]) => + <Input + type="dropdown" + key={label} + label={label} + value={settings.nodes.labelAttributes ? settings.nodes.labelAttributes[label] || 'Default' : undefined} + options={['Default', ...Object.keys(type.attributes).filter(x => x != 'labels')]} + onChange={(val) => updateSettings({ nodes: { ...settings.nodes, labelAttributes: { ... settings.nodes.labelAttributes, [label]: val as string } } })} + /> + )} </div> </div> @@ -229,6 +271,20 @@ const NodelinkSettings = ({ settings, graphMetadata, updateSettings }: Visualiza /> </div> </div> + <div> + <span className="text-xs font-semibold">Labels</span> + { Object.entries(graphMetadata.edges.types).map(([label, type]) => + <Input + type="dropdown" + key={label} + label={label} + value={settings.edges.labelAttributes ? settings.edges.labelAttributes[label] || 'Default' : undefined} + options={['Default', ...Object.keys(type.attributes).filter(x => x != 'Type')]} + onChange={(val) => updateSettings({ edges: { ...settings.edges, labelAttributes: { ... settings.edges.labelAttributes, [label]: val as string } } })} + /> + )} + </div> + </SettingsContainer> ); }; @@ -239,7 +295,7 @@ export const NodeLinkComponent: VISComponentType<NodelinkVisProps> = { description: 'General Patterns and Connections', component: React.forwardRef((props: VisualizationPropTypes<NodelinkVisProps>, ref) => <NodeLinkVis {...props} ref={nodeLinkVisRef} />), settingsComponent: NodelinkSettings, - settings: settings, + settings: patchLegacySettings(settings), exportImage: () => { if (nodeLinkVisRef.current) { nodeLinkVisRef.current.exportImageInternal(); diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/types.ts b/libs/shared/lib/vis/visualizations/nodelinkvis/types.ts index 4d039f916..64b03fde0 100644 --- a/libs/shared/lib/vis/visualizations/nodelinkvis/types.ts +++ b/libs/shared/lib/vis/visualizations/nodelinkvis/types.ts @@ -64,6 +64,7 @@ export interface LinkType { alpha?: number; source: string; target: string; + attributes: Record<string, any>; } export type LinkTypeD3 = d3.SimulationLinkDatum<NodeTypeD3> & { _id: string }; -- GitLab