// Menu bar var selectedMenuItem = null; /** * * @param {HTMLElement} menuitem */ function openMenuItem(menuitem) { edgeTo = edgeFrom = rightSelection = null; closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg); if (menuitem === selectedMenuItem) { closeMenuItem(); return; } var fadeOuts = []; Array.from(document.getElementsByClassName('selectedmenuitem')).forEach(other => { other.classList.remove('selectedmenuitem'); Array.from(other.getElementsByClassName('submenu')).forEach(subMenu => fadeOuts.push({element: subMenu, min: 0})); }); fadeOut(...fadeOuts); menuitem.classList.add('selectedmenuitem'); var fadeIns = []; Array.from(menuitem.getElementsByClassName('submenu')).forEach(subMenu => fadeIns.push({element: subMenu, max: 0.95})); fadeIn(null, ...fadeIns); selectedMenuItem = menuitem; } function closeContextMenus(...menus) { var items = [] menus.forEach(menu => items.push({element: menu, min: 0, step: 0.1})); fadeOut(...items); } function closeMenuItem() { if (!selectedMenuItem) return; selectedMenuItem.classList.remove('selectedmenuitem'); var fadeOuts = []; Array.from(selectedMenuItem.getElementsByClassName('submenu')).forEach(subMenu => fadeOuts.push({element: subMenu, min: 0})); fadeOut(...fadeOuts); selectedMenuItem = null; } function openFileMenu(menuitem) { openMenuItem(menuitem); } function openViewMenu(menuitem) { openMenuItem(menuitem); } function openSettingsMenu(menuitem) { openMenuItem(menuitem); } function openAboutMenu(menuitem) { openMenuItem(menuitem); } function openSearchMenu(menuitem) { openMenuItem(menuitem); } document.getElementById('filepanel').style.opacity = 0; function openFileDisplay() { deselect(); function callback() { fileHeading.innerHTML = 'Open Workflow Definition'; var pStyle = window.getComputedStyle(filePanel); var hStyle = window.getComputedStyle(fileHeading); fileContent.style.top = fileHeading.offsetHeight + parseFloat(pStyle.paddingTop) + parseFloat(hStyle.marginTop) + parseFloat(hStyle.marginBottom); var bStyle = window.getComputedStyle(fileButtons); fileContent.style.bottom = fileButtons.offsetHeight + parseFloat(pStyle.paddingBottom) + parseFloat(bStyle.marginTop) + parseFloat(bStyle.marginBottom); } fadeIn(callback, {element: filePanel, max: 0.95, step: 0.025}, {element: curtain, max: 0.5, step: 0.025}); closeMenuItem(); } function closeFileDisplay() { var panel = document.getElementById('filepanel'); var curtain = document.getElementById('curtain'); fadeOut({element: panel, min: 0, step: 0.025}, {element: curtain, min: 0, step: 0.025}); } function fadeIn(callback, ...items) { requestAnimationFrame(() => { items.forEach(i => i.element.style.display = 'block'); if (callback) callback(); }); function fade() { var proceed = false; items.forEach(i => { var newOpacity = (parseFloat(i.element.style.opacity) || 0) + (i.step || 0.05); if (newOpacity <= i.max) { i.element.style.opacity = newOpacity; // console.log(i.max, i.element.style.opacity) proceed = true; } else if ((parseFloat(i.element.style.opacity) || 0) > i.max) i.element.style.opacity = i.max; }); if (proceed) requestAnimationFrame(fade); } requestAnimationFrame(fade); } function fadeOut(...items) { function fade() { var proceed = false; items.forEach(i => { var newOpacity = (parseFloat(i.element.style.opacity) || 0) - (i.step || 0.05); if (newOpacity >= i.min) { i.element.style.opacity = newOpacity; // console.log(i.max, i.element.style.opacity) proceed = true; } else { if ((parseFloat(i.element.style.opacity) || 0) < i.min) i.element.style.opacity = i.min; i.element.style.display = 'none'; } }); if (proceed) requestAnimationFrame(fade); } requestAnimationFrame(fade); } // Available workflow definition files var workflowFiles = []; // Workflow data var workflow = {}; const wfGraph = ForceGraph(); function defineOnClick(item, url) { item.onclick = (_ => { fetch(url) .then(response => response.json()) .then(data => { closeFileDisplay(); for (var key in data) workflow[key] = data[key]; prepareWorkflow(); updateGraph(); }); }); } fetch('http://localhost:8080/index.json') .then(response => response.json()) .then(data => { workflowFiles = data; for (var i = 0; i < workflowFiles.length; i++) { var item = document.createElement('div'); item.innerHTML = workflowFiles[i].name; var url = 'http://localhost:8080' + workflowFiles[i].url; defineOnClick(item, url); fileContent.appendChild(item); } var url = 'http://localhost:8080' + data[0].url; return fetch(url); }) .then((response) => response.json()) .then((data) => { for (var key in data) workflow[key] = data[key]; wfGraph(document.getElementById('graph')).graphData({nodes: workflow.states, links: workflow.actions}); runnn(); }); //Actors of the workflow var actors = []; const selectedActor = document.getElementById('actor'); //Viewers of the workflow var viewers = []; const selectedViewer = document.getElementById('viewer'); //Actions/States with no explicit viewers var viewableByAll = [] //Possible initiators var initiators = [] //Implicit state from which initial actions can be selected var initState = null; const NO_ACTOR = 'None'; const NO_VIEWER = NO_ACTOR; //source & target nodes of all currently highlighted actions var highlightedSources = []; var highlightedTargets = []; function selectActor() { closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg); edgeFrom = edgeTo = rightSelection = null; 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() { closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg); edgeFrom = edgeTo = rightSelection = null; highlightedSources = []; highlightedTargets = []; selectedActor.value = NO_ACTOR; workflow.states.forEach(st => { if (st.stateData.viewerNames.includes(selectedViewer.value)) { highlightedSources.push(st.id); } }); } var selection = null; // The currently selected node/edge. var rightSelection = null; // The currently right clicked node/edge. var edgeTo = null; // Target of an edge to be created. var edgeFrom = null; // Start on an edge to be created. const curtain = document.getElementById('curtain'); //Side Panel const sidePanel = document.getElementById('sidepanel'); const sideContent = document.getElementById('sidecontent'); const sideHeading = document.getElementById('sideheading'); const sideButtons = document.getElementById('sidebuttons'); //File panel const filePanel = document.getElementById('filepanel'); const fileHeading = document.getElementById('fileheading'); const fileContent = document.getElementById('filecontent'); const fileButtons = document.getElementById('filebuttons'); //Context menus const contextMenuBg = document.getElementById('ctmenubg'); //Click on background const contextMenuSt = document.getElementById('ctmenust'); //Click on state const contextMenuEd = document.getElementById('ctmenued'); //Click on edge // Counters for placeholder IDs of states/actions added via GUI var stateIdCounter = 0; var actionIdCounter = 0; var stateAbbreviations = []; var newStateCoords = {'x': 0, 'y': 0}; //Initial coordinates of the next new state /** * Marks the given item as selected. * @param {*} item The node or edge to select. */ function select(item) { closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg); edgeFrom = edgeTo = rightSelection = null; selection = selection === item ? null : item; if (selection === item) { while (sideContent.firstChild) sideContent.removeChild(sideContent.lastChild); function callback() { sideHeading.innerHTML = item.name; var data = document.createElement('div'); var content = generatePanelContent(selection); content.forEach(c => data.appendChild(c)); sideContent.appendChild(data); var spStyle = window.getComputedStyle(sidePanel); var shStyle = window.getComputedStyle(sideHeading); sideContent.style.top = sideHeading.offsetHeight + parseFloat(spStyle.paddingTop) + parseFloat(shStyle.marginTop) + parseFloat(shStyle.marginBottom); var sbStyle = window.getComputedStyle(sideButtons); sideContent.style.bottom = sideButtons.offsetHeight + parseFloat(spStyle.paddingBottom) + parseFloat(sbStyle.marginTop) + parseFloat(sbStyle.marginBottom); // console.log(sideHeading.offsetHeight + shStyle.marginTop + shStyle.marginBottom); } fadeIn(callback, {element: sidePanel, max: 0.95}); } else { fadeOut({element: sidePanel, min: 0}); // sidePanel.style.display = 'none'; } console.log(item); } function deselect() { fadeOut({element: sidePanel, min: 0}); // sidePanel.style.display = 'none'; selection = null; } function rightSelect() { select(rightSelection); } /** * Adds a new state to the workflow and auto-selects it. */ function addState() { var nodeId = stateIdCounter ++; var x = newStateCoords.x; var y = newStateCoords.y; state = {id: nodeId, x: x, y: y, name: 'state_' + nodeId, fx: x, fy: y, val: 5}; workflow.states.push(state); updateGraph(); select(state); } /** * 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, actionData: { viewerNames: [], actorNames: [] }}; workflow.actions.push(action); updateGraph(); select(action); } function markEdgeTo() { edgeTo = rightSelection; closeContextMenus(contextMenuSt); // contextMenuSt.style.display = 'none'; } function markEdgeFrom() { edgeFrom = rightSelection; closeContextMenus(contextMenuSt); // contextMenuSt.style.display = 'none'; } function removeSelection() { if (selection) { if (selection.actionData) removeAction(selection); else removeState(selection); deselect(); edgeFrom = edgeTo = rightSelection = null; closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg); } } function removeRightSelection() { if (rightSelection) { if (rightSelection.actionData) removeAction(rightSelection); else removeState(rightSelection); if (selection === rightSelection) deselect(); edgeFrom = edgeTo = rightSelection = null; closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg); } } function generatePanelContent(selection) { var children = []; var data = selection.stateData || selection.actionData for (var key in data) { if (key === 'viewerNames' || key === 'actorNames') continue; var h = document.createElement('h2'); var heading = document.createTextNode(key.substring(0,1).toUpperCase() + key.substring(1)); h.appendChild(heading); children.push(h); var content = data[key]; if (content instanceof Array && content.length > 0 && content[0] instanceof Message) { content.forEach(msg => msg.format().forEach(child => children.push(child))); } else if (content instanceof Payload) { content.format().forEach(child => children.push(child)); } else if (content instanceof Array && content.length > 0 && content[0] instanceof Role) { var viewerList = document.createElement('ul'); content.forEach(viewer => { var v = document.createElement('li'); v.appendChild(document.createTextNode(viewer.name)); viewerList.appendChild(v); }); children.push(viewerList); } else { var p = document.createElement('p'); var text = document.createTextNode(JSON.stringify(data[key])); p.appendChild(text); children.push(p); } } return children; } /** * 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); var abbreviation = state.stateData && state.stateData.abbreviation; abbreviation && stateAbbreviations.splice(stateAbbreviations.indexOf(abbreviation), 1); } 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. /** * Updates the nodes and edges of the workflow graph. */ function updateGraph() { identifyOverlappingEdges() computeCurvatures() wfGraph.graphData({nodes: workflow.states, links: workflow.actions}); } /** * 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; } }); } function prepareWorkflow() { stateIdCounter = workflow.states ? workflow.states.length : 0; actionIdCounter = workflow.states ? workflow.actions.length : 0; //Parse workflow workflow.states.forEach(state => { var messages = []; state.stateData.messages.forEach(msg => messages.push(new Message(msg))); state.stateData.messages = messages; var viewers = []; state.stateData.viewers.forEach(v => viewers.push(new Role(v))); state.stateData.viewers = viewers; state.stateData.payload = new Payload(state.stateData.payload); }) workflow.actions.forEach(action => { var messages = []; action.actionData.messages.forEach(msg => messages.push(new Message(msg))); action.actionData.messages = messages; var viewers = []; action.actionData.viewers.forEach(v => viewers.push(new Role(v))); action.actionData.viewers = viewers; var actors = []; action.actionData.actors.forEach(v => actors.push(new Role(v))); action.actionData.actors = actors; var viewActors = []; action.actionData['actor Viewers'].forEach(v => viewActors.push(new Role(v))); action.actionData['actor Viewers'] = viewActors; action.actionData.form = new Payload(action.actionData.form); }) 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)); })); //Prepare actor highlighting 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); }); //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; }); } //Prepare viewer highlighting 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); }); //Compute abbreviations of the names of all states 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; }); } function getRoleName(role) { if (typeof role == 'string') { return role; } else if (role instanceof Role) { return role.name; } else { return JSON.stringify(role); } } /** * Checks if two roles are equal. * @param {*} role1 * @param {*} role2 * @returns */ function equalRoles(role1, role2) { role1 instanceof Role && (role1 = role1.json); role2 instanceof Role && (role2 = role2.json); 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; } /** * * @param {*} event * @param {HTMLElement} menu */ function openContextMenu(x, y, menu) { menu.style.top = y - 25; menu.style.left = x + 20; fadeIn(null, {element: menu, max: 1, step: 0.1}) // menu.style.display = 'block'; edgeFrom = edgeTo = null; } function runnn() { prepareWorkflow(); const edgeColourDefault = '#999999ff'; const edgeColourSelected = '#000000ff'; const edgeColourHighlightDefault = '#6ed4d4'; const edgeColourHighlightSelected = 'magenta'; const edgeColourSubtleDefault = '#99999955'; const edgeColourSubtleSelected = '#00000055'; /** * * @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'; var isSelected = selection === node || rightSelection === node; if (node.stateData && node.stateData.final !== 'False' && node.stateData.final !== '') { if (node.stateData.final === 'True' || node.stateData.final === 'ok') { return (isSelected ? '#3ac713' : '#31a810') + alpha; } else if (node.stateData.final === 'not-ok') { return (isSelected ? '#ec4e7b' : '#e7215a') + alpha; } else { return (isSelected ? '#ffbc15' : '#eeaa00') + alpha; } } else if (node.name === '@@INIT') { return (isSelected ? '#ffbc15' : '#eeaa00') + alpha; } else { return (isSelected ? '#538cd9' : '#3679d2') + 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) { var isSelected = selection === edge || rightSelection === edge; if (isHighlightedEdge(edge)) { return isSelected ? edgeColourHighlightSelected : edgeColourHighlightDefault; } else if (selectedActor.value !== NO_ACTOR) { return isSelected ? edgeColourSubtleSelected : edgeColourSubtleDefault; } else { return isSelected ? edgeColourSelected : edgeColourDefault; } } wfGraph .linkDirectionalArrowLength(6) .linkDirectionalArrowRelPos(1) .linkColor(getEdgeColour) .linkCurvature('curvature') .linkCanvasObjectMode(() => 'after') .linkCanvasObject((edge, context) => { const MAX_FONT_SIZE = 4; const LABEL_NODE_MARGIN = wfGraph.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 Inter`; 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 => (((edge === selection || edge === rightSelection) ? 1 : 0) + (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 === selection || node === rightSelection) { ctx.strokeStyle = 'black'; ctx.lineWidth = 1; ctx.setLineDash([1, 2]); ctx.lineCap = 'round'; ctx.stroke(); } if (! (node.stateData && node.stateData.abbreviation)) return; ctx.fillStyle = 'white'; ctx.font = '4px Inter'; 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, _) => { if (edgeFrom) { connect(edgeFrom, node); edgeFrom = null; } else if (edgeTo) { connect(node, edgeTo); edgeTo = null; } else select(node); closeMenuItem(); }) .onNodeRightClick((node, event) => { openContextMenu(event.layerX, event.layerY, contextMenuSt); closeContextMenus(contextMenuBg, contextMenuEd); // contextMenuBg.style.display = contextMenuEd.style.display = 'none'; rightSelection = node; closeMenuItem(); }) .onLinkClick((edge, _) => { select(edge); closeMenuItem(); }) .onLinkRightClick((edge, event) => { openContextMenu(event.layerX, event.layerY, contextMenuEd); closeContextMenus(contextMenuBg, contextMenuSt); // contextMenuBg.style.display = contextMenuSt.style.display = 'none'; rightSelection = edge; closeMenuItem() }) .onBackgroundClick(_ => { closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg); deselect(); edgeFrom = edgeTo = rightSelection = null; closeMenuItem(); }) .onBackgroundRightClick(event => { newStateCoords = wfGraph.screen2GraphCoords(event.layerX, event.layerY); openContextMenu(event.layerX, event.layerY, contextMenuBg); closeContextMenus(contextMenuEd, contextMenuSt); // contextMenuEd.style.display = contextMenuSt.style.display = 'none'; edgeFrom = edgeTo = rightSelection = null; closeMenuItem(); }) .autoPauseRedraw(false); updateGraph(); }