283 lines
11 KiB
TypeScript
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;
|
|
},
|
|
},
|
|
}); |