blob: 17d99241611832df67269519226f4be79b74b8a8 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import template from "./workflow-steps.template.html";
import angular from "angular";
const MODULE_NAME = 'inspector.workflow-steps';
angular.module(MODULE_NAME, [])
.directive('workflowSteps', workflowStepsDirective);
export default MODULE_NAME;
export function workflowStepsDirective() {
return {
template: template,
restrict: 'E',
scope: {
workflow: '=',
task: '=?',
nested: '=?',
},
controller: ['$sce', '$timeout', '$scope', '$element', controller],
controllerAs: 'vm',
};
function controller($sce, $timeout, $scope, $element) {
let vm = this;
vm.stringify = stringify;
$scope.workflowId = $scope.workflow.data.workflowId;
vm.getWorkflowStepsClasses = () => {
const c = [];
c.push('workflow-status-'+$scope.workflow.data.status);
if ($scope.workflow.data.status && $scope.workflow.data.status.startsWith('ERROR')) {
c.push('workflow-error');
}
return c;
}
$scope.expandStates = {};
if ($scope.workflow.tag && !_.isNil($scope.workflow.tag.stepIndex)) {
$scope.expandStates[$scope.workflow.tag.stepIndex] = true;
}
vm.onSizeChange = () => $timeout(()=>recompute($scope, $element));
$scope.$watch('workflow', vm.onSizeChange);
$scope.$watch(() => $element[0].offsetHeight, (newVal, oldVal) => {
if (oldVal!=newVal) vm.onSizeChange();
});
vm.onSizeChange();
}
function recompute($scope, $element) {
let svg = $element[0].querySelector('#workflow-step-arrows.workflow-'+$scope.workflowId);
let steps = $element[0].querySelectorAll('.workflow-'+$scope.workflowId+'.workflow-step');
let arrows = makeArrows($scope.workflow, steps, { width: $scope.nested ? 32 : 56 });
svg.innerHTML = arrows.join('\n');
}
}
function makeArrows(workflow, steps, options) {
const sectionWidth = ((options || {}).width) || 56;
workflow = workflow || {};
workflow.data = workflow.data || {};
let [stepsPrev,stepsNext] = getWorkflowStepsPrevNext(workflow);
const arrows = [];
const strokeWidth = 1.5;
const arrowheadLength = 6;
const arrowheadWidth = arrowheadLength/3/strokeWidth;
const defs = [];
defs.push('<marker id="arrowhead" markerWidth="'+3*arrowheadWidth+'" markerHeight="'+3*arrowheadWidth+'" refX="'+0+'" refY="'+1.5*arrowheadWidth+'" orient="auto"><polygon fill="#000" points="0 0, '+3*arrowheadWidth+' '+1.5*arrowheadWidth+', 0 '+(3*arrowheadWidth)+'" /></marker><');
defs.push('<marker id="arrowhead-gray" markerWidth="'+3*arrowheadWidth+'" markerHeight="'+3*arrowheadWidth+'" refX="'+0+'" refY="'+1.5*arrowheadWidth+'" orient="auto"><polygon class="fill-future" points="0 0, '+3*arrowheadWidth+' '+1.5*arrowheadWidth+', 0 '+(3*arrowheadWidth)+'" /></marker><');
defs.push('<marker id="arrowhead-red" markerWidth="'+3*arrowheadWidth+'" markerHeight="'+3*arrowheadWidth+'" refX="'+0+'" refY="'+1.5*arrowheadWidth+'" orient="auto"><polygon class="fill-failed" points="0 0, '+3*arrowheadWidth+' '+1.5*arrowheadWidth+', 0 '+(3*arrowheadWidth)+'" /></marker><');
if (steps) {
let gradientCount = 0;
function arrowSvg(y1, y2, opts) {
var start = y1==='start/end';
var end = y2==='start/end';
if (y1==null || y2==null || (start&&end)) {
// ignore if out of bounds
return "";
}
if (!opts) opts = {};
const color = opts.class ? '' : opts.color || (opts.colorEnd && opts.colorEnd==opts.colorStart ? opts.colorEnd : '#000');
const rightFarEdge = sectionWidth;
const rightArrowheadStart = rightFarEdge - arrowheadLength;
const leftFarEdge = 10;
const leftActive = rightArrowheadStart + (leftFarEdge - rightArrowheadStart) * (opts.width || 1);
const curveX = opts.curveX || 1;
const curveY = opts.curveY || 1;
// const controlPointRightFarEdge = rightFarEdge + (leftActive - rightFarEdge) * curveX;
const controlPointRightArrowheadStart = rightArrowheadStart + (leftActive - rightArrowheadStart) * curveX;
// average of above two, to see which works best
// const controlPointRightIntermediate = (rightFarEdge+rightArrowheadStart)/2 + (leftActive - (rightFarEdge+rightArrowheadStart)/2) * curveX;
// const controlPointRightExaggerated = rightArrowheadStart + (leftActive - rightFarEdge) * curveX;
const controlPointStart = controlPointRightArrowheadStart;
const controlPointEnd = controlPointRightArrowheadStart;
const strokeConstant = color ? 'stroke="'+color+'"' : ''
let standard =
'stroke-width="'+(opts.lineWidth || strokeWidth)+'" '+
'fill="transparent" '+
'/>';
if (opts.class) standard = 'class="'+opts.class+'" '+standard;
if (!opts.hideArrowhead) standard = 'marker-end="url(#'+(opts.arrowheadId || 'arrowhead')+')" ' +standard;
if (opts.dashLength) standard = 'stroke-dasharray="'+opts.dashLength+'" '+standard;
if (start) {
return '<path d="M ' + leftFarEdge + ' ' + y2 +
' L ' + rightArrowheadStart + ' ' + y2 + '" '+
strokeConstant+' '+standard;
}
if (end) {
return '<path d="M ' + rightFarEdge + ' ' + y1 +
' L ' + (leftFarEdge+arrowheadLength) + ' ' + y1 + '" '+
strokeConstant+' '+standard;
}
const yMCH = ((y2 - y1) / 2) * curveY;
const yM = (y1 + y2) / 2;
if (!opts.colorEnd || opts.colorEnd==opts.colorStart || y2==y1) {
standard = strokeConstant + ' ' + standard;
} else {
const gradientId = 'gradient'+(gradientCount++);
const gradY = y2>=y1 ? 'y2="1"' : 'y1="1"';
defs.push('<linearGradient id="'+gradientId+'" x2="0" '+gradY+'><stop offset="0" stop-color="'+opts.colorStart+'"/><stop offset="1" stop-color="'+opts.colorEnd+'"/></linearGradient>');
standard = 'stroke="url(#'+gradientId+')" ' + standard;
}
const result = '<path d="M ' + rightFarEdge + ' ' + y1 +
// ' L ' + r0 + ' ' + y1 + ' ' +
' C ' + controlPointStart + ' ' + y1 + ', ' + leftActive + ' ' + (yM - yMCH) + ', ' + leftActive + ' ' + yM + ' ' +
' S ' + controlPointEnd + ' ' + y2 + ', ' + rightArrowheadStart + ' ' + y2 + '" '+standard;
return result;
}
function stepY(n) {
if (n==-1) return 'start/end';
if (!steps || n<0 || n>=steps.length || _.isNil(n)) {
console.log("workflow arrow bounds error", steps, n);
return null;
}
return steps[n].offsetTop + steps[n].offsetHeight / 2;
}
function arrowStep(n1, n2, opts) {
let s1 = stepY(n1);
let s2 = stepY(n2);
const deltaForArrowMax = 6;
const deltaForArrowTarget = 0.125;
if (typeof s1 === "number") s1 += Math.min(steps[n1].offsetHeight * deltaForArrowTarget, deltaForArrowMax);
if (typeof s2 === "number") s2 -= Math.min(steps[n2].offsetHeight * deltaForArrowTarget, deltaForArrowMax);
return arrowSvg(s1, s2, opts);
}
let jumpSizes = {1: true};
function arrowStep2(prev, i, opts) {
let curveX = 0.5;
let curveY = 0.75;
let width = 0.5;
if (prev==-1 || i==-1) {
// curve values don't matter for start/end
} else if (prev==i) {
width = 0.15;
curveX = 0.1;
curveY = 0.75;
} else {
let rank = jumpSizes.indexOf(''+Math.abs(prev-i));
if (rank<0) {
console.log("Missing workflow link: ", prev, i);
rank = 0;
}
if (prev > i) rank = rank + 0.5;
width = 0.2 + 0.6 * (rank + 0.5) / (jumpSizes.length + 0.5);
curveX = 0.8 + 0.2*width;
curveY = 0.8 + 0.2*width;
// higher values (above) look nicer, but make disambiguation of complex paths harder
// curveX = 0.5 + 0.3*width;
// curveY = 0.4 + 0.4*width;
}
return arrowStep(prev, i, {hideArrowhead: prev==i, width, curveX, curveY, ...opts});
}
function colorFor(step, references) {
if (!references) return 'red';
const i = references.indexOf(step);
if (i==-3) return 'red';
// skew quadratically for lightness
const skewTowards1 = x => (1 - (1-x)*(1-x));
let gray = Math.round(240 * skewTowards1(i / references.length) );
return 'rgb('+gray+','+gray+','+gray+')';
}
let arrowSpecs = {};
function recordTransition(from, to, opts) {
if (to!=-1 && from!=-1 && to!=from) {
jumpSizes[Math.abs(from-to)] = true;
}
if (to<0) to=-1; // in record, -2 means end, -3 means error; here -1 means end because nothing should go to -1
if (arrowSpecs[[from,to]]) {
// prefer earlier additions (real steps) over theoretical ones
} else {
arrowSpecs[[from, to]] = {from, to, ...(opts || {})};
}
}
for (var i = -3; i < steps.length; i++) {
const prevsHere = stepsPrev[i];
if (prevsHere && prevsHere.length) {
prevsHere.forEach(prev => {
// last in list has higher z-order; this ensures within each prevStep we preserve order,
// so inbound arrows are correct. currently we also prefer earlier steps, which isn't quite right for outbound arrows;
// ideally we'd reconstruct the flow order, but that's a bit more work than we want to do just now.
// so insertion point is always 0. (header items added at end so we don't need to include those here.)
recordTransition(prev, i, { insertionPoint: 0, visited: true, colorStart: colorFor(i, stepsNext[prev]), colorEnd: colorFor(prev, prevsHere) });
});
}
}
// now make pale arrows for the default flow
var indexOfId = {};
for (var i = 0; i < steps.length; i++) {
const s = workflow.data.stepsDefinition[i];
if (!s) console.log("Missing step", i, workflow.data, steps);
if (s.id) indexOfId[s.id] = i;
}
function isStepType(step, type) {
if (!step) return false;
if (step.type) return step.type == type;
let s = step.startsWith ? step : step.s || step.shorthand || step.userSuppliedShorthand;
if (s) return s == type || s.startsWith(type);
return false;
}
for (var i = 0; i < steps.length; i++) {
const s = workflow.data.stepsDefinition[i];
let opts = { insertionPoint: 0 };
// errors shown elsewhere
// if (workflow.data.currentStepIndex === i && workflow.data.status && workflow.data.status.startsWith('ERROR')) {
// recordTransition(i, -2, { ...opts, class: 'arrow-failed', arrowheadId: 'arrowhead-red' });
// }
opts = { ...opts, class: 'arrow-future', arrowheadId: 'arrowhead-gray', dashLength: 8 };
let next = null;
if (s.next) {
if (s.next.toLowerCase()=='end') next = -1;
else if (indexOfId[s.next]) next = indexOfId[s.next];
}
if (isStepType(s, 'return')) next = -1;
if (next!=null) {
// special next per step
recordTransition(i, next, opts);
if (!s.condition) continue;
}
// if nothing special, or if was conditional, then go to next step
// (only go forward 1, even if it is conditional, otherwise too many arrows)
next = i+1;
if (i + 1 >= steps.length) next = -1;
recordTransition(i, next, opts);
}
jumpSizes = Object.keys(jumpSizes).sort();
// insert arrows
Object.values(arrowSpecs).forEach(arrowSpec =>
arrows.splice(arrowSpec.insertionPoint || 0, 0, arrowStep2(arrowSpec.from, arrowSpec.to, arrowSpec)) );
// then defs at start
arrows.splice(0, 0, '<defs>'+defs.join('')+'</defs>');
}
return arrows;
}
function getWorkflowStepsPrevNext(workflow) {
let stepsPrev = {}
let stepsNext = {}
if (workflow && workflow.data.oldStepInfo) {
Object.entries(workflow.data.oldStepInfo).forEach(([k,v]) => {
stepsPrev[k] = v.previous || [];
stepsNext[k] = v.next || [];
});
}
// mock data
// // first in list is most recent
// stepsPrev = {
// '-1': [ 3 ],
// 0: [ -1 ],
// 1: [ 0 ],
// 2: [ 1 ],
// 3: [ 2 ],
// }
// stepsNext = {
// '-1': [ 0 ],
// 0: [ 1 ],
// 1: [ 2 ],
// 2: [ 3 ],
// 3: [ -1 ],
// }
//
// stepsPrev = {
// '-1': [ 2 ],
// 0: [ -1 ],
// 1: [ 1, 4, 0 ],
// 2: [ 3, 1 ],
// 3: [ 2 ],
// 4: [ 1 ],
// }
// stepsNext = {
// '-1': [ 0 ],
// 0: [ 1 ],
// 1: [ 2, 1, 4, 0 ],
// 2: [ -1, 3 ],
// 3: [ 2 ],
// 4: [ 1 ],
// }
// // even more complex
// stepsPrev = {
// '-1': [ 2 ],
// 0: [ 3, -1 ],
// 1: [ 1, 4, 0 ],
// 2: [ 3, 1 ],
// 3: [ 2, 0 ],
// 4: [ 1 ],
// }
// stepsNext = {
// '-1': [ 0 ],
// 0: [ 1, 3 ],
// 1: [ 2, 1, 4, 0 ],
// 2: [ -1, 3 ],
// 3: [ 2, 0 ],
// 4: [ 1 ],
// }
return [stepsPrev, stepsNext];
}
function stringify(data) { return JSON.stringify(data, null, 2); }