diff --git a/documentation/md/style.md b/documentation/md/style.md index 312fc628e0..dea69bfd5f 100644 --- a/documentation/md/style.md +++ b/documentation/md/style.md @@ -223,6 +223,14 @@ Border: * **`border-color`** : The colour of the node's border. * **`border-opacity`** : The opacity of the node's border. + Outline: + + * **`outline-width`** : The size of the node's outline. + * **`outline-style`** : The style of the node's outline; may be `solid`, `dotted`, `dashed`, or `double`. + * **`outline-color`** : The colour of the node's outline. + * **`outline-opacity`** : The opacity of the node's outline. + * **`outline-offset`** : The offset of the node's outline. + Padding: A padding defines an addition to a node's dimension. For example, `padding` adds to a node's outer (i.e. total) width and height. This can be used to add spacing between a compound node parent and its children. diff --git a/src/collection/dimensions/bounds.js b/src/collection/dimensions/bounds.js index 31aae6e0bc..35c82a83b0 100644 --- a/src/collection/dimensions/bounds.js +++ b/src/collection/dimensions/bounds.js @@ -1,5 +1,5 @@ import * as is from '../../is'; -import { assignBoundingBox, expandBoundingBoxSides, clearBoundingBox, expandBoundingBox, makeBoundingBox, copyBoundingBox } from '../../math'; +import { assignBoundingBox, expandBoundingBoxSides, clearBoundingBox, expandBoundingBox, makeBoundingBox, copyBoundingBox, shiftBoundingBox, updateBoundingBox } from '../../math'; import { defaults, getPrefixedProperty, hashIntsArray } from '../../util'; let fn, elesfn; @@ -428,6 +428,52 @@ let updateBoundsFromLabel = function( bounds, ele, prefix ){ return bounds; }; +let updateBoundsFromOutline = function( bounds, ele ){ + if( ele.cy().headless() ){ return; } + + let outlineOpacity = ele.pstyle('outline-opacity').value; + let outlineWidth = ele.pstyle('outline-width').value; + + if (outlineOpacity > 0 && outlineWidth > 0) { + let outlineOffset = ele.pstyle('outline-offset').value; + let nodeShape = ele.pstyle( 'shape' ).value; + + let outlineSize = outlineWidth + outlineOffset; + let scaleX = (bounds.w + outlineSize * 2) / bounds.w; + let scaleY = (bounds.h + outlineSize * 2) / bounds.h; + let xOffset = 0; + let yOffset = 0; + + if (["diamond", "pentagon", "round-triangle"].includes(nodeShape)) { + scaleX = (bounds.w + outlineSize * 2.4) / bounds.w; + yOffset = -outlineSize/3.6; + } else if (["concave-hexagon", "rhomboid", "right-rhomboid"].includes(nodeShape)) { + scaleX = (bounds.w + outlineSize * 2.4) / bounds.w; + } else if (nodeShape === "star") { + scaleX = (bounds.w + outlineSize * 2.8) / bounds.w; + scaleY = (bounds.h + outlineSize * 2.6) / bounds.h; + yOffset = -outlineSize / 3.8; + } else if (nodeShape === "triangle") { + scaleX = (bounds.w + outlineSize * 2.8) / bounds.w; + scaleY = (bounds.h + outlineSize * 2.4) / bounds.h; + yOffset = -outlineSize/1.4; + } else if (nodeShape === "vee") { + scaleX = (bounds.w + outlineSize * 4.4) / bounds.w; + scaleY = (bounds.h + outlineSize * 3.8) / bounds.h; + yOffset = -outlineSize * .5; + } + + let hDelta = (bounds.h * scaleY) - bounds.h; + let wDelta = (bounds.w * scaleX) - bounds.w; + expandBoundingBoxSides(bounds, [Math.ceil(hDelta/2), Math.ceil(wDelta/2)]); + + if (xOffset != 0 || yOffset !== 0) { + let oBounds = shiftBoundingBox(bounds, xOffset, yOffset); + updateBoundingBox(bounds, oBounds); + } + } +}; + // get the bounding box of the elements (in raw model position) let boundingBoxImpl = function( ele, options ){ let cy = ele._private.cy; @@ -510,6 +556,9 @@ let boundingBoxImpl = function( ele, options ){ updateBounds( bounds, ex1, ey1, ex2, ey2 ); + if( styleEnabled && options.includeOutlines ){ + updateBoundsFromOutline( bounds, ele ); + } } else if( isEdge && options.includeEdges ){ if( styleEnabled && !headless ){ @@ -735,6 +784,7 @@ let getKey = function( opts ){ key += tf( opts.includeSourceLabels ); key += tf( opts.includeTargetLabels ); key += tf( opts.includeOverlays ); + key += tf( opts.includeOutlines ); return key; }; @@ -824,6 +874,7 @@ let defBbOpts = { includeTargetLabels: true, includeOverlays: true, includeUnderlays: true, + includeOutlines: true, useCache: true }; diff --git a/src/extensions/renderer/canvas/drawing-nodes.js b/src/extensions/renderer/canvas/drawing-nodes.js index 3717f60881..2f4122d24b 100644 --- a/src/extensions/renderer/canvas/drawing-nodes.js +++ b/src/extensions/renderer/canvas/drawing-nodes.js @@ -1,6 +1,7 @@ /* global Path2D */ import * as is from '../../../is'; +import { expandPolygon, joinLines } from '../../../math'; import * as util from '../../../util'; let CRp = {}; @@ -74,6 +75,11 @@ CRp.drawNode = function( context, node, shiftToOriginWithBb, drawLabel = true, s let borderColor = node.pstyle('border-color').value; let borderStyle = node.pstyle('border-style').value; let borderOpacity = node.pstyle('border-opacity').value * eleOpacity; + let outlineWidth = node.pstyle('outline-width').pfValue; + let outlineColor = node.pstyle('outline-color').value; + let outlineStyle = node.pstyle('outline-style').value; + let outlineOpacity = node.pstyle('outline-opacity').value * eleOpacity; + let outlineOffset = node.pstyle('outline-offset').value; context.lineJoin = 'miter'; // so borders are square with the node shape @@ -85,33 +91,50 @@ CRp.drawNode = function( context, node, shiftToOriginWithBb, drawLabel = true, s r.colorStrokeStyle( context, borderColor[0], borderColor[1], borderColor[2], bdrOpy ); }; + let setupOutlineColor = ( otlnOpy = outlineOpacity ) => { + r.colorStrokeStyle( context, outlineColor[0], outlineColor[1], outlineColor[2], otlnOpy ); + }; + // // setup shape - let styleShape = node.pstyle('shape').strValue; - let shapePts = node.pstyle('shape-polygon-points').pfValue; - - if( usePaths ){ - context.translate( pos.x, pos.y ); - + let getPath = (width, height, shape, points) => { let pathCache = r.nodePathCache = r.nodePathCache || []; let key = util.hashStrings( - styleShape === 'polygon' ? styleShape + ',' + shapePts.join(',') : styleShape, - '' + nodeHeight, - '' + nodeWidth + shape === 'polygon' ? shape + ',' + points.join(',') : shape, + '' + height, + '' + width ); let cachedPath = pathCache[ key ]; + let path; + let cacheHit = false; if( cachedPath != null ){ path = cachedPath; - pathCacheHit = true; + cacheHit = true; rs.pathCache = path; } else { path = new Path2D(); pathCache[ key ] = rs.pathCache = path; } + + return { + path, + cacheHit + }; + }; + + let styleShape = node.pstyle('shape').strValue; + let shapePts = node.pstyle('shape-polygon-points').pfValue; + + if( usePaths ){ + context.translate( pos.x, pos.y ); + + const shapePath = getPath(nodeWidth, nodeHeight, styleShape, shapePts); + path = shapePath.path; + pathCacheHit = shapePath.cacheHit; } let drawShape = () => { @@ -127,11 +150,11 @@ CRp.drawNode = function( context, node, shiftToOriginWithBb, drawLabel = true, s } r.nodeShapes[ r.getNodeShape( node ) ].draw( - ( path || context ), - npos.x, - npos.y, - nodeWidth, - nodeHeight ); + ( path || context ), + npos.x, + npos.y, + nodeWidth, + nodeHeight ); } if( usePaths ){ @@ -246,7 +269,133 @@ CRp.drawNode = function( context, node, shiftToOriginWithBb, drawLabel = true, s if( context.setLineDash ){ // for very outofdate browsers context.setLineDash( [ ] ); } + } + }; + + let drawOutline = () => { + if( outlineWidth > 0 ){ + context.lineWidth = outlineWidth; + context.lineCap = 'butt'; + + if( context.setLineDash ){ // for very outofdate browsers + switch( outlineStyle ){ + case 'dotted': + context.setLineDash( [ 1, 1 ] ); + break; + + case 'dashed': + context.setLineDash( [ 4, 2 ] ); + break; + + case 'solid': + case 'double': + context.setLineDash( [ ] ); + break; + } + } + + let npos = pos; + + if( usePaths ){ + npos = { + x: 0, + y: 0 + }; + } + + let shape = r.getNodeShape( node ); + + let scaleX = (nodeWidth + borderWidth + (outlineWidth + outlineOffset)) / nodeWidth; + let scaleY = (nodeHeight + borderWidth + (outlineWidth + outlineOffset)) / nodeHeight; + let sWidth = nodeWidth * scaleX; + let sHeight = nodeHeight * scaleY; + + let points = r.nodeShapes[ shape ].points; + let path; + + if (usePaths) { + let outlinePath = getPath(sWidth, sHeight, shape, points); + path = outlinePath.path; + } + + // draw the outline path, either by using expanded points or by scaling + // the dimensions, depending on shape + if (shape === "ellipse") { + r.drawEllipsePath(path || context, npos.x, npos.y, sWidth, sHeight); + } else if ([ + 'round-diamond', 'round-heptagon', 'round-hexagon', 'round-octagon', + 'round-pentagon', 'round-polygon', 'round-triangle', 'round-tag' + ].includes(shape)) { + let sMult = 0; + let offsetX = 0; + let offsetY = 0; + if (shape === 'round-diamond') { + sMult = (borderWidth + outlineOffset + outlineWidth) * 1.4; + } else if (shape === 'round-heptagon') { + sMult = (borderWidth + outlineOffset + outlineWidth) * 1.075; + offsetY = -(borderWidth/2 + outlineOffset + outlineWidth) / 35; + } else if (shape === 'round-hexagon') { + sMult = (borderWidth + outlineOffset + outlineWidth) * 1.12; + } else if (shape === 'round-pentagon') { + sMult = (borderWidth + outlineOffset + outlineWidth) * 1.13; + offsetY = -(borderWidth/2 + outlineOffset + outlineWidth) / 15; + } else if (shape === 'round-tag') { + sMult = (borderWidth + outlineOffset + outlineWidth) * 1.12; + offsetX = (borderWidth/2 + outlineWidth + outlineOffset) * .07; + } else if (shape === 'round-triangle') { + sMult = (borderWidth + outlineOffset + outlineWidth) * (Math.PI/2); + offsetY = -(borderWidth + outlineOffset/2 + outlineWidth) / Math.PI; + } + + if (sMult !== 0) { + scaleX = (nodeWidth + sMult)/nodeWidth; + scaleY = (nodeHeight + sMult)/nodeHeight; + } + r.drawRoundPolygonPath(path || context, npos.x + offsetX, npos.y + offsetY, nodeWidth * scaleX, nodeHeight * scaleY, points); + } else if (['roundrectangle', 'round-rectangle'].includes(shape)) { + r.drawRoundRectanglePath(path || context, npos.x, npos.y, sWidth, sHeight); + } else if (['cutrectangle', 'cut-rectangle'].includes(shape)) { + r.drawCutRectanglePath(path || context, npos.x, npos.y, sWidth, sHeight); + } else if (['bottomroundrectangle', 'bottom-round-rectangle'].includes(shape)) { + r.drawBottomRoundRectanglePath(path || context, npos.x, npos.y, sWidth, sHeight); + } else if (shape === "barrel") { + r.drawBarrelPath(path || context, npos.x, npos.y, sWidth, sHeight); + } else if (shape.startsWith("polygon") || ['rhomboid', 'right-rhomboid', 'round-tag', 'tag', 'vee'].includes(shape)) { + let pad = (borderWidth + outlineWidth + outlineOffset) / nodeWidth; + points = joinLines(expandPolygon(points, pad)); + r.drawPolygonPath(path || context, npos.x, npos.y, nodeWidth, nodeHeight, points); + } else { + let pad = (borderWidth + outlineWidth + outlineOffset) / nodeWidth; + points = joinLines(expandPolygon(points, -pad)); + r.drawPolygonPath(path || context, npos.x, npos.y, nodeWidth, nodeHeight, points); + } + + if( usePaths ){ + context.stroke( path ); + } else { + context.stroke(); + } + + if( outlineStyle === 'double' ){ + context.lineWidth = borderWidth / 3; + + let gco = context.globalCompositeOperation; + context.globalCompositeOperation = 'destination-out'; + + if( usePaths ){ + context.stroke( path ); + } else { + context.stroke(); + } + + context.globalCompositeOperation = gco; + } + + // reset in case we changed the border style + if( context.setLineDash ){ // for very outofdate browsers + context.setLineDash( [ ] ); + } } }; @@ -276,6 +425,8 @@ CRp.drawNode = function( context, node, shiftToOriginWithBb, drawLabel = true, s context.translate( gx, gy ); + setupOutlineColor(); + drawOutline(); setupShapeColor( ghostOpacity * bgOpacity ); drawShape(); drawImages( effGhostOpacity, true ); @@ -296,6 +447,8 @@ CRp.drawNode = function( context, node, shiftToOriginWithBb, drawLabel = true, s context.translate( pos.x, pos.y ); } + setupOutlineColor(); + drawOutline(); setupShapeColor(); drawShape(); drawImages(eleOpacity, true); @@ -303,6 +456,7 @@ CRp.drawNode = function( context, node, shiftToOriginWithBb, drawLabel = true, s drawBorder(); drawPie( darkness !== 0 || borderWidth !== 0 ); drawImages(eleOpacity, false); + darken(); if( usePaths ){ diff --git a/src/style/apply.js b/src/style/apply.js index b731f5b4a2..18debf1aba 100644 --- a/src/style/apply.js +++ b/src/style/apply.js @@ -357,9 +357,9 @@ styfn.updateStyleHints = function(ele){ // if( isNode ){ - let { nodeBody, nodeBorder, backgroundImage, compound, pie } = _p.styleKeys; + let { nodeBody, nodeBorder, nodeOutline, backgroundImage, compound, pie } = _p.styleKeys; - let nodeKeys = [ nodeBody, nodeBorder, backgroundImage, compound, pie ].filter(k => k != null).reduce(util.hashArrays, [ + let nodeKeys = [ nodeBody, nodeBorder, nodeOutline, backgroundImage, compound, pie ].filter(k => k != null).reduce(util.hashArrays, [ util.DEFAULT_HASH_SEED, util.DEFAULT_HASH_SEED_ALT ]); diff --git a/src/style/properties.js b/src/style/properties.js index 3804852bfd..93bc945525 100644 --- a/src/style/properties.js +++ b/src/style/properties.js @@ -142,7 +142,7 @@ const styfn = {}; return length === 1 || length === 2 || length === 4; } - } + }, }; let diff = { @@ -297,6 +297,14 @@ const styfn = {}; { name: 'border-style', type: t.borderStyle } ]; + let nodeOutline = [ + { name: 'outline-color', type: t.color }, + { name: 'outline-opacity', type: t.zeroOneNumber }, + { name: 'outline-width', type: t.size, triggersBounds: diff.any }, + { name: 'outline-style', type: t.borderStyle }, + { name: 'outline-offset', type: t.size, triggersBounds: diff.any } + ]; + let backgroundImage = [ { name: 'background-image', type: t.urls }, { name: 'background-image-crossorigin', type: t.bgCrossOrigin }, @@ -421,6 +429,7 @@ const styfn = {}; // node props ...nodeBody, ...nodeBorder, + ...nodeOutline, ...backgroundImage, ...pie, ...compound, @@ -451,6 +460,7 @@ const styfn = {}; // node props nodeBody, nodeBorder, + nodeOutline, backgroundImage, pie, compound, @@ -622,6 +632,11 @@ styfn.getDefaultProperties = function(){ 'border-opacity': 1, 'border-width': 0, 'border-style': 'solid', + 'outline-color': '#999', + 'outline-opacity': 1, + 'outline-width': 0, + 'outline-offset': 0, + 'outline-style': 'solid', 'height': 30, 'width': 30, 'shape': 'ellipse',