Merge branch 'ghost-nodes' into 'main'
Ghost Nodes See merge request uni2work/workflows/workflow-visualiser!4
This commit is contained in:
commit
3bebb949a2
21
README.md
21
README.md
@ -1,16 +1,23 @@
|
|||||||
# Workflow Visualiser
|
# Workflow Visualiser
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
You need to host a local http server for providing the definition files. Please create a dedicated directory @ `localhost` for that purpose.
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
#### Data
|
||||||
|
|
||||||
First, you need to generate the visualisation data:
|
First, you need to generate the visualisation data:
|
||||||
|
|
||||||
```
|
```sh
|
||||||
cabal run workflow-visualiser -- --all <path-to-source-yaml-directory> <directory-of-http-server>
|
$ cabal run workflow-visualiser -- --all <path-to-source-yaml-directory> <directory-of-http-server>
|
||||||
```
|
```
|
||||||
|
|
||||||
Afterwards you can run your server and listen on port `8080`.
|
#### Frontend
|
||||||
### Open Editor
|
You need to install some libraries:
|
||||||
Open `editor.html` in your browser and select the desired workflow via the file menu.
|
```sh
|
||||||
|
$ npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run
|
||||||
|
Run the following command. This will generate a Webpack bundle from the Typescript source, launch a local http server and open the application in your browser:
|
||||||
|
```sh
|
||||||
|
$ npm start
|
||||||
|
```
|
||||||
@ -213,7 +213,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
|
|||||||
</svg>
|
</svg>
|
||||||
New State
|
New State
|
||||||
</div>
|
</div>
|
||||||
<div class="menubottom menu-lightmode">
|
<div id="add-edge" class="menubottom menu-lightmode">
|
||||||
<svg height="10" width="10" xmlns="http://www.w3.org/2000/svg">
|
<svg height="10" width="10" xmlns="http://www.w3.org/2000/svg">
|
||||||
<polyline points="1,1 9,9" style="fill:none;stroke-width:2" stroke-linecap="round" />
|
<polyline points="1,1 9,9" style="fill:none;stroke-width:2" stroke-linecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
339
editor.ts
339
editor.ts
@ -92,7 +92,7 @@ type FadeDef = {
|
|||||||
* @param {HTMLElement} menuitem
|
* @param {HTMLElement} menuitem
|
||||||
*/
|
*/
|
||||||
function openMenuItem(menuitem: HTMLElement) {
|
function openMenuItem(menuitem: HTMLElement) {
|
||||||
edgeTo = edgeFrom = rightSelection = null;
|
edgeTarget = rightSelection = null;
|
||||||
closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg);
|
closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg);
|
||||||
if (menuitem === selectedMenuItem) {
|
if (menuitem === selectedMenuItem) {
|
||||||
closeMenuItem();
|
closeMenuItem();
|
||||||
@ -202,13 +202,14 @@ export function search(text: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function format(possibleTargets: (WF.WFEdge | WF.WFNode)[], results: IndexSearchResult, heading: string) {
|
function format(possibleTargets: (WF.WFEdge | WF.WFNode | WF.WFGhostNode)[], results: IndexSearchResult, heading: string) {
|
||||||
var h = document.createElement('h3');
|
var h = document.createElement('h3');
|
||||||
h.innerHTML = heading;
|
h.innerHTML = heading;
|
||||||
searchResultList.appendChild(h);
|
searchResultList.appendChild(h);
|
||||||
results.forEach(result => {
|
results.forEach(result => {
|
||||||
var target: WF.WFEdge | WF.WFNode | null = null;
|
var target: WF.WFEdge | WF.WFNode | null = null;
|
||||||
possibleTargets.forEach(stateOrEdge => {
|
possibleTargets.forEach(stateOrEdge => {
|
||||||
|
if (stateOrEdge instanceof WF.WFGhostNode) return;
|
||||||
if (stateOrEdge.id === result)
|
if (stateOrEdge.id === result)
|
||||||
target = stateOrEdge;
|
target = stateOrEdge;
|
||||||
});
|
});
|
||||||
@ -219,9 +220,12 @@ export function search(text: string) {
|
|||||||
head.classList.add('search-result-head');
|
head.classList.add('search-result-head');
|
||||||
r.appendChild(head);
|
r.appendChild(head);
|
||||||
var info = document.createElement('div');
|
var info = document.createElement('div');
|
||||||
if ((<WF.WFEdge | WF.WFNode>target).hasOwnProperty('actionData'))
|
if ((<WF.WFEdge | WF.WFNode>target).hasOwnProperty('actionData')) {
|
||||||
info.innerText = (<WF.WFEdge>target).source.name + ' → ' + (<WF.WFEdge>target).target.name;
|
var eTarget = (<WF.WFEdge>target)
|
||||||
else
|
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 = (<WF.WFNode>target).stateData.abbreviation;
|
info.innerText = (<WF.WFNode>target).stateData.abbreviation;
|
||||||
info.setAttribute('title', info.innerText);
|
info.setAttribute('title', info.innerText);
|
||||||
info.classList.add('search-result-info');
|
info.classList.add('search-result-info');
|
||||||
@ -394,7 +398,7 @@ var highlightedTargets : string[] = [];
|
|||||||
|
|
||||||
export function selectActor() {
|
export function selectActor() {
|
||||||
closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg);
|
closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg);
|
||||||
edgeFrom = edgeTo = rightSelection = null;
|
edgeTarget = rightSelection = null;
|
||||||
highlightedSources = [];
|
highlightedSources = [];
|
||||||
highlightedTargets = [];
|
highlightedTargets = [];
|
||||||
selectedViewer.value = NO_VIEWER;
|
selectedViewer.value = NO_VIEWER;
|
||||||
@ -408,21 +412,21 @@ export function selectActor() {
|
|||||||
|
|
||||||
export function selectViewer() {
|
export function selectViewer() {
|
||||||
closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg);
|
closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg);
|
||||||
edgeFrom = edgeTo = rightSelection = null;
|
edgeTarget = rightSelection = null;
|
||||||
highlightedSources = [];
|
highlightedSources = [];
|
||||||
highlightedTargets = [];
|
highlightedTargets = [];
|
||||||
selectedActor.value = NO_ACTOR;
|
selectedActor.value = NO_ACTOR;
|
||||||
workflow.states.forEach(st => {
|
workflow.states.forEach(st => {
|
||||||
|
if (st instanceof WF.WFGhostNode) return;
|
||||||
if (st.stateData.viewerNames.includes(selectedViewer.value)) {
|
if (st.stateData.viewerNames.includes(selectedViewer.value)) {
|
||||||
highlightedSources.push(st.id);
|
highlightedSources.push(st.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var selection : WF.WFNode | WF.WFEdge | null = null; // The currently selected node/edge.
|
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 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 edgeTarget : WF.WFNode | null = null; // Possible source/target for an edge after dragging the respective ghost node.
|
||||||
var edgeFrom : WF.WFNode | null = null; // Start on an edge to be created.
|
|
||||||
//Utility elements
|
//Utility elements
|
||||||
const curtain = <HTMLElement>document.getElementById('curtain');
|
const curtain = <HTMLElement>document.getElementById('curtain');
|
||||||
const submenuBackdrop = <HTMLElement>document.getElementById('submenu-backdrop');
|
const submenuBackdrop = <HTMLElement>document.getElementById('submenu-backdrop');
|
||||||
@ -491,8 +495,9 @@ document.getElementById('side-panel-focus')?.addEventListener('click', _ => focu
|
|||||||
document.getElementById('side-panel-delete')?.addEventListener('click', _ => removeSelection());
|
document.getElementById('side-panel-delete')?.addEventListener('click', _ => removeSelection());
|
||||||
document.getElementById('file-panel-cancel')?.addEventListener('click', _ => closeFileDisplay());
|
document.getElementById('file-panel-cancel')?.addEventListener('click', _ => closeFileDisplay());
|
||||||
document.getElementById('add-state')?.addEventListener('click', _ => addState());
|
document.getElementById('add-state')?.addEventListener('click', _ => addState());
|
||||||
document.getElementById('edge-from')?.addEventListener('click', _ => markEdgeFrom());
|
document.getElementById('add-edge')?.addEventListener('click', _ => addEdge());
|
||||||
document.getElementById('edge-to')?.addEventListener('click', _ => markEdgeTo());
|
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-side-panel')?.addEventListener('click', _ => deselect());
|
||||||
document.getElementById('close-file-panel')?.addEventListener('click', _ => closeFileDisplay());
|
document.getElementById('close-file-panel')?.addEventListener('click', _ => closeFileDisplay());
|
||||||
|
|
||||||
@ -507,15 +512,16 @@ document.querySelectorAll('.delete-item').forEach(elem => elem.addEventListener(
|
|||||||
* Marks the given item as selected.
|
* Marks the given item as selected.
|
||||||
* @param {*} item The node or edge to select.
|
* @param {*} item The node or edge to select.
|
||||||
*/
|
*/
|
||||||
export function select(item: WF.WFEdge | WF.WFNode) {
|
export function select(item: WF.WFEdge | WF.WFNode | WF.WFGhostNode) {
|
||||||
closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg);
|
closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg);
|
||||||
edgeFrom = edgeTo = rightSelection = null;
|
edgeTarget = rightSelection = null;
|
||||||
selection = selection === item ? null : item;
|
selection = selection === item ? null : item;
|
||||||
if (selection === item) {
|
if (!(item instanceof WF.WFGhostNode) && selection === item) {
|
||||||
while (sideContent.firstChild)
|
while (sideContent.firstChild)
|
||||||
sideContent.removeChild(<ChildNode>sideContent.lastChild);
|
sideContent.removeChild(<ChildNode>sideContent.lastChild);
|
||||||
function callback() {
|
function callback() {
|
||||||
if (item.hasOwnProperty('actionData')) {
|
if (item instanceof WF.WFGhostNode) return;
|
||||||
|
if (item instanceof WF.WFEdge) {
|
||||||
sideInfoEdge.style.display = 'block';
|
sideInfoEdge.style.display = 'block';
|
||||||
sideInfoNode.style.display = 'none';
|
sideInfoNode.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
@ -565,26 +571,52 @@ export function addState() {
|
|||||||
var nodeId = stateIdCounter ++;
|
var nodeId = stateIdCounter ++;
|
||||||
var x = newStateCoords.x;
|
var x = newStateCoords.x;
|
||||||
var y = newStateCoords.y;
|
var y = newStateCoords.y;
|
||||||
var state : WF.WFNode = { id: 'state_' + nodeId,
|
var state = new WF.WFNode ({
|
||||||
x: x,
|
id: 'state_' + nodeId,
|
||||||
y: y,
|
x: x,
|
||||||
name: 'state_' + nodeId,
|
y: y,
|
||||||
fx: x,
|
name: 'state_' + nodeId,
|
||||||
fy: y,
|
fx: x,
|
||||||
val: 5,
|
fy: y,
|
||||||
stateData: new WF.StateData({ abbreviation: `S${nodeId}`, final: 'false' }) };
|
val: 5,
|
||||||
|
stateData: { abbreviation: `S${nodeId}`, final: 'false' }
|
||||||
|
});
|
||||||
workflow.states.push(state);
|
workflow.states.push(state);
|
||||||
updateGraph();
|
updateGraph();
|
||||||
select(state);
|
select(state);
|
||||||
nodeIndex.add(state.id, state.name);
|
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.
|
* Adds a new action between two states.
|
||||||
* @param source The source state.
|
* @param source The source state.
|
||||||
* @param target The target state.
|
* @param target The target state.
|
||||||
*/
|
*/
|
||||||
function connect(source: WF.WFNode, target: WF.WFNode) {
|
function connect(source: WF.WFNode | WF.WFGhostNode, target: WF.WFNode | WF.WFGhostNode) {
|
||||||
let linkId = actionIdCounter ++;
|
let linkId = actionIdCounter ++;
|
||||||
var action : WF.WFEdge = new WF.WFEdge({
|
var action : WF.WFEdge = new WF.WFEdge({
|
||||||
id: (linkId).toString(),
|
id: (linkId).toString(),
|
||||||
@ -600,34 +632,56 @@ function connect(source: WF.WFNode, target: WF.WFNode) {
|
|||||||
actionIndex.add(action.id, action.name);
|
actionIndex.add(action.id, action.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markEdgeTo() {
|
export function ghostEdgeTo() {
|
||||||
edgeTo = <WF.WFNode>rightSelection;
|
var to = <WF.WFNode>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);
|
closeContextMenus(contextMenuSt);
|
||||||
// contextMenuSt.style.display = 'none';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markEdgeFrom() {
|
export function ghostEdgeFrom() {
|
||||||
edgeFrom = <WF.WFNode>rightSelection;
|
var from = <WF.WFNode>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);
|
closeContextMenus(contextMenuSt);
|
||||||
// contextMenuSt.style.display = 'none';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeSelection() {
|
export function removeSelection() {
|
||||||
if (selection) {
|
if (selection) {
|
||||||
if (selection.hasOwnProperty('actionData')) removeAction(<WF.WFEdge>selection);
|
if (selection instanceof WF.WFEdge) removeAction(selection);
|
||||||
else removeState(<WF.WFNode>selection);
|
else removeState(<WF.WFNode>selection);
|
||||||
deselect();
|
deselect();
|
||||||
edgeFrom = edgeTo = rightSelection = null;
|
edgeTarget = rightSelection = null;
|
||||||
closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg);
|
closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeRightSelection() {
|
export function removeRightSelection() {
|
||||||
if (rightSelection) {
|
if (rightSelection) {
|
||||||
if (rightSelection.hasOwnProperty('actionData')) removeAction(<WF.WFEdge>rightSelection);
|
if (rightSelection instanceof WF.WFEdge) removeAction(rightSelection);
|
||||||
else removeState(<WF.WFNode>rightSelection);
|
else removeState(<WF.WFNode>rightSelection);
|
||||||
if (selection === rightSelection) deselect();
|
if (selection === rightSelection) deselect();
|
||||||
edgeFrom = edgeTo = rightSelection = null;
|
edgeTarget = rightSelection = null;
|
||||||
closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg);
|
closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -674,6 +728,10 @@ function generatePanelContent(selection: WF.WFNode | WF.WFEdge) {
|
|||||||
* @param action The action to remove.
|
* @param action The action to remove.
|
||||||
*/
|
*/
|
||||||
function removeAction(action: WF.WFEdge) {
|
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);
|
workflow.actions.splice(workflow.actions.indexOf(action), 1);
|
||||||
actionIndex.remove(action.id);
|
actionIndex.remove(action.id);
|
||||||
}
|
}
|
||||||
@ -683,14 +741,15 @@ function removeAction(action: WF.WFEdge) {
|
|||||||
* Removes a state from the workflow.
|
* Removes a state from the workflow.
|
||||||
* @param {*} state The state to remove.
|
* @param {*} state The state to remove.
|
||||||
*/
|
*/
|
||||||
function removeState(state: WF.WFNode) {
|
function removeState(state: WF.WFNode | WF.WFGhostNode) {
|
||||||
workflow.actions
|
|
||||||
.filter(edge => edge.source === state || edge.target === state)
|
|
||||||
.forEach(edge => removeAction(edge));
|
|
||||||
workflow.states.splice(workflow.states.indexOf(state), 1);
|
workflow.states.splice(workflow.states.indexOf(state), 1);
|
||||||
var abbreviation = state.stateData && state.stateData.abbreviation;
|
if (state instanceof WF.WFNode) {
|
||||||
abbreviation && stateAbbreviations.splice(stateAbbreviations.indexOf(abbreviation), 1);
|
workflow.actions
|
||||||
nodeIndex.remove(state.id);
|
.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<string, WF.WFEdge[]> = new Map(); // All edges whose targets equal their sources.
|
var selfLoops: Map<string, WF.WFEdge[]> = new Map(); // All edges whose targets equal their sources.
|
||||||
@ -776,7 +835,7 @@ function prepareWorkflow() {
|
|||||||
|
|
||||||
//Create search index
|
//Create search index
|
||||||
workflow.states.forEach(state =>
|
workflow.states.forEach(state =>
|
||||||
nodeIndex.add(state.id, state.name)
|
(state instanceof WF.WFNode) && nodeIndex.add(state.id, state.name)
|
||||||
);
|
);
|
||||||
workflow.actions.forEach(action =>
|
workflow.actions.forEach(action =>
|
||||||
actionIndex.add(action.id, action.name)
|
actionIndex.add(action.id, action.name)
|
||||||
@ -817,6 +876,7 @@ function prepareWorkflow() {
|
|||||||
});
|
});
|
||||||
//Identify all viewers of every state
|
//Identify all viewers of every state
|
||||||
workflow.states.forEach(st => {
|
workflow.states.forEach(st => {
|
||||||
|
if (st instanceof WF.WFGhostNode) return;
|
||||||
if (st.name === '@@INIT') {
|
if (st.name === '@@INIT') {
|
||||||
initState = st;
|
initState = st;
|
||||||
} else if (st.stateData.viewers.length() === 0) {
|
} else if (st.stateData.viewers.length() === 0) {
|
||||||
@ -858,6 +918,7 @@ function prepareWorkflow() {
|
|||||||
|
|
||||||
//Compute abbreviations of the names of all states
|
//Compute abbreviations of the names of all states
|
||||||
workflow.states.forEach(state => {
|
workflow.states.forEach(state => {
|
||||||
|
if (state instanceof WF.WFGhostNode) return;
|
||||||
// var label = node.name.substring(0, 5);
|
// 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)];
|
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++) {
|
for (var i = 0; i < label.length; i++) {
|
||||||
@ -917,7 +978,7 @@ function openContextMenu(x: number, y: number, menu: HTMLElement) {
|
|||||||
menu.style.left = (x + 20).toString();
|
menu.style.left = (x + 20).toString();
|
||||||
fadeIn(null, {element: menu, max: 1, step: 0.1})
|
fadeIn(null, {element: menu, max: 1, step: 0.1})
|
||||||
// menu.style.display = 'block';
|
// menu.style.display = 'block';
|
||||||
edgeFrom = edgeTo = null;
|
edgeTarget = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -935,28 +996,75 @@ const edgeColourSubtleSelected = '#00000055';
|
|||||||
const edgeColourSubtleSelectedDarkMode = '#ffffff55';
|
const edgeColourSubtleSelectedDarkMode = '#ffffff55';
|
||||||
const edgeColourMostSubtle = '#99999944';
|
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
|
* @param node
|
||||||
* @returns The colour the given node should have.
|
* @returns The colour the given node should have.
|
||||||
*/
|
*/
|
||||||
function getNodeColour(node: WF.WFNode) {
|
function getNodeColour(node: WF.WFNode | WF.WFGhostNode) : Colour {
|
||||||
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;
|
var isSelected = selection === node || rightSelection === node;
|
||||||
if (node.stateData && node.stateData.final !== 'false' && node.stateData.final !== '') {
|
if (node instanceof WF.WFNode && node.stateData.final !== 'false' && node.stateData.final !== '') {
|
||||||
if (node.stateData.final === 'true' || node.stateData.final === 'ok') {
|
if (node.stateData.final === 'true' || node.stateData.final === 'ok') {
|
||||||
return (isSelected ? '#3ac713' : '#31a810') + alpha;
|
return isSelected ? nodeColourSelectedFinal : nodeColourDefaultFinal;
|
||||||
} else if (node.stateData.final === 'not-ok') {
|
} else if (node.stateData.final === 'not-ok') {
|
||||||
return (isSelected ? '#ec4e7b' : '#e7215a') + alpha;
|
return isSelected ? nodeColourSelectedNotOk : nodeColourDefaultNotOk;
|
||||||
} else {
|
} else {
|
||||||
return (isSelected ? '#ffbc15' : '#eeaa00') + alpha;
|
return isSelected ? nodeColourSelectedUnknown : nodeColourDefaultUnknown;
|
||||||
}
|
}
|
||||||
|
} else if (node instanceof WF.WFGhostNode) {
|
||||||
|
return darkMode ? nodeColourGhostDark : nodeColourGhost;
|
||||||
} else if (node.name === '@@INIT') {
|
} else if (node.name === '@@INIT') {
|
||||||
return (isSelected ? '#ffbc15' : '#eeaa00') + alpha;
|
return isSelected ? nodeColourSelectedInit : nodeColourDefaultInit;
|
||||||
} else {
|
} else {
|
||||||
return (isSelected ? '#538cd9' : '#3679d2') + alpha;
|
return isSelected ? nodeColourSelected : nodeColourDefault;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1081,43 +1189,104 @@ function getEdgeColour(edge: LinkObject) {
|
|||||||
.linkDirectionalParticleColor(() => darkMode ? '#ffffff55' : '#00000055')
|
.linkDirectionalParticleColor(() => darkMode ? '#ffffff55' : '#00000055')
|
||||||
.linkDirectionalParticleWidth((edge: LinkObject) => (isHighlightedActorEdge(edge as WF.WFEdge)) ? 3 : 0)
|
.linkDirectionalParticleWidth((edge: LinkObject) => (isHighlightedActorEdge(edge as WF.WFEdge)) ? 3 : 0)
|
||||||
.nodeCanvasObject((node: NodeObject, ctx: CanvasRenderingContext2D) => {
|
.nodeCanvasObject((node: NodeObject, ctx: CanvasRenderingContext2D) => {
|
||||||
const wfNode = node as WF.WFNode;
|
const wfNode = (node instanceof WF.WFNode) ? node as WF.WFNode : node as WF.WFGhostNode;
|
||||||
ctx.fillStyle = getNodeColour(wfNode);
|
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.beginPath();
|
||||||
ctx.arc(wfNode.x, wfNode.y, 2*wfNode.val, 0, 2 * Math.PI, false);
|
ctx.arc(wfNode.x, wfNode.y, 2*wfNode.val, 0, 2 * Math.PI, false);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
if (node === selection || node === rightSelection) {
|
ctx.restore();
|
||||||
ctx.strokeStyle = darkMode ? 'white' : 'black';
|
|
||||||
ctx.lineWidth = 1;
|
if (node instanceof WF.WFGhostNode) {
|
||||||
|
ctx.save()
|
||||||
ctx.setLineDash([1, 2]);
|
ctx.setLineDash([1, 2]);
|
||||||
|
ctx.strokeStyle = nodeColourDefaultNotOk.value();
|
||||||
|
ctx.shadowColor = nodeColourDefaultNotOk.dark(0x80);
|
||||||
|
ctx.shadowBlur = 20;
|
||||||
ctx.lineCap = 'round';
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
ctx.stroke();
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! (wfNode.stateData && wfNode.stateData.abbreviation)) return;
|
|
||||||
|
|
||||||
ctx.fillStyle = 'white';
|
|
||||||
ctx.font = '4px Inter';
|
ctx.font = '4px Inter';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
ctx.fillText(wfNode.stateData.abbreviation, wfNode.x, wfNode.y);
|
|
||||||
|
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) => {
|
.onNodeDragEnd((node: NodeObject) => {
|
||||||
node.fx = node.x;
|
node.fx = node.x;
|
||||||
node.fy = node.y;
|
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 = <WF.WFNode>edgeTarget);
|
||||||
|
edgesTo.forEach(edge => edge.target = <WF.WFNode>edgeTarget);
|
||||||
|
removeState(node);
|
||||||
|
edgeTarget = null;
|
||||||
|
updateGraph();
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.onNodeClick((node: NodeObject, _: MouseEvent) => {
|
.onNodeClick((node: NodeObject, _: MouseEvent) => {
|
||||||
const wfNode = node as WF.WFNode;
|
edgeTarget = null;
|
||||||
if (edgeFrom) {
|
(node instanceof WF.WFNode) && select(node as WF.WFNode);
|
||||||
connect(edgeFrom, wfNode);
|
|
||||||
edgeFrom = null;
|
|
||||||
} else if (edgeTo) {
|
|
||||||
connect(wfNode, edgeTo);
|
|
||||||
edgeTo = null;
|
|
||||||
} else select(wfNode);
|
|
||||||
closeMenuItem();
|
closeMenuItem();
|
||||||
})
|
})
|
||||||
.onNodeRightClick((node: NodeObject, event: MouseEvent) => {
|
.onNodeRightClick((node: NodeObject, event: MouseEvent) => {
|
||||||
|
if (node instanceof WF.WFGhostNode) {
|
||||||
|
closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
//@ts-ignore TODO replace layerX/layerY
|
//@ts-ignore TODO replace layerX/layerY
|
||||||
openContextMenu(event.layerX, event.layerY, contextMenuSt);
|
openContextMenu(event.layerX, event.layerY, contextMenuSt);
|
||||||
closeContextMenus(contextMenuBg, contextMenuEd);
|
closeContextMenus(contextMenuBg, contextMenuEd);
|
||||||
@ -1140,7 +1309,7 @@ function getEdgeColour(edge: LinkObject) {
|
|||||||
.onBackgroundClick((_: Event) => {
|
.onBackgroundClick((_: Event) => {
|
||||||
closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg);
|
closeContextMenus(contextMenuEd, contextMenuSt, contextMenuBg);
|
||||||
deselect();
|
deselect();
|
||||||
edgeFrom = edgeTo = rightSelection = null;
|
edgeTarget = rightSelection = null;
|
||||||
closeMenuItem();
|
closeMenuItem();
|
||||||
})
|
})
|
||||||
.onBackgroundRightClick((event: MouseEvent) => {
|
.onBackgroundRightClick((event: MouseEvent) => {
|
||||||
@ -1150,11 +1319,35 @@ function getEdgeColour(edge: LinkObject) {
|
|||||||
openContextMenu(event.layerX, event.layerY, contextMenuBg);
|
openContextMenu(event.layerX, event.layerY, contextMenuBg);
|
||||||
closeContextMenus(contextMenuEd, contextMenuSt);
|
closeContextMenus(contextMenuEd, contextMenuSt);
|
||||||
// contextMenuEd.style.display = contextMenuSt.style.display = 'none';
|
// contextMenuEd.style.display = contextMenuSt.style.display = 'none';
|
||||||
edgeFrom = edgeTo = rightSelection = null;
|
edgeTarget = rightSelection = null;
|
||||||
closeMenuItem();
|
closeMenuItem();
|
||||||
})
|
})
|
||||||
.autoPauseRedraw(false);
|
.autoPauseRedraw(false);
|
||||||
|
|
||||||
|
//Remove all ghost states from the force computations to enable dragging them onto normal states
|
||||||
|
var oldChargeInit = <any>((<any>wfGraph.d3Force('charge')).initialize);
|
||||||
|
var oldCenterInit = <any>((<any>wfGraph.d3Force('center')).initialize);
|
||||||
|
var oldLinkInit = <any>((<any>wfGraph.d3Force('link')).initialize);
|
||||||
|
(<any>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);
|
||||||
|
};
|
||||||
|
|
||||||
|
(<any>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);
|
||||||
|
};
|
||||||
|
|
||||||
|
(<any>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();
|
updateGraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,18 +1,19 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entry: './editor.ts',
|
mode: 'development',
|
||||||
|
entry: './editor.js',
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
test: /\.tsx?$/,
|
test: /\.tsx?$/,
|
||||||
use: 'ts-loader',
|
use: 'js-loader',
|
||||||
exclude: /node_modules/,
|
exclude: /node_modules/,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
extensions: ['.tsx', '.ts', '.js'],
|
extensions: ['.js'],
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
filename: 'bundle.js',
|
filename: 'bundle.js',
|
||||||
|
|||||||
42
workflow.ts
42
workflow.ts
@ -20,6 +20,15 @@ export type NodeFormat = {
|
|||||||
val: number
|
val: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GhostNodeFormat = {
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
fx: number,
|
||||||
|
fy: number,
|
||||||
|
id: string,
|
||||||
|
val: number
|
||||||
|
}
|
||||||
|
|
||||||
export type SDFormat = {
|
export type SDFormat = {
|
||||||
abbreviation: string,
|
abbreviation: string,
|
||||||
final: boolean | string,
|
final: boolean | string,
|
||||||
@ -30,8 +39,8 @@ export type SDFormat = {
|
|||||||
|
|
||||||
export type EdgeFormat = {
|
export type EdgeFormat = {
|
||||||
actionData: ADFormat,
|
actionData: ADFormat,
|
||||||
source: WFNode | string,
|
source: WFNode | WFGhostNode | string,
|
||||||
target: WFNode | string,
|
target: WFNode | WFGhostNode | string,
|
||||||
id: string,
|
id: string,
|
||||||
name: string,
|
name: string,
|
||||||
nodePairId : string
|
nodePairId : string
|
||||||
@ -82,7 +91,7 @@ export type MessageFormat = {
|
|||||||
|
|
||||||
export class Workflow {
|
export class Workflow {
|
||||||
|
|
||||||
states: WFNode[];
|
states: (WFNode | WFGhostNode)[];
|
||||||
actions: WFEdge[];
|
actions: WFEdge[];
|
||||||
|
|
||||||
constructor(json: WF) {
|
constructor(json: WF) {
|
||||||
@ -95,7 +104,7 @@ export class Workflow {
|
|||||||
}
|
}
|
||||||
// Resolve ID refs to nodes
|
// Resolve ID refs to nodes
|
||||||
var actions = json.actions.map(action => {
|
var actions = json.actions.map(action => {
|
||||||
function transformRef(ref: string | WFNode) {
|
function transformRef(ref: string | WFNode | WFGhostNode) {
|
||||||
if (ref instanceof String)
|
if (ref instanceof String)
|
||||||
stateMap.has(<string>ref)
|
stateMap.has(<string>ref)
|
||||||
&& (ref = <WFNode>stateMap.get(<string>ref))
|
&& (ref = <WFNode>stateMap.get(<string>ref))
|
||||||
@ -155,11 +164,32 @@ export class StateData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class WFGhostNode implements NodeObject {
|
||||||
|
|
||||||
|
text: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
fx: number;
|
||||||
|
fy: number;
|
||||||
|
id: string;
|
||||||
|
val: number;
|
||||||
|
|
||||||
|
constructor(json: GhostNodeFormat) {
|
||||||
|
this.text = '?';
|
||||||
|
this.x = json.x;
|
||||||
|
this.y = json.y;
|
||||||
|
this.fx = json.fx;
|
||||||
|
this.fy = json.fy;
|
||||||
|
this.id = json.id;
|
||||||
|
this.val = json.val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class WFEdge implements LinkObject {
|
export class WFEdge implements LinkObject {
|
||||||
|
|
||||||
actionData: ActionData;
|
actionData: ActionData;
|
||||||
source: WFNode;
|
source: WFNode | WFGhostNode;
|
||||||
target: WFNode;
|
target: WFNode | WFGhostNode;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
nodePairId : string;
|
nodePairId : string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user