// SPDX-FileCopyrightText: 2023 David Mosbach // // 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, tools, footer]; 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 : HTMLElement | null = null; Array.from(document.getElementsByClassName('submenu')) .forEach(subMenu => (subMenu).style.top = (mainMenu.offsetHeight + 15).toString()); var lastSubMenu : HTMLElement | null = null; const footer = document.getElementById('footer'); const tools = document.getElementById('tools'); 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) { edgeTarget = 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: 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: subMenu, max: 1}); lastSubMenu = 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: 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 = 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: HTMLElement) { 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: string) { 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: HTMLDivElement, target: WF.WFNode | WF.WFEdge) { div.onclick = (_ => { searchInput.value = ''; closeMenuItem(); select(target); focusSelection(); }); } function format(possibleTargets: (WF.WFEdge | WF.WFNode | WF.WFGhostNode)[], 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 instanceof WF.WFGhostNode) return; 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')) { var eTarget = (target) var src = (eTarget.source instanceof WF.WFNode) ? eTarget.source.name : '?'; var tgt = (eTarget.target instanceof WF.WFNode) ? eTarget.target.name : '?'; info.innerText = src + ' → ' + tgt; } 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: 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(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(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 = '

' + workflowFiles[i].name + '

' + 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: WF.Role[] = []; const selectedActor = document.getElementById('actor'); //Viewers of the workflow var viewers: (WF.Role | string)[] = []; const selectedViewer = 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); edgeTarget = 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); edgeTarget = rightSelection = null; highlightedSources = []; highlightedTargets = []; selectedActor.value = NO_ACTOR; workflow.states.forEach(st => { if (st instanceof WF.WFGhostNode) return; if (st.stateData.viewerNames.includes(selectedViewer.value)) { highlightedSources.push(st.id); } }); } var selection : WF.WFNode | WF.WFGhostNode | WF.WFEdge | null = null; // The currently selected node/edge. var rightSelection : WF.WFNode | WF.WFEdge | null = null; // The currently right clicked node/edge. var edgeTarget : WF.WFNode | null = null; // Possible source/target for an edge after dragging the respective ghost node. //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: string[] = []; var newStateCoords = {'x': 0, 'y': 0}; //Initial coordinates of the next new state sidePanel.style.top = (mainMenu.offsetHeight + 15).toString(); sidePanel.style.bottom = (footer.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('add-edge')?.addEventListener('click', _ => addEdge()); document.getElementById('edge-from')?.addEventListener('click', _ => ghostEdgeFrom()); document.getElementById('edge-to')?.addEventListener('click', _ => ghostEdgeTo()); 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 | WF.WFGhostNode) { closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg); edgeTarget = rightSelection = null; selection = selection === item ? null : item; if (!(item instanceof WF.WFGhostNode) && selection === item) { while (sideContent.firstChild) sideContent.removeChild(sideContent.lastChild); function callback() { if (item instanceof WF.WFGhostNode) return; if (item instanceof WF.WFEdge) { 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 = new WF.WFNode ({ id: 'state_' + nodeId, x: x, y: y, name: 'state_' + nodeId, fx: x, fy: y, val: 5, stateData: { abbreviation: `S${nodeId}`, final: 'false' } }); workflow.states.push(state); updateGraph(); select(state); nodeIndex.add(state.id, state.name); } export function addEdge() { var x = newStateCoords.x - 20; var y = newStateCoords.y + 20; var ghostState = new WF.WFGhostNode({ id: `@@ghost@(${x},${y})`, x: x, y: y, fx: x, fy: y, val: 7 }); var x = newStateCoords.x + 20; var y = newStateCoords.y - 20; var ghostState2 = new WF.WFGhostNode({ id: `@@ghost@(${x},${y})`, x: x, y: y, fx: x, fy: y, val: 7 }); workflow.states.push(ghostState, ghostState2); updateGraph(); connect(ghostState, ghostState2); } /** * Adds a new action between two states. * @param source The source state. * @param target The target state. */ function connect(source: WF.WFNode | WF.WFGhostNode, target: WF.WFNode | WF.WFGhostNode) { 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 ghostEdgeTo() { var to = rightSelection; var x = to.x - 40; var y = to.y - 40; var from = new WF.WFGhostNode({ id: `@@ghost.to@${to.name}@(${to.x},${to.y})`, x: x, y: y, fx: x, fy: y, val: 7 }); workflow.states.push(from); connect(from, to); closeContextMenus(contextMenuSt); } export function ghostEdgeFrom() { var from = rightSelection; var x = from.x + 40; var y = from.y + 40; var to = new WF.WFGhostNode({ id: `@@ghost.from@${from.name}@(${from.x},${from.y})`, x: x, y: y, fx: x, fy: y, val: 7 }); workflow.states.push(to); connect(from, to); closeContextMenus(contextMenuSt); } export function removeSelection() { if (selection) { if (selection instanceof WF.WFEdge) removeAction(selection); else removeState(selection); deselect(); edgeTarget = rightSelection = null; closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg); } } export function removeRightSelection() { if (rightSelection) { if (rightSelection instanceof WF.WFEdge) removeAction(rightSelection); else removeState(rightSelection); if (selection === rightSelection) deselect(); edgeTarget = rightSelection = null; closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg); } } function generatePanelContent(selection: WF.WFNode | WF.WFEdge) { var children : HTMLElement[] = []; 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 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') ? (data[key as keyof (WF.StateData |WF.ActionData)]).join(' ') : JSON.stringify(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) { if (action.source instanceof WF.WFGhostNode) removeState(action.source); if (action.target instanceof WF.WFGhostNode) removeState(action.target); 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 | WF.WFGhostNode) { workflow.states.splice(workflow.states.indexOf(state), 1); if (state instanceof WF.WFNode) { workflow.actions .filter(edge => edge.source === state || edge.target === state) .forEach(edge => removeAction(edge)); stateAbbreviations.splice(stateAbbreviations.indexOf(state.stateData.abbreviation), 1); nodeIndex.remove(state.id); } } var selfLoops: Map = new Map(); // All edges whose targets equal their sources. var overlappingEdges: Map = 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 => (state instanceof WF.WFNode) && 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 instanceof WF.WFGhostNode) return; 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 : 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 => { if (state instanceof WF.WFGhostNode) return; // 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 && ((role1.authorized['dnf-terms' as keyof JSON])[0][0].var === (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'; edgeTarget = 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'; class Colour { private baseValue: string; baseDark: string; constructor(r: number, g: number, b: number) { var arr = [r,g,b]; if (! arr.every((val: number) => val >= 0 && val <= 255)) throw new Error('rgb out of bounds (0,255)'); this.baseValue = '#' + arr.map((v: number) => v.toString(16).padStart(2, '0')).join(''); this.baseDark = '#' + arr.map((v: number) => (Math.max(v-80, 0)).toString(16).padStart(2, '0')).join(''); Object.seal(this); console.log('colour:', this.baseValue, 'dark:', this.baseDark); } value(alpha?: number) { if (alpha === undefined) return this.baseValue; if (! (alpha >= 0 && alpha <= 255)) throw new Error('rgba out of bounds (0,255)'); return this.baseValue + alpha.toString(16).padStart(2, '0'); } dark(alpha?: number) { if (alpha === undefined) return this.baseDark; if (! (alpha >= 0 && alpha <= 255)) throw new Error('rgba out of bounds (0,255)'); return this.baseDark + alpha.toString(16).padStart(2, '0'); } } const nodeColourDefault = new Colour(0x36, 0x79, 0xd2); const nodeColourSelected = new Colour(0x53, 0x8c, 0xd9); const nodeColourDefaultUnknown = new Colour(0xee, 0xaa, 0x00); const nodeColourSelectedUnknown = new Colour(0xff, 0xbc, 0x15); const nodeColourDefaultFinal = new Colour(0x31, 0xa8, 0x10); const nodeColourSelectedFinal = new Colour(0x3a, 0xc7, 0x13); const nodeColourDefaultNotOk = new Colour(0xe7, 0x21, 0x5a); const nodeColourSelectedNotOk = new Colour(0xec, 0x4e, 0x7b); const nodeColourDefaultInit = new Colour(0xee, 0xaa, 0x00); const nodeColourSelectedInit = new Colour(0xff, 0xbc, 0x15); const nodeColourGhost = new Colour(0xff, 0xff, 0xff); const nodeColourGhostDark = new Colour(0x00, 0x00, 0x00); /** * * @param node * @returns The colour the given node should have. */ function getNodeColour(node: WF.WFNode | WF.WFGhostNode) : Colour { var isSelected = selection === node || rightSelection === node; if (node instanceof WF.WFNode && node.stateData.final !== 'false' && node.stateData.final !== '') { if (node.stateData.final === 'true' || node.stateData.final === 'ok') { return isSelected ? nodeColourSelectedFinal : nodeColourDefaultFinal; } else if (node.stateData.final === 'not-ok') { return isSelected ? nodeColourSelectedNotOk : nodeColourDefaultNotOk; } else { return isSelected ? nodeColourSelectedUnknown : nodeColourDefaultUnknown; } } else if (node instanceof WF.WFGhostNode) { return darkMode ? nodeColourGhostDark : nodeColourGhost; } else if (node.name === '@@INIT') { return isSelected ? nodeColourSelectedInit : nodeColourDefaultInit; } else { return isSelected ? nodeColourSelected : nodeColourDefault; } } 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 instanceof WF.WFNode) ? node as WF.WFNode : node as WF.WFGhostNode; var standard = (selectedActor.value === NO_ACTOR && selectedViewer.value === NO_VIEWER) || highlightedSources.includes(wfNode.id) || highlightedTargets.includes(wfNode.id) var alpha : number; if (wfNode instanceof WF.WFGhostNode) alpha = 0x80; else if (standard) alpha = 0xff; else alpha = 0x55; var colour = getNodeColour(wfNode); ctx.save(); ctx.fillStyle = colour.value(alpha); ctx.shadowColor = colour.dark(0x80); ctx.shadowBlur = 20; ctx.beginPath(); ctx.arc(wfNode.x, wfNode.y, 2*wfNode.val, 0, 2 * Math.PI, false); ctx.fill(); ctx.restore(); if (node instanceof WF.WFGhostNode) { ctx.save() ctx.setLineDash([1, 2]); ctx.strokeStyle = nodeColourDefaultNotOk.value(); ctx.shadowColor = nodeColourDefaultNotOk.dark(0x80); ctx.shadowBlur = 20; ctx.lineCap = 'round'; ctx.lineWidth = 1; ctx.stroke(); ctx.restore(); } else if (edgeTarget === node) { ctx.save(); ctx.lineCap = 'round'; ctx.lineWidth = 2; ctx.strokeStyle = nodeColourDefaultFinal.value(); ctx.stroke(); ctx.restore(); } else if (node === selection || node === rightSelection) { ctx.save(); ctx.lineCap = 'round'; ctx.lineWidth = 1; ctx.strokeStyle = darkMode ? 'white' : 'black'; ctx.stroke(); ctx.restore(); } ctx.font = '4px Inter'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; if (wfNode instanceof WF.WFNode && wfNode.stateData.abbreviation) { ctx.fillStyle = 'white'; ctx.fillText(wfNode.stateData.abbreviation, wfNode.x, wfNode.y); } else if (wfNode instanceof WF.WFGhostNode) { ctx.fillStyle = darkMode ? 'white' : 'black'; ctx.fillText(wfNode.text, wfNode.x, wfNode.y); } }) .onNodeDrag((node: NodeObject, delta: { x: number, y: number }) => { edgeTarget = null; if (!(node instanceof WF.WFGhostNode)) return; const fineTuningThreshold = 0; if (Math.sqrt(Math.round(Math.abs(delta.x * delta.y))) > fineTuningThreshold) return; for (const node2 of workflow.states) { if (!(node2 instanceof WF.WFNode)) continue; if (Math.sqrt(Math.pow(node.x - node2.x, 2) + Math.pow(node.y - node2.y, 2)) <= 2*node2.val) { edgeTarget = node2; break; } } console.log('close:', edgeTarget); }) .onNodeDragEnd((node: NodeObject) => { node.fx = node.x; node.fy = node.y; if (node instanceof WF.WFGhostNode && edgeTarget) { var edgesFrom : WF.WFEdge[] = []; var edgesTo : WF.WFEdge[] = []; workflow.actions.forEach(edge => { edge.source === node && edgesFrom.push(edge); edge.target === node && edgesTo.push(edge); }); if (!(edgesFrom || edgesTo)) throw new Error('Could not find an edge for the dragged ghost node'); edgesFrom.forEach(edge => edge.source = edgeTarget); edgesTo.forEach(edge => edge.target = edgeTarget); removeState(node); edgeTarget = null; updateGraph(); } }) .onNodeClick((node: NodeObject, _: MouseEvent) => { edgeTarget = null; (node instanceof WF.WFNode) && select(node as WF.WFNode); closeMenuItem(); }) .onNodeRightClick((node: NodeObject, event: MouseEvent) => { if (node instanceof WF.WFGhostNode) { closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg); return; } //@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(); edgeTarget = 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'; edgeTarget = rightSelection = null; closeMenuItem(); }) .autoPauseRedraw(false); //Remove all ghost states from the force computations to enable dragging them onto normal states var oldChargeInit = ((wfGraph.d3Force('charge')).initialize); var oldCenterInit = ((wfGraph.d3Force('center')).initialize); var oldLinkInit = ((wfGraph.d3Force('link')).initialize); (wfGraph.d3Force('charge')).initialize = function(_nodes: NodeObject[], ...args: any) { var nodes : WF.WFNode[] = []; _nodes.forEach(node => (node instanceof WF.WFNode && nodes.push(node))); //TODO already store them instead of computing each tick oldChargeInit(nodes, args); }; (wfGraph.d3Force('center')).initialize = function(_nodes: NodeObject[], ...args: any) { var nodes : WF.WFNode[] = []; _nodes.forEach(node => (node instanceof WF.WFNode && nodes.push(node))); oldCenterInit(nodes, args); }; (wfGraph.d3Force('link')).initialize = function(_nodes: NodeObject[], ...args: any) { var nodes : WF.WFNode[] = []; _nodes.forEach(node => (node instanceof WF.WFNode && nodes.push(node))); oldLinkInit(nodes, args); }; 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; } })