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

Enable CSS-like linear gradient fills for flowchart nodes #5913

Open
wants to merge 24 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7a827f9
Add rendering utility for linear gradient style
enourbakhsh Oct 1, 2024
4cd08d4
Extend lexical grammar to parse linear-gradient fill syntax in flowch…
enourbakhsh Oct 1, 2024
799a216
Enable linear-gradient fills for flowchart nodes in rendering logic
enourbakhsh Oct 1, 2024
5b12ba9
Optimize regex for handling gradient style lookups
enourbakhsh Oct 1, 2024
e2fd462
Refactor gradient creator following in-depth testing
enourbakhsh Nov 1, 2024
da4c27d
Add rigorous tests for linear gradient style rendering
enourbakhsh Nov 1, 2024
4cddeb7
Set display to none for temporary element and improve comment re styl…
enourbakhsh Nov 3, 2024
7fc4bb8
Fix handling of out-of-bounds coordinates
enourbakhsh Nov 3, 2024
3c5c881
Expand linear-gradient to work with both style and classDef
enourbakhsh Nov 4, 2024
789dd07
Support solid background layers beneath gradients
enourbakhsh Nov 4, 2024
a6eb35b
Allow custom transition stops; preserve fill sequence; fix operation …
enourbakhsh Nov 13, 2024
17dc351
Fix transition hint error handling
enourbakhsh Nov 13, 2024
74b6c19
Add graceful handling for invalid transition stops
enourbakhsh Nov 13, 2024
c47e9e3
Improve language used for simple fill style
enourbakhsh Nov 13, 2024
853cc1c
Add documentation
enourbakhsh Nov 13, 2024
ac3298a
Import expect from vitest and resolve TypeScript error in gradient un…
enourbakhsh Nov 13, 2024
17919dd
Fix fill style application to pass cypress ci tests; do regex search …
enourbakhsh Nov 13, 2024
842f756
Prevent unwanted multilayer fill by removing duplicate class
enourbakhsh Nov 14, 2024
ab9b38a
Use regex to filter out 'none' from fill styles; improve docs
enourbakhsh Nov 14, 2024
70b4d89
Replace tangent with encasing for correct mathematical terminology in…
enourbakhsh Nov 15, 2024
b7a6cbd
Apply important flag to inline fills to fix priority issues
enourbakhsh Nov 15, 2024
b31c08c
Add changeset
enourbakhsh Nov 16, 2024
098159a
Expand on the changeset summary
enourbakhsh Nov 16, 2024
5f46a0f
Improve documentation and add version placeholder
enourbakhsh Nov 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/small-kings-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'mermaid': minor
---

feat: Add support for multi-layer linear gradient fills in flowcharts

- Implements CSS-like syntax for defining `linear-gradient()` fills in flowchart nodes.
- Ensures dynamic adaptation of gradient lines to various node shapes.
- Introduces a reusable gradient utility for broader diagram support in the future.

pr: 5913
2 changes: 1 addition & 1 deletion cypress/integration/rendering/flowchart-v2.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ flowchart TD
`
flowchart
Node1:::class1 --> Node2:::class2
Node1:::class1 --> Node3:::class2
Node1 --> Node3:::class2
Node3 --> Node4((I am a circle)):::larger

classDef class1 fill:lightblue
Expand Down
410 changes: 409 additions & 1 deletion docs/syntax/flowchart.md

Large diffs are not rendered by default.

137 changes: 134 additions & 3 deletions packages/mermaid/src/diagrams/flowchart/flowRenderer-v3-unified.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { select } from 'd3';
import type { Selection } from 'd3';
import { getConfig } from '../../diagram-api/diagramAPI.js';
import type { DiagramStyleClassDef } from '../../diagram-api/types.js';
import { log } from '../../logger.js';
Expand All @@ -8,6 +9,7 @@ import { setupViewPortForSVG } from '../../rendering-util/setupViewPortForSVG.js
import type { LayoutData } from '../../rendering-util/types.js';
import utils from '../../utils.js';
import { getDirection } from './flowDb.js';
import { createLinearGradient } from '../../rendering-util/createGradient.js';

export const getClasses = function (
text: string,
Expand Down Expand Up @@ -54,6 +56,7 @@ export const draw = async function (text: string, id: string, _version: string,
data4Layout.diagramId = id;
log.debug('REF1:', data4Layout);
await render(data4Layout, svg);
log.debug('SVG structure:', svg.node().outerHTML);
const padding = data4Layout.config.flowchart?.diagramPadding ?? 8;
utils.insertTitle(
svg,
Expand All @@ -62,12 +65,140 @@ export const draw = async function (text: string, id: string, _version: string,
diag.db.getDiagramTitle()
);
setupViewPortForSVG(svg, padding, 'flowchart', conf?.useMaxWidth || false);
log.debug(
'Rendering completed. Starting to process nodes for gradient application and link wrapping...'
);

// If node has a link, wrap it in an anchor SVG object.
// Loop through all nodes
for (const vertex of data4Layout.nodes) {
const node = select(`#${id} [id="${vertex.id}"]`);
log.debug(
`Processing node - ID: "${vertex.id}", domID: "${vertex.domId}", Label: "${vertex.label}"`
);

