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)); // TODO check if contents of 'authorized' also match if applicable (!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] .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; }); updateGraph();