Sequence diagram fixes & improvements

This commit fixes some bugs, and I believe, improves upon the current
implementation.

In no particular order, it adds:

1. Control over note font size, family and alignment (now defaults to
center)
2. Dynamic actor resizing - actor's width will now scale if its
description is bigger than the static configured width
3. Dynamic actor margins - the margin between actors will now be
dynamically calculated by taking into account the width of connecting
messages or notes
4. Fixed a small visual annoyance where a loop arrow would intersect
with the text it loops on
5. Fixed a bug where if global config -> fontFamily wasn't defined, it
would override the actorFontFamily with an undefined
6. Removed some stale / commented out code
7. Added missing config variables to the global config object in mermaidAPI.js
8. Added messageFontSize, messageFontFamily to control message (non-note)
font settings
9. Memoized the actor widths in a pre-calculation that takes notes and
signals lengths into account
10. Removed redundant console.log lines
11. Extracted out actor width & margin calculation to getMaxMessageWidthPerActor, and
calculateActorMargins
This commit is contained in:
Danny Shemesh
2020-04-23 07:37:32 +03:00
parent 197d006860
commit 5f6887b316
3 changed files with 344 additions and 39 deletions

View File

@@ -19,6 +19,13 @@ const conf = {
height: 65, height: 65,
actorFontSize: 14, actorFontSize: 14,
actorFontFamily: '"Open-Sans", "sans-serif"', actorFontFamily: '"Open-Sans", "sans-serif"',
// Note font settings
noteFontSize: 14,
noteFontFamily: '"trebuchet ms", verdana, arial',
noteAlign: 'center',
// Message font settings
messageFontSize: 16,
messageFontFamily: '"trebuchet ms", verdana, arial',
// Margin around loop boxes // Margin around loop boxes
boxMargin: 10, boxMargin: 10,
boxTextMargin: 5, boxTextMargin: 5,
@@ -171,18 +178,52 @@ export const bounds = {
const _drawLongText = (text, x, y, g, width) => { const _drawLongText = (text, x, y, g, width) => {
let textHeight = 0; let textHeight = 0;
let prevTextHeight = 0;
const alignmentToAnchor = {
left: 'start',
start: 'start',
center: 'middle',
middle: 'middle',
right: 'end',
end: 'end'
};
const lines = text.split(common.lineBreakRegex); const lines = text.split(common.lineBreakRegex);
for (const line of lines) { for (const line of lines) {
const textObj = svgDraw.getTextObj(); const textObj = svgDraw.getTextObj();
textObj.x = x; const alignment = alignmentToAnchor[conf.noteAlign] || 'middle';
switch (alignment) {
case 'start':
textObj.x = x + conf.noteMargin;
break;
case 'middle':
textObj.x = x + width / 2;
break;
case 'end':
textObj.x = x + width - conf.noteMargin;
break;
}
textObj.y = y + textHeight; textObj.y = y + textHeight;
textObj.textMargin = conf.noteMargin;
textObj.dy = '1em'; textObj.dy = '1em';
textObj.text = line; textObj.text = line;
textObj.class = 'noteText'; textObj.class = 'noteText';
const textElem = svgDraw.drawText(g, textObj, width);
const textElem = svgDraw
.drawText(g, textObj)
.style('text-anchor', alignment)
.style('font-size', conf.noteFontSize)
.style('font-family', conf.noteFontFamily)
.attr('dominant-baseline', 'central')
.attr('alignment-baseline', 'central');
textHeight += (textElem._groups || textElem)[0][0].getBBox().height; textHeight += (textElem._groups || textElem)[0][0].getBBox().height;
textElem.attr('y', y + (prevTextHeight + textHeight + 2 * conf.noteMargin) / 2);
prevTextHeight = textHeight;
} }
return textHeight; return textHeight;
}; };
@@ -204,13 +245,7 @@ const drawNote = function(elem, startx, verticalPos, msg, forceWidth) {
let g = elem.append('g'); let g = elem.append('g');
const rectElem = svgDraw.drawRect(g, rect); const rectElem = svgDraw.drawRect(g, rect);
const textHeight = _drawLongText( const textHeight = _drawLongText(msg.message, startx, verticalPos, g, rect.width);
msg.message,
startx - 4,
verticalPos + 24,
g,
rect.width - conf.noteMargin
);
bounds.insert( bounds.insert(
startx, startx,
@@ -218,6 +253,7 @@ const drawNote = function(elem, startx, verticalPos, msg, forceWidth) {
startx + rect.width, startx + rect.width,
verticalPos + 2 * conf.noteMargin + textHeight verticalPos + 2 * conf.noteMargin + textHeight
); );
rectElem.attr('height', textHeight + 2 * conf.noteMargin); rectElem.attr('height', textHeight + 2 * conf.noteMargin);
bounds.bumpVerticalPos(textHeight + 2 * conf.noteMargin); bounds.bumpVerticalPos(textHeight + 2 * conf.noteMargin);
}; };
@@ -245,6 +281,8 @@ const drawMessage = function(elem, startx, stopx, verticalPos, msg, sequenceInde
.append('text') // text label for the x axis .append('text') // text label for the x axis
.attr('x', txtCenter) .attr('x', txtCenter)
.attr('y', verticalPos - 7 + counterBreaklines * breaklineOffset) .attr('y', verticalPos - 7 + counterBreaklines * breaklineOffset)
.style('font-size', conf.messageFontSize)
.style('font-family', conf.messageFontFamily)
.style('text-anchor', 'middle') .style('text-anchor', 'middle')
.attr('class', 'messageText') .attr('class', 'messageText')
.text(breakline.trim()) .text(breakline.trim())
@@ -252,7 +290,7 @@ const drawMessage = function(elem, startx, stopx, verticalPos, msg, sequenceInde
counterBreaklines++; counterBreaklines++;
} }
const offsetLineCounter = counterBreaklines - 1; const offsetLineCounter = counterBreaklines - 1;
const totalOffset = offsetLineCounter * breaklineOffset; let totalOffset = offsetLineCounter * breaklineOffset;
let textWidths = textElems.map(function(textElem) { let textWidths = textElems.map(function(textElem) {
return (textElem._groups || textElem)[0][0].getBBox().width; return (textElem._groups || textElem)[0][0].getBBox().width;
@@ -280,6 +318,8 @@ const drawMessage = function(elem, startx, stopx, verticalPos, msg, sequenceInde
totalOffset} H ${startx}` totalOffset} H ${startx}`
); );
} else { } else {
totalOffset += 5;
line = g line = g
.append('path') .append('path')
.attr( .attr(
@@ -377,18 +417,26 @@ const drawMessage = function(elem, startx, stopx, verticalPos, msg, sequenceInde
export const drawActors = function(diagram, actors, actorKeys, verticalPos) { export const drawActors = function(diagram, actors, actorKeys, verticalPos) {
// Draw the actors // Draw the actors
let prevWidth = 0;
let prevMargin = 0;
for (let i = 0; i < actorKeys.length; i++) { for (let i = 0; i < actorKeys.length; i++) {
const key = actorKeys[i]; const actor = actors[actorKeys[i]];
// Add some rendering data to the object // Add some rendering data to the object
actors[key].x = i * conf.actorMargin + i * conf.width; actor.width = actor.width || calculateActorWidth(actor);
actors[key].y = verticalPos; actor.height = conf.height;
actors[key].width = conf.diagramMarginX; actor.margin = actor.margin || conf.actorMargin;
actors[key].height = conf.diagramMarginY;
actor.x = prevWidth + prevMargin;
actor.y = verticalPos;
// Draw the box with the attached line // Draw the box with the attached line
svgDraw.drawActor(diagram, actors[key].x, verticalPos, actors[key].description, conf); svgDraw.drawActor(diagram, actor, conf);
bounds.insert(actors[key].x, verticalPos, actors[key].x + conf.width, conf.height); bounds.insert(actor.x, verticalPos, actor.x + actor.width, conf.height);
prevWidth += actor.width;
prevMargin += actor.margin;
} }
// Add a margin between the actor boxes and the first arrow // Add a margin between the actor boxes and the first arrow
@@ -401,7 +449,10 @@ export const setConf = function(cnf) {
keys.forEach(function(key) { keys.forEach(function(key) {
conf[key] = cnf[key]; conf[key] = cnf[key];
}); });
conf.actorFontFamily = cnf.fontFamily;
if (cnf.fontFamily) {
conf.actorFontFamily = conf.noteFontFamily = cnf.fontFamily;
}
}; };
const actorActivations = function(actor) { const actorActivations = function(actor) {
@@ -412,18 +463,89 @@ const actorActivations = function(actor) {
const actorFlowVerticaBounds = function(actor) { const actorFlowVerticaBounds = function(actor) {
// handle multiple stacked activations for same actor // handle multiple stacked activations for same actor
const actors = parser.yy.getActors(); const actorObj = parser.yy.getActors()[actor];
const activations = actorActivations(actor); const activations = actorActivations(actor);
const left = activations.reduce(function(acc, activation) { const left = activations.reduce(function(acc, activation) {
return Math.min(acc, activation.startx); return Math.min(acc, activation.startx);
}, actors[actor].x + conf.width / 2); }, actorObj.x + actorObj.width / 2);
const right = activations.reduce(function(acc, activation) { const right = activations.reduce(function(acc, activation) {
return Math.max(acc, activation.stopx); return Math.max(acc, activation.stopx);
}, actors[actor].x + conf.width / 2); }, actorObj.x + actorObj.width / 2);
return [left, right]; return [left, right];
}; };
/**
* This calculates the actor's width, taking into account both the statically configured width,
* and the actor's description.
*
* If the description text has greater length, we extend the width of the actor, so it's description
* won't overflow.
*
* @param actor - An actor object
* @return - The width for the given actor
*/
const calculateActorWidth = function(actor) {
if (!actor.description) {
return conf.width;
}
return Math.max(
conf.width,
calculateTextWidth(actor.description, conf.actorFontSize, conf.actorFontFamily)
);
};
/**
* This calculates the width of the given text, font size and family.
*
* @param text - The text to calculate the width of
* @param fontSize - The font size of the given text
* @param fontFamily - The font family (one, or more fonts) to render
*/
export const calculateTextWidth = function(text, fontSize, fontFamily) {
if (!text) {
return 0;
}
fontSize = fontSize ? fontSize : conf.actorFontSize;
fontFamily = fontFamily ? fontFamily : conf.actorFontFamily;
// We can't really know if the user supplied font family will render on the user agent;
// thus, we'll take the max width between the user supplied font family, and a default
// of sans-serif.
const fontFamilies = ['sans-serif', fontFamily];
const lines = text.split(common.lineBreakRegex);
let maxWidth = 0;
const body = d3.select('body');
// We don'y want to leak DOM elements - if a removal operation isn't available
// for any reason, do not continue.
if (!body.remove) {
return 0;
}
const g = body.append('svg');
for (let line of lines) {
for (let fontFamily of fontFamilies) {
const textObj = svgDraw.getTextObj();
textObj.text = line;
const textElem = svgDraw
.drawText(g, textObj)
.style('font-size', fontSize)
.style('font-family', fontFamily);
maxWidth = Math.max(maxWidth, (textElem._groups || textElem)[0][0].getBBox().width);
}
}
g.remove();
// Adds some padding, so the text won't sit exactly within the actor's borders
return maxWidth + 35;
};
/** /**
* Draws a flowchart in the tag with id: id based on the graph definition in text. * Draws a flowchart in the tag with id: id based on the graph definition in text.
* @param text * @param text
@@ -445,6 +567,10 @@ export const draw = function(text, id) {
const actorKeys = parser.yy.getActorKeys(); const actorKeys = parser.yy.getActorKeys();
const messages = parser.yy.getMessages(); const messages = parser.yy.getMessages();
const title = parser.yy.getTitle(); const title = parser.yy.getTitle();
const maxMessageWidthPerActor = getMaxMessageWidthPerActor(actors, messages);
calculateActorMargins(actors, maxMessageWidthPerActor);
drawActors(diagram, actors, actorKeys, 0); drawActors(diagram, actors, actorKeys, 0);
// The arrow head definition is attached to the svg once // The arrow head definition is attached to the svg once
@@ -469,12 +595,15 @@ export const draw = function(text, id) {
bounds.insert(activationData.startx, verticalPos - 10, activationData.stopx, verticalPos); bounds.insert(activationData.startx, verticalPos - 10, activationData.stopx, verticalPos);
} }
// const lastMsg
// Draw the messages/signals // Draw the messages/signals
let sequenceIndex = 1; let sequenceIndex = 1;
messages.forEach(function(msg) { messages.forEach(function(msg) {
let loopData; let loopData;
const noteWidth = Math.max(
conf.width,
calculateTextWidth(msg.message, conf.noteFontSize, conf.noteFontFamily)
);
switch (msg.type) { switch (msg.type) {
case parser.yy.LINETYPE.NOTE: case parser.yy.LINETYPE.NOTE:
bounds.bumpVerticalPos(conf.boxMargin); bounds.bumpVerticalPos(conf.boxMargin);
@@ -485,26 +614,35 @@ export const draw = function(text, id) {
if (msg.placement === parser.yy.PLACEMENT.RIGHTOF) { if (msg.placement === parser.yy.PLACEMENT.RIGHTOF) {
drawNote( drawNote(
diagram, diagram,
startx + (conf.width + conf.actorMargin) / 2, startx + (actors[msg.from].width + conf.actorMargin) / 2,
bounds.getVerticalPos(), bounds.getVerticalPos(),
msg msg,
noteWidth
); );
} else if (msg.placement === parser.yy.PLACEMENT.LEFTOF) { } else if (msg.placement === parser.yy.PLACEMENT.LEFTOF) {
drawNote( drawNote(
diagram, diagram,
startx - (conf.width + conf.actorMargin) / 2, startx - noteWidth + (actors[msg.from].width - conf.actorMargin) / 2,
bounds.getVerticalPos(), bounds.getVerticalPos(),
msg msg,
noteWidth
); );
} else if (msg.to === msg.from) { } else if (msg.to === msg.from) {
// Single-actor over // Single-actor over
drawNote(diagram, startx, bounds.getVerticalPos(), msg); drawNote(
diagram,
startx + (actors[msg.to].width - noteWidth) / 2,
bounds.getVerticalPos(),
msg,
noteWidth
);
} else { } else {
// Multi-actor over // Multi-actor over
forceWidth = Math.abs(startx - stopx) + conf.actorMargin; forceWidth = Math.abs(startx - stopx) + conf.actorMargin;
drawNote( drawNote(
diagram, diagram,
(startx + stopx + conf.width - forceWidth) / 2, (startx + stopx + noteWidth - forceWidth) / 2,
bounds.getVerticalPos(), bounds.getVerticalPos(),
msg, msg,
forceWidth forceWidth
@@ -670,6 +808,137 @@ export const draw = function(text, id) {
); );
}; };
/**
* Retrieves the max message width of each actor, supports signals (messages, loops)
* and notes.
*
* It will enumerate each given message, and will determine its text width, in relation
* to the actor it originates from, and destined to.
*
* @param actors - The actors map
* @param messages - A list of message objects to iterate
*/
const getMaxMessageWidthPerActor = function(actors, messages) {
const maxMessageWidthPerActor = {};
messages.forEach(function(msg) {
if (actors[msg.to] && actors[msg.from]) {
const actor = actors[msg.to];
// If this is the first actor, and the message is left of it, no need to calculate the margin
if (msg.placement == parser.yy.PLACEMENT.LEFTOF && !actor.prevActor) {
return;
}
// If this is the last actor, and the message is right of it, no need to calculate the margin
if (msg.placement == parser.yy.PLACEMENT.RIGHTOF && !actor.nextActor) {
return;
}
const isNote = msg.placement !== undefined;
const isMessage = !isNote;
const fontSize = isNote ? conf.noteFontSize : conf.messageFontSize;
const fontFamily = isNote ? conf.noteFontFamily : conf.messageFontFamily;
const messageWidth = calculateTextWidth(msg.message, fontSize, fontFamily);
/*
* The following scenarios should be supported:
*
* - There's a message (non-note) between fromActor and toActor
* - If fromActor is on the right and toActor is on the left, we should
* define the toActor's margin
* - If fromActor is on the left and toActor is on the right, we should
* define the fromActor's margin
* - There's a note, in which case fromActor == toActor
* - If the note is to the left of the actor, we should define the previous actor
* margin
* - If the note is on the actor, we should define both the previous and next actor
* margins, each being the half of the note size
* - If the note is on the right of the actor, we should define the current actor
* margin
*/
if (isMessage && msg.from == actor.nextActor) {
maxMessageWidthPerActor[msg.to] = Math.max(
maxMessageWidthPerActor[msg.to] || 0,
messageWidth
);
} else if (
(isMessage && msg.from == actor.prevActor) ||
msg.placement == parser.yy.PLACEMENT.RIGHTOF
) {
maxMessageWidthPerActor[msg.from] = Math.max(
maxMessageWidthPerActor[msg.from] || 0,
messageWidth
);
} else if (msg.placement == parser.yy.PLACEMENT.LEFTOF) {
maxMessageWidthPerActor[actor.prevActor] = Math.max(
maxMessageWidthPerActor[actor.prevActor] || 0,
messageWidth
);
} else if (msg.placement == parser.yy.PLACEMENT.OVER) {
if (actor.prevActor) {
maxMessageWidthPerActor[actor.prevActor] = Math.max(
maxMessageWidthPerActor[actor.prevActor] || 0,
messageWidth / 2
);
}
if (actor.nextActor) {
maxMessageWidthPerActor[msg.from] = Math.max(
maxMessageWidthPerActor[msg.from] || 0,
messageWidth / 2
);
}
}
}
});
return maxMessageWidthPerActor;
};
/**
* This will calculate the optimal margin for each given actor, for a given
* actor->messageWidth map.
*
* An actor's margin is determined by the width of the actor, the width of the
* largest message that originates from it, and the configured conf.actorMargin.
*
* @param actors - The actors map to calculate margins for
* @param actorToMessageWidth - A map of actor key -> max message width it holds
*/
const calculateActorMargins = function(actors, actorToMessageWidth) {
for (let actorKey in actorToMessageWidth) {
const actor = actors[actorKey];
if (!actor) {
continue;
}
const nextActor = actors[actor.nextActor];
// No need to space out an actor that doesn't have a next link
if (!nextActor) {
continue;
}
actor.width = Math.max(
conf.width,
calculateTextWidth(actor.description, conf.actorFontSize, conf.actorFontFamily)
);
nextActor.width = Math.max(
conf.width,
calculateTextWidth(nextActor.description, conf.actorFontSize, conf.actorFontFamily)
);
const messageWidth = actorToMessageWidth[actorKey];
const actorWidth = messageWidth + conf.actorMargin - actor.width / 2 - nextActor.width / 2;
actor.margin = Math.max(actorWidth, conf.actorMargin);
}
};
export default { export default {
bounds, bounds,
drawActors, drawActors,

View File

@@ -79,10 +79,11 @@ let actorCnt = -1;
* @param actor - The actor to draw. * @param actor - The actor to draw.
* @param config - The sequence diagram config object. * @param config - The sequence diagram config object.
*/ */
export const drawActor = function(elem, left, verticalPos, description, conf) { export const drawActor = function(elem, actor, conf) {
const center = left + conf.width / 2; const center = actor.x + actor.width / 2;
const g = elem.append('g'); const g = elem.append('g');
if (verticalPos === 0) { if (actor.y === 0) {
actorCnt++; actorCnt++;
g.append('line') g.append('line')
.attr('id', 'actor' + actorCnt) .attr('id', 'actor' + actorCnt)
@@ -96,18 +97,18 @@ export const drawActor = function(elem, left, verticalPos, description, conf) {
} }
const rect = getNoteRect(); const rect = getNoteRect();
rect.x = left; rect.x = actor.x;
rect.y = verticalPos; rect.y = actor.y;
rect.fill = '#eaeaea'; rect.fill = '#eaeaea';
rect.width = conf.width; rect.width = actor.width;
rect.height = conf.height; rect.height = actor.height;
rect.class = 'actor'; rect.class = 'actor';
rect.rx = 3; rect.rx = 3;
rect.ry = 3; rect.ry = 3;
drawRect(g, rect); drawRect(g, rect);
_drawTextCandidateFunc(conf)( _drawTextCandidateFunc(conf)(
description, actor.description,
g, g,
rect.x, rect.x,
rect.y, rect.y,

View File

@@ -270,7 +270,42 @@ const config = {
* This will show the node numbers * This will show the node numbers
* **Default value false**. * **Default value false**.
*/ */
showSequenceNumbers: false showSequenceNumbers: false,
/**
* This sets the font size of the actor's description
* **Default value 14**.
*/
actorFontSize: 14,
/**
* This sets the font family of the actor's description
* **Default value "Open-Sans", "sans-serif"**.
*/
actorFontFamily: '"Open-Sans", "sans-serif"',
/**
* This sets the font size of actor-attached notes.
* **Default value 14**.
*/
noteFontSize: 14,
/**
* This sets the font family of actor-attached notes.
* **Default value "trebuchet ms", verdana, arial**.
*/
noteFontFamily: '"trebuchet ms", verdana, arial',
/**
* This sets the text alignment of actor-attached notes.
* **Default value center**.
*/
noteAlign: 'center',
/**
* This sets the font size of actor messages.
* **Default value 16**.
*/
messageFontSize: 16,
/**
* This sets the font family of actor messages.
* **Default value "trebuchet ms", verdana, arial**.
*/
messageFontFamily: '"trebuchet ms", verdana, arial'
}, },
/** /**