UNPKG

@kui-shell/plugin-wskflow

Version:

Visualizations for Composer apps

1,008 lines 59.5 kB
/* * Copyright 2017 The Kubernetes Authors * * Licensed 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. */ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; import Debug from 'debug'; import { textualPropertiesOfCode } from './util'; const debug = Debug('plugins/wskflow/graph2doms'); const defaultMaxLabelLength = 10; const wfColorAct = { active: '#81C784', failed: '#EC7063', activeHovered: '#33a02c', failedHovered: 'red', inactive: 'lightgrey', edgeInactive: 'grey', inactiveBorder: 'grey' }; const containerId = 'wskflowDiv'; export default function graph2doms(tab, JSONgraph, ifReuseContainer, activations, { layoutOptions = {}, composites = { label: { fontSize: '4px', offset: { x: 0, y: -3 } } } } = {}) { return __awaiter(this, void 0, void 0, function* () { const [d3, { default: $ }, { default: ELK }] = yield Promise.all([ import('d3'), import('jquery'), import('elkjs/lib/elk.bundled.js') ]); const maxLabelLength = (JSONgraph.properties && JSONgraph.properties.maxLabelLength) || defaultMaxLabelLength; const defaultFontSize = (JSONgraph.properties && JSONgraph.properties.fontSize) || '7px'; const zoom = d3.behavior.zoom().on('zoom', redraw); // eslint-disable-line @typescript-eslint/no-use-before-define const containerElement = ifReuseContainer || $(`<div id="${containerId}"></div>`); const wskflowContainer = $('<div id="wskflowContainer"></div>'); let enterClickMode = false; $(containerElement).append(wskflowContainer); $(wskflowContainer).addClass('grabbable'); // we want to use grab/grabbing cursor const ssvg = d3 .select($(wskflowContainer)[0]) .append('svg') .attr('id', 'wskflowSVG') .attr('data-is-session-flow', !!activations) .call(zoom); const container = ssvg.append('g').on('dblclick.zoom', null); const svg = container .append('g') .attr('id', 'wskflowMainG') .attr('class', 'kui--screenshotable'); const defs = svg.append('svg:defs'); // a pattern mask for "not deployed" defs .append('svg:pattern') .attr('id', 'pattern-stripe') .attr('width', 1.2) .attr('height', 1.2) .attr('patternUnits', 'userSpaceOnUse') .attr('patternTransform', 'rotate(45)') .append('svg:rect') .attr('width', 0.6) .attr('height', 2) .attr('transform', 'translate(0,0)') .attr('fill', '#aaa'); // this will be the opacity of the mask, from #fff (full masking) to #000 (no masking) defs .append('svg:mask') .attr('id', 'mask-stripe') .append('svg:rect') .attr('x', 0) .attr('y', 0) .attr('width', '100%') .attr('height', '100%') .attr('fill', 'url(#pattern-stripe)'); // a heavier pattern mask defs .append('svg:pattern') .attr('id', 'pattern-stripe-heavy') .attr('width', 1.2) .attr('height', 1.2) .attr('patternUnits', 'userSpaceOnUse') .attr('patternTransform', 'rotate(45)') .append('svg:rect') .attr('width', 1) .attr('height', 2) .attr('transform', 'translate(0,0)') .attr('fill', '#aaa'); // this will be the opacity of the mask, from #fff (full masking) to #000 (no masking) defs .append('svg:mask') .attr('id', 'mask-stripe-heavy') .append('svg:rect') .attr('x', 0) .attr('y', 0) .attr('width', '100%') .attr('height', '100%') .attr('fill', 'url(#pattern-stripe-heavy)'); // define an arrow head const arrowHead = (id) => { const marker = defs .append('svg:marker') .attr('id', id) .attr('viewBox', '0 -5 10 10') .attr('markerUnits', 'userSpaceOnUse') .attr('markerWidth', 4.2) // marker settings .attr('markerHeight', 7) .attr('orient', 'auto'); marker.append('svg:path').attr('d', 'M0,-5L10,0L0,5'); return marker; }; arrowHead('end'); arrowHead('forwardingEnd').attr('refX', 10); arrowHead('edgeTraversedEnd') .attr('markerWidth', 5.25) .attr('markerHeight', 8.75) .attr('refX', 2.4); arrowHead('greenEnd'); arrowHead('trueEnd'); arrowHead('falseEnd'); defs .append('svg:g') .attr('id', 'retryIconNormal') .attr('transform', 'scale(0.02) rotate(90)') .append('svg:path') .attr('d', 'M852.8,558.8c0,194.5-158.2,352.8-352.8,352.8c-194.5,0-352.8-158.3-352.8-352.8c0-190.8,152.4-346.7,341.8-352.5v117.4l176.4-156.9L489,10v118C256.3,133.8,68.8,324.8,68.8,558.8C68.8,796.6,262.2,990,500,990c237.8,0,431.2-193.4,431.2-431.2H852.8z'); if (!$('#qtip')[0]) { // add qtip to the document $(document.body).append("<div id='qtip'><span id='qtipArrow'>&#9668</span><div id='qtipContent'></div></div>"); } if (activations) { $(wskflowContainer).append("<div id='actList' style='position: absolute; display:none; background-color: rgba(0, 0, 0, 0.8); color: white; font-size: 0.75em; padding: 1ex; width:225px; right: 5px; top: 5px;'></div>"); } const root = svg.append('g'); let elkData; // // After many tests, this is the right way to manually adjust values // for `transform` without introducing unwanted behavior when // zooming by mouse scrolling using d3's zoom feature. // // We have to set d3's zoom variable to have the same values as the // values we'd like for `transform`. So when d3 calculates the // values for event.translate/scale, it takes our manual adjustments // into account. // let applyAutoScale = true; // if resizing the window will resize the graph. true by default. let customZoom = false; function resizeToFit(meetOrSlice) { // resizeToFit implements a zoom-to-fit behavior using viewBox. // it no longer requires knowing the size of the container // disadventage is we cannot decide the max zoom level: it will always zoom to fit the entire container ssvg.attr('viewBox', `0 0 ${elkData.width} ${elkData.height}`); ssvg.attr('preserveAspectRatio', `xMidYMin ${meetOrSlice}`); container.attr('transform', ''); $('#wskflowSVG').removeAttr('transform'); zoom.translate([0, 0]); zoom.scale(1); } const resizeToContain = () => resizeToFit('meet'); const resizeToCover = () => { resizeToFit('slice'); applyAutoScale = false; }; function drawGraph(nodes, links) { debug('drawGraph'); // #1 add the nodes' groups const nodeData = root.selectAll('.node').data(nodes, function (d) { return d.id; }); const node = nodeData .enter() .append('g') .attr('transform', function (d) { return 'translate(' + (d.x || 0) + ' ' + (d.y || 0) + ')'; }) .attr('class', function (d) { // console.log(d); let className = 'node'; if (d.children) { className += ' compound'; } else { className += ' leaf'; } if (d.tooltipHeader || d.type === 'function' || d.type === 'action') { // e.g. par and map; since these have an explanation (tooltipHeader), note them as such className += ' wskflow-node-with-special-meaning'; } if (d.properties && d.properties.compoundNoParents) { className += ' no-parents'; } if (d.type !== undefined) { className += ' ' + d.type; } else { className += ' ' + d.label; } // for tests, it is helpful to have a single css discriminant for functions and actions if (d.type === 'action' || d.type === 'function') { className += ' Task'; } if (d.retryCount || d.repeatCount) { className += ' repeat'; } return className; }) .attr('id', function (d) { return d.id; }) .attr('data-task-index', d => d.taskIndex) // add a data-task-index for every task. taskIndex is maintained at the graph model level .attr('data-name', function (d) { // make sure we obey the `/namespace/name` format const label = d.type === 'Entry' || d.type === 'Exit' ? d.type : d.multiLineLabel ? d.fullFunctionCode.replace(/\n/g, '') : d.label; return label && (label.charAt(0) !== '/' ? `/_/${label}` : label); }) .attr('data-kind', d => d.properties && d.properties.kind) // e.g. 'trigger' .attr('data-kind-detail', d => d.properties && d.properties.kindDetail) // e.g. the name of the trigger .attr('data-deployed', function (d) { if (activations) { // empty } else { // only for preview graphs, not for session graphs if (d.type === 'action') { if (d.deployed) { return 'deployed'; } else { return 'not-deployed'; } } else if (d.deployed === false) { return 'not-deployed'; } } }) .attr('data-status', d => { if (activations) { if (d.visited) { let failed = true; // assumption: all fail is fail. if one succes, we count it as success d.visited.forEach(i => { if (activations[i] && activations[i].response.success) { failed = false; } }); if (failed) { $(this).attr('failed', true); return 'failed'; } else { return 'success'; } } else { return 'not-run'; } } }); // add representing boxes for nodes const svgns = 'http://www.w3.org/2000/svg'; node .append(d => { return document.createElementNS(svgns, d.properties && d.properties.kind === 'trigger' ? 'polygon' : 'rect'); }) .attr('class', d => { return 'atom' + (d.type === 'action' || d.onclick ? ' clickable' : '') + (d.onclick ? ' has-onclick' : ''); }) .attr('points', d => { if (d.properties && d.properties.kind === 'trigger') { return '0 0, 0 13.5, 12 18, 24 13.5, 24 0, 12 6.25, 0 0'; } }) .attr('width', function (d) { return d.width; }) .attr('height', function (d) { return d.height; }) .attr('rx', function (d) { if (d.type === 'Entry' || d.type === 'Exit') { return '50%'; } }) .attr('ry', function (d) { if (d.type === 'Entry' || d.type === 'Exit') { return '50%'; } }) .style('fill', function (d) { if (d.children) { return 'transparent'; } }) .style('cursor', function (d) { if (activations) { if (d.visited && d.type === 'action') { return 'pointer'; } else { return 'normal'; } } else { if (d.type === 'action') { return 'pointer'; } else { return 'normal'; } } }) .on('mouseover', function (d) { let qtipText = ''; let qtipPre = false; if (activations) { if (d.children === undefined && d.visited && $('#actList').css('display') !== 'block') { if (d.type === 'action') { // first, describe # activations if # > 1 if (d.visited.length > 1) { qtipText += "<div style='padding-bottom:2px;'>" + activations.length + ' activations</div>'; } let date; d.visited.forEach(i => { // first part: time const a = activations[i]; const start = new Date(a.start); let timeString = ''; if (date === undefined || date !== start.getMonth() + 1 + '/' + start.getDate()) { date = start.getMonth() + 1 + '/' + start.getDate(); timeString += date + ' '; } timeString += start.toLocaleTimeString(undefined, { hour12: false }); let duration = a.duration.toString(); let unit = 'ms'; if (a.duration > 1000) { duration = (a.duration / 1000).toFixed(2); unit = 's'; } let c; if (a.response.success) { c = wfColorAct.active; } else { c = wfColorAct.failed; } qtipText += "<span style='color:" + c + "'>" + timeString + '</span> (' + duration + unit + ')<break></break>'; let result = JSON.stringify(a.response.result); if (result.length > 40) { result = result.substring(0, 40) + '... '; } qtipText += result; qtipText = "<div style='padding-bottom:2px;'>" + qtipText + '</div>'; }); // else if(d.type == "Exit" || d.type == 'Entry'){ } else if (d.type === 'Exit') { const act = activations[d.visited[0]]; const start = new Date(act.start); let timeString = start.getMonth() + 1 + '/' + start.getDate() + ' '; timeString += start.toLocaleTimeString(undefined, { hour12: false }); let result = act.response.result ? JSON.stringify(act.response.result, undefined, 4) : ''; if (result.length > 200) { result = result.substring(0, 200) + '\u2026'; } // horizontal ellipsis qtipText += `<div style='padding-bottom:2px'><span class='qtip-prefix'>${d.type}</span> <span style='color:${wfColorAct.active}'>${timeString}</span></div>${result}`; } } } else { if (d.children) { if (d.type === 'try') { qtipText = "<span class='qtip-prefix red-text'>Try Block</span>"; } else if (d.type === 'handler') { qtipText = "<span class='qtip-prefix red-text'>Handler Block</span>"; } else if (d.type === 'try_catch') { const label = d.label || d.tooltip || ''; if (label.indexOf(':') !== -1) { qtipText = "<span class='qtip-prefix'>Try-Catch</span> <span style='color: orange'>" + label.substring(label.indexOf(':') + 1) + '</span>'; // $(this).siblings("use").attr("xlink:href", "#retryIconOrange").attr("href", "#retryIconOrange"); } else if (label.indexOf('Repeat') !== -1) { const num = label.split(' ')[1]; qtipText = "<span class='qtip-prefix'>Repeat </span>" + "<span style='color: orange'>" + num + ' times</span> then continue'; // $(this).siblings("use").attr("xlink:href", "#retryIconOrange").attr("href", "#retryIconOrange"); } else { // qtipText = "<span style='color: #E5E8E8'>"+label+"</span>" qtipText = `<span class='qtip-prefix'>${label}</span>`; } } } else if (d.type === 'action' && $('#' + d.id).attr('data-deployed') === 'not-deployed') { qtipText = `<span class='qtip-prefix red-text'>Warning |</span> This action is not deployed`; } else if (d.type === 'action' || d.type === 'function') { const typeForDisplay = d.type === 'function' ? 'Inline Function' : 'Action'; if (d.type === 'function') { qtipPre = true; // use white-space: pre for function body } if (d.multiLineLabel) { qtipText = `<span class='qtip-prefix ${d.type}'>${typeForDisplay}</span>`; } else { qtipText = `<span class='qtip-prefix ${d.type}' style="padding-right:5px; ">${typeForDisplay} |</span> ${d.label || d.tooltip || d.prettyCode}`; } } else if (d.type === 'retain') { let edgeId; const isOrigin = d.id.indexOf('__origin') > -1; const expectedSrc = isOrigin ? d.id : d.id.replace(/__terminus/, '__origin'); const expectedTerminus = isOrigin ? d.id.replace(/__origin/, '__terminus') : d.id; for (let i = 0; i < links.length; i++) { if (links[i].source === expectedSrc && links[i].target === expectedTerminus) { edgeId = links[i].id; break; } } if (edgeId) { if (isOrigin) { qtipText = `<span class='qtip-prefix ${d.type}' style='padding-right:5px; color: #85C1E9'>Retain |</span> Data forwarding</span>`; } else { qtipText = `<span class='qtip-prefix ${d.type}' style='padding-right:5px; color: #85C1E9'>Retain |</span> Data merging</span>`; } $(".link[id='" + edgeId + "']").addClass('hover forwarding-edge'); } } else if (d.type === 'Entry') { if (d.properties && d.properties.kind === 'trigger') { qtipText = `<span class='qtip-prefix ${d.type}' style='padding-right:5px; color: #85C1E9'>Trigger |</span> ${d.properties.kindDetail}`; } else { qtipText = d.properties.title || 'The composition starts here'; } } else if (d.type === 'Exit') { qtipText = d.properties.title || 'The composition ends here'; } else if (d.type === 'let' || d.type === 'literal') { qtipText = `<div class='qtip-prefix let' style="margin-bottom:1ex; padding-right:5px; ">${d.type}</div>${d.label || d.tooltip}`; qtipPre = true; // use white-space: pre } if (d.properties && d.properties.choice) { qtipText += "<div class='top-pad'>This is the <span style='color: orange;'>Y</span>/<span style='color: #DC7633;'>N</span> condition of an if</div>"; // also highlight the edges $(".link[source='" + (d.id + '_ptrue') + "']").addClass('hover'); $(".link[source='" + (d.id + '_pfalse') + "']").addClass('hover'); } } if (!qtipText && d.tooltip) { // // the above rules are pretty specific to Apache Composer // (TODO refactor); this allows for a more modern custom // tooltip to be driven by the graph model producer // qtipText = `<div class='qtip-prefix ${d.tooltipColor ? 'color-base' + d.tooltipColor : 'function'}' style="margin-bottom:1ex; padding-right:5px; ">${d.tooltipHeader || d.type}</div>${d.tooltip}`; } if (qtipText && qtipText.length !== 0) { $('#qtipContent').html(qtipText); $('#qtip').addClass('visible'); if (qtipPre) $('#qtip').addClass('qtip-pre'); else $('#qtip').removeClass('qtip-pre'); const rect = $(this)[0].getBoundingClientRect(); let qtipX = rect.left + rect.width; let qtipY = rect.top + rect.height / 2 - $('#qtip').height() / 2; if ($('#wskflowContainer').hasClass('picture-in-picture')) { // currentScale: 0.25 const scaleString = $('#wskflowContainer').css('transform'); let scale; try { scale = parseFloat(scaleString.substring('matrix('.length, scaleString.indexOf(','))); } catch (e) { console.log(e); console.log(scaleString); scale = 0.25; } qtipX /= scale; qtipY = $(this).offset().top + $(this)[0].getBoundingClientRect().height / 2 - ($('#qtip').height() / 2) * scale - $('#wskflowContainer').offset().top; qtipY /= scale; } $('#qtip').css({ left: qtipX, top: qtipY }); } }) .on('mouseout', function () { $('.link').removeClass('hover'); $('#qtip').removeClass('visible'); }) .on('mousedown', () => { enterClickMode = true; }) .on('click', function (d) { if (!enterClickMode) return; enterClickMode = false; $('#qtip').removeClass('visible'); if (d.onclick) { tab.REPL.click(d.onclick, d3.event); } else if (activations) { if (d.visited) { if ($('#actList').css('display') !== 'block') { $('#listClose').click(); } // if(d.type == "Exit" || d.type == 'Entry'){ if (d.type === 'Exit') { // console.log(fsm.States[d.id].act[0]); /* pictureInPicture( tab, activations[d.visited[0]], d3.event.currentTarget.parentNode, // highlight this node $('#wskflowContainer')[0], 'App Visualization' // container to pip )(d3.event) */ /* pictureInPicture(`wsk activation get ${id}`, {echo: true}), d3.event.currentTarget.parentNode, // highlight this node $("#wskflowContainer")[0], 'App Visualization' // container to pip )(d3.event) // pass along the raw dom event */ } else if (d.type === 'action') { $('#qtip').removeClass('visible'); if (d.visited.length === 1) { /* pictureInPicture( tab, activations[d.visited[0]], d3.event.currentTarget.parentNode, // highlight this node $('#wskflowContainer')[0], 'App Visualization' // container to pip )(d3.event) */ // pass along the raw dom event } else { // let act = fsm.States[d.id].act; let actListContent = "<div style='padding-bottom:5px'>Click on an activation here to view details</div>"; actListContent += `<div>${d.label}<break</break>${d.visited.length} activations, ordered by start time: </div>`; actListContent += "<ol style='padding-left: 15px;'>"; let date; d.visited.forEach(n => { // first part: time const a = activations[n]; const start = new Date(a.start); let timeString = ''; let lis = ''; if (date === undefined || date !== start.getMonth() + 1 + '/' + start.getDate()) { date = start.getMonth() + 1 + '/' + start.getDate(); timeString += date + ' '; } timeString += start.toLocaleTimeString(undefined, { hour12: false }); let duration = a.duration.toString(); let unit = 'ms'; if (a.duration > 1000) { duration = (a.duration / 1000).toFixed(2); unit = 's'; } let c; if (a.response.success) { c = wfColorAct.active; } else { c = wfColorAct.failed; } lis += `<span class='actItem' style='color:${c}; text-decoration:underline; cursor: pointer;' index=${n}>${timeString}</span> (${duration + unit})<break></break>`; let result = a.response ? JSON.stringify(a.response.result) : ''; if (result.length > 40) { result = result.substring(0, 40) + '... '; } lis += result; actListContent += '<li>' + lis + '</li>'; }); actListContent += '</ol>'; actListContent = "<div id='listClose' style='font-size:14px; color:lightgrey; display:inline-block; float:right; cursor:pointer; padding-right:5px;'>✖</div>" + actListContent; $('#actList') .html(actListContent) .css('display', 'block'); $('.actItem') .hover(function () { $(this).css('text-decoration', 'none'); }, function () { $(this).css('text-decoration', 'underline'); }) .click(function () { // repl.exec(`wsk action get "${d.name}"`, {sidecarPrevious: 'get myApp', echo: true}); // const index = $(this).attr('index') // pictureInPicture(`wsk activation get ${id}`, {echo: true}), /* pictureInPicture( tab, activations[index], $(this).parent()[0], // highlight this node $('#wskflowContainer')[0], 'App Visualization' // container to pip )(e) */ // pass along the raw dom event }); $('#listClose').click(function () { $('#actList').css('display', 'none'); $('#' + d.id) .children('rect') .css('fill', wfColorAct.active); $(this).css('fill', wfColorAct.activeHovered); }); $('#qtip').removeClass('visible'); } } } } else { if (d.type === 'action' && $('#' + d.id).attr('data-deployed') === 'deployed') { if (d.name) { // repl.exec(`wsk action get "${d.name}"`, {sidecarPrevious: 'get myApp', echo: true}); tab.REPL.click(`wsk action get "${d.name}"`, d3.event); } else { debug(`clicking on an inline function: ${d.label}`); } } } }); // add node labels const textElements = node .append('text') .attr('width', d => d.width) .attr('x', function (d) { // if(d.type == "try_catch" || d.type == "try" || d.type == "handler") if (d.children) { return composites.label.offset.x; } else if (d.multiLineLabel) { return d.x; } else { return d.width / 2; } }) .attr('y', function (d) { // if(d.type == "try_catch" || d.type == "try" || d.type == "handler") if (d.properties && d.properties.kind === 'trigger') { return d.height * 0.675; } if (d.children) { return composites.label.offset.y; } else if (d.multiLineLabel) { return (d.height - d.multiLineLabel.length * 6) / 2; } else { return (d.height / 2 + (d.type === 'Entry' || d.type === 'Exit' ? 1.5 : d.type === 'Dummy' ? 1.5 : d.type === 'let' ? 3.5 : 2)); } }) .attr('font-size', function (d) { if (d.children) { return composites.label.fontSize; } else if (d.properties && d.properties.fontSize) { return d.properties.fontSize; } else if (d.type === 'Entry' || d.type === 'Exit') { return '6px'; } else { return defaultFontSize; } }) .style('text-anchor', function (d) { if (!d.children && !d.multiLineLabel) { return 'middle'; } }) .style('pointer-events', 'none') .attr('multi-line-label', d => d.multiLineLabel && d.multiLineLabel.join('\n')) .text(function (d) { if (d.type === 'retain' || (d.type === 'Dummy' && d.label === d.id)) { // the === checks for unlabeled Dummy nodes return ''; } else if (d.id === 'root') { return ''; } else if (d.type === 'try') { return 'Try'; } else if (d.type === 'handler') { return 'Catch'; // else if(d.type == "try_catch" || d.type === 'retry'){ } else if (d.children) { // return "Try Catch"; return d.label; /* else if(d.type == "if"){ return d.label; } */ } else if (d.type === 'let' || d.type === 'literal') { const _ = '_'; const maxLen = 5; const rest = d.value.length > maxLen ? ['\u2026', _] : []; if (typeof d.value === 'string') { return d.value.substring(0, 10) + (d.value.length > maxLen ? '\u2026' : ''); } else if (Array.isArray(d.value)) { // render array literals const array = d.value .slice(0, maxLen - 1) .map(() => _) .concat(rest); return `[${array}]`; // '\u2026' horizontal ellipsis } else { // render object literals const keys = Object.keys(d.value) .slice(0, maxLen) .concat(rest); return `{${keys}}`; } /* if(d.label.length>30) return d.label.substring(0, 27)+"..."; else return d.label; */ } else if (d.type === 'action' || d.type === 'function') { return d.label; } else { let t = d.label; if (t === undefined) { t = d.id; } if (t.length > maxLabelLength && d.type !== 'Dummy') { // let dummy nodes use longer labels return t.substring(0, maxLabelLength - 1) + '...'; } else { return t; } } }); for (let idx = 0; idx < textElements[0].length; idx++) { const textElement = textElements[0][idx]; const label = textElement.attributes && textElement.attributes.getNamedItem('multi-line-label'); const width = textElement.attributes && textElement.attributes.getNamedItem('width'); if (label) { const { maxLineLength } = textualPropertiesOfCode(label.value); const tabWidth = 2; const farLeft = (width.value - maxLineLength * 3) / 2; label.value.split(/\n/).forEach(line => { const ws = /^\s+/; const wsMatch = line.match(ws); /* const length = line.length */ const initialWhitespace = wsMatch ? wsMatch[0].length : 0; d3.select(textElement) .append('tspan') .text(line.replace(ws, '')) .attr('x', farLeft + initialWhitespace * tabWidth) .attr('dy', '1.25em'); }); } } // #2 add paths with arrows for the edges root .selectAll('.link') .data(links, function (d) { return d.id; }) .enter() .append('path') .attr('id', function (d) { return d.id; }) .attr('d', 'M0 0') .attr('marker-end', function (d) { if (d.visited) { return 'url(#greenEnd)'; } else if (d.source.indexOf('__origin') >= 0 && d.target.indexOf('__terminus') >= 0) { return 'url(#forwardingEnd)'; } else { return 'url(#end)'; } }) .attr('class', function (d) { let s = 'link'; if (d.source.indexOf('try_') === 0 && d.target.indexOf('handler_') === 0) { s += ' TryCatchEdge'; } if (d.source.indexOf('__origin') >= 0 && d.target.indexOf('__terminus') >= 0) { s += ' forwardingLink has-hover-effect'; } if (d.sourcePort && d.sourcePort.indexOf('_ptrue') !== -1) { s += ' true-branch'; } if (d.sourcePort && d.sourcePort.indexOf('_pfalse') !== -1) { s += ' false-branch'; } if (d.properties) { for (const key in d.properties) { s += ` ${key}`; } } return s; }) .attr('data-visited', d => d.visited) // edge was visited? .attr('source', function (d) { return d.sourcePort; }) .on('mouseout', function () { $('#qtip').removeClass('visible'); }) .on('mouseover', function (edge) { if (edge.properties && edge.properties.type === 'retain') { // special handling of mouse hover events for retain edges // $(`#${edge.source}`).addClass('hover'); // $(`#${edge.target}`).addClass('hover'); const qtipText = `<span class='qtip-prefix ${edge.properties.type}' style='padding-right:5px; color: #85C1E9'>Retain |</span> Data forwarding edge</span>`; $('#qtipContent').html(qtipText); $('#qtip').addClass('visible'); const rect = $(this)[0].getBoundingClientRect(); const qtipX = rect.left + rect.width; const qtipY = rect.top + rect.height / 2 - $('#qtip').height() / 2; $('#qtip').css({ left: qtipX, top: qtipY }); } }) .attr('d', function (d) { let path = ''; if (d.sourcePoint && d.targetPoint) { path += 'M' + d.sourcePoint.x + ' ' + d.sourcePoint.y + ' '; (d.bendPoints || []).forEach(function (bp) { path += 'L' + bp.x + ' ' + bp.y + ' '; }); const isTryCatchEdge = d.target.endsWith('-handler'); const isForwardingEdge = d.source.indexOf('__origin') >= 0 && d.target.indexOf('__terminus') >= 0; const offsetY = isForwardingEdge || isTryCatchEdge ? 0 : 4.2; // arrowhead hacking const offsetX = isTryCatchEdge ? -4.2 : 0; path += 'L' + (d.targetPoint.x + offsetX) + ' ' + (d.targetPoint.y - offsetY) + ' '; } return path; }); const addMorePathAttr = () => root .selectAll('path') .attr('data-from-name', function (d) { const fromId = d.source; const isName = $('#' + fromId).attr('data-name'); if (isName) { return isName; } }) .attr('data-to-name', function (d) { const toId = d.target; const isName = $('#' + toId).attr('data-name'); if (isName) { return isName; } }); // edge labels const addEdgeLabels = () => links.forEach(edge => { if (edge.labels) { edge.labels.forEach(({ text }) => { d3.select('#' + edge.source) .append('text') .classed('edge-label', true) .attr('x', 10) .attr('y', 0) .text(text); }); } if (edge.sourcePort && (edge.sourcePort.indexOf('_ptrue') !== -1 || edge.sourcePort.indexOf('_pfalse') !== -1)) { let t; let d; let x; let y; let reverse; let cssClass; if (edge.sourcePort.indexOf('_ptrue') !== -1) { // add a text label next to its t = 'Y'; cssClass = 'true-branch'; } else if (edge.sourcePort.indexOf('_pfalse') !== -1) { t = 'N'; cssClass = 'false-branch'; } if (t !== undefined) { for (let i = 0; i < nodes.length; i++) { if (nodes[i].id === edge.source) { let tx; let fx; for (let j = 0; j < nodes[i].ports.length; j++) { if (nodes[i].ports[j].id === edge.sourcePort) { d = nodes[i].ports[j].properties.portSide; x = nodes[i].ports[j].x; y = nodes[i].ports[j].y; // break; } if (nodes[i].ports[j].id.indexOf('_ptrue') !== -1) { tx = nodes[i].ports[j].x; } if (nodes[i].ports[j].id.indexOf('_pfalse') !== -1) { fx = nodes[i].ports[j].x; } } if (tx && fx) { if (tx > fx) { reverse = true; } } break; } } } if (t !== undefined && d !== undefined) { // add label if (d === 'WEST') { x -= 7; y -= 3; } else if (d === 'EAST') { x += 7; y -= 3; } else if (d === 'NORTH') { y -= 7; if (reverse) { if (t === 'N') { x -= 7; } else { x += 3; } } else { if (t === 'Y') { x -= 7; } else { x += 3; } } } else if (d === 'SOUTH') { y += 7; if (reverse) { if (t === 'N') { x -= 7; } else { x += 3; } } else { if (t === 'Y') { x -= 7; } else { x += 3; } } } const target = $(`#${e