// Apply gradients to the node's shape if specified in the node's CSS styles
// This has to be done before wrapping the node in an anchor element to avoid selection issues
log.debug(`Attempting to select node using domID with query: #${id} [id="${vertex.domId}"]`);
const nodeSvg = select(`#${id} [id="${vertex.domId}"]`); // selection of the node's SVG element using domId

if (!nodeSvg.empty()) {
log.debug(`Found SVG element for node: ${vertex.domId}`);

// Get the bounding box of the node's shape to extract dimensions
// Assuming shapeElement is a selection of various SVG elements
const shapeElement: Selection<SVGGraphicsElement, unknown, HTMLElement, any> = nodeSvg.select(
'rect, ellipse, circle, polygon, path'
);

if (!shapeElement.empty() && shapeElement.node() !== null) {
log.debug(`Working on node ${vertex.id}->${vertex.domId}`);

// Combine style arrays, defaulting to empty arrays if missing
// Note that `cssCompiledStyles` (from `classDef`) applies first, with `cssStyles` (from `style`)
// rendered on top, allowing layered effects like multiple semi-transparent backgrounds
const styles = [...(vertex.cssStyles || []), ...(vertex.cssCompiledStyles || [])];

// Log all cssCompiledStyles for the node if available
Copy link
Author

Choose a reason for hiding this comment

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

Suggested change
// Log all cssCompiledStyles for the node if available
// Log the combined style array if available

if (styles) {
log.debug(`CSS styles for node ${vertex.id}:`, styles);
} else {
log.debug(`No CSS styles found for node ${vertex.id}.`);
}

// Separate out linear-gradient and simple fill styles in their original order
const allFillStyles = styles.flatMap(
(style) =>
style.match(/fill\s*:\s*(linear-gradient\([^()]*?(?:\([^()]*?\)[^()]*)*\)|[^;]+)/g) ||
[]
);

// Filter out 'none' from fill styles
const effectiveFillStyles = allFillStyles.filter((style) => !/fill\s*:\s*none/.test(style));

// Layer fill styles if there’s more than one given or if any are gradients
if (
effectiveFillStyles.length > 1 ||
effectiveFillStyles.some((style) => style.includes('linear-gradient('))
) {
// Note: the `!important` flag is added directly to all the inline fill styles below to prevent overrides by
// cssImportantStyles in src/mermaidAPI.ts, ensuring classDef-based fill properties don’t block our fills

// Remove any existing or default fill (e.g. from the theme) that might unexpectedly
// bleed through (semi-)transparent areas of the fill layers
shapeElement.style('fill', 'none', 'important');

// Iterate over fill styles in the order they were defined
effectiveFillStyles.forEach((style, index) => {
// Clone the shape element to apply each fill as an overlay
const shapeClone = shapeElement.clone(true);

if (style.includes('linear-gradient(')) {
// It's a gradient style
const linearGradientStyle = style.replace(/fill\s*:\s*linear-gradient\((.+)\)/, '$1');
const gradientId = `gradient-${vertex.id}-${index}`;
log.debug(`Found gradient style ${index + 1} for node ${vertex.id}: "${style}"`);

// Get the user-defined number of transition stops (first match) for non-linear interpolation
const transitionRegex = /num-transition-stops\s*:\s*(\d+)/;
const numTransitionStops = parseInt(
styles.find((s) => transitionRegex.exec(s))?.match(transitionRegex)?.[1] || '5',
10
);

// Create the linear gradient for each occurrence
createLinearGradient(
svg,
shapeElement,
linearGradientStyle,
gradientId,
false,
numTransitionStops
);

// Apply the gradient fill to the cloned shape
shapeClone.style('fill', `url(#${gradientId})`, 'important');
log.debug(
`Applied gradient ID "${gradientId}" to node ${vertex.id} with URL url(#${gradientId})`
);
} else {
// It's a simple fill style
const color = style.replace(/fill\s*:\s*/, '');

// Apply the simple fill to the cloned shape
shapeClone.style('fill', color, 'important');
log.debug(`Applied simple fill color "${color}" to node ${vertex.id}`);
}

// Insert the cloned element before the original shape to keep the text/labels on top
const parentNode = shapeElement.node()?.parentNode;
const cloneNode = shapeClone.node();
if (parentNode && cloneNode) {
const nextSibling = shapeElement.node()?.nextSibling;
if (nextSibling) {
parentNode.insertBefore(cloneNode, nextSibling);
} else {
parentNode.appendChild(cloneNode);
}
} else {
log.error(`Parent or clone node not found for shape element: ${vertex.domId}`);
}
});
} else {
log.debug(
`No layered fill styles found for node ${vertex.id}->${vertex.domId}. Reverting to theme's default fill color.`
);
}
log.debug(`Underlying SVG element for node ${vertex.id}: `, shapeElement.node());
} else {
log.debug(`Could not find a shape element for node: ${vertex.id}->${vertex.domId}`);
}
continue; // Skip to the next iteration if no node was found
}

