Files
lucia-frontend/src/stores/mapPathStore.ts
T

283 lines
11 KiB
TypeScript

// 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;
},
},
});