mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-08-19 08:16:42 +02:00
Merge pull request #6274 from Shahir-47/bug/5955_adjust-diagram-position-with-legend-width
fix: Prevent Legend Labels from Overlapping Diagram Elements in Journey Diagrams
This commit is contained in:
@@ -63,4 +63,165 @@ section Checkout from website
|
||||
{ journey: { useMaxWidth: false } }
|
||||
);
|
||||
});
|
||||
|
||||
it('should initialize with a left margin of 150px for user journeys', () => {
|
||||
renderGraph(
|
||||
`
|
||||
---
|
||||
config:
|
||||
journey:
|
||||
maxLabelWidth: 320
|
||||
---
|
||||
journey
|
||||
title User Journey Example
|
||||
section Onboarding
|
||||
Sign Up: 5:
|
||||
Browse Features: 3:
|
||||
Use Core Functionality: 4:
|
||||
section Engagement
|
||||
Browse Features: 3
|
||||
Use Core Functionality: 4
|
||||
`,
|
||||
{ journey: { useMaxWidth: true } }
|
||||
);
|
||||
|
||||
let diagramStartX;
|
||||
|
||||
cy.contains('foreignobject', 'Sign Up').then(($diagram) => {
|
||||
diagramStartX = parseFloat($diagram.attr('x'));
|
||||
expect(diagramStartX).to.be.closeTo(150, 2);
|
||||
});
|
||||
});
|
||||
|
||||
it('should maintain sufficient space between legend and diagram when legend labels are longer', () => {
|
||||
renderGraph(
|
||||
`journey
|
||||
title Web hook life cycle
|
||||
section Darkoob
|
||||
Make preBuilt:5: Darkoob user
|
||||
register slug : 5: Darkoob userf deliberately increasing the size of this label to check if distance between legend and diagram is maintained
|
||||
Map slug to a Prebuilt Job:5: Darkoob user
|
||||
section External Service
|
||||
set Darkoob slug as hook for an Event : 5 : admin Exjjjnjjjj qwerty
|
||||
listen to the events : 5 : External Service
|
||||
call darkoob endpoint : 5 : External Service
|
||||
section Darkoob
|
||||
check for inputs : 5 : DarkoobAPI
|
||||
run the prebuilt job : 5 : DarkoobAPI
|
||||
`,
|
||||
{ journey: { useMaxWidth: true } }
|
||||
);
|
||||
|
||||
let LabelEndX, diagramStartX;
|
||||
|
||||
// Get right edge of the legend
|
||||
cy.contains('tspan', 'Darkoob userf').then((textBox) => {
|
||||
const bbox = textBox[0].getBBox();
|
||||
LabelEndX = bbox.x + bbox.width;
|
||||
});
|
||||
|
||||
// Get left edge of the diagram
|
||||
cy.contains('foreignobject', 'Make preBuilt').then((rect) => {
|
||||
diagramStartX = parseFloat(rect.attr('x'));
|
||||
});
|
||||
|
||||
// Assert right edge of the diagram is greater than or equal to the right edge of the label
|
||||
cy.then(() => {
|
||||
expect(diagramStartX).to.be.gte(LabelEndX);
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap a single long word with hyphenation', () => {
|
||||
renderGraph(
|
||||
`
|
||||
---
|
||||
config:
|
||||
journey:
|
||||
maxLabelWidth: 100
|
||||
---
|
||||
journey
|
||||
title Long Word Test
|
||||
section Test
|
||||
VeryLongWord: 5: Supercalifragilisticexpialidocious
|
||||
`,
|
||||
{ journey: { useMaxWidth: true } }
|
||||
);
|
||||
|
||||
// Verify that the line ends with a hyphen, indicating proper hyphenation for words exceeding maxLabelWidth.
|
||||
cy.get('tspan').then((tspans) => {
|
||||
const hasHyphen = [...tspans].some((t) => t.textContent.trim().endsWith('-'));
|
||||
return expect(hasHyphen).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap text on whitespace without adding hyphens', () => {
|
||||
renderGraph(
|
||||
`
|
||||
---
|
||||
config:
|
||||
journey:
|
||||
maxLabelWidth: 200
|
||||
---
|
||||
journey
|
||||
title Whitespace Test
|
||||
section Test
|
||||
TextWithSpaces: 5: Gustavo Fring is played by Giancarlo Esposito and is a character in Breaking Bad.
|
||||
`,
|
||||
{ journey: { useMaxWidth: true } }
|
||||
);
|
||||
|
||||
// Verify that none of the text spans end with a hyphen.
|
||||
cy.get('tspan').each(($el) => {
|
||||
const text = $el.text();
|
||||
expect(text.trim()).not.to.match(/-$/);
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap long labels into multiple lines, keep them under max width, and maintain margins', () => {
|
||||
renderGraph(
|
||||
`
|
||||
---
|
||||
config:
|
||||
journey:
|
||||
maxLabelWidth: 320
|
||||
---
|
||||
journey
|
||||
title User Journey Example
|
||||
section Onboarding
|
||||
Sign Up: 5: This is a long label that will be split into multiple lines to test the wrapping functionality
|
||||
Browse Features: 3: This is another long label that will be split into multiple lines to test the wrapping functionality
|
||||
Use Core Functionality: 4: This is yet another long label that will be split into multiple lines to test the wrapping functionality
|
||||
section Engagement
|
||||
Browse Features: 3
|
||||
Use Core Functionality: 4
|
||||
`,
|
||||
{ journey: { useMaxWidth: true } }
|
||||
);
|
||||
|
||||
let diagramStartX, maxLineWidth;
|
||||
|
||||
// Get the diagram's left edge x-coordinate
|
||||
cy.contains('foreignobject', 'Sign Up')
|
||||
.then(($diagram) => {
|
||||
diagramStartX = parseFloat($diagram.attr('x'));
|
||||
})
|
||||
.then(() => {
|
||||
cy.get('text.legend').then(($lines) => {
|
||||
// Check that there are multiple lines
|
||||
expect($lines.length).to.be.equal(9);
|
||||
|
||||
// Check that all lines are under the maxLabelWidth
|
||||
$lines.each((index, el) => {
|
||||
const bbox = el.getBBox();
|
||||
expect(bbox.width).to.be.lte(320);
|
||||
maxLineWidth = Math.max(maxLineWidth || 0, bbox.width);
|
||||
});
|
||||
|
||||
/** The expected margin between the diagram and the legend is 150px, as defined by
|
||||
* conf.leftMargin in user-journey-config.js
|
||||
*/
|
||||
expect(diagramStartX - maxLineWidth).to.be.closeTo(150, 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -559,6 +559,10 @@ export interface JourneyDiagramConfig extends BaseDiagramConfig {
|
||||
* Margin between actors
|
||||
*/
|
||||
leftMargin?: number;
|
||||
/**
|
||||
* Maximum width of actor labels
|
||||
*/
|
||||
maxLabelWidth?: number;
|
||||
/**
|
||||
* Width of actor boxes
|
||||
*/
|
||||
|
@@ -13,15 +13,17 @@ export const setConf = function (cnf) {
|
||||
};
|
||||
|
||||
const actors = {};
|
||||
let maxWidth = 0;
|
||||
|
||||
/** @param diagram - The diagram to draw to. */
|
||||
function drawActorLegend(diagram) {
|
||||
const conf = getConfig().journey;
|
||||
// Draw the actors
|
||||
const maxLabelWidth = conf.maxLabelWidth;
|
||||
maxWidth = 0;
|
||||
let yPos = 60;
|
||||
|
||||
Object.keys(actors).forEach((person) => {
|
||||
const colour = actors[person].color;
|
||||
|
||||
const circleData = {
|
||||
cx: 20,
|
||||
cy: yPos,
|
||||
@@ -32,21 +34,90 @@ function drawActorLegend(diagram) {
|
||||
};
|
||||
svgDraw.drawCircle(diagram, circleData);
|
||||
|
||||
// First, measure the full text width without wrapping.
|
||||
let measureText = diagram.append('text').attr('visibility', 'hidden').text(person);
|
||||
const fullTextWidth = measureText.node().getBoundingClientRect().width;
|
||||
measureText.remove();
|
||||
|
||||
let lines = [];
|
||||
|
||||
// If the text is naturally within the max width, use it as a single line.
|
||||
if (fullTextWidth <= maxLabelWidth) {
|
||||
lines = [person];
|
||||
} else {
|
||||
// Otherwise, wrap the text using the knuth-plass algorithm.
|
||||
const words = person.split(' '); // Split the text into words.
|
||||
let currentLine = '';
|
||||
measureText = diagram.append('text').attr('visibility', 'hidden');
|
||||
|
||||
words.forEach((word) => {
|
||||
// check the width of the line with the new word.
|
||||
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
||||
measureText.text(testLine);
|
||||
const textWidth = measureText.node().getBoundingClientRect().width;
|
||||
|
||||
if (textWidth > maxLabelWidth) {
|
||||
// If adding the new word exceeds max width, push the current line.
|
||||
if (currentLine) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
currentLine = word; // Start a new line with the current word.
|
||||
|
||||
// If the word itself is too long, break it with a hyphen.
|
||||
measureText.text(word);
|
||||
if (measureText.node().getBoundingClientRect().width > maxLabelWidth) {
|
||||
let brokenWord = '';
|
||||
for (const char of word) {
|
||||
brokenWord += char;
|
||||
measureText.text(brokenWord + '-');
|
||||
if (measureText.node().getBoundingClientRect().width > maxLabelWidth) {
|
||||
// Push the broken part with a hyphen.
|
||||
lines.push(brokenWord.slice(0, -1) + '-');
|
||||
brokenWord = char;
|
||||
}
|
||||
}
|
||||
currentLine = brokenWord;
|
||||
}
|
||||
} else {
|
||||
// If the line with the new word fits, add the new word to the current line.
|
||||
currentLine = testLine;
|
||||
}
|
||||
});
|
||||
|
||||
// Push the last line.
|
||||
if (currentLine) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
measureText.remove(); // Remove the text element used for measuring.
|
||||
}
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const labelData = {
|
||||
x: 40,
|
||||
y: yPos + 7,
|
||||
y: yPos + 7 + index * 20,
|
||||
fill: '#666',
|
||||
text: person,
|
||||
textMargin: conf.boxTextMargin | 5,
|
||||
text: line,
|
||||
textMargin: conf.boxTextMargin ?? 5,
|
||||
};
|
||||
svgDraw.drawText(diagram, labelData);
|
||||
|
||||
yPos += 20;
|
||||
// Draw the text and measure the width.
|
||||
const textElement = svgDraw.drawText(diagram, labelData);
|
||||
const lineWidth = textElement.node().getBoundingClientRect().width;
|
||||
|
||||
// Use conf.leftMargin as the initial spacing baseline,
|
||||
// but expand maxWidth if the line is wider.
|
||||
if (lineWidth > maxWidth && lineWidth > conf.leftMargin - lineWidth) {
|
||||
maxWidth = lineWidth;
|
||||
}
|
||||
});
|
||||
|
||||
yPos += Math.max(20, lines.length * 20);
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Cleanup?
|
||||
const conf = getConfig().journey;
|
||||
const LEFT_MARGIN = conf.leftMargin;
|
||||
let leftMargin = 0;
|
||||
export const draw = function (text, id, version, diagObj) {
|
||||
const conf = getConfig().journey;
|
||||
|
||||
@@ -84,7 +155,8 @@ export const draw = function (text, id, version, diagObj) {
|
||||
});
|
||||
|
||||
drawActorLegend(diagram);
|
||||
bounds.insert(0, 0, LEFT_MARGIN, Object.keys(actors).length * 50);
|
||||
leftMargin = conf.leftMargin + maxWidth;
|
||||
bounds.insert(0, 0, leftMargin, Object.keys(actors).length * 50);
|
||||
drawTasks(diagram, tasks, 0);
|
||||
|
||||
const box = bounds.getBounds();
|
||||
@@ -92,23 +164,23 @@ export const draw = function (text, id, version, diagObj) {
|
||||
diagram
|
||||
.append('text')
|
||||
.text(title)
|
||||
.attr('x', LEFT_MARGIN)
|
||||
.attr('x', leftMargin)
|
||||
.attr('font-size', '4ex')
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('y', 25);
|
||||
}
|
||||
|
||||
const height = box.stopy - box.starty + 2 * conf.diagramMarginY;
|
||||
const width = LEFT_MARGIN + box.stopx + 2 * conf.diagramMarginX;
|
||||
const width = leftMargin + box.stopx + 2 * conf.diagramMarginX;
|
||||
|
||||
configureSvgSize(diagram, height, width, conf.useMaxWidth);
|
||||
|
||||
// Draw activity line
|
||||
diagram
|
||||
.append('line')
|
||||
.attr('x1', LEFT_MARGIN)
|
||||
.attr('x1', leftMargin)
|
||||
.attr('y1', conf.height * 4) // One section head + one task + margins
|
||||
.attr('x2', width - LEFT_MARGIN - 4) // Subtract stroke width so arrow point is retained
|
||||
.attr('x2', width - leftMargin - 4) // Subtract stroke width so arrow point is retained
|
||||
.attr('y2', conf.height * 4)
|
||||
.attr('stroke-width', 4)
|
||||
.attr('stroke', 'black')
|
||||
@@ -234,7 +306,7 @@ export const drawTasks = function (diagram, tasks, verticalPos) {
|
||||
}
|
||||
|
||||
const section = {
|
||||
x: i * conf.taskMargin + i * conf.width + LEFT_MARGIN,
|
||||
x: i * conf.taskMargin + i * conf.width + leftMargin,
|
||||
y: 50,
|
||||
text: task.section,
|
||||
fill,
|
||||
@@ -258,7 +330,7 @@ export const drawTasks = function (diagram, tasks, verticalPos) {
|
||||
}, {});
|
||||
|
||||
// Add some rendering data to the object
|
||||
task.x = i * conf.taskMargin + i * conf.width + LEFT_MARGIN;
|
||||
task.x = i * conf.taskMargin + i * conf.width + leftMargin;
|
||||
task.y = taskPos;
|
||||
task.width = conf.diagramMarginX;
|
||||
task.height = conf.diagramMarginY;
|
||||
|
@@ -1496,6 +1496,10 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file)
|
||||
type: integer
|
||||
default: 150
|
||||
minimum: 0
|
||||
maxLabelWidth:
|
||||
description: Maximum width of actor labels
|
||||
type: integer
|
||||
default: 360
|
||||
width:
|
||||
description: Width of actor boxes
|
||||
type: integer
|
||||
|
Reference in New Issue
Block a user