diff --git a/analysis/stack-trace/frames.js b/analysis/stack-trace/frames.js index c441dc51..63078528 100644 --- a/analysis/stack-trace/frames.js +++ b/analysis/stack-trace/frames.js @@ -83,8 +83,17 @@ class Frame { } getModuleName (systemInfo) { - const filePath = this.fileName.split(systemInfo.pathSeparator) - if (!filePath.includes('node_modules')) return null + let filePath = this.fileName.split(systemInfo.pathSeparator) + // For eval frames, use the source file listed in the `evalOrigin` + if (this.fileName === '' && this.isEval) { + const m = this.evalOrigin.match(/\((.*?):\d+:\d+\)$/) + if (m) { + filePath = m[1].split(systemInfo.pathSeparator) + } + } + if (!filePath.includes('node_modules')) { + return null + } // Find the last node_modules directory, and count how many were // encountered. diff --git a/package.json b/package.json index 700f538e..4f9c79ec 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@nearform/bubbleprof", "description": "Programmable interface to Clinic.js Bubbleprof", "repository": "nearform/node-clinic-bubbleprof", - "version": "2.1.0", + "version": "2.1.1", "engines": { "node": "^11.0.0 || ^10.0.0 || ^8.11.1" }, @@ -40,7 +40,7 @@ "d3-ease": "^1.0.3", "d3-format": "^1.3.0", "d3-interpolate": "^1.2.0", - "d3-scale": "^2.1.0", + "d3-scale": "^3.0.0", "d3-selection": "^1.3.0", "d3-shape": "^1.2.0", "d3-time": "^1.0.8", diff --git a/test/analysis-aggregate-mark-module.test.js b/test/analysis-aggregate-mark-module.test.js index 71834731..5464fb44 100644 --- a/test/analysis-aggregate-mark-module.test.js +++ b/test/analysis-aggregate-mark-module.test.js @@ -50,10 +50,23 @@ test('Aggregate Node - mark module', function (t) { type: 'TickObject' }) + const aggregateNodeEval = new FakeAggregateNode({ + aggregateId: 2, + parentAggregateId: 1, + children: [], + frames: [ + { fileName: '/node_modules/promise/lib/core.js' }, + { fileName: '', isEval: true, evalOrigin: 'eval at denodeifyWithoutCount (/node_modules/promise/lib/node-extensions.js:90:10)' } + ], + mark: ['external', null, null], + type: 'TickObject' + }) + const systemInfo = new FakeSystemInfo('/') const aggregateNodesInput = [ aggregateNodeRoot, aggregateNodeNodecore, - aggregateNodeInternal, aggregateNodeExternal + aggregateNodeInternal, aggregateNodeExternal, + aggregateNodeEval ] startpoint(aggregateNodesInput, { objectMode: true }) @@ -76,6 +89,10 @@ test('Aggregate Node - mark module', function (t) { aggregateNodesOutput[3].mark.toJSON(), ['external', 'deep', null] ) + t.strictDeepEqual( + aggregateNodesOutput[4].mark.toJSON(), + ['external', 'promise', null] + ) t.end() })) }) diff --git a/visualizer/draw/area-chart.js b/visualizer/draw/area-chart.js index d30e2633..51acd921 100644 --- a/visualizer/draw/area-chart.js +++ b/visualizer/draw/area-chart.js @@ -2,6 +2,8 @@ const d3 = require('./d3-subset.js') const HtmlContent = require('./html-content.js') +const getCSSVarValue = require('./util/getCssVarValue.js') + const { numberiseIfNumericString, uniqueObjectKey @@ -19,7 +21,7 @@ class AreaChart extends HtmlContent { } }, contentProperties)) - this.cssVarValues = {} + this.cssVarValues = getCSSVarValue() this.initialized = false this.topmostUI = this.ui @@ -48,10 +50,16 @@ class AreaChart extends HtmlContent { this.draw() } }) + this.ui.on('initializeFromData', () => { this.initializeFromData() }) + this.ui.on('themeChanged', () => { + this.cssVarValues = getCSSVarValue(this.ui.currentTheme) + this.draw() + }) + this.highlightedLayoutNode = null this.hoverListener = layoutNode => { @@ -81,17 +89,6 @@ class AreaChart extends HtmlContent { }) } - /** - * Get the value of a CSS variable - * @param {String} varName - */ - getCSSVarValue (varName) { - if (!this.cssVarValues[varName]) { - this.cssVarValues[varName] = window.getComputedStyle(document.body).getPropertyValue(varName) - } - return this.cssVarValues[varName] - } - /** * Generic function to define the styles for the canvas based visualization * For SVG we could do this with CSS - but for Canvas we don't have access to CSS @@ -105,7 +102,7 @@ class AreaChart extends HtmlContent { 'type-other': '--type-colour-5' } - const fillColour = this.getCSSVarValue(colourIds[type] || 'type-other') + const fillColour = this.cssVarValues(colourIds[type] || 'type-other') let opacity = 0.8 if (isEven) opacity = 0.6 if (isHighlighted) opacity = 1 @@ -500,7 +497,7 @@ class AreaChart extends HtmlContent { this.canvasContext.beginPath() this.areaMaker.context(this.canvasContext)(d) // Reset area's colour, so repeated hover in/out doesn't overlay colours increasing their intensity - this.canvasContext.fillStyle = this.getCSSVarValue('--main-bg-color') + this.canvasContext.fillStyle = this.cssVarValues('--main-bg-color') this.canvasContext.fill() this.canvasContext.fillStyle = d3Col.toString() this.canvasContext.fill() diff --git a/visualizer/draw/svg-container.js b/visualizer/draw/bubble-node-container.js similarity index 64% rename from visualizer/draw/svg-container.js rename to visualizer/draw/bubble-node-container.js index 3fee693c..661ae90a 100644 --- a/visualizer/draw/svg-container.js +++ b/visualizer/draw/bubble-node-container.js @@ -1,15 +1,17 @@ 'use strict' const HtmlContent = require('./html-content.js') -const SvgNodeDiagram = require('./svg-node-diagram.js') +const BubbleNodeDiagram = require('./bubble-node-diagram.js') -class SvgContainer extends HtmlContent { +class BubbleNodeContainer extends HtmlContent { constructor (parentContent, contentProperties = {}) { const defaultProperties = { htmlElementType: 'svg' } super(parentContent, Object.assign(defaultProperties, contentProperties)) + // this.renderType = + if (contentProperties.svgBounds) { const defaultBounds = { minX: 0, @@ -21,8 +23,8 @@ class SvgContainer extends HtmlContent { } this.svgBounds = Object.assign(defaultBounds, contentProperties.svgBounds) } - - this.svgNodeDiagram = new SvgNodeDiagram(this) + this.ui.nodeLinkContainer = this + this.svgNodeDiagram = new BubbleNodeDiagram(this) this.ui.svgNodeDiagram = this.svgNodeDiagram this.ui.on('setData', () => { @@ -30,6 +32,10 @@ class SvgContainer extends HtmlContent { }) } + get renderType () { + return this.contentProperties.htmlElementType + } + setData () { const { minX, @@ -39,9 +45,21 @@ class SvgContainer extends HtmlContent { this.svgBounds.height = this.ui.layout.scale.finalSvgHeight || this.ui.layout.settings.svgHeight this.svgBounds.width = this.ui.layout.settings.svgWidth + if (this.renderType === 'svg') { + this.d3Element + .attr('viewBox', `${minX} ${minY} ${this.svgBounds.width} ${this.svgBounds.height}`) + .attr('preserveAspectRatio', preserveAspectRatio) + } + + if (this.renderType === 'canvas') { + this.initializeCanvas() + } + } + + initializeCanvas () { this.d3Element - .attr('viewBox', `${minX} ${minY} ${this.svgBounds.width} ${this.svgBounds.height}`) - .attr('preserveAspectRatio', preserveAspectRatio) + .attr('width', this.svgBounds.width) + .attr('height', this.svgBounds.height) } initializeElements () { @@ -63,4 +81,4 @@ class SvgContainer extends HtmlContent { } } -module.exports = SvgContainer +module.exports = BubbleNodeContainer diff --git a/visualizer/draw/svg-node-diagram.js b/visualizer/draw/bubble-node-diagram.js similarity index 50% rename from visualizer/draw/svg-node-diagram.js rename to visualizer/draw/bubble-node-diagram.js index 00469282..0a506a05 100644 --- a/visualizer/draw/svg-node-diagram.js +++ b/visualizer/draw/bubble-node-diagram.js @@ -2,14 +2,17 @@ const d3 = require('./d3-subset.js') const LineCoordinates = require('../layout/line-coordinates.js') -const SvgNode = require('./svg-node.js') +const BubbleNode = require('./bubble-node.js') +const ShadowCanvas = require('./util/shadowCanvas.js') -class SvgNodeDiagram { - constructor (svgContainer) { - this.svgContainer = svgContainer - this.ui = svgContainer.ui +class BubbleNodeDiagram { + constructor (bubbleNodeContainer) { + this.bubbleNodeContainer = bubbleNodeContainer + this.ui = bubbleNodeContainer.ui this.svgNodes = new Map() + this.canvasCtx = null + this.shadowCanvas = null this.ui.on('initializeFromData', () => { // Called once, creates group contents using d3's .append() @@ -22,6 +25,7 @@ class SvgNodeDiagram { }) this.ui.on('svgDraw', () => { + if (this.canvasCtx) this.shadowCanvas.clear() // Called any time the SVG DOM elements need to be modified or redrawn this.draw() }) @@ -32,8 +36,19 @@ class SvgNodeDiagram { } initializeElements () { - this.d3Container = this.svgContainer.d3Element + if (this.bubbleNodeContainer.renderType === 'svg') { + this.d3Container = this.bubbleNodeContainer.d3Element + } + if (this.bubbleNodeContainer.renderType === 'canvas') { + this.canvasCtx = this.bubbleNodeContainer.d3Element.node().getContext('2d') + this.d3Container = d3.select(document.createElement('custom')) + } + if (this.canvasCtx) { + this.shadowCanvas = new ShadowCanvas() + // document.getElementById('node-link').append(this.shadowCanvas.canvasElement) + // this.shadowCanvas.canvasElement.style = 'position:absolute; top:0; left: 0; opacity: 1' + } // Group to which one group for each node is appended this.d3Element = this.d3Container.append('g') .classed('node-links-wrapper', true) @@ -46,9 +61,13 @@ class SvgNodeDiagram { .enter() this.dataArray.forEach(layoutNode => { - if (!this.svgNodes.has(layoutNode.id)) this.svgNodes.set(layoutNode.id, new SvgNode(this)) + if (!this.svgNodes.has(layoutNode.id)) this.svgNodes.set(layoutNode.id, new BubbleNode(this)) this.svgNodes.get(layoutNode.id).setData(layoutNode) }) + + if (this.shadowCanvas) { + this.shadowCanvas.setDimensions(this.ui.layout.settings.svgWidth, this.ui.layout.settings.svgHeight) + } } initializeFromData () { @@ -59,30 +78,61 @@ class SvgNodeDiagram { const d3NodeGroup = d3.select(nodes[i]) this.svgNodes.get(layoutNode.id).initializeFromData(d3NodeGroup) }) - .on('mouseenter', layoutNode => this.ui.highlightNode(layoutNode)) - .on('mouseleave', () => this.ui.highlightNode(null)) - .on('click', (layoutNode) => { - d3.event.stopPropagation() - this.ui.queueAnimation('selectGraphNode', (animationQueue) => { - this.ui.selectNode(layoutNode, animationQueue).then(targetUI => { - if (targetUI !== this.ui) { - this.ui.originalUI.emit('navigation', { from: this.ui, to: targetUI }) + + if (!this.canvasCtx) { + this.d3NodeGroups + .on('mouseenter', layoutNode => this.ui.highlightNode(layoutNode)) + .on('mouseleave', () => this.ui.highlightNode(null)) + .on('click', (layoutNode) => { + d3.event.stopPropagation() + this.ui.queueAnimation('selectGraphNode', (animationQueue) => { + this.ui.selectNode(layoutNode, animationQueue).then(targetUI => { + if (targetUI !== this.ui) { + this.ui.originalUI.emit('navigation', { from: this.ui, to: targetUI }) + } + animationQueue.execute() + }) + }) + }) + } else { + this.bubbleNodeContainer.d3Element + .on('mousemove', () => { + const layoutNode = this.shadowCanvas.getData(d3.event) + if (layoutNode) { + if (!this.ui.highlightedDataNode) { + this.ui.highlightNode(layoutNode) } - animationQueue.execute() + } else { + this.ui.highlightNode(null) + } + }) + .on('click', () => { + const layoutNode = this.shadowCanvas.getData(d3.event) + d3.event.stopPropagation() + if (!layoutNode) { + return + } + this.ui.queueAnimation('selectGraphNode', (animationQueue) => { + this.ui.selectNode(layoutNode, animationQueue).then(targetUI => { + if (targetUI !== this.ui) { + this.ui.originalUI.emit('navigation', { from: this.ui, to: targetUI }) + } + animationQueue.execute() + }) }) }) - }) + } } animate (isExpanding, onComplete) { this.ui.isAnimating = true - this.svgContainer.d3Element.classed('fade-elements-in', isExpanding) - this.svgContainer.d3Element.classed('fade-elements-out', !isExpanding) - this.svgContainer.d3Element.classed('complete-fade-out', false) - this.svgContainer.d3Element.classed('complete-fade-in', false) + this.bubbleNodeContainer.d3Element.classed('fade-elements-in', isExpanding) + this.bubbleNodeContainer.d3Element.classed('fade-elements-out', !isExpanding) + this.bubbleNodeContainer.d3Element.classed('complete-fade-out', false) + this.bubbleNodeContainer.d3Element.classed('complete-fade-in', false) - const parentContainer = this.ui.parentUI.svgNodeDiagram.svgContainer + const parentContainer = this.ui.parentUI.svgNodeDiagram.bubbleNodeContainer parentContainer.d3Element.classed('fade-elements-in', false) parentContainer.d3Element.classed('fade-elements-out', false) parentContainer.d3Element.classed('complete-fade-out', isExpanding) @@ -90,13 +140,17 @@ class SvgNodeDiagram { this.bbox = this.d3Container.node().getBoundingClientRect() - const svgNodeAnimations = [] - this.svgNodes.forEach(svgNode => svgNode.animate(svgNodeAnimations, isExpanding)) + if (this.canvasCtx) { + onComplete() + } else { + const svgNodeAnimations = [] + this.svgNodes.forEach(svgNode => svgNode.animate(svgNodeAnimations, isExpanding)) - Promise.all(svgNodeAnimations).then(() => { - this.ui.isAnimating = false - if (onComplete) onComplete() - }) + Promise.all(svgNodeAnimations).then(() => { + this.ui.isAnimating = false + if (onComplete) onComplete() + }) + } } deselectAll () { @@ -105,6 +159,7 @@ class SvgNodeDiagram { draw () { if (this.ui.isAnimating) return + if (this.canvasCtx) this.shadowCanvas.clear() this.bbox = this.d3Container.node().getBoundingClientRect() this.svgNodes.forEach(svgNode => svgNode.draw()) @@ -145,4 +200,4 @@ class SvgNodeDiagram { } } -module.exports = SvgNodeDiagram +module.exports = BubbleNodeDiagram diff --git a/visualizer/draw/svg-node-section.js b/visualizer/draw/bubble-node-section.js similarity index 86% rename from visualizer/draw/svg-node-section.js rename to visualizer/draw/bubble-node-section.js index 2f934d4b..1bb53128 100644 --- a/visualizer/draw/svg-node-section.js +++ b/visualizer/draw/bubble-node-section.js @@ -3,12 +3,14 @@ const d3 = require('./d3-subset.js') const LineCoordinates = require('../layout/line-coordinates.js') const { validateNumber } = require('../validation.js') +const canvasStyles = require('./util/canvasStyles.js') -class SvgNodeSection { +class BubbleNodeSection { constructor (parentContent, settings) { this.parentContent = parentContent this.ui = parentContent.ui this.layoutNode = parentContent.layoutNode + this.canvasCtx = parentContent.canvasCtx const { dataPosition, @@ -24,24 +26,23 @@ class SvgNodeSection { initializeFromData () { this.d3NodeGroup = this.parentContent.d3NodeGroup - - if (this.shapeClass === 'SvgBubble') { + if (this.shapeClass === 'BubbleNodeBubble') { this.d3InnerCircle = this.d3NodeGroup.append('circle') .classed('inner-circle', true) } - const svgNodeElementClasses = { - SvgLine, - SvgBubble + const bubbleNodeElementClasses = { + BubbleNodeLine, + BubbleNodeBubble } - const SvgNodeElementClass = svgNodeElementClasses[this.shapeClass] + const BubbleNodeElementClass = bubbleNodeElementClasses[this.shapeClass] - this.byParty = new SvgNodeElementClass(this, this.d3NodeGroup, 'party') + this.byParty = new BubbleNodeElementClass(this, this.d3NodeGroup, 'party') .setData(this.layoutNode) .initializeFromData() - this.byType = new SvgNodeElementClass(this, this.d3NodeGroup, 'typeCategory') + this.byType = new BubbleNodeElementClass(this, this.d3NodeGroup, 'typeCategory') .setData(this.layoutNode) .initializeFromData() } @@ -57,21 +58,33 @@ class SvgNodeSection { this.byParty.draw() this.byType.draw() - + const { colours } = canvasStyles() if (this.d3InnerCircle) { + const r = Math.max(this.parentContent.getRadius() - this.ui.settings.lineWidth * 4, 0) + const x = this.parentContent.circleCentre.x + const y = this.parentContent.circleCentre.y + this.d3InnerCircle - .attr('cx', this.parentContent.circleCentre.x) - .attr('cy', this.parentContent.circleCentre.y) - .attr('r', Math.max(this.parentContent.getRadius() - this.ui.settings.lineWidth * 4, 0)) + .attr('cx', x) + .attr('cy', y) + .attr('r', r) + + if (this.canvasCtx) { + this.canvasCtx.beginPath() + this.canvasCtx.fillStyle = colours['inner-circle'] + this.canvasCtx.arc(x, y, r, 0, 2 * Math.PI) + this.canvasCtx.fill() + } } } } -class SvgNodeElement { +class BubbleNodeElement { constructor (parentContent, d3Group, dataType) { this.svgNode = parentContent.parentContent this.d3Group = d3Group this.ui = parentContent.ui + this.canvasCtx = parentContent.canvasCtx this.dataPosition = parentContent.dataPosition this.dataType = dataType @@ -112,7 +125,7 @@ class SvgNodeElement { } // Check if this node should animate to/from the line (was from 'between data' in parent) or the arc (was within & big enough to be visible) - if (this.constructor.name === 'SvgBubble') { + if (this.constructor.name === 'BubbleNodeBubble') { elementProperties.nodeWasBetweenInParent = false } else { switch (dataNode.constructor.name) { @@ -171,8 +184,8 @@ class SvgNodeElement { const degrees = contractedSvgNode.degrees - const segmentDecimal = this.constructor.name === 'SvgBubble' ? segmentDatum.data[1] : segmentDatum[1] - const nodeTime = this.svgNode.layoutNode.node[(this.constructor.name === 'SvgBubble' ? 'getWithinTime' : 'getBetweenTime')]() + const segmentDecimal = this.constructor.name === 'BubbleNodeBubble' ? segmentDatum.data[1] : segmentDatum[1] + const nodeTime = this.svgNode.layoutNode.node[(this.constructor.name === 'BubbleNodeBubble' ? 'getWithinTime' : 'getBetweenTime')]() const parentTime = nodeWasBetweenInParent ? parentBetweenTime : parentWithinTime const segmentDecimalOfParentTime = parentTime ? (nodeTime / parentTime) * segmentDecimal : 1 @@ -187,7 +200,7 @@ class SvgNodeElement { } let expandedPath - if (this.constructor.name === 'SvgBubble') { + if (this.constructor.name === 'BubbleNodeBubble') { const arcObject = unpackArcString(adjustArcPath(this.arcMaker(segmentDatum), this)) removeA2FromPath(arcObject, this) expandedPath = repackArcString(arcObject) @@ -206,7 +219,7 @@ class SvgNodeElement { resolveSegment(segmentDatum) }) - if (!nodeWasBetweenInParent && this.constructor.name === 'SvgLine') { + if (!nodeWasBetweenInParent && this.constructor.name === 'BubbleNodeLine') { const endArc = unpackArcString(endPath) if (endArc) { d3Transition.attrTween('d', tweenArcToLine(endArc, parentBubble.arcMaker, this.ui.settings.animationEasing, isExpanding)) @@ -214,7 +227,7 @@ class SvgNodeElement { } } - if (this.constructor.name === 'SvgBubble') { + if (this.constructor.name === 'BubbleNodeBubble') { d3Transition.attrTween('d', tweenArcToArc(arcDatum, parentBubble, this, this.ui.settings.animationEasing, isExpanding)) return } @@ -225,7 +238,7 @@ class SvgNodeElement { } } -class SvgLine extends SvgNodeElement { +class BubbleNodeLine extends BubbleNodeElement { initializeFromData () { const d3Enter = this.d3Group.selectAll('path.segmented-line') .data(this.decimalsArray) @@ -236,6 +249,7 @@ class SvgLine extends SvgNodeElement { this.d3Shapes = d3Enter.append('path') .attr('class', decimal => `line-segment ${classPrepend}-${decimal[0]}`) + .attr('styleId', decimal => `${classPrepend}-${decimal[0]}`) .style('stroke-width', this.ui.settings.lineWidth + (this.dataType === 'typeCategory' ? 1.5 : -0.5)) .on('mouseover', decimal => this.ui.emit(highlightEvent, decimal[0])) .on('mouseout', () => this.ui.emit(highlightEvent, null)) @@ -282,12 +296,24 @@ class SvgLine extends SvgNodeElement { const segmentPath = getLineUpdatingOrigin(currentOrigin, this.degrees, this.length * segmentDatum[1]) + if (this.canvasCtx) { + const { colours, lineWidths, strokeDash } = canvasStyles() + + const styleId = d3LineSegment.attr('styleId') + this.canvasCtx.beginPath() + this.canvasCtx.strokeStyle = colours[styleId] + this.canvasCtx.lineWidth = lineWidths[styleId] + this.canvasCtx.setLineDash(strokeDash[styleId]) + const canvaspath = new window.Path2D(segmentPath) + this.canvasCtx.stroke(canvaspath) + } + d3LineSegment.attr('d', segmentPath) }) } } -class SvgBubble extends SvgNodeElement { +class BubbleNodeBubble extends BubbleNodeElement { setData (layoutNode) { super.setData(layoutNode) this.arcData = d3.pie().value((arcDatum) => arcDatum[1])(this.decimalsArray) @@ -304,6 +330,7 @@ class SvgBubble extends SvgNodeElement { this.d3Shapes = d3Enter.append('path') .attr('class', arcDatum => `line-segment ${classPrepend}-${arcDatum.data[0]}`) + .attr('styleId', arcDatum => `${classPrepend}-${arcDatum.data[0]}`) .style('stroke-width', this.ui.settings.lineWidth + (this.dataType === 'typeCategory' ? 1.5 : -0.5)) .on('mouseover', arcDatum => this.ui.emit(highlightEvent, arcDatum.data[0])) .on('mouseout', () => this.ui.emit(highlightEvent, null)) @@ -326,6 +353,25 @@ class SvgBubble extends SvgNodeElement { const adjustedArc = adjustArcPath(initialArc, this) return adjustedArc }) + + this.d3Shapes.attr('d', (arcDatum, i, nodes) => { + const initialArc = this.arcMaker(arcDatum) + const adjustedArc = adjustArcPath(initialArc, this) + + if (this.canvasCtx) { + const { colours, lineWidths, strokeDash } = canvasStyles() + + const node = d3.select(nodes[i]) + const styleId = node.attr('styleId') + this.canvasCtx.beginPath() + this.canvasCtx.strokeStyle = colours[styleId] + this.canvasCtx.lineWidth = lineWidths[styleId] + this.canvasCtx.setLineDash(strokeDash[styleId]) + const canvaspath = new window.Path2D(adjustedArc) + this.canvasCtx.stroke(canvaspath) + } + return adjustedArc + }) } } @@ -588,4 +634,4 @@ function getEllipseAngle (degrees) { return LineCoordinates.degreesToRadians(degrees + 90) } -module.exports = SvgNodeSection +module.exports = BubbleNodeSection diff --git a/visualizer/draw/svg-node.js b/visualizer/draw/bubble-node.js similarity index 70% rename from visualizer/draw/svg-node.js rename to visualizer/draw/bubble-node.js index 91a35af2..dfcf53c3 100644 --- a/visualizer/draw/svg-node.js +++ b/visualizer/draw/bubble-node.js @@ -1,25 +1,28 @@ 'use strict' const LineCoordinates = require('../layout/line-coordinates.js') -const SvgNodeSection = require('./svg-node-section.js') +const BubbleNodeSection = require('./bubble-node-section.js') +const canvasStyles = require('./util/canvasStyles.js') -class SvgNode { +class BubbleNode { constructor (parentContent) { this.parentContent = parentContent this.ui = parentContent.ui + this.canvasCtx = parentContent.canvasCtx + this.shadowCanvas = parentContent.shadowCanvas // Set and updated in .setCoordinates(): this.strokePadding = null this.degrees = null this.originPoint = null - this.asyncBetweenLines = new SvgNodeSection(this, { + this.asyncBetweenLines = new BubbleNodeSection(this, { dataPosition: 'between', - shapeClass: 'SvgLine' + shapeClass: 'BubbleNodeLine' }) - this.syncBubbles = new SvgNodeSection(this, { + this.syncBubbles = new BubbleNodeSection(this, { dataPosition: 'within', - shapeClass: 'SvgBubble' + shapeClass: 'BubbleNodeBubble' }) } @@ -93,11 +96,13 @@ class SvgNode { .classed(partyClass, true) .classed('text-label', true) .classed('name-label', true) + .attr('styleId', partyClass) this.d3TimeLabel = this.d3NodeGroup.append('text') .classed(partyClass, true) .classed('text-label', true) .classed('time-label', true) + .attr('styleId', partyClass) this.setCoordinates() return this @@ -120,15 +125,22 @@ class SvgNode { } draw () { + if (this.canvasCtx) { + this.shadowCanvas.ctx.beginPath() + this.shadowCanvas.ctx.fillStyle = this.shadowCanvas.addDataItem(this.layoutNode) + // thicker white border on shadow shapes avoids some false positives with antialiasing and edges - is there a better way? + this.shadowCanvas.ctx.strokeStyle = '#ffffff' + this.shadowCanvas.ctx.lineWidth = 2 + } + if (this.layoutNode.node.constructor.name === 'ShortcutNode') { this.drawShortcut() } else { this.drawOuterPath() - this.drawNameLabel() - this.drawTimeLabel() - this.asyncBetweenLines.draw() this.syncBubbles.draw() + this.drawNameLabel() + this.drawTimeLabel() } } @@ -214,7 +226,20 @@ class SvgNode { }) outerPath += `L ${toArrowheadLeftBase.x2} ${toArrowheadLeftBase.y2} Z` - this.d3OuterPath.attr('d', outerPath) + if (this.canvasCtx) { + const { colours } = canvasStyles() + this.canvasCtx.beginPath() + // this.canvasCtx.strokeStyle = colours['outer-path-stroke'] + // this.canvasCtx.lineWidth = lineWidths['outer-path'] + this.canvasCtx.fillStyle = colours['shortcut'] + const canvaspath = new window.Path2D(outerPath) + // this.canvasCtx.stroke(canvaspath) + this.canvasCtx.fill(canvaspath) + this.shadowCanvas.ctx.fill(canvaspath) + this.shadowCanvas.ctx.stroke(canvaspath) + } else { + this.d3OuterPath.attr('d', outerPath) + } const toArrowMidpoint = new LineCoordinates({ x1: start.x, @@ -226,14 +251,60 @@ class SvgNode { this.d3TimeLabel.classed('hidden', true) this.d3NameLabel.text(formatNameLabel(this.layoutNode.node.name)) .classed(`party-${this.layoutNode.node.mark.get('party')}`, true) + .attr('styleId', `party-${this.layoutNode.node.mark.get('party')}`) .classed('on-line-label', true) - trimText(this.d3NameLabel, length - this.strokePadding) + trimText(this.d3NameLabel, length - this.strokePadding, this.canvasCtx) const transformString = `translate(${toArrowMidpoint.x2}, ${toArrowMidpoint.y2}) rotate(${this.labelDegrees})` this.d3NameLabel.attr('transform', transformString) if (!window.CSS.supports('dominant-baseline', 'middle')) { this.d3NameLabel.attr('dy', 3) } + + if (this.canvasCtx) { + const canvasTransforms = {} + canvasTransforms.translate = { x: toArrowMidpoint.x2, y: toArrowMidpoint.y2 } + canvasTransforms.rotate = this.labelDegrees + canvasTransforms.font = 'normal 9pt sans-serif' + this.drawCanvasNameLabel(this.d3NameLabel.text(), canvasTransforms) + } + } + + drawCanvasNameLabel (nameLabel, canvasTransforms) { + if (this.canvasCtx && !this.d3NameLabel.classed('hidden')) { + const { colours } = canvasStyles() + this.canvasCtx.beginPath() + this.canvasCtx.fillStyle = colours[this.d3NameLabel.attr('styleId')] + + this.canvasCtx.translate(canvasTransforms.translate.x, canvasTransforms.translate.y) + this.canvasCtx.rotate(canvasTransforms.rotate * Math.PI / 180) + this.shadowCanvas.ctx.translate(canvasTransforms.translate.x, canvasTransforms.translate.y) + this.shadowCanvas.ctx.rotate(canvasTransforms.rotate * Math.PI / 180) + + this.canvasCtx.textAlign = canvasTransforms.align || 'center' + this.canvasCtx.font = canvasTransforms.font || 'normal 9pt sans-serif' + + const labelLength = this.canvasCtx.measureText(nameLabel).width + const labelHeight = 12 + + const startX = canvasTransforms.align === 'start' + ? 0 + : canvasTransforms.align === 'end' + ? labelLength + : labelLength / 2 + + this.canvasCtx.fillText(nameLabel, 0, 0) + this.canvasCtx.setTransform(1, 0, 0, 1, 0, 0) + + // don't draw internal borders on labels + if (this.drawType !== 'labelOnLine' && this.drawType !== 'labelInCircle') { + this.shadowCanvas.ctx.rect(-startX, (-labelHeight / 2), labelLength, labelHeight) + this.shadowCanvas.ctx.fill() + this.shadowCanvas.ctx.stroke() + } + + this.shadowCanvas.ctx.setTransform(1, 0, 0, 1, 0, 0) + } } drawNameLabel () { @@ -257,12 +328,13 @@ class SvgNode { const spaceInCircle = Math.max(this.getRadius() * 2 - this.strokePadding - this.lineWidth, 0) const spaceOnLine = Math.max(this.getLength() - this.strokePadding, 0) + const canvasTransforms = {} + if (!this.layoutNode.children.length) { // Is a leaf / endpoint - can position at end of line, continuing line - // First see if the label fits fine on the line or in the circle - if (this.drawType === 'labelOnLine') textAfterTrim = trimText(this.d3NameLabel, spaceOnLine) - if (this.drawType === 'labelInCircle') textAfterTrim = trimText(this.d3NameLabel, spaceInCircle) + if (this.drawType === 'labelOnLine') textAfterTrim = trimText(this.d3NameLabel, spaceOnLine, this.canvasCtx) + if (this.drawType === 'labelInCircle') textAfterTrim = trimText(this.d3NameLabel, spaceInCircle, this.canvasCtx) if (textAfterTrim !== labelPlusTime) { // See if putting the label after the line truncates it less @@ -284,7 +356,9 @@ class SvgNode { const lengthToBottom = this.parentContent.getVerticalLength(x2, y2, this.degrees) const lengthToEdge = Math.min(lengthToSide, lengthToBottom) - const textAfterTrimToEdge = trimText(this.d3NameLabel, lengthToEdge) + // console.log({lengthToSide, lengthToBottom}) + + const textAfterTrimToEdge = trimText(this.d3NameLabel, lengthToEdge, this.canvasCtx) if (textAfterTrimToEdge.length > textAfterTrim.length) { this.d3NameLabel.classed('upper-label', false) @@ -298,13 +372,22 @@ class SvgNode { const transformString = `translate(${x2}, ${y2}) rotate(${this.labelDegrees})` this.d3NameLabel.attr('transform', transformString) + canvasTransforms.translate = { x: x2, y: y2 } + canvasTransforms.rotate = this.labelDegrees + canvasTransforms.font = 'normal 9pt sans-serif' + canvasTransforms.align = this.labelDegrees < 0 ? 'end' : 'start' this.d3TimeLabel.classed('hidden', true) // Tell the rest of the drawing logic that the expected on-line/in-cirlce label has been moved if (this.drawType === 'labelOnLine' || this.drawType === 'labelInCircle') this.drawType = 'labelAfterLine' + this.drawCanvasNameLabel(labelPlusTime, canvasTransforms) return } else { this.d3NameLabel.text(textAfterTrim) + canvasTransforms.translate = { x: x2, y: y2 } + canvasTransforms.rotate = this.labelDegrees + canvasTransforms.font = 'normal 9pt sans-serif' + this.drawCanvasNameLabel(textAfterTrim, canvasTransforms) } this.d3NameLabel.classed('hidden', !textAfterTrim) @@ -317,7 +400,7 @@ class SvgNode { // If not returned yet, has space and is not a leaf/endpoint, so position on line or circle if (this.drawType === 'labelOnLine') { - if (!textAfterTrim) textAfterTrim = trimText(this.d3NameLabel, spaceOnLine) + if (!textAfterTrim) textAfterTrim = trimText(this.d3NameLabel, spaceOnLine, this.canvasCtx) if (!textAfterTrim) { this.drawType = 'noNameLabel' @@ -335,7 +418,11 @@ class SvgNode { const transformString = `translate(${toMidwayPoint.x2}, ${toMidwayPoint.y2}) rotate(${this.labelDegrees})` this.d3NameLabel.attr('transform', transformString) + canvasTransforms.translate = { x: toMidwayPoint.x2, y: toMidwayPoint.y2 } + canvasTransforms.rotate = this.labelDegrees + this.d3NameLabel.classed('on-line-label', true) + this.drawCanvasNameLabel(textAfterTrim, canvasTransforms) return } @@ -344,7 +431,7 @@ class SvgNode { this.d3TimeLabel.classed('hidden', false) this.d3NameLabel.text(nameLabel) - textAfterTrim = trimText(this.d3NameLabel, spaceInCircle) + textAfterTrim = trimText(this.d3NameLabel, spaceInCircle, this.canvasCtx) if (!textAfterTrim) { this.drawType = 'noNameLabel' @@ -355,8 +442,12 @@ class SvgNode { this.d3NameLabel.attr('transform', `translate(${this.circleCentre.x}, ${this.circleCentre.y}) rotate(${labelRotation(this.degrees - 90)})`) + canvasTransforms.translate = { x: this.circleCentre.x, y: this.circleCentre.y } + canvasTransforms.rotate = labelRotation(this.degrees - 90) + this.d3NameLabel.classed('in-circle-label', true) } + this.drawCanvasNameLabel(textAfterTrim, canvasTransforms) } drawTimeLabel () { @@ -369,7 +460,7 @@ class SvgNode { this.d3TimeLabel.text(formatTimeLabel(this.layoutNode.node.stats.overall)) // Position in circle - const textAfterTrim = trimText(this.d3TimeLabel, this.getRadius() * 1.5 - this.strokePadding) + const textAfterTrim = trimText(this.d3TimeLabel, this.getRadius() * 1.5 - this.strokePadding, this.canvasCtx) this.d3TimeLabel.classed('hidden', !textAfterTrim) this.d3TimeLabel.attr('transform', `translate(${this.circleCentre.x}, ${this.circleCentre.y}) rotate(${labelRotation(this.degrees - 90)})`) @@ -379,6 +470,17 @@ class SvgNode { if (!window.CSS.supports('dominant-baseline', 'text-before-edge')) { this.d3TimeLabel.attr('dy', 11) } + if (this.canvasCtx && !this.d3TimeLabel.classed('hidden')) { + const { colours } = canvasStyles() + this.canvasCtx.beginPath() + this.canvasCtx.fillStyle = colours[this.d3NameLabel.attr('styleId')] + this.canvasCtx.translate(this.circleCentre.x, this.circleCentre.y) + this.canvasCtx.rotate(labelRotation(this.degrees - 90) * Math.PI / 180) + this.canvasCtx.textAlign = 'center' + this.canvasCtx.font = 'normal 9pt sans-serif' + this.canvasCtx.fillText(formatTimeLabel(this.layoutNode.node.stats.overall), 0, 11) + this.canvasCtx.setTransform(1, 0, 0, 1, 0, 0) + } } drawOuterPath () { @@ -427,7 +529,30 @@ class SvgNode { // Arc definition: A radiusX radiusY x-axis-rotation large-arc-flag sweep-flag x y outerPath += `A ${arcRadius} ${arcRadius} 0 1 0 ${toLineBottomLeft.x2} ${toLineBottomLeft.y2} Z` - this.d3OuterPath.attr('d', outerPath) + const { lineWidths, colours } = canvasStyles() + + if (this.canvasCtx) { + this.canvasCtx.beginPath() + this.canvasCtx.strokeStyle = colours['outer-path-stroke'] + this.canvasCtx.lineWidth = lineWidths['outer-path'] + this.canvasCtx.fillStyle = colours['outer-path'] + const canvaspath = new window.Path2D(outerPath) + this.canvasCtx.stroke(canvaspath) + this.canvasCtx.fill(canvaspath) + + this.shadowCanvas.ctx.fill(canvaspath) + this.shadowCanvas.ctx.stroke(canvaspath) + } else { + this.d3OuterPath.attr('d', outerPath) + } + } + + clearCanvas () { + if (!this.canvasCtx) { + return + } + this.canvasCtx.clearRect(0, 0, this.parentContent.bubbleNodeContainer.d3Element.attr('width'), this.parentContent.bubbleNodeContainer.d3Element.attr('height')) + this.shadowCanvas.clear() } getRadius (layoutNode = this.layoutNode) { @@ -461,23 +586,30 @@ function labelRotation (degrees) { return degrees } -function trimText (d3Text, maxLength, reps = 0) { +function trimText (d3Text, maxLength, canvasCtx, reps = 0) { d3Text.classed('hidden', false) - const width = d3Text.node().getBBox().width + // const width = d3Text.node().getBBox().width + // getBBox is SVG specific - and accounts for transforms - but breaks things when text is not SVG + let width = 0 + if (canvasCtx) { + width = canvasCtx.measureText(d3Text.text()).width + } else { + width = d3Text.node().getBBox().width + } const textString = d3Text.text() - - if (width > maxLength) { - const decimal = maxLength / width + // console.log({textString, maxLength, width}) + const absMaxLength = Math.abs(maxLength) + if (width > absMaxLength) { + const decimal = absMaxLength / width const trimToLength = Math.floor(textString.length * decimal) - 2 - if (trimToLength > 1 && reps < 5) { reps++ // Limit recursion in case unusual characters e.g. diacritics cause infinite loop const ellipsisChar = '…' const newText = textString.slice(0, trimToLength) + ellipsisChar d3Text.text(newText) // Check new text fits - won't if early chars are wider than later chars, e.g. 'Mmmmmm!!!!!!' - return (trimText(d3Text, maxLength, reps)) + return (trimText(d3Text, maxLength, canvasCtx, reps)) } d3Text.text('') return '' @@ -506,4 +638,4 @@ function formatNameLabel (string) { return string } -module.exports = SvgNode +module.exports = BubbleNode diff --git a/visualizer/draw/bubbleprof-ui.js b/visualizer/draw/bubbleprof-ui.js index ad05d1de..ffb17060 100644 --- a/visualizer/draw/bubbleprof-ui.js +++ b/visualizer/draw/bubbleprof-ui.js @@ -26,6 +26,8 @@ class BubbleprofUI extends EventEmitter { this.isAnimating = false + this.renderInCanvas = false + this.settings = Object.assign({}, defaultSettings, settings) this.mainContainer = {} this.layoutNode = null // Is assigned if this is a sublayout with .layoutNode @@ -42,6 +44,8 @@ class BubbleprofUI extends EventEmitter { this.name = this.parentUI && this.parentUI.selectedDataNode.name this.name = this.name || 'Main view' + this.currentTheme = 'default' + // Main divisions of the page this.sections = new Map() @@ -152,8 +156,9 @@ class BubbleprofUI extends EventEmitter { sublayoutHtml.addCollapseControl() const closeBtn = sublayoutHtml.addContent(undefined, { classNames: 'close-btn' }) - const sublayoutSvg = sublayoutHtml.addContent('SvgContainer', { id: 'sublayout-svg', svgBounds: {} }) - sublayoutHtml.addContent('HoverBox', { svg: sublayoutSvg }) + const subLayoutSettings = { htmlElementType: this.renderInCanvas ? 'canvas' : 'svg', id: 'sublayout-svg', svgBounds: {} } + const subLayout = sublayoutHtml.addContent('BubbleNodeContainer', subLayoutSettings) + sublayoutHtml.addContent('HoverBox', { svg: subLayout }) uiWithinSublayout.initializeElements() @@ -632,6 +637,16 @@ class BubbleprofUI extends EventEmitter { }) } + addNodeContainer (renderInCanvas = false) { + this.renderInCanvas = renderInCanvas + const nodeLink = this.sections.get('node-link') + const settings = { htmlElementType: renderInCanvas ? 'canvas' : 'svg', id: 'node-link-svg', svgBounds: {} } + const nodeLinkContainer = nodeLink.addContent('BubbleNodeContainer', settings) + nodeLinkContainer.initializeElements() + const hoverBox = nodeLink.addContent('HoverBox', { svg: nodeLinkContainer }) + hoverBox.initializeElements() + } + // Clear sublayouts until the topmost layout is `targetUI`, or the original UI if no target is given. // Pass `{ silent: true }` to avoid updating the hash when this is called in response to a history update. traverseUp (targetUI = null, options = {}) { @@ -719,6 +734,11 @@ class BubbleprofUI extends EventEmitter { newLayout.generate() this.setData(newLayout) } + + themeChanged (themeName) { + this.currentTheme = themeName + this.emit('themeChanged') + } } class AnimationQueue extends EventEmitter { diff --git a/visualizer/draw/html-content-types.js b/visualizer/draw/html-content-types.js index f17584d8..bb79b9c4 100644 --- a/visualizer/draw/html-content-types.js +++ b/visualizer/draw/html-content-types.js @@ -13,6 +13,6 @@ module.exports = { InteractiveKey: require('./interactive-key.js'), AreaChart: require('./area-chart.js'), Lookup: require('./lookup.js'), - SvgContainer: require('./svg-container.js'), + BubbleNodeContainer: require('./bubble-node-container.js'), SideBarDrag: require('./side-bar-drag.js') } diff --git a/visualizer/draw/index.js b/visualizer/draw/index.js index 40daa1a8..efbfbb72 100644 --- a/visualizer/draw/index.js +++ b/visualizer/draw/index.js @@ -38,6 +38,7 @@ function drawOuterUI () { const d3Html = d3.select('html') // Toggle light theme d3Html.classed('light-theme', !d3Html.classed('light-theme')) + ui.themeChanged(!d3Html.classed('light-theme') ? 'light' : 'default') } } }) @@ -161,12 +162,7 @@ function drawOuterUI () { ` }) - // Main panel - nodelink diagram - const nodeLink = ui.sections.get('node-link') - - const nodeLinkSVG = nodeLink.addContent('SvgContainer', { id: 'node-link-svg', svgBounds: {} }) - - nodeLink.addContent('HoverBox', { svg: nodeLinkSVG }) + // removed nodeLink init here, it's now done later in the process from main.js so we can choose to render canvas or SVG // Sidebar const sideBar = ui.sections.get('side-bar') diff --git a/visualizer/draw/util/canvasStyles.js b/visualizer/draw/util/canvasStyles.js new file mode 100644 index 00000000..5b5da2d0 --- /dev/null +++ b/visualizer/draw/util/canvasStyles.js @@ -0,0 +1,55 @@ +const getCssVarValue = require('./getCssVarValue') + +module.exports = function canvasStyles (theme = 'default') { + const cssVarValues = getCssVarValue(theme) + const colours = { + 'party-user': cssVarValues('--party-colour-1'), + 'party-external': cssVarValues('--party-colour-2'), + 'party-nodecore': cssVarValues('--party-colour-3'), + 'party-root': cssVarValues('--party-colour-3'), + 'type-files-streams': cssVarValues('--type-colour-1'), + 'type-networks': cssVarValues('--type-colour-2'), + 'type-crypto': cssVarValues('--type-colour-3'), + 'type-timing-promises': cssVarValues('--type-colour-4'), + 'type-other': cssVarValues('--type-colour-5'), + 'outer-path': cssVarValues('--node-background'), + 'outer-path-stroke': cssVarValues('--shortcut-stroke'), + 'selected-node': cssVarValues('--highlight-bg-color'), + 'inner-circle': cssVarValues('--main-bg-color'), + 'shortcut': cssVarValues('--translucent-reverse') + } + + const solid = [] + const dashed = [1.3, 0.7] + + const strokeDash = { + 'party-user': solid, + 'party-external': solid, + 'party-nodecore': dashed, + 'party-root': dashed, + 'type-files-streams': dashed, + 'type-networks': solid, + 'type-crypto': dashed, + 'type-timing-promises': solid, + 'type-other': dashed + } + + const lineWidths = { + 'party-user': 1.5, + 'party-external': 1.5, + 'party-nodecore': 1.5, + 'party-root': 1.5, + 'type-files-streams': 3.5, + 'type-networks': 3.5, + 'type-crypto': 3.5, + 'type-timing-promises': 3.5, + 'type-other': 3.5, + 'outer-path': 0.5 + } + + return { + colours, + strokeDash, + lineWidths + } +} diff --git a/visualizer/draw/util/getCssVarValue.js b/visualizer/draw/util/getCssVarValue.js new file mode 100644 index 00000000..e8c19ab0 --- /dev/null +++ b/visualizer/draw/util/getCssVarValue.js @@ -0,0 +1,14 @@ +/** +* Get the value of a CSS variable +* @param {String} varName +*/ + +module.exports = function (theme = 'default') { + const cssVarValues = { [theme]: {} } + return function getCSSVarValue (varName) { + if (!cssVarValues[theme][varName]) { + cssVarValues[theme][varName] = window.getComputedStyle(document.body).getPropertyValue(varName) + } + return cssVarValues[theme][varName] + } +} diff --git a/visualizer/draw/util/shadowCanvas.js b/visualizer/draw/util/shadowCanvas.js new file mode 100644 index 00000000..c99ac255 --- /dev/null +++ b/visualizer/draw/util/shadowCanvas.js @@ -0,0 +1,42 @@ +class ShadowCanvas { + constructor (width, height) { + this.canvasElement = document.createElement('canvas') + + this.ctx = this.canvasElement.getContext('2d') + this.colourIndex = 1 + this.dataMap = {} + } + + setDimensions (width, height) { + this.canvasElement.setAttribute('width', width) + this.canvasElement.setAttribute('height', height) + } + + addDataItem (dataItem) { + var ret = [] + if (this.colourIndex < 16777215) { + ret.push(this.colourIndex & 0xff) // R + ret.push((this.colourIndex & 0xff00) >> 8) // G + ret.push((this.colourIndex & 0xff0000) >> 16) // B + this.colourIndex += 1 + } + var col = 'rgb(' + ret.join(',') + ')' + this.dataMap[col] = dataItem + + return col + } + + getData (mouseEvent) { + const { offsetX, offsetY } = mouseEvent + const col = this.ctx.getImageData(offsetX, offsetY, 1, 1).data + return this.dataMap['rgb(' + col[0] + ',' + col[1] + ',' + col[2] + ')'] + } + + clear () { + this.colourIndex = 1 + this.dataMap = {} + this.ctx.clearRect(0, 0, this.canvasElement.getAttribute('width'), this.canvasElement.getAttribute('height')) + } +} + +module.exports = ShadowCanvas diff --git a/visualizer/main.js b/visualizer/main.js index 4077d9c1..4b0e4ac2 100644 --- a/visualizer/main.js +++ b/visualizer/main.js @@ -28,6 +28,10 @@ setTimeout(() => { console.log('layout is exposed on window.layout') } + // render as canvas if there are more than 400 connections - otherwise SVG is ok + const renderAsCanvas = layout.connections.length > 400 + ui.addNodeContainer(renderAsCanvas) + /* istanbul ignore next */ ui.setData(layout, dataSet) diff --git a/visualizer/style.css b/visualizer/style.css index fda051b7..e8fd5030 100644 --- a/visualizer/style.css +++ b/visualizer/style.css @@ -95,6 +95,12 @@ html.light-theme { --small-chevron: var(--down-cyan-chevron-arrow); --icon-color:var(--cyan); + + --type-colour-1: rgb(89, 46, 18); /* Rusty orange */ + --type-colour-2: rgb(0, 173, 130); /* Pale green */ + --type-colour-3: rgb(255, 51, 252); /* Pinkish lilac */ + --type-colour-4: rgb(82, 0, 189); /* Deep violet */ + --type-colour-5: rgb(153, 145, 0); /* Rich yellow */ } /* Main layout */ @@ -381,6 +387,8 @@ svg.bubbleprof .node-group .inner-circle { svg.bubbleprof.fade-elements-in .outer-path, svg.bubbleprof.fade-elements-in .text-label, svg.bubbleprof.fade-elements-in .inner-circle, +canvas.bubbleprof.fade-elements-in, +canvas.bubbleprof.complete-fade-in, svg.bubbleprof.complete-fade-in { animation-duration: var(--transition-duration); animation-fill-mode: forwards; @@ -391,6 +399,8 @@ svg.bubbleprof.complete-fade-in { svg.bubbleprof.fade-elements-out .outer-path, svg.bubbleprof.fade-elements-out .text-label, svg.bubbleprof.fade-elements-out .inner-circle, +canvas.bubbleprof.fade-elements-out, +canvas.bubbleprof.complete-fade-out, svg.bubbleprof.complete-fade-out { animation-duration: var(--transition-duration); animation-fill-mode: forwards; @@ -422,7 +432,7 @@ svg.bubbleprof.complete-fade-out { } } -#sublayout-svg { +#sublayout-svg, #sublayout-canvas { position: absolute; top: 0; }