// If the node selected by ID has a link, wrap it in an anchor SVG object
const node = select(`#${id} [id="${vertex.id}"]`); // vertex.domId works fine as well
log.debug(`Select node using ID with query: #${id} [id="${vertex.id}"]`);
if (!node || !vertex.link) {
continue;
continue; // Skip if the node does not exist or does not have a link property
}
const link = doc.createElementNS('http://www.w3.org/2000/svg', 'a');
link.setAttributeNS('http://www.w3.org/2000/svg', 'class', vertex.cssClasses);
Expand Down
18 changes: 17 additions & 1 deletion packages/mermaid/src/diagrams/flowchart/parser/flow.jison
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
/* lexical grammar */
%lex
%x string
%x linearGradientText
%x md_string
%x acc_title
%x acc_descr
Expand Down Expand Up @@ -165,6 +166,16 @@ that id.

<*>\s*\~\~[\~]+\s* return 'LINK';


/*
Capture linear-gradient(...).
This includes `linear-gradient(-...)` which otherwise conflicts with `(-` in ellipseText.
*/
<*>"linear-gradient"[\s]*\(\s* { this.pushState("linearGradientText"); return 'LINEAR_GRADIENT_START'; }
<linearGradientText>([^()]|(\([^()]*\)))+ { return 'LINEAR_GRADIENT_CONTENT'; } // Handles text, commas, and nested parentheses
<linearGradientText>\) { this.popState(); return 'LINEAR_GRADIENT_END'; }


<ellipseText>[-/\)][\)] { this.popState(); return '-)'; }
<ellipseText>[^\(\)\[\]\{\}]|-\!\)+ return "TEXT"
<*>"(-" { this.pushState("ellipseText"); return '(-'; }
Expand Down Expand Up @@ -582,7 +593,12 @@ style: styleComponent
{$$ = $style + $styleComponent;}
;

styleComponent: NUM | NODE_STRING| COLON | UNIT | SPACE | BRKT | STYLE | PCT ;
styleComponent: NUM | NODE_STRING | COLON | UNIT | SPACE | BRKT | STYLE | PS | TEXT | PE | LINGRAD;

LINGRAD
: LINEAR_GRADIENT_START LINEAR_GRADIENT_CONTENT LINEAR_GRADIENT_END
{ $$ = 'linear-gradient(' + $2 + ')'; }
;

/* Token lists */
idStringToken : NUM | NODE_STRING | DOWN | MINUS | DEFAULT | COMMA | COLON | AMP | BRKT | MULT | UNICODE_TEXT;
Expand Down
Loading
Loading