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; //Actors 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(getRoleName(a)); })); // console.log(actors); // workflow.actions.forEach(a => console.log(a.actionData.actorNames)); function getRoleName(role) { if (typeof role == 'string') { return role; } else if (role.tag == 'payload-reference') { return role['payload-label']; } else if (role.authorized) { return role.authorized['dnf-terms'][0][0].var + ' (auth)'; } else { return role.user || JSON.stringify(role); } } const NO_ACTOR = 'None'; //Prepare actor highlighting const selectedActor = document.getElementById('actor'); var allActors = document.createElement('option'); allActors.text = NO_ACTOR; selectedActor.add(allActors); actors.forEach(actor => { var option = document.createElement('option'); option.text = getRoleName(actor); selectedActor.add(option); }); //Viewers of the workflow var viewers = []; //Actions/States with no explicit viewers var viewableByAll = [] //Possible initiators var initiators = [] //Implicit state from which initial actions can be selected var initState = null; //Identify all viewers of every action workflow.actions.forEach(act => { if (act.actionData.viewers.length === 0) { viewableByAll.push(act.actionData); } else { act.actionData.viewers.forEach(v => { var includes = false; viewers.forEach(viewer => includes = includes || equalRoles(v, viewer)); (!includes) && viewers.push(v); (!act.actionData.viewerNames) && (act.actionData.viewerNames = []); act.actionData.viewerNames.push(getRoleName(v)); }) } if (act.actionData.mode === 'initial') { act.actionData.actorNames.forEach(an => !initiators.includes(an) && initiators.push(an)); } }); //Identify all viewers of every state workflow.states.forEach(st => { if (st.name === '@@INIT') { initState = st; } else if (st.stateData.viewers.length === 0) { viewableByAll.push(st.stateData); } else { st.stateData.viewers.forEach(v => { var includes = false; viewers.forEach(viewer => includes = includes || equalRoles(v, viewer)); (!includes) && viewers.push(v); (!st.stateData.viewerNames) && (st.stateData.viewerNames = []); st.stateData.viewerNames.push(getRoleName(v)); }) } }); initState.stateData.viewerNames = initiators; const ALL_VIEW = "Not explicitly specified"; if (viewableByAll.length > 0) { viewers.push(ALL_VIEW); var viewerNames = [] viewers.forEach(viewer => viewerNames.push(getRoleName(viewer))); viewableByAll.forEach(data => { data.viewerNames = viewerNames; }); } const NO_VIEWER = NO_ACTOR; //Prepare viewer highlighting const selectedViewer = document.getElementById('viewer'); var allViewers = document.createElement('option'); allViewers.text = NO_VIEWER; selectedViewer.add(allViewers); viewers.forEach(viewer => { var option = document.createElement('option'); option.text = getRoleName(viewer); selectedViewer.add(option); }); //source & target nodes of all currently highlighted actions var highlightedSources = []; var highlightedTargets = []; function selectActor() { // console.log(selectedActor.value); highlightedSources = []; highlightedTargets = []; selectedViewer.value = NO_VIEWER; workflow.actions.forEach(act => { if (act.actionData.mode != 'automatic' && act.actionData.actorNames.includes(selectedActor.value)) { highlightedSources.push(act.source.id); highlightedTargets.push(act.target.id); } }); } function selectViewer() { highlightedSources = []; highlightedTargets = []; selectedActor.value = NO_ACTOR; workflow.states.forEach(st => { if (st.stateData.viewerNames.includes(selectedViewer.value)) { highlightedSources.push(st.id); } }); } 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. const sidePanel = document.getElementById('sidepanel'); const edgeColourDefault = '#999999ff'; const edgeColourSelected = '#000000ff'; const edgeColourHighlightDefault = 'magenta'; const edgeColourHighlightSelected = 'red'; const edgeColourSubtleDefault = '#99999955'; const edgeColourSubtleSelected = '#00000055'; /** * Checks if two roles are equal. * @param {*} role1 * @param {*} role2 * @returns */ function equalRoles(role1, role2) { var equal = role1.tag === role2.tag; if (role1.tag == 'payload-reference') { equal = equal && (role1['payload-label'] === role2['payload-label']); } else if (role1.tag == 'user') { equal = equal && (role1.user === role2.user); } else if (role1.tag == 'authorized') { equal = 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 = selection === item ? null : item; if (selection === item) { sidePanel.style.display = 'block' document.getElementById('sideheading').innerHTML = item.name; var data = document.createElement('div'); var text = document.createTextNode(JSON.stringify(selection.stateData && selection.stateData || selection.actionData)); data.appendChild(text); sidePanel.appendChild(data); } else { sidePanel.style.display = 'none' } 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 getNodeColour(node) { var standard = (selectedActor.value === NO_ACTOR && selectedViewer.value === NO_VIEWER) || highlightedSources.includes(node.id) || highlightedTargets.includes(node.id) var alpha = standard ? 'ff' : '55'; if (node.stateData && node.stateData.final !== 'False' && node.stateData.final !== '') { if (node.stateData.final === 'True' || node.stateData.final === 'ok') { return (selection === node ? '#a4eb34' : '#7fad36') + alpha; } else if (node.stateData.final === 'not-ok') { return (selection === node ? '#f77474' : '#f25050') + alpha; } else { //console.log(node.stateData.final); } } else if (node.name === '@@INIT') { return (selection === node ? '#e8cd84' : '#d1ad4b') + alpha; } else { return (selection === node ? '#5fbad9' : '#4496b3') + alpha; } } function isHighlightedEdge(edge) { var data = edge.actionData var isViewer = data.viewerNames.includes(selectedViewer.value) var isActor = data.mode != 'automatic' && data.actorNames.includes(selectedActor.value) var isActorAuto = data.mode == 'automatic' && highlightedTargets.includes(edge.source.id) return isViewer || isActor || isActorAuto; } function getEdgeColour(edge) { if (isHighlightedEdge(edge)) { return selection === edge ? edgeColourHighlightSelected : edgeColourHighlightDefault; } else if (selectedActor.value !== NO_ACTOR) { return selection === edge ? edgeColourSubtleSelected : edgeColourSubtleDefault; } else { return selection === edge ? edgeColourSelected : edgeColourDefault; } } //Compute abbreviations of the names of all states var stateAbbreviations = []; workflow.states.forEach(state => { // var label = node.name.substring(0, 5); var label = state.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++) { if (label[i] === '(') continue; // if the state name contains whitespace after the brace var isBrace = label[i][0] === '('; label[i] = label[i].substring(isBrace ? 1 : 0, isBrace ? 2 : 1); } labelString = label.join('').substring(0,6); var counter = 1; var len = labelString.length; while (stateAbbreviations.includes(labelString)) { labelString = labelString.substring(0,len) + "'" + counter++; } stateAbbreviations.push(labelString); state.stateData.abbreviation = labelString; }); const Graph = ForceGraph() (document.getElementById('graph')) .linkDirectionalArrowLength(6) .linkDirectionalArrowRelPos(1) .linkColor(getEdgeColour) .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 + 100 * curvature); 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 = getEdgeColour(edge); context.fillText(label, 0, 0); context.restore(); }) .linkLineDash(edge => edge.actionData.mode == 'automatic' && [2, 3]) //[dash, gap] .linkWidth(edge => (isHighlightedEdge(edge)) ? 3 : 1) .linkDirectionalParticles(2) .linkDirectionalParticleColor(() => '#00000055') .linkDirectionalParticleWidth(edge => (isHighlightedEdge(edge)) ? 3 : 0) .nodeCanvasObject((node, ctx) => { ctx.fillStyle = getNodeColour(node); ctx.beginPath(); ctx.arc(node.x, node.y, 2*node.val, 0, 2 * Math.PI, false); ctx.fill(); if (! (node.stateData && node.stateData.abbreviation)) return; ctx.fillStyle = 'white'; ctx.font = '4px Sans-Serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(node.stateData.abbreviation, 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();