uni2work.workflows.visualiser/editor.js
2023-05-06 21:29:23 +02:00

344 lines
13 KiB
JavaScript

var workflow = {}
// fetch('./test.json')
// .then((response) => response.json())
// .then((data) => {
// for (var key in data)
// workflow[key] = data[key];
// });
// Counters for placeholder IDs of states/actions added via GUI
var stateIdCounter = workflow.states ? workflow.states.length : 0;
var actionIdCounter = workflow.states ? workflow.actions.length : 0;
//Persons & roles of the workflow
var actors = [];
workflow.actions.forEach(act => act.actionData.actors.forEach(a => {
var includes = false;
actors.forEach(actor => includes = includes || equalRoles(a, actor));
(!includes) && actors.push(a);
(!act.actionData.actorNames) && (act.actionData.actorNames = []);
act.actionData.actorNames.push(getActorName(a));
}));
// console.log(actors);
// workflow.actions.forEach(a => console.log(a.actionData.actorNames));
function getActorName(actor) {
return actor.tag == 'payload-reference' ? actor['payload-label'] : actor.authorized['dnf-terms'][0][0].var + ' (auth)';
}
//Prepare actor highlighting
const selectedActor = document.getElementById('actor');
var allActors = document.createElement('option');
allActors.text = 'All Actors';
selectedActor.add(allActors);
actors.forEach(actor => {
var option = document.createElement('option');
option.text = getActorName(actor);
selectedActor.add(option);
});
function selectActor() {
console.log(selectedActor.value);
}
var selfLoops = {}; // All edges whose targets equal their sources.
var overlappingEdges = {}; // All edges whose target and source are connected by further.
const selfLoopCurvMin = 0.5; // Minimum curvature of a self loop.
const curvatureMinMax = 0.2; // Minimum/maximum curvature (1 +/- x) of overlapping edges.
var selection = null; // The currently selected node/edge.
/**
* Checks if two roles are equal.
* @param {*} role1
* @param {*} role2
* @returns
*/
function equalRoles(role1, role2) {
var equal = role1.tag === role2.tag && role1['payload-label'] === role2['payload-label'];
if (equal && role1.tag == 'authorized') {
equal = role1.authorized['dnf-terms'][0][0].var === role2.authorized['dnf-terms'][0][0].var;
}
return equal;
}
/**
* Identifies and stores self loops as well as overlapping edges (i.e. multiple edges sharing the
* same source and target).
*/
function identifyOverlappingEdges() {
selfLoops = {};
overlappingEdges = {};
workflow.actions.forEach(edge => {
var source = typeof(edge.source) === 'string' ? edge.source : edge.source.id;
var target = typeof(edge.target) === 'string' ? edge.target : edge.target.id;
var pre = source <= target ? source : target;
var post = source <= target ? target : source;
edge.nodePairId = pre + '_' + post;
var category = edge.source === edge.target ? selfLoops : overlappingEdges;
if (!category[edge.nodePairId]) category[edge.nodePairId] = [];
category[edge.nodePairId].push(edge);
});
}
/**
* Computes the curvature of the loops stored in `selfLoops` and overlapping edges
* stored in `overlappingEdges`.
*/
function computeCurvatures() {
// Self loops
Object.keys(selfLoops).forEach(id => {
var edges = selfLoops[id];
for (let i = 0; i < edges.length; i++)
edges[i].curvature = selfLoopCurvMin + i / 10;
});
// Overlapping edges
Object.keys(overlappingEdges)
.filter(nodePairId => overlappingEdges[nodePairId].length > 1)
.forEach(nodePairId => {
var edges = overlappingEdges[nodePairId];
var lastIndex = edges.length - 1;
var lastEdge = edges[lastIndex];
lastEdge.curvature = curvatureMinMax;
let delta = 2 * curvatureMinMax / lastIndex;
for (let i = 0; i < lastIndex; i++) {
edges[i].curvature = - curvatureMinMax + i * delta;
if (lastEdge.source !== edges[i].source) edges[i].curvature *= -1;
}
});
}
/**
* Marks the given item as selected.
* @param {*} item The node or edge to select.
*/
function select(item) {
selection = item;
console.log(item);
// TODO
}
/**
* Updates the nodes and edges of the workflow graph.
*/
function updateGraph() {
identifyOverlappingEdges()
computeCurvatures()
Graph.graphData({nodes: workflow.states, links: workflow.actions});
}
/**
* Adds a new action between two states.
* @param {*} source The source state.
* @param {*} target The target state.
*/
function connect(source, target) {
let linkId = actionIdCounter ++;
action = {id: linkId, source: source, target: target, name: 'action_' + linkId};
workflow.actions.push(action);
updateGraph();
}
/**
* Adds a new state to the workflow.
* @param {*} x The x coordinate on the canvas.
* @param {*} y The y coordinate on the canvas.
* @returns The new state.
*/
function addState(x, y) {
let nodeId = stateIdCounter ++;
state = {id: nodeId, x: x, y: y, name: 'state_' + nodeId, fx: x, fy: y, val: 5};
workflow.states.push(state);
updateGraph();
return state;
}
/**
* Removes an edge from the workflow.
* @param {*} action The action to remove.
*/
function removeAction(action) {
workflow.actions.splice(workflow.actions.indexOf(action), 1);
}
/**
* Removes a state from the workflow.
* @param {*} state The state to remove.
*/
function removeState(state) {
workflow.actions
.filter(edge => edge.source === state || edge.target === state)
.forEach(edge => removeAction(edge));
workflow.states.splice(workflow.states.indexOf(state), 1);
}
/**
*
* @param {*} node
* @returns The colour the given node should have.
*/
function getColour(node) {
if (node.stateData && node.stateData.final !== 'False' && node.stateData.final !== '') {
if (node.stateData.final === 'True' || node.stateData.final === 'ok') {
return selection === node ? '#a4eb34' : '#7fad36';
} else if (node.stateData.final === 'not-ok') {
return selection === node ? '#f77474' : '#f25050';
} else {
//console.log(node.stateData.final);
}
} else if (node.name === '@@INIT') {
return selection === node ? '#e8cd84' : '#d1ad4b';
} else {
return selection === node ? '#5fbad9' : '#4496b3';
}
}
const Graph = ForceGraph()
(document.getElementById('graph'))
.linkDirectionalArrowLength(6)
.linkDirectionalArrowRelPos(1)
.linkColor(edge => {
if (edge.actionData.mode != 'automatic' && edge.actionData.actorNames.includes(selectedActor.value)) {
return selection === edge ? 'red' : 'magenta';
} else {
return selection === edge ? 'black' : '#999999';
}
})
.linkCurvature('curvature')
.linkCanvasObjectMode(() => 'after')
.linkCanvasObject((edge, context) => {
const MAX_FONT_SIZE = 4;
const LABEL_NODE_MARGIN = Graph.nodeRelSize() * edge.source.val * 1.5;
const source = edge.source;
const target = edge.target;
const curvature = edge.curvature || 0;
var textPos = (source === target) ? {x: source.x, y: source.y} : Object.assign(...['x', 'y'].map(c => ({
[c]: source[c] + (target[c] - source[c]) / 2
})));
const edgeVector = {x: target.x - source.x, y: target.y - source.y};
if (source !== target) {
var evLength = Math.sqrt(Math.pow(edgeVector.x, 2) + Math.pow(edgeVector.y, 2));
var perpendicular = {x: edgeVector.x, y: (-Math.pow(edgeVector.x, 2) / edgeVector.y)};
var pLength = Math.sqrt(Math.pow(perpendicular.x, 2) + Math.pow(perpendicular.y, 2));
perpendicular.x = perpendicular.x / pLength;
perpendicular.y = perpendicular.y / pLength;
var fromSource = {x: source.x + perpendicular.x, y: source.y + perpendicular.y};
// If source would cycle around target in clockwise direction, would fromSource point into this direction?
// If not, the perpendicular vector must be flipped in order to ensure that the label is displayed on the
// intended curved edge.
var isClockwise = (source.x < target.x && fromSource.y > source.y) ||
(source.x > target.x && fromSource.y < source.y) ||
(source.x === target.x && ((source.y < target.y && fromSource.x < source.x) ||
source.y > target.y && fromSource.x > source.x));
var offset = 0.5 * evLength * (isClockwise ? -curvature : curvature);
textPos = {x: textPos.x + perpendicular.x * offset, y: textPos.y + perpendicular.y * offset};
} else if (edge.__controlPoints) { // Position label relative to the Bezier control points of the self loop
edgeVector.x = edge.__controlPoints[2] - edge.__controlPoints[0];
edgeVector.y = edge.__controlPoints[3] - edge.__controlPoints[1];
var ctrlCenter = {x: edge.__controlPoints[0] + (edge.__controlPoints[2] - edge.__controlPoints[0]) / 2,
y: edge.__controlPoints[1] + (edge.__controlPoints[3] - edge.__controlPoints[1]) / 2};
var fromSource = {x: ctrlCenter.x - source.x, y: ctrlCenter.y - source.y};
var fromSrcLen = Math.sqrt(Math.pow(fromSource.x, 2) + Math.pow(fromSource.y, 2));
fromSource.x /= fromSrcLen;
fromSource.y /= fromSrcLen;
// The distance of the control point is 70 * curvature. Slightly more than half of it is appropriate here:
textPos = {x: source.x + fromSource.x * 37 * curvature, y: source.y + fromSource.y * 37 * curvature};
}
const maxTextLength = (source !== target) ? Math.sqrt(Math.pow(edgeVector.x, 2) + Math.pow(edgeVector.y, 2)) - LABEL_NODE_MARGIN
: 1.5 * Math.sqrt(4 * source.val);
var textAngle = Math.atan2(edgeVector.y, edgeVector.x);
// maintain label vertical orientation for legibility
if (textAngle > Math.PI / 2) textAngle = -(Math.PI - textAngle);
if (textAngle < -Math.PI / 2) textAngle = -(-Math.PI - textAngle);
var label = edge.name;
// estimate fontSize to fit in link length
//context.font = '1px Sans-Serif';
const fontSize = MAX_FONT_SIZE;// Math.min(MAX_FONT_SIZE, maxTextLength / context.measureText(label).width);
context.font = `${fontSize}px Sans-Serif`;
var textLen = context.measureText(label).width;
if (textLen > maxTextLength) {
var allowedLen = maxTextLength * (label.length / textLen);
label = label.substring(0, allowedLen);
if (label !== edge.name) label += '...';
textLen = context.measureText(label).width;
}
const bckgDimensions = [textLen, fontSize];
// draw text label (with background rect)
context.save();
context.translate(textPos.x, textPos.y);
context.rotate(textAngle);
context.fillStyle = 'rgba(255, 255, 255, 0.8)';
context.fillRect(- bckgDimensions[0] / 2, - bckgDimensions[1] / 2, ...bckgDimensions);
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillStyle = selection === edge ? 'black' : 'darkgrey';
context.fillText(label, 0, 0);
context.restore();
})
.linkLineDash(edge => edge.actionData.mode == 'automatic' && [2, 3]) //[dash, gap]
.linkWidth(edge => (edge.actionData.mode != 'automatic' && edge.actionData.actorNames.includes(selectedActor.value)) ? 3 : 1)
.linkDirectionalParticles(2)
.linkDirectionalParticleColor(() => '#00000055')
.linkDirectionalParticleWidth(edge => (edge.actionData.mode != 'automatic' && edge.actionData.actorNames.includes(selectedActor.value)) ? 3 : 0)
.nodeCanvasObject((node, ctx) => {
ctx.fillStyle = getColour(node);
ctx.beginPath();
ctx.arc(node.x, node.y, 2*node.val, 0, 2 * Math.PI, false);
ctx.fill();
ctx.fillStyle = 'white';
ctx.font = '4px Sans-Serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// var label = node.name.substring(0, 5);
var label = node.name.split(' '); // [node.name.substring(0, 6), node.name.substring(6, 12), node.name.substring(12, 18)];
for (var i = 0; i < label.length; i++) {
var isBrace = label[i][0] === '(';
label[i] = label[i].substring(isBrace ? 1 : 0, isBrace ? 2 : 1);
}
// for (var i = 0; i < label.length; i++) {
// ctx.fillText(label[i], node.x, (node.y - 4) + i * 4);
// }
ctx.fillText(label.join('').substring(0,6), node.x, node.y);
})
.onNodeDragEnd(node => {
node.fx = node.x;
node.fy = node.y;
})
.onNodeClick((node, _) => select(node))
.onNodeRightClick((node, _) => removeState(node))
.onLinkClick((edge, _) => select(edge))
.onLinkRightClick((edge, _) => removeAction(edge))
.onBackgroundClick(event => {
var coords = Graph.screen2GraphCoords(event.layerX, event.layerY);
var newState = addState(coords.x, coords.y);
selection = newState;
})
.autoPauseRedraw(false);
updateGraph();