1187 lines
46 KiB
TypeScript
1187 lines
46 KiB
TypeScript
// 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, { LinkObject, NodeObject } from 'force-graph';
|
|
import { Index, IndexSearchResult } from 'flexsearch';
|
|
//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(<HTMLElement>subMenu))
|
|
);
|
|
Array.from(fileMenu.children).forEach(child => menus.push(<HTMLElement>child));
|
|
Array.from((<HTMLElement>document.getElementById('editmenu')).children).forEach(child => menus.push(<HTMLElement>child));
|
|
Array.from(contextMenuBg.children).forEach(child => menus.push(<HTMLElement>child));
|
|
Array.from(contextMenuEd.children).forEach(child => menus.push(<HTMLElement>child));
|
|
Array.from(contextMenuSt.children).forEach(child => menus.push(<HTMLElement>child));
|
|
Array.from(document.getElementsByClassName('graph-tooltip')).forEach(tooltip => menus.push(<HTMLElement>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 = <HTMLElement>document.getElementById('mainmenu');
|
|
var selectedMenuItem : HTMLElement | null = null;
|
|
|
|
Array.from(document.getElementsByClassName('submenu'))
|
|
.forEach(subMenu => (<HTMLElement>subMenu).style.top = (mainMenu.offsetHeight + 15).toString());
|
|
|
|
var lastSubMenu : HTMLElement | null = 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 =
|
|
}
|
|
|
|
type FadeDef = {
|
|
element: HTMLElement | null,
|
|
min?: number,
|
|
max?: number,
|
|
step?: number
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {HTMLElement} menuitem
|
|
*/
|
|
function openMenuItem(menuitem: HTMLElement) {
|
|
edgeTo = edgeFrom = rightSelection = null;
|
|
closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg);
|
|
if (menuitem === selectedMenuItem) {
|
|
closeMenuItem();
|
|
return;
|
|
}
|
|
var fadeOuts : FadeDef[] = [];
|
|
Array.from(document.getElementsByClassName('selectedmenuitem')).forEach(other => {
|
|
other.classList.remove('selectedmenuitem');
|
|
Array.from(other.getElementsByClassName('submenu')).forEach(subMenu => fadeOuts.push({element: <HTMLElement>subMenu, min: 0, step: 0.1}));
|
|
});
|
|
fadeOut(null, ...fadeOuts);
|
|
menuitem.classList.add('selectedmenuitem');
|
|
var fadeIns : FadeDef[] = [{element: submenuBackdrop, max: 1}];
|
|
Array.from(menuitem.getElementsByClassName('submenu')).forEach(subMenu => {
|
|
fadeIns.push({element: <HTMLElement>subMenu, max: 1});
|
|
lastSubMenu = <HTMLElement>subMenu;
|
|
});
|
|
fadeIn(positionSubmenuBackdrop, ...fadeIns);
|
|
selectedMenuItem = menuitem;
|
|
}
|
|
|
|
function closeContextMenus(...menus: HTMLElement[]) {
|
|
var items: FadeDef[] = []
|
|
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: <HTMLElement>subMenu, min: 0, step: 0.1}));
|
|
fadeOut(() => searchResults.style.display = 'none', ...fadeOuts);
|
|
selectedMenuItem = null;
|
|
}
|
|
|
|
export function openFileMenu(menuitem: HTMLElement) {
|
|
openMenuItem(menuitem);
|
|
}
|
|
|
|
export function openEditMenu(menuitem: HTMLElement) {
|
|
openMenuItem(menuitem);
|
|
}
|
|
|
|
export function openViewMenu(menuitem: HTMLElement) {
|
|
openMenuItem(menuitem);
|
|
}
|
|
|
|
export function openSettingsMenu(menuitem: HTMLElement) {
|
|
openMenuItem(menuitem);
|
|
}
|
|
|
|
export function openAboutMenu(menuitem: HTMLElement) {
|
|
openMenuItem(menuitem);
|
|
}
|
|
|
|
function focusSelection() {
|
|
if (!selection) return;
|
|
var x = 0;
|
|
var y = 0;
|
|
if (selection.hasOwnProperty('actionData')) {
|
|
selection = <WF.WFEdge>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 = <WF.WFNode>selection
|
|
x = selection.x;
|
|
y = selection.y;
|
|
}
|
|
wfGraph.centerAt(x, y, 400);
|
|
wfGraph.zoom(5, 400);
|
|
}
|
|
|
|
export function openSearchMenu(menuitem: HTMLElement) {
|
|
if (selectedMenuItem === menuitem) return;
|
|
var val = searchInput.value;
|
|
if (val === '' || val === null)
|
|
while (searchResultList.firstChild)
|
|
searchResultList.removeChild(<ChildNode>searchResultList.lastChild);
|
|
openMenuItem(menuitem);
|
|
}
|
|
|
|
(<HTMLElement>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 = <HTMLInputElement>document.getElementById('search-option-states');
|
|
const soEdges = <HTMLInputElement>document.getElementById('search-option-edges');
|
|
|
|
export function search(text: string) {
|
|
while (searchResultList.firstChild)
|
|
searchResultList.removeChild(<ChildNode>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: HTMLDivElement, target: WF.WFNode | WF.WFEdge) {
|
|
div.onclick = (_ => {
|
|
searchInput.value = '';
|
|
closeMenuItem();
|
|
select(target);
|
|
focusSelection();
|
|
});
|
|
}
|
|
|
|
function format(possibleTargets: (WF.WFEdge | WF.WFNode)[], results: IndexSearchResult, heading: string) {
|
|
var h = document.createElement('h3');
|
|
h.innerHTML = heading;
|
|
searchResultList.appendChild(h);
|
|
results.forEach(result => {
|
|
var target: WF.WFEdge | WF.WFNode | null = 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 = (<WF.WFEdge | WF.WFNode>target).name;
|
|
head.classList.add('search-result-head');
|
|
r.appendChild(head);
|
|
var info = document.createElement('div');
|
|
if ((<WF.WFEdge | WF.WFNode>target).hasOwnProperty('actionData'))
|
|
info.innerText = (<WF.WFEdge>target).source.name + ' → ' + (<WF.WFEdge>target).target.name;
|
|
else
|
|
info.innerText = (<WF.WFNode>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: Function | null, ...items: FadeDef[]) {
|
|
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: Function | null, ...items: FadeDef[]) {
|
|
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(<FrameRequestCallback>callback);
|
|
}
|
|
requestAnimationFrame(fade);
|
|
}
|
|
|
|
type WFResource = {
|
|
name: string,
|
|
url: string,
|
|
description: string
|
|
}
|
|
|
|
// Available workflow definition files
|
|
var workflowFiles : WFResource[] = [];
|
|
// Workflow data
|
|
var workflow : WF.Workflow = new WF.Workflow({
|
|
states : [],
|
|
actions : []
|
|
});
|
|
const wfGraph = ForceGraph();
|
|
|
|
function defineOnClick(item: HTMLElement, url: string, title: string) {
|
|
item.onclick = (_ => {
|
|
fetch(url)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
closeFileDisplay();
|
|
searchInput.value = '';
|
|
while (searchResultList.firstChild)
|
|
searchResultList.removeChild(<ChildNode>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);
|
|
(<HTMLElement>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) => {
|
|
(<HTMLElement>document.getElementById('filename')).innerText = workflowFiles[0].name;
|
|
document.title = workflowFiles[0].name + ' | Editor';
|
|
workflow = new WF.Workflow({
|
|
states : data.states,
|
|
actions : data.actions
|
|
});
|
|
wfGraph(<HTMLElement>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: WF.Role[] = [];
|
|
const selectedActor = <HTMLSelectElement>document.getElementById('actor');
|
|
//Viewers of the workflow
|
|
var viewers: (WF.Role | string)[] = [];
|
|
const selectedViewer = <HTMLSelectElement>document.getElementById('viewer');
|
|
//Actions/States with no explicit viewers
|
|
var viewableByAll: (WF.ActionData | WF.StateData)[] = [];
|
|
//Possible initiators
|
|
var initiators: string[] = [];
|
|
//Implicit state from which initial actions can be selected
|
|
var initState : WF.WFNode | null = null;
|
|
const NO_ACTOR = 'None';
|
|
const NO_VIEWER = NO_ACTOR;
|
|
|
|
//source & target nodes of all currently highlighted actions
|
|
var highlightedSources : string[] = [];
|
|
var highlightedTargets : string[] = [];
|
|
|
|
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 : WF.WFNode | WF.WFEdge | null = null; // The currently selected node/edge.
|
|
var rightSelection : WF.WFNode | WF.WFEdge | null = null; // The currently right clicked node/edge.
|
|
var edgeTo : WF.WFNode | null = null; // Target of an edge to be created.
|
|
var edgeFrom : WF.WFNode | null = null; // Start on an edge to be created.
|
|
//Utility elements
|
|
const curtain = <HTMLElement>document.getElementById('curtain');
|
|
const submenuBackdrop = <HTMLElement>document.getElementById('submenu-backdrop');
|
|
//Side Panel
|
|
const sidePanel = <HTMLElement>document.getElementById('sidepanel');
|
|
const sideContent = <HTMLElement>document.getElementById('sidecontent');
|
|
const sideHeading = <HTMLElement>document.getElementById('sideheading');
|
|
const sideButtons = <HTMLElement>document.getElementById('sidebuttons');
|
|
const sideInfoEdge = <HTMLElement>document.getElementById('sidecontentedge');
|
|
const sideInfoNode = <HTMLElement>document.getElementById('sidecontentnode');
|
|
//File panel
|
|
const fileMenuBtn = <HTMLElement>document.getElementById('file-menu-btn');
|
|
const fileMenu = <HTMLElement>document.getElementById('filemenu');
|
|
const filePanel = <HTMLElement>document.getElementById('filepanel');
|
|
const fileHeading = <HTMLElement>document.getElementById('fileheading');
|
|
const fileContent = <HTMLElement>document.getElementById('filecontent');
|
|
const fileButtons = <HTMLElement>document.getElementById('filebuttons');
|
|
//Edit
|
|
const editMenuBtn = <HTMLElement>document.getElementById('edit-menu-btn');
|
|
//View
|
|
const viewMenuBtn = <HTMLElement>document.getElementById('view-menu-btn');
|
|
//Settings
|
|
const settingsMenuBtn = <HTMLElement>document.getElementById('settings-menu-btn');
|
|
//About
|
|
const aboutMenuBtn = <HTMLElement>document.getElementById('about-menu-btn');
|
|
//Context menus
|
|
const contextMenuBg = <HTMLElement>document.getElementById('ctmenubg'); //Click on background
|
|
const contextMenuSt = <HTMLElement>document.getElementById('ctmenust'); //Click on state
|
|
const contextMenuEd = <HTMLElement>document.getElementById('ctmenued'); //Click on edge
|
|
//Search
|
|
const searchMenuBtn = <HTMLElement>document.getElementById('search-menu-btn');
|
|
const searchContainer = <HTMLElement>document.getElementById('search-container');
|
|
const searchInput = <HTMLSelectElement>document.getElementById('search-input');
|
|
const searchResults = <HTMLElement>document.getElementById('search-results');
|
|
const searchResultList = <HTMLElement>document.getElementById('search-result-list');
|
|
const searchOptions = <HTMLElement>document.getElementById('search-options')
|
|
// Counters for placeholder IDs of states/actions added via GUI
|
|
var stateIdCounter = 0;
|
|
var actionIdCounter = 0;
|
|
var stateAbbreviations: string[] = [];
|
|
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: WF.WFEdge | WF.WFNode) {
|
|
closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg);
|
|
edgeFrom = edgeTo = rightSelection = null;
|
|
selection = selection === item ? null : item;
|
|
if (selection === item) {
|
|
while (sideContent.firstChild)
|
|
sideContent.removeChild(<ChildNode>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(<WF.WFNode | WF.WFEdge>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 : WF.WFNode = { 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: WF.WFNode, target: WF.WFNode) {
|
|
let linkId = actionIdCounter ++;
|
|
var action : WF.WFEdge = 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 = <WF.WFNode>rightSelection;
|
|
closeContextMenus(contextMenuSt);
|
|
// contextMenuSt.style.display = 'none';
|
|
}
|
|
|
|
export function markEdgeFrom() {
|
|
edgeFrom = <WF.WFNode>rightSelection;
|
|
closeContextMenus(contextMenuSt);
|
|
// contextMenuSt.style.display = 'none';
|
|
}
|
|
|
|
export function removeSelection() {
|
|
if (selection) {
|
|
if (selection.hasOwnProperty('actionData')) removeAction(<WF.WFEdge>selection);
|
|
else removeState(<WF.WFNode>selection);
|
|
deselect();
|
|
edgeFrom = edgeTo = rightSelection = null;
|
|
closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg);
|
|
}
|
|
}
|
|
|
|
export function removeRightSelection() {
|
|
if (rightSelection) {
|
|
if (rightSelection.hasOwnProperty('actionData')) removeAction(<WF.WFEdge>rightSelection);
|
|
else removeState(<WF.WFNode>rightSelection);
|
|
if (selection === rightSelection) deselect();
|
|
edgeFrom = edgeTo = rightSelection = null;
|
|
closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg);
|
|
}
|
|
}
|
|
|
|
function generatePanelContent(selection: WF.WFNode | WF.WFEdge) {
|
|
var children : HTMLElement[] = [];
|
|
var data = selection.hasOwnProperty('stateData') ? (<WF.WFNode>selection).stateData : (<WF.WFEdge>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 as keyof (WF.StateData | WF.ActionData)];
|
|
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')
|
|
? (<any[]>data[key as keyof (WF.StateData |WF.ActionData)]).join(' ')
|
|
: JSON.stringify(<any[]>data[key as keyof (WF.StateData |WF.ActionData)]));
|
|
p.appendChild(text);
|
|
children.push(p);
|
|
}
|
|
|
|
}
|
|
return children;
|
|
}
|
|
|
|
/**
|
|
* Removes an edge from the workflow.
|
|
* @param action The action to remove.
|
|
*/
|
|
function removeAction(action: WF.WFEdge) {
|
|
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: WF.WFNode) {
|
|
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: Map<string, WF.WFEdge[]> = new Map(); // All edges whose targets equal their sources.
|
|
var overlappingEdges: Map<string, WF.WFEdge[]> = 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, []);
|
|
(<WF.WFEdge[]>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 => (<WF.WFEdge[]>overlappingEdges.get(nodePairId)).length > 1)
|
|
.forEach(nodePairId => {
|
|
var edges = <WF.WFEdge[]>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, <WF.Role>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, <WF.Role>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 : string[] = []
|
|
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: WF.Role | string) {
|
|
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: WF.Role | WF.RoleFormat, role2: WF.Role | WF.RoleFormat) {
|
|
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' as keyof WF.RoleFormat] === role2['payload-label' as keyof WF.RoleFormat]);
|
|
} 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 && ((<any[]><unknown>role1.authorized['dnf-terms' as keyof JSON])[0][0].var === (<any[]><unknown>role2.authorized['dnf-terms' as keyof JSON])[0][0].var);
|
|
}
|
|
return equal;
|
|
}
|
|
|
|
function openContextMenu(x: number, y: number, menu: HTMLElement) {
|
|
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: WF.WFNode) {
|
|
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: WF.WFEdge) {
|
|
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: WF.WFEdge) {
|
|
var data = edge.actionData;
|
|
return data.viewerNames.includes(selectedViewer.value);
|
|
}
|
|
|
|
function getEdgeColour(edge: LinkObject) {
|
|
var isSelected = selection === edge || rightSelection === edge;
|
|
if (isHighlightedActorEdge(edge as WF.WFEdge)) {
|
|
return isSelected ? edgeColourHighlightSelected : edgeColourHighlightDefault;
|
|
} else if (selectedViewer.value !== NO_VIEWER && !isHighlightedViewerEdge(edge as WF.WFEdge)) {
|
|
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: LinkObject, context: CanvasRenderingContext2D) => {
|
|
const wfEdge = edge as WF.WFEdge;
|
|
const MAX_FONT_SIZE = 4;
|
|
const LABEL_NODE_MARGIN = wfGraph.nodeRelSize() * wfEdge.source.val * 1.5;
|
|
|
|
const source = wfEdge.source;
|
|
const target = wfEdge.target;
|
|
const curvature = wfEdge.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 (wfEdge.__controlPoints) { // Position label relative to the Bezier control points of the self loop
|
|
edgeVector.x = wfEdge.__controlPoints[2] - wfEdge.__controlPoints[0];
|
|
edgeVector.y = wfEdge.__controlPoints[3] - wfEdge.__controlPoints[1];
|
|
var ctrlCenter = {x: wfEdge.__controlPoints[0] + (wfEdge.__controlPoints[2] - wfEdge.__controlPoints[0]) / 2,
|
|
y: wfEdge.__controlPoints[1] + (wfEdge.__controlPoints[3] - wfEdge.__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 = wfEdge.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 !== wfEdge.name) label += '...';
|
|
textLen = context.measureText(label).width;
|
|
}
|
|
|
|
const bckgDimensions = [textLen, fontSize] as const;
|
|
|
|
// 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: LinkObject) => (edge as WF.WFEdge).actionData.mode == 'automatic' ? [2, 3] : null) //[dash, gap]
|
|
.linkWidth((edge: LinkObject) => ((edge === selection || edge === rightSelection) ? 2 : 0) + ((isHighlightedActorEdge(edge as WF.WFEdge) || isHighlightedViewerEdge(edge as WF.WFEdge)) ? 4 : 1))
|
|
.linkDirectionalParticles(2)
|
|
.linkDirectionalParticleColor(() => darkMode ? '#ffffff55' : '#00000055')
|
|
.linkDirectionalParticleWidth((edge: LinkObject) => (isHighlightedActorEdge(edge as WF.WFEdge)) ? 3 : 0)
|
|
.nodeCanvasObject((node: NodeObject, ctx: CanvasRenderingContext2D) => {
|
|
const wfNode = node as WF.WFNode;
|
|
ctx.fillStyle = getNodeColour(wfNode);
|
|
ctx.beginPath();
|
|
ctx.arc(wfNode.x, wfNode.y, 2*wfNode.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 (! (wfNode.stateData && wfNode.stateData.abbreviation)) return;
|
|
|
|
ctx.fillStyle = 'white';
|
|
ctx.font = '4px Inter';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(wfNode.stateData.abbreviation, wfNode.x, wfNode.y);
|
|
})
|
|
.onNodeDragEnd((node: NodeObject) => {
|
|
node.fx = node.x;
|
|
node.fy = node.y;
|
|
})
|
|
.onNodeClick((node: NodeObject, _: MouseEvent) => {
|
|
const wfNode = node as WF.WFNode;
|
|
if (edgeFrom) {
|
|
connect(edgeFrom, wfNode);
|
|
edgeFrom = null;
|
|
} else if (edgeTo) {
|
|
connect(wfNode, edgeTo);
|
|
edgeTo = null;
|
|
} else select(wfNode);
|
|
closeMenuItem();
|
|
})
|
|
.onNodeRightClick((node: NodeObject, event: MouseEvent) => {
|
|
//@ts-ignore TODO replace layerX/layerY
|
|
openContextMenu(event.layerX, event.layerY, contextMenuSt);
|
|
closeContextMenus(contextMenuBg, contextMenuEd);
|
|
// contextMenuBg.style.display = contextMenuEd.style.display = 'none';
|
|
rightSelection = node as WF.WFNode;
|
|
closeMenuItem();
|
|
})
|
|
.onLinkClick((edge: LinkObject, _: MouseEvent) => {
|
|
select(edge as WF.WFEdge);
|
|
closeMenuItem();
|
|
})
|
|
.onLinkRightClick((edge: LinkObject, event: MouseEvent) => {
|
|
//@ts-ignore TODO replace layerX/layerY
|
|
openContextMenu(event.layerX, event.layerY, contextMenuEd);
|
|
closeContextMenus(contextMenuBg, contextMenuSt);
|
|
// contextMenuBg.style.display = contextMenuSt.style.display = 'none';
|
|
rightSelection = edge as WF.WFEdge;
|
|
closeMenuItem()
|
|
})
|
|
.onBackgroundClick((_: Event) => {
|
|
closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg);
|
|
deselect();
|
|
edgeFrom = edgeTo = rightSelection = null;
|
|
closeMenuItem();
|
|
})
|
|
.onBackgroundRightClick((event: MouseEvent) => {
|
|
//@ts-ignore TODO replace layerX/layerY
|
|
newStateCoords = wfGraph.screen2GraphCoords(event.layerX, event.layerY);
|
|
//@ts-ignore TODO replace layerX/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(<HTMLElement>searchContainer.parentElement);
|
|
break;
|
|
case 'o':
|
|
e.preventDefault();
|
|
openFileDisplay();
|
|
default:
|
|
break;
|
|
}
|
|
})
|