mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-08-24 02:37:05 +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 } }
|
{ 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
|
* Margin between actors
|
||||||
*/
|
*/
|
||||||
leftMargin?: number;
|
leftMargin?: number;
|
||||||
|
/**
|
||||||
|
* Maximum width of actor labels
|
||||||
|
*/
|
||||||
|
maxLabelWidth?: number;
|
||||||
/**
|
/**
|
||||||
* Width of actor boxes
|
* Width of actor boxes
|
||||||
*/
|
*/
|
||||||
|
@@ -13,15 +13,17 @@ export const setConf = function (cnf) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const actors = {};
|
const actors = {};
|
||||||
|
let maxWidth = 0;
|
||||||
|
|
||||||
/** @param diagram - The diagram to draw to. */
|
/** @param diagram - The diagram to draw to. */
|
||||||
function drawActorLegend(diagram) {
|
function drawActorLegend(diagram) {
|
||||||
const conf = getConfig().journey;
|
const conf = getConfig().journey;
|
||||||
// Draw the actors
|
const maxLabelWidth = conf.maxLabelWidth;
|
||||||
|
maxWidth = 0;
|
||||||
let yPos = 60;
|
let yPos = 60;
|
||||||
|
|
||||||
Object.keys(actors).forEach((person) => {
|
Object.keys(actors).forEach((person) => {
|
||||||
const colour = actors[person].color;
|
const colour = actors[person].color;
|
||||||
|
|
||||||
const circleData = {
|
const circleData = {
|
||||||
cx: 20,
|
cx: 20,
|
||||||
cy: yPos,
|
cy: yPos,
|
||||||
@@ -32,21 +34,90 @@ function drawActorLegend(diagram) {
|
|||||||
};
|
};
|
||||||
svgDraw.drawCircle(diagram, circleData);
|
svgDraw.drawCircle(diagram, circleData);
|
||||||
|
|
||||||
const labelData = {
|
// First, measure the full text width without wrapping.
|
||||||
x: 40,
|
let measureText = diagram.append('text').attr('visibility', 'hidden').text(person);
|
||||||
y: yPos + 7,
|
const fullTextWidth = measureText.node().getBoundingClientRect().width;
|
||||||
fill: '#666',
|
measureText.remove();
|
||||||
text: person,
|
|
||||||
textMargin: conf.boxTextMargin | 5,
|
|
||||||
};
|
|
||||||
svgDraw.drawText(diagram, labelData);
|
|
||||||
|
|
||||||
yPos += 20;
|
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 + index * 20,
|
||||||
|
fill: '#666',
|
||||||
|
text: line,
|
||||||
|
textMargin: conf.boxTextMargin ?? 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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?
|
// TODO: Cleanup?
|
||||||
const conf = getConfig().journey;
|
const conf = getConfig().journey;
|
||||||
const LEFT_MARGIN = conf.leftMargin;
|
let leftMargin = 0;
|
||||||
export const draw = function (text, id, version, diagObj) {
|
export const draw = function (text, id, version, diagObj) {
|
||||||
const conf = getConfig().journey;
|
const conf = getConfig().journey;
|
||||||
|
|
||||||
@@ -84,7 +155,8 @@ export const draw = function (text, id, version, diagObj) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
drawActorLegend(diagram);
|
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);
|
drawTasks(diagram, tasks, 0);
|
||||||
|
|
||||||
const box = bounds.getBounds();
|
const box = bounds.getBounds();
|
||||||
@@ -92,23 +164,23 @@ export const draw = function (text, id, version, diagObj) {
|
|||||||
diagram
|
diagram
|
||||||
.append('text')
|
.append('text')
|
||||||
.text(title)
|
.text(title)
|
||||||
.attr('x', LEFT_MARGIN)
|
.attr('x', leftMargin)
|
||||||
.attr('font-size', '4ex')
|
.attr('font-size', '4ex')
|
||||||
.attr('font-weight', 'bold')
|
.attr('font-weight', 'bold')
|
||||||
.attr('y', 25);
|
.attr('y', 25);
|
||||||
}
|
}
|
||||||
|
|
||||||
const height = box.stopy - box.starty + 2 * conf.diagramMarginY;
|
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);
|
configureSvgSize(diagram, height, width, conf.useMaxWidth);
|
||||||
|
|
||||||
// Draw activity line
|
// Draw activity line
|
||||||
diagram
|
diagram
|
||||||
.append('line')
|
.append('line')
|
||||||
.attr('x1', LEFT_MARGIN)
|
.attr('x1', leftMargin)
|
||||||
.attr('y1', conf.height * 4) // One section head + one task + margins
|
.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('y2', conf.height * 4)
|
||||||
.attr('stroke-width', 4)
|
.attr('stroke-width', 4)
|
||||||
.attr('stroke', 'black')
|
.attr('stroke', 'black')
|
||||||
@@ -234,7 +306,7 @@ export const drawTasks = function (diagram, tasks, verticalPos) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const section = {
|
const section = {
|
||||||
x: i * conf.taskMargin + i * conf.width + LEFT_MARGIN,
|
x: i * conf.taskMargin + i * conf.width + leftMargin,
|
||||||
y: 50,
|
y: 50,
|
||||||
text: task.section,
|
text: task.section,
|
||||||
fill,
|
fill,
|
||||||
@@ -258,7 +330,7 @@ export const drawTasks = function (diagram, tasks, verticalPos) {
|
|||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
// Add some rendering data to the object
|
// 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.y = taskPos;
|
||||||
task.width = conf.diagramMarginX;
|
task.width = conf.diagramMarginX;
|
||||||
task.height = conf.diagramMarginY;
|
task.height = conf.diagramMarginY;
|
||||||
|
@@ -1496,6 +1496,10 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file)
|
|||||||
type: integer
|
type: integer
|
||||||
default: 150
|
default: 150
|
||||||
minimum: 0
|
minimum: 0
|
||||||
|
maxLabelWidth:
|
||||||
|
description: Maximum width of actor labels
|
||||||
|
type: integer
|
||||||
|
default: 360
|
||||||
width:
|
width:
|
||||||
description: Width of actor boxes
|
description: Width of actor boxes
|
||||||
type: integer
|
type: integer
|
||||||
|
Reference in New Issue
Block a user