Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Node outlines #3158

Merged
merged 12 commits into from
Nov 10, 2023
7 changes: 7 additions & 0 deletions documentation/md/style.md
Original file line number Diff line number Diff line change
@@ -223,6 +223,13 @@ 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`, or `dashed`.
* **`outline-color`** : The colour of the node's outline.
* **`outline-opacity`** : The opacity 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.
29 changes: 29 additions & 0 deletions src/collection/dimensions/bounds.js
Original file line number Diff line number Diff line change
@@ -428,6 +428,31 @@ 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;

if (outlineOpacity > 0) {
let outlineWidth = ele.pstyle( 'outline-width' ).pfValue;
let shape = ele.pstyle('shape').strValue;
let size = outlineWidth * 2;

if (shape === "vee" || shape === "triangle") {
size *= 1.5;
}

let ex1 = bounds.x1 - size;
let ex2 = bounds.x2 + size;
let ey1 = bounds.y1 - size;
let ey2 = bounds.y2 + size;

updateBounds(bounds, ex1, ey1, ex2, ey2);
}

return bounds;
};

// get the bounding box of the elements (in raw model position)
let boundingBoxImpl = function( ele, options ){
let cy = ele._private.cy;
@@ -510,6 +535,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 ){
@@ -824,6 +852,7 @@ let defBbOpts = {
includeTargetLabels: true,
includeOverlays: true,
includeUnderlays: true,
includeOutlines: true,
useCache: true
};

78 changes: 72 additions & 6 deletions src/extensions/renderer/canvas/drawing-nodes.js
Original file line number Diff line number Diff line change
@@ -74,6 +74,10 @@ 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;

context.lineJoin = 'miter'; // so borders are square with the node shape

@@ -85,6 +89,10 @@ 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

@@ -114,7 +122,7 @@ CRp.drawNode = function( context, node, shiftToOriginWithBb, drawLabel = true, s
}
}

let drawShape = () => {
let drawPath = () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a huge deal but this function should probably be named differently, especially since the core render methods are prefixed with the draw prefix. Maybe something like ensurePath would be more accurate semantically?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good

if( !pathCacheHit ){

let npos = pos;
@@ -127,12 +135,16 @@ 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 );
}
}

let drawShape = () => {
drawPath();

if( usePaths ){
context.fill( path );
@@ -246,7 +258,57 @@ CRp.drawNode = function( context, node, shiftToOriginWithBb, drawLabel = true, s
if( context.setLineDash ){ // for very outofdate browsers
context.setLineDash( [ ] );
}
}
};

let drawOutline = () => {
if( outlineWidth > 0 ){
// because outline and border are drawn along the same path,
// draw outline at border width plus twice the outline width
context.lineWidth = borderWidth + outlineWidth * 2;
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':
context.setLineDash( [ ] );
break;
}
}

drawPath();

if( usePaths ){
context.stroke( path );
} else {
context.stroke();
}

const gco = context.globalCompositeOperation;
context.globalCompositeOperation = 'destination-out';

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to worry about what happens when an outline renders over another already rendered element here? Wouldn't it clip the outline above that too?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about that too the other day. The GCO is fine for things like edge arrows, since they're so small. However, there are important use cases for having semitransparent nodes -- e.g. with overlap -- and the GCO would visually delete things.

It would be best to use a clip operation here. It can be a bit cheaper if you use the nodePathCache for the clipping rather than having to redraw/regenerate the node body.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've managed to update the rendering to clip instead of use gco, but only by rendering a rect with the node shape cut out, and clipping to that. I might very well be missing something, but clipping to the node shape has the opposite effect of clipping the outer portion of the outline instead of the inside portion.

There are still a bunch of irregular shapes whose outline exceeds the node width/height + outline width though, and I'm not sure how to accurately expand the rect bounds in those cases, and when calculating the overall node bounds in bounds.js if an outline is present. I can apply an arbitrary multiplier, but that feels like a hack which would likely have adverse implications on node layout, etc.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. Here are some initial thoughts:

(1) Two multipliers, one for width and one for height, might suffice for predefined shapes. One possibility is something like bb.w = k_w * outlineWidth + otherMeasuresForWidth.

(2) Assuming we go with (1), how would we address custom polygon shapes? This leads into (3). Alternatively, we could lean on the existing bounds-expansion property. The dev, in that case, must specify their own expansion of the bounds to accommodate the outline on their custom polygon.

(3) The proper way to calculate the adjusted bounding box would be to take the intersection of the relevant lines in the shape, when they're pushed out by the outline width. This would be more expensive, and it would mean that we may as well use a path for the outline, since we'd have the points to form the path anyway.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taking a step back, pretty much all of the complexity that's arisen has come with trying to solve the use case of the background and/or border having a partial opacity, and not wanting any of the outline below to show through.

But we already have that with borders, where a border with partial opacity effectively creates two visual borders due to the fact that the inner half overlaps the node shape:

Screen Shot 2023-09-27 at 11 14 10 AM

In an ideal world, it might be worth considering rendering both borders and outlines such that they never overlapped other elements. In practice though this has proven to be less straightforward than it would seem, whether accomplished by offsetting the stroke or by clipping, and there's a chance that the computational cost would end up outweighing the aesthetics, making it hard to justify the tradeoff.

While adding outlines admittedly moves us closer to that potential decision point, I wonder if we could/should consider simplifying the outline implementation for now by not worrying about clipping the inner portion, and consider that broader rethink around how strokes are rendered (whether border or outline) down the road.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we rebrand the feature?

What you're describing sounds like a under-border rather than an outline. That way, you can still use it as an outline -- with the opacity caveats etc. -- but it would have clear expectations for other devs who use the feature. Nobody would be disappointed about the transparent node use cases, since it's obvious that those use cases aren't suited to an under-border.

The implementation would be identical to the existing borders, apart from the draw order.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maxkfranz that could be a possibility as a fallback, but my preference would be to keep the outline framing if possible. I will give the suggestion you proposed above (calculating the path by pushing out the points by the outline width) a shot.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good


r.eleFillStyle( context, node, 1 );

if( usePaths ){
context.fill( path );
} else {
context.fill();
}

context.globalCompositeOperation = gco;

// reset in case we changed the border style
if( context.setLineDash ){ // for very outofdate browsers
context.setLineDash( [ ] );
}
}
};

@@ -276,6 +338,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 +360,8 @@ CRp.drawNode = function( context, node, shiftToOriginWithBb, drawLabel = true, s
context.translate( pos.x, pos.y );
}

setupOutlineColor();
drawOutline();
setupShapeColor();
drawShape();
drawImages(eleOpacity, true);
4 changes: 2 additions & 2 deletions src/style/apply.js
Original file line number Diff line number Diff line change
@@ -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
]);
16 changes: 15 additions & 1 deletion src/style/properties.js
Original file line number Diff line number Diff line change
@@ -142,7 +142,8 @@ const styfn = {};

return length === 1 || length === 2 || length === 4;
}
}
},
outlineStyle: { enums: [ 'solid', 'dotted', 'dashed' ] },
};

let diff = {
@@ -297,6 +298,13 @@ 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.outlineStyle },
];

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,10 @@ styfn.getDefaultProperties = function(){
'border-opacity': 1,
'border-width': 0,
'border-style': 'solid',
'outline-color': '#999',
'outline-opacity': 1,
'outline-width': 0,
'outline-style': 'solid',
'height': 30,
'width': 30,
'shape': 'ellipse',