uni2work.workflows.visualiser/editor.js
2023-08-15 04:58:25 +02:00

1112 lines
46 KiB
JavaScript

// SPDX-FileCopyrightText: 2023 David Mosbach <david.mosbach@campus.lmu.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as WF from './workflow.js';
import './forcegraph.js';
import 'https://unpkg.com/force-graph@1.43.0/dist/force-graph.min.js';
//@ts-ignore
import Index from './node_modules/flexsearch/src/index.js'; // 'https://cdn.jsdelivr.net/gh/nextapps-de/flexsearch@0.7.31/dist/flexsearch.bundle.js'
//Theme
var darkMode = false;
export function toggleTheme() {
darkMode = !darkMode;
var menus = [mainMenu, sidePanel, filePanel];
Array.from(document.getElementsByClassName('menuitem')).forEach(item => item !== fileMenu &&
Array.from(item.getElementsByClassName('submenu')).forEach(subMenu => menus.push(subMenu)));
Array.from(fileMenu.children).forEach(child => menus.push(child));
Array.from(document.getElementById('editmenu').children).forEach(child => menus.push(child));
Array.from(contextMenuBg.children).forEach(child => menus.push(child));
Array.from(contextMenuEd.children).forEach(child => menus.push(child));
Array.from(contextMenuSt.children).forEach(child => menus.push(child));
Array.from(document.getElementsByClassName('graph-tooltip')).forEach(tooltip => menus.push(tooltip));
var contentHints = [
document.getElementById('filename'),
document.getElementById('sidecontentedge'),
document.getElementById('sidecontentnode')
];
var searchIcon = document.getElementById('search-icon');
if (darkMode) {
menus.forEach(target => {
target?.classList.add('menu-darkmode');
target?.classList.remove('menu-lightmode');
});
contentHints.forEach(hint => {
hint?.classList.add('contenttype-darkmode');
hint?.classList.remove('contenttype-lightmode');
});
searchIcon?.classList.add('search-icon-darkmode');
searchIcon?.classList.remove('search-icon-lightmode');
wfGraph.backgroundColor('black');
}
else {
menus.forEach(target => {
target?.classList.add('menu-lightmode');
target?.classList.remove('menu-darkmode');
});
contentHints.forEach(hint => {
hint?.classList.add('contenttype-lightmode');
hint?.classList.remove('contenttype-darkmode');
});
searchIcon?.classList.add('search-icon-lightmode');
searchIcon?.classList.remove('search-icon-darkmode');
wfGraph.backgroundColor('white');
}
}
// Menu bar
const mainMenu = document.getElementById('mainmenu');
var selectedMenuItem = null;
Array.from(document.getElementsByClassName('submenu'))
.forEach(subMenu => subMenu.style.top = (mainMenu.offsetHeight + 15).toString());
var lastSubMenu = null;
function positionSubmenuBackdrop() {
if (!lastSubMenu || !submenuBackdrop)
return;
var smRect = lastSubMenu.getBoundingClientRect();
submenuBackdrop.style.top = smRect.top.toString(); // sideHeading.offsetHeight + parseFloat(smStyle.paddingTop) + parseFloat(shStyle.marginTop) + parseFloat(shStyle.marginBottom);
submenuBackdrop.style.left = smRect.left.toString();
submenuBackdrop.style.width = lastSubMenu.offsetWidth.toString();
submenuBackdrop.style.height = lastSubMenu.offsetHeight.toString();
// var sbStyle = window.getComputedStyle(sideButtons);
// sideContent.style.bottom = sideButtons.offsetHeight + parseFloat(smStyle.paddingBottom) + parseFloat(sbStyle.marginTop) + parseFloat(sbStyle.marginBottom);
// console.log(sideHeading.offsetHeight + shStyle.marginTop + shStyle.marginBottom);
// var width =
}
/**
*
* @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, step: 0.1 }));
});
fadeOut(null, ...fadeOuts);
menuitem.classList.add('selectedmenuitem');
var fadeIns = [{ element: submenuBackdrop, max: 1 }];
Array.from(menuitem.getElementsByClassName('submenu')).forEach(subMenu => {
fadeIns.push({ element: subMenu, max: 1 });
lastSubMenu = subMenu;
});
fadeIn(positionSubmenuBackdrop, ...fadeIns);
selectedMenuItem = menuitem;
}
function closeContextMenus(...menus) {
var items = [];
menus.forEach(menu => items.push({ element: menu, min: 0, step: 0.1 }));
fadeOut(null, ...items);
}
function closeMenuItem() {
if (!selectedMenuItem)
return;
selectedMenuItem.classList.remove('selectedmenuitem');
var fadeOuts = [{ element: submenuBackdrop, min: 0, step: 0.1 }];
Array.from(selectedMenuItem.getElementsByClassName('submenu')).forEach(subMenu => fadeOuts.push({ element: subMenu, min: 0, step: 0.1 }));
fadeOut(() => searchResults.style.display = 'none', ...fadeOuts);
selectedMenuItem = null;
}
export function openFileMenu(menuitem) {
openMenuItem(menuitem);
}
export function openEditMenu(menuitem) {
openMenuItem(menuitem);
}
export function openViewMenu(menuitem) {
openMenuItem(menuitem);
}
export function openSettingsMenu(menuitem) {
openMenuItem(menuitem);
}
export function openAboutMenu(menuitem) {
openMenuItem(menuitem);
}
function focusSelection() {
if (!selection)
return;
var x = 0;
var y = 0;
if (selection.hasOwnProperty('actionData')) {
selection = selection;
x = selection.source.x + (selection.target.x - selection.source.x) / 2;
y = selection.source.y + (selection.target.y - selection.source.y) / 2;
}
else {
selection = selection;
x = selection.x;
y = selection.y;
}
wfGraph.centerAt(x, y, 400);
wfGraph.zoom(5, 400);
}
export function openSearchMenu(menuitem) {
if (selectedMenuItem === menuitem)
return;
var val = searchInput.value;
if (val === '' || val === null)
while (searchResultList.firstChild)
searchResultList.removeChild(searchResultList.lastChild);
openMenuItem(menuitem);
}
document.getElementById('filepanel').style.opacity = '0';
//Search
var nodeIndex = new Index({ tokenize: 'forward' });
var actionIndex = new Index({ tokenize: 'forward' });
// const searchDocument = new FlexSearch.Document();
// const searchWorker = new FlexSearch.Worker();
const soStates = document.getElementById('search-option-states');
const soEdges = document.getElementById('search-option-edges');
export function search(text) {
while (searchResultList.firstChild)
searchResultList.removeChild(searchResultList.lastChild);
var searchStates = soStates.checked;
var searchActions = soEdges.checked;
var stateResults = searchStates ? nodeIndex.search(text, searchActions ? 5 : 10) : null;
var actionResults = searchActions ? actionIndex.search(text, searchStates ? 5 : 10) : null;
function defineFocus(div, target) {
div.onclick = (_ => {
searchInput.value = '';
closeMenuItem();
select(target);
focusSelection();
});
}
function format(possibleTargets, results, heading) {
var h = document.createElement('h3');
h.innerHTML = heading;
searchResultList.appendChild(h);
results.forEach(result => {
var target = null;
possibleTargets.forEach(stateOrEdge => {
if (stateOrEdge.id === result)
target = stateOrEdge;
});
if (!target)
return;
var r = document.createElement('div');
var head = document.createElement('div');
head.innerText = target.name;
head.classList.add('search-result-head');
r.appendChild(head);
var info = document.createElement('div');
if (target.hasOwnProperty('actionData'))
info.innerText = target.source.name + ' → ' + target.target.name;
else
info.innerText = target.stateData.abbreviation;
info.setAttribute('title', info.innerText);
info.classList.add('search-result-info');
r.appendChild(info);
searchResultList.appendChild(r);
defineFocus(r, target);
});
}
searchResultList.style.maxHeight = (parseFloat(searchResults.style.maxHeight) - searchOptions.offsetHeight).toString();
// console.log('maxh', searchResults.style.maxHeight - searchOptions.offsetHeight)
stateResults && format(workflow.states, stateResults, 'States');
actionResults && format(workflow.actions, actionResults, 'Edges');
positionSubmenuBackdrop();
}
export function showSearchResults() {
searchResults.style.display = 'block';
}
export 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)).toString();
var bStyle = window.getComputedStyle(fileButtons);
fileContent.style.bottom = (fileButtons.offsetHeight + parseFloat(pStyle.paddingBottom) + parseFloat(bStyle.marginTop) + parseFloat(bStyle.marginBottom)).toString();
}
fadeIn(callback, { element: filePanel, max: 1, step: 0.025 }, { element: curtain, max: 0.5, step: 0.025 });
closeMenuItem();
}
export function closeFileDisplay() {
var panel = document.getElementById('filepanel');
fadeOut(null, { element: panel, min: 0, step: 0.025 }, { element: curtain, min: 0, step: 0.025 });
}
function fadeIn(callback, ...items) {
requestAnimationFrame(() => {
items.forEach(i => i.element && (i.element.style.display = 'block'));
if (callback)
callback();
});
function fade() {
var proceed = false;
items.forEach(i => {
if (!i.element || i.max === undefined)
return;
var newOpacity = (parseFloat(i.element.style.opacity) || 0) + (i.step || 0.05);
if (newOpacity <= i.max) {
i.element.style.opacity = newOpacity.toString();
// 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.toString();
});
if (proceed)
requestAnimationFrame(fade);
}
requestAnimationFrame(fade);
}
function fadeOut(callback, ...items) {
function fade() {
var proceed = false;
items.forEach(i => {
if (!i.element || i.min === undefined)
return;
var newOpacity = (parseFloat(i.element.style.opacity) || 0) - (i.step || 0.05);
if (newOpacity >= i.min) {
i.element.style.opacity = newOpacity.toString();
// 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.toString();
i.element.style.display = 'none';
}
});
if (proceed)
requestAnimationFrame(fade);
else if (callback)
requestAnimationFrame(callback);
}
requestAnimationFrame(fade);
}
// Available workflow definition files
var workflowFiles = [];
// Workflow data
var workflow = new WF.Workflow({
states: [],
actions: []
});
//@ts-ignore
const wfGraph = ForceGraph();
function defineOnClick(item, url, title) {
item.onclick = (_ => {
fetch(url)
.then(response => response.json())
.then(data => {
closeFileDisplay();
searchInput.value = '';
while (searchResultList.firstChild)
searchResultList.removeChild(searchResultList.lastChild);
workflow = new WF.Workflow({
states: data.states,
actions: data.actions
});
nodeIndex = new Index({ tokenize: 'forward' });
actionIndex = new Index({ tokenize: 'forward' });
prepareWorkflow();
updateGraph();
wfGraph.centerAt(0, 0, 400);
wfGraph.zoom(1, 400);
document.getElementById('filename').innerText = title;
document.title = title + ' | Editor';
});
});
}
fetch('http://localhost:8080/spaß/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 = '<h3>' + workflowFiles[i].name + '</h3>' + workflowFiles[i].description;
var url = 'http://localhost:8080/spaß' + workflowFiles[i].url;
defineOnClick(item, url, workflowFiles[i].name);
fileContent.appendChild(item);
}
var url = 'http://localhost:8080/spaß' + workflowFiles[0].url;
return fetch(url);
})
.then((response) => response.json())
.then((data) => {
document.getElementById('filename').innerText = workflowFiles[0].name;
document.title = workflowFiles[0].name + ' | Editor';
workflow = new WF.Workflow({
states: data.states,
actions: data.actions
});
wfGraph(document.getElementById('graph')).graphData({ nodes: workflow.states, links: workflow.actions });
Array.from(document.getElementsByClassName('graph-tooltip')).forEach(tooltip => tooltip.classList.add('menu-lightmode'));
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 = [];
export 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);
}
});
}
export 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.
//Utility elements
const curtain = document.getElementById('curtain');
const submenuBackdrop = document.getElementById('submenu-backdrop');
//Side Panel
const sidePanel = document.getElementById('sidepanel');
const sideContent = document.getElementById('sidecontent');
const sideHeading = document.getElementById('sideheading');
const sideButtons = document.getElementById('sidebuttons');
const sideInfoEdge = document.getElementById('sidecontentedge');
const sideInfoNode = document.getElementById('sidecontentnode');
//File panel
const fileMenuBtn = document.getElementById('file-menu-btn');
const fileMenu = document.getElementById('filemenu');
const filePanel = document.getElementById('filepanel');
const fileHeading = document.getElementById('fileheading');
const fileContent = document.getElementById('filecontent');
const fileButtons = document.getElementById('filebuttons');
//Edit
const editMenuBtn = document.getElementById('edit-menu-btn');
//View
const viewMenuBtn = document.getElementById('view-menu-btn');
//Settings
const settingsMenuBtn = document.getElementById('settings-menu-btn');
//About
const aboutMenuBtn = document.getElementById('about-menu-btn');
//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
//Search
const searchMenuBtn = document.getElementById('search-menu-btn');
const searchContainer = document.getElementById('search-container');
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
const searchResultList = document.getElementById('search-result-list');
const searchOptions = document.getElementById('search-options');
// 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
sidePanel.style.top = (mainMenu.offsetHeight + 15).toString();
searchContainer.style.left = (mainMenu.offsetWidth / 2 - searchContainer.offsetWidth / 2).toString();
searchResults.style.left = searchContainer.style.left;
searchResults.style.maxHeight = (0.8 * window.innerHeight).toString();
//Event handlers
curtain.addEventListener('click', _ => closeFileDisplay());
fileMenuBtn.addEventListener('click', function (_) { openFileMenu(this); });
editMenuBtn.addEventListener('click', function (_) { openEditMenu(this); });
viewMenuBtn.addEventListener('click', function (_) { openViewMenu(this); });
settingsMenuBtn.addEventListener('click', function (_) { openSettingsMenu(this); });
aboutMenuBtn.addEventListener('click', function (_) { openAboutMenu(this); });
searchMenuBtn.addEventListener('click', function (_) { openSearchMenu(this); });
searchInput.addEventListener('click', function (_) { showSearchResults(); });
searchInput.addEventListener('input', function (_) { search(this.value); });
document.getElementById('search-button')?.addEventListener('click', _ => searchInput.focus());
document.getElementById('open-file')?.addEventListener('click', _ => openFileDisplay());
document.getElementById('actor')?.addEventListener('change', _ => selectActor());
document.getElementById('viewer')?.addEventListener('change', _ => selectViewer());
document.getElementById('theme-toggle')?.addEventListener('click', _ => toggleTheme());
document.getElementById('side-panel-cancel')?.addEventListener('click', _ => deselect());
document.getElementById('side-panel-focus')?.addEventListener('click', _ => focusSelection());
document.getElementById('side-panel-delete')?.addEventListener('click', _ => removeSelection());
document.getElementById('file-panel-cancel')?.addEventListener('click', _ => closeFileDisplay());
document.getElementById('add-state')?.addEventListener('click', _ => addState());
document.getElementById('edge-from')?.addEventListener('click', _ => markEdgeFrom());
document.getElementById('edge-to')?.addEventListener('click', _ => markEdgeTo());
document.getElementById('close-side-panel')?.addEventListener('click', _ => deselect());
document.getElementById('close-file-panel')?.addEventListener('click', _ => closeFileDisplay());
document.querySelectorAll('.edit-item').forEach(elem => elem.addEventListener('click', _ => rightSelect()));
document.querySelectorAll('.delete-item').forEach(elem => elem.addEventListener('click', _ => removeRightSelection()));
/**
* Marks the given item as selected.
* @param {*} item The node or edge to select.
*/
export 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() {
if (item.hasOwnProperty('actionData')) {
sideInfoEdge.style.display = 'block';
sideInfoNode.style.display = 'none';
}
else {
sideInfoEdge.style.display = 'none';
sideInfoNode.style.display = 'block';
}
var heading = item.name;
if (heading.length > 90)
heading = heading.substring(0, 88) + '...';
sideHeading.innerHTML = heading;
sideHeading.setAttribute('title', 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)).toString();
var sbStyle = window.getComputedStyle(sideButtons);
sideContent.style.bottom = (sideButtons.offsetHeight + parseFloat(spStyle.paddingBottom) + parseFloat(sbStyle.marginTop) + parseFloat(sbStyle.marginBottom)).toString();
// console.log(sideHeading.offsetHeight + shStyle.marginTop + shStyle.marginBottom);
}
fadeIn(callback, { element: sidePanel, max: 1 });
}
else {
fadeOut(null, { element: sidePanel, min: 0 });
// sidePanel.style.display = 'none';
}
console.log(item);
}
export function deselect() {
fadeOut(null, { element: sidePanel, min: 0 });
// sidePanel.style.display = 'none';
selection = null;
}
export function rightSelect() {
if (!rightSelection)
return;
select(rightSelection);
}
/**
* Adds a new state to the workflow and auto-selects it.
*/
export function addState() {
var nodeId = stateIdCounter++;
var x = newStateCoords.x;
var y = newStateCoords.y;
var state = { id: 'state_' + nodeId,
x: x,
y: y,
name: 'state_' + nodeId,
fx: x,
fy: y,
val: 5,
stateData: new WF.StateData({ abbreviation: `S${nodeId}`, final: 'false' }) };
workflow.states.push(state);
updateGraph();
select(state);
nodeIndex.add(state.id, state.name);
}
/**
* Adds a new action between two states.
* @param source The source state.
* @param target The target state.
*/
function connect(source, target) {
let linkId = actionIdCounter++;
var action = new WF.WFEdge({
id: (linkId).toString(),
source: source,
target: target,
name: 'action_' + linkId,
actionData: {},
nodePairId: ''
});
workflow.actions.push(action);
updateGraph();
select(action);
actionIndex.add(action.id, action.name);
}
export function markEdgeTo() {
edgeTo = rightSelection;
closeContextMenus(contextMenuSt);
// contextMenuSt.style.display = 'none';
}
export function markEdgeFrom() {
edgeFrom = rightSelection;
closeContextMenus(contextMenuSt);
// contextMenuSt.style.display = 'none';
}
export function removeSelection() {
if (selection) {
if (selection.hasOwnProperty('actionData'))
removeAction(selection);
else
removeState(selection);
deselect();
edgeFrom = edgeTo = rightSelection = null;
closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg);
}
}
export function removeRightSelection() {
if (rightSelection) {
if (rightSelection.hasOwnProperty('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.hasOwnProperty('stateData') ? 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 WF.Message) {
content.forEach(msg => {
if (msg instanceof WF.Message)
msg.format().forEach(child => children.push(child));
else {
var m = document.createElement('p');
m.innerHTML = msg;
children.push(m);
}
});
}
else if (content instanceof WF.Payload) {
content.format().forEach(child => children.push(child));
}
else if (content instanceof WF.Roles) {
content.format().forEach(child => children.push(child));
}
else {
var p = document.createElement('p');
var text = document.createTextNode((key == 'comment')
? data[key].join(' ')
: 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);
actionIndex.remove(action.id);
}
/**
* 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);
nodeIndex.remove(state.id);
}
var selfLoops = new Map(); // All edges whose targets equal their sources.
var overlappingEdges = new Map(); // 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 = new Map();
overlappingEdges = new Map();
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.has(edge.nodePairId))
category.set(edge.nodePairId, []);
category.get(edge.nodePairId).push(edge);
});
}
/**
* Computes the curvature of the loops stored in `selfLoops` and overlapping edges
* stored in `overlappingEdges`.
*/
function computeCurvatures() {
// Self loops
Array.from(selfLoops.keys()).forEach(id => {
var edges = selfLoops.get(id);
if (!edges) {
console.error('Undefined nodePairId: ' + id);
return;
}
for (let i = 0; i < edges.length; i++)
edges[i].curvature = selfLoopCurvMin + i / 10;
});
// Overlapping edges
Array.from(overlappingEdges.keys())
.filter(nodePairId => overlappingEdges.get(nodePairId).length > 1)
.forEach(nodePairId => {
var edges = overlappingEdges.get(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() {
actors = [];
viewers = [];
viewableByAll = [];
initiators = [];
highlightedSources = [];
highlightedTargets = [];
stateAbbreviations = [];
stateIdCounter = workflow.states ? workflow.states.length : 0;
actionIdCounter = workflow.states ? workflow.actions.length : 0;
Array.from(selectedActor.options).forEach(option => selectedActor.remove(parseFloat(option.value)));
Array.from(selectedViewer.options).forEach(option => selectedViewer.remove(parseFloat(option.value)));
selectedActor.value = NO_ACTOR;
selectedViewer.value = NO_VIEWER;
//Create search index
workflow.states.forEach(state => nodeIndex.add(state.id, state.name));
workflow.actions.forEach(action => actionIndex.add(action.id, action.name));
workflow.actions.forEach(act => act.actionData.actors.roles.forEach(a => {
var includes = false;
actors.forEach(actor => includes = includes || equalRoles(a, actor));
(!includes) && actors.push(a);
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.roles.length === 0) {
viewableByAll.push(act.actionData);
}
else {
act.actionData.viewers.roles.forEach(v => {
var includes = false;
viewers.forEach(viewer => includes = includes || equalRoles(v, viewer));
(!includes) && viewers.push(v);
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.roles.forEach(v => {
var includes = false;
viewers.forEach(viewer => includes = includes || equalRoles(v, viewer));
(!includes) && viewers.push(v);
st.stateData.viewerNames.push(getRoleName(v));
});
}
});
if (initState)
initState.stateData.viewerNames = initiators;
else
console.error('Failed to determine initial state');
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);
}
var 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 WF.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 WF.Role && (role1 = role1.json);
role2 instanceof WF.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') {
if (!role1.authorized || (role2.tag == 'authorized' && !role2.authorized)) {
console.error("Missing attribute 'authorized' for one of:", role1, role2);
equal = equal && (role1.authorized == role2.authorized);
}
else if (!role2.authorized)
equal = false;
else
equal = equal && (role1.authorized['dnf-terms'][0][0].var === role2.authorized['dnf-terms'][0][0].var);
}
return equal;
}
function openContextMenu(x, y, menu) {
menu.style.top = (y - 25).toString();
menu.style.left = (x + 20).toString();
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 edgeColourSelectedDarkMode = '#ffffffff';
const edgeColourHighlightDefault = '#6ed4d4';
const edgeColourHighlightSelected = 'magenta';
const edgeColourSubtleDefault = '#99999955';
const edgeColourSubtleSelected = '#00000055';
const edgeColourSubtleSelectedDarkMode = '#ffffff55';
const edgeColourMostSubtle = '#99999944';
/**
*
* @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 isHighlightedActorEdge(edge) {
var data = edge.actionData;
var isActor = data.mode != 'automatic' && data.actorNames.includes(selectedActor.value);
var isActorAuto = data.mode == 'automatic' && highlightedTargets.includes(edge.source.id);
return isActor || isActorAuto;
}
function isHighlightedViewerEdge(edge) {
var data = edge.actionData;
return data.viewerNames.includes(selectedViewer.value);
}
function getEdgeColour(edge) {
var isSelected = selection === edge || rightSelection === edge;
if (isHighlightedActorEdge(edge)) {
return isSelected ? edgeColourHighlightSelected : edgeColourHighlightDefault;
}
else if (selectedViewer.value !== NO_VIEWER && !isHighlightedViewerEdge(edge)) {
return isSelected ? edgeColourSubtleDefault : edgeColourMostSubtle;
}
else if (selectedActor.value !== NO_ACTOR) {
return isSelected ? (darkMode ? edgeColourSubtleSelectedDarkMode : edgeColourSubtleSelected)
: edgeColourSubtleDefault;
}
else {
return isSelected ? (darkMode ? edgeColourSelectedDarkMode : edgeColourSelected)
: edgeColourDefault;
}
}
wfGraph
.linkDirectionalArrowLength(4)
.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;
var textPos = (source === target)
? { x: source.x, y: source.y }
: { x: source.x + (target.x - source.x) / 2,
y: source.y + (target.y - source.y) / 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 = darkMode ? 'rgba(0, 0, 0, 0.8)' : '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) ? 2 : 0) + ((isHighlightedActorEdge(edge) || isHighlightedViewerEdge(edge)) ? 4 : 1))
.linkDirectionalParticles(2)
.linkDirectionalParticleColor(() => darkMode ? '#ffffff55' : '#00000055')
.linkDirectionalParticleWidth((edge) => (isHighlightedActorEdge(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 = darkMode ? 'white' : '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();
}
//Keyboard commands
document.addEventListener('keydown', e => {
console.log(e.ctrlKey, e.key);
if (e.key === 'Escape') {
closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg);
closeMenuItem();
closeFileDisplay();
deselect();
rightSelection = null;
searchInput.blur();
}
else if (!e.ctrlKey)
return;
switch (e.key) {
case 'f':
e.preventDefault();
searchInput.focus();
openSearchMenu(searchContainer.parentElement);
break;
case 'o':
e.preventDefault();
openFileDisplay();
default:
break;
}
});