// The Lucia project. // Copyright 2023-2026 DSP, inc. All rights reserved. // Authors: // chiayin.kuo@dsp.im (chiayin), 2023/1/31 // imacat.yang@dsp.im (imacat), 2023/9/23 // cindy.chang@dsp.im (Cindy Chang), 2024/5/30 /** * @module stores/mapPathStore Cytoscape map interaction store for * node/edge click highlighting with path insights visualization. */ import { defineStore } from 'pinia'; import { useAllMapDataStore } from '@/stores/allMapData'; import { INSIGHTS_FIELDS_AND_LABELS } from '@/constants/constants'; import ImgCapsuleGlow1 from '@/assets/capsule1-glow.svg'; import ImgCapsuleGlow2 from '@/assets/capsule2-glow.svg'; import ImgCapsuleGlow3 from '@/assets/capsule3-glow.svg'; import ImgCapsuleGlow4 from '@/assets/capsule4-glow.svg'; import ImgCapsule1 from '@/assets/capsule1.svg'; import ImgCapsule2 from '@/assets/capsule2.svg'; import ImgCapsule3 from '@/assets/capsule3.svg'; import ImgCapsule4 from '@/assets/capsule4.svg'; const ImgCapsulesGlow = [ImgCapsuleGlow1, ImgCapsuleGlow2, ImgCapsuleGlow3, ImgCapsuleGlow4]; const ImgCapsules = [ImgCapsule1, ImgCapsule2, ImgCapsule3, ImgCapsule4]; /** * Builds insight paths for a single field by linking graph nodes/edges * to the insight data entries. */ function buildInsightPathsForField(fieldKey, curButton, startNode, insightWithPath) { insightWithPath[fieldKey] = {}; for (let listIndex = 0; listIndex < curButton.length; listIndex++) { insightWithPath[fieldKey][listIndex] = { edges: [], nodes: [] }; let curGraphNode, prevGraphNode, curEdge; for (let nodeIndex = 0; nodeIndex < curButton[listIndex].length; nodeIndex++) { if (nodeIndex === 0) { curGraphNode = startNode.outgoers('node').filter(neighborOfStart => neighborOfStart.data('label') === curButton[listIndex][nodeIndex] ); curEdge = startNode.edgesTo(curGraphNode); } else if (prevGraphNode) { curGraphNode = prevGraphNode.outgoers('node').filter(neighbor => neighbor.data('label') === curButton[listIndex][nodeIndex] ); curEdge = prevGraphNode.edgesWith(curGraphNode); } insightWithPath[fieldKey][listIndex].nodes.push(curGraphNode); insightWithPath[fieldKey][listIndex].edges.push(curEdge); if (nodeIndex === curButton[listIndex].length - 1) { const endNode = curGraphNode.outgoers('node').filter(neighbor => neighbor.data('label').toLowerCase() === 'end' ); const lastEdge = curGraphNode.edgesWith(endNode); insightWithPath[fieldKey][listIndex].edges.push(lastEdge); } prevGraphNode = curGraphNode; } } } /** * Matches a single graph path against all insight button entries * for a specific field. */ function matchPathAgainstButton(curPath, curPathByEdge, curButton, fieldIndex, mapGraphPathToInsight) { for (let listIndex = 0; listIndex < curButton.length; listIndex++) { for (let nodeIndex = 0; nodeIndex < curButton[listIndex].length; nodeIndex++) { if (curPath[1].data('label') === curButton[listIndex][nodeIndex]) { const matchResult = depthFirstSearchMatchTwoPaths(curPath, 1, curButton, listIndex, nodeIndex); if (matchResult) { mapGraphPathToInsight[fieldIndex] = { [listIndex]: { pathByNode: [...curPath], pathByEdge: [...curPathByEdge], pathType: INSIGHTS_FIELDS_AND_LABELS[fieldIndex][0], } }; } } } } } /** * Recursively checks whether two paths match by comparing node labels * along both paths using depth-first search. */ function depthFirstSearchMatchTwoPaths(curPath, curPathIndex, curButton, listIndex, nodeIndex) { if (listIndex >= curButton.length) { return false; } if (nodeIndex >= curButton[listIndex].length) { return false; } if (curPathIndex === curPath.length || nodeIndex === curButton[listIndex].length) { return true; } if (curPathIndex >= curPath.length || nodeIndex >= curButton[listIndex].length) { return false; } const nodeLabel = curPath[curPathIndex].data('label'); if (nodeLabel !== curButton[listIndex][nodeIndex]) { return false; } if (nodeIndex === curButton[listIndex].length - 1) { return true; } return depthFirstSearchMatchTwoPaths(curPath, curPathIndex + 1, curButton, listIndex + 1, nodeIndex) || depthFirstSearchMatchTwoPaths(curPath, curPathIndex + 1, curButton, listIndex, nodeIndex + 1); } export const useMapPathStore = defineStore('mapPathStore', { state: () => ({ clickedPath: [], insights: {}, insightWithPath: {}, cytoscape: { process: { curved: { horizontal: null, vertical: null, }, elbow: { horizontal: null, vertical: null, } }, bpmn: { curved: { horizontal: null, vertical: null, }, elbow: { horizontal: null, vertical: null, } } }, processOrBPMN: 'process', curveType: 'curved', directionType: 'horizontal', allPaths: [], allPathsByEdge: [], startNode: null, mapGraphPathToInsight: {}, activeTrace: 0, activeListIndex: 0, lastClickedNode: null, isBPMNOn: false, }), actions: { async setCytoscape(cytoscape, processOrBPMN = 'process', curveType = 'curved', directionType = 'horizontal') { this.processOrBPMN = processOrBPMN; this.curveType = curveType; this.directionType = directionType; this.cytoscape[processOrBPMN][curveType][directionType] = cytoscape; await this.createInsightWithPath(); if (processOrBPMN === 'process') { await this.highlightMostFrequentPath(); } }, async createInsightWithPath() { const { insights } = useAllMapDataStore(); this.insights = { ...insights }; this.startNode = this.cytoscape[this.processOrBPMN][this.curveType][this.directionType]?.nodes() .filter(function (elem) { return elem.data('label').toLowerCase() === 'start'; }); for (const [, fieldAndLabel] of INSIGHTS_FIELDS_AND_LABELS.entries()) { const curButton = this.insights[fieldAndLabel[0]]; if (!curButton) continue; buildInsightPathsForField(fieldAndLabel[0], curButton, this.startNode, this.insightWithPath); } }, async createPaths() { this.startNode = this.cytoscape[this.processOrBPMN][this.curveType][this.directionType]?.nodes() .filter(function (elem) { return elem.data('label').toLowerCase() === 'start'; }); // Depth First Search from the starting node this.depthFirstSearchCreatePath(this.startNode, [this.startNode], []); const { insights } = useAllMapDataStore(); this.insights = { ...insights }; this.matchGraphPathWithInsightsPath(); }, /** * Builds all paths from the start node using depth-first search. * @param node - The current node. * @param currentPathByNode - Accumulated nodes along the path. * @param curPathByEdge - Accumulated edges along the path. */ depthFirstSearchCreatePath(node, currentPathByNode, curPathByEdge) { const outgoingEdges = node.outgoers('edge'); if (outgoingEdges.length === 0) { // Reached the end node this.allPaths.push([...currentPathByNode]); this.allPathsByEdge.push([...curPathByEdge]) } else { outgoingEdges.targets().forEach((targetNode) => { if (!currentPathByNode.includes(targetNode)) { const connectingEdge = targetNode.edgesWith(currentPathByNode[currentPathByNode.length - 1]); // Avoid loops: only continue if the target node is not already in the current path this.depthFirstSearchCreatePath(targetNode, [...currentPathByNode, targetNode], [...curPathByEdge, connectingEdge] ); } }); } }, /** * Matches graph paths with insights paths by comparing * node labels along each path to the insights data arrays. */ matchGraphPathWithInsightsPath() { for (let whichPath = 0; whichPath < this.allPaths.length; whichPath++) { const curPath = this.allPaths[whichPath]; if (curPath.length < 2) continue; const curPathByEdge = this.allPathsByEdge[whichPath]; for (let i = 0; i < INSIGHTS_FIELDS_AND_LABELS.length; i++) { const curButton = this.insights[INSIGHTS_FIELDS_AND_LABELS[i][0]]; if (!curButton) continue; matchPathAgainstButton(curPath, curPathByEdge, curButton, i, this.mapGraphPathToInsight); } } }, highlightClickedPath(clickedActiveTraceIndex: number, clickedPathListIndex: number) { const key = INSIGHTS_FIELDS_AND_LABELS[clickedActiveTraceIndex]?.[0]; const path = this.insightWithPath?.[key]?.[clickedPathListIndex]; if (!path) return; path.edges.forEach(edgeToHighlight => { edgeToHighlight.addClass('highlight-edge'); }); path.nodes.forEach(nodeToHighlight => { nodeToHighlight.data('nodeImageUrl', ImgCapsulesGlow[nodeToHighlight.data('level')]); }); }, clearAllHighlight() { this.cytoscape[this.processOrBPMN][this.curveType][this.directionType]?.edges().removeClass('highlight-edge'); this.cytoscape[this.processOrBPMN][this.curveType][this.directionType]?.nodes().removeClass('highlight-node'); this.cytoscape[this.processOrBPMN][this.curveType][this.directionType]?.nodes().forEach(nodeToReset => { nodeToReset.data('nodeImageUrl', ImgCapsules[nodeToReset.data('level')]) }); }, onNodeClickHighlightEdges(clickedNode) { this.clearAllHighlight(); clickedNode.addClass('highlight-node'); clickedNode.data('nodeImageUrl', ImgCapsulesGlow[clickedNode.data('level')]); clickedNode.outgoers('edge').forEach(edgeToHighlight => edgeToHighlight.addClass('highlight-edge')); clickedNode.incomers('edge').forEach(edgeToHighlight => edgeToHighlight.addClass('highlight-edge')); this.lastClickedNode = clickedNode; }, onEdgeClickHighlightNodes(clickedEdge) { this.clearAllHighlight(); const sourceNode = clickedEdge.source(); const targetNode = clickedEdge.target(); sourceNode.addClass('highlight-node'); targetNode.addClass('highlight-node'); sourceNode.data('nodeImageUrl', ImgCapsulesGlow[sourceNode.data('level')]); targetNode.data('nodeImageUrl', ImgCapsulesGlow[targetNode.data('level')]); clickedEdge.addClass('highlight-edge'); }, async highlightMostFrequentPath() { const LIST_INDEX = 0; const traces = this.insightWithPath?.['most_freq_traces']; if (!traces?.[LIST_INDEX]) return; traces[LIST_INDEX].nodes.forEach(nodeToHighlight => { nodeToHighlight.data('nodeImageUrl', ImgCapsulesGlow[nodeToHighlight.data('level')]); }); traces[LIST_INDEX].edges.forEach(edgeToHighlight => edgeToHighlight.addClass('highlight-edge')); }, setIsBPMNOn(isOn: boolean) { this.isBPMNOn = isOn; }, }, });