Merge pull request #1334 from russellgeraghty/feature/user-journey

Feature/user journey
This commit is contained in:
Knut Sveidqvist 2020-04-26 17:17:33 +02:00 committed by GitHub
commit 397f57accb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 1319 additions and 1 deletions

View File

@ -16,6 +16,10 @@ For more information and help in getting started, please view our [documentation
:trophy: **Mermaid was nominated and won the [JS Open Source Awards (2019)](https://osawards.com/javascript/#nominees) in the category "The most exciting use of technology"!!! Thanks to all involved, people committing pull requests, people answering questions and special thanks to Tyler Long who is helping me maintain the project.**
## New diagram
This version comes with a new diagram type, user journey diagrams.
## New diagrams in 8.4
With version 8.4 class diagrams have got some new features, bug fixes and documentation. Another new feature in 8.4 is the new diagram type, state diagrams.
@ -182,6 +186,25 @@ pie
<td colspan="2" align="center"><i>Coming soon!</i></td>
</tr>
<!-- </Git> -->
<!-- <Journey> -->
<tr>
<td><pre>
journey
title Hello
section Go to work
Make tea: 5: Me
Go upstairs: 3: Me
Do work: 1: Me, Cat
section Go home
Go downstairs: 5: Me
Sit down: 3: Me
</pre></td>
<td align="center">
<img alt="User Journey Diagram" src="https://raw.githubusercontent.com/mermaid-js/mermaid/master/img/gray-journey.png" />
</td>
</tr>
<!-- </Journey> -->
</table>
## Related projects

View File

@ -0,0 +1,33 @@
/* eslint-env jest */
import { imgSnapshotTest } from '../../helpers/util.js';
describe('User journey diagram', () => {
it('Simple test', () => {
imgSnapshotTest(
`journey
title Adding journey diagram functionality to mermaid
section Order from website
`,
{}
);
});
it('should render a user journey chart', () => {
imgSnapshotTest(
`
journey
title Go shopping
section Get to the shops
Get car keys: Dad
Get into car: Dad, Mum, Child#1, Child#2
Drive to supermarket: Dad
section Do shopping
Do actual shop: Mum
Get in the way: Dad, Child#1, Child#2
`,
{}
);
});
});

View File

@ -0,0 +1,41 @@
<html>
<head>
<link
href="https://fonts.googleapis.com/css?family=Montserrat&display=swap"
rel="stylesheet"
/>
</head>
<body>
<h1>User Journey</h1>
<div class="mermaid">
journey
title Go shopping
section Get to the shops
Get car keys:5: Dad
Get into car:5: Dad, Mum, Child 1, Child 2
Really drive to supermarket:3: Dad
section Do shopping
Do actual shop:3: Mum
Get in the way:2: Dad, Child 1, Child 2
Pay: 2: Dad
section Go home
Lose keys:3: Dad
Get cross:1: Dad, Child 1
Find keys:4: Mum
Get into car:4: Dad, Mum, Child 1, Child 2
Drive home:3: Dad
</div>
<script src="./mermaid.js"></script>
<script>
mermaid.initialize({
theme: 'forest',
logLevel: 3,
journey: { taskMargin: 30 },
});
</script>
</body>
</html>

23
docs/user-journey.md Normal file
View File

@ -0,0 +1,23 @@
# User Journey Diagram
> User journeys describe at a high level of detail exactly what steps different users take to complete a specific task within a system, application or website. This technique shows the current (as-is) user workflow, and reveals areas of improvement for the to-be workflow. (Wikipedia)
Mermaid can render user journey diagrams:
```markdown
journey
title My working day
section Go to work
Make tea: 5: Me
Go upstairs: 3: Me
Do work: 1: Me, Cat
section Go home
Go downstairs: 5: Me
Sit down: 5: Me
```
Each user journey is split into sections, these describe the part of the task
the user is trying to complete.
Tasks syntax is `Task name: <score>: <comma separated list of actors>`

BIN
img/gray-journey.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -27,7 +27,8 @@
"test": "yarn lint && jest src/.*",
"test:watch": "jest --watch src",
"prepublishOnly": "yarn build && yarn release && yarn test && yarn e2e",
"prepush": "yarn test"
"prepush": "yarn test",
"prepare": "yarn build"
},
"repository": {
"type": "git",

View File

@ -0,0 +1,123 @@
let title = '';
let currentSection = '';
const sections = [];
const tasks = [];
const rawTasks = [];
export const clear = function() {
sections.length = 0;
tasks.length = 0;
currentSection = '';
title = '';
rawTasks.length = 0;
};
export const setTitle = function(txt) {
title = txt;
};
export const getTitle = function() {
return title;
};
export const addSection = function(txt) {
currentSection = txt;
sections.push(txt);
};
export const getSections = function() {
return sections;
};
export const getTasks = function() {
let allItemsProcessed = compileTasks();
const maxDepth = 100;
let iterationCount = 0;
while (!allItemsProcessed && iterationCount < maxDepth) {
allItemsProcessed = compileTasks();
iterationCount++;
}
tasks.push(...rawTasks);
return tasks;
};
const updateActors = function() {
const tempActors = [];
tasks.forEach(task => {
if (task.people) {
tempActors.push(...task.people);
}
});
const unique = new Set(tempActors);
return [...unique].sort();
};
export const addTask = function(descr, taskData) {
const pieces = taskData.substr(1).split(':');
let score = 0;
let peeps = [];
if (pieces.length === 1) {
score = Number(pieces[0]);
peeps = [];
} else {
score = Number(pieces[0]);
peeps = pieces[1].split(',');
}
const peopleList = peeps.map(s => s.trim());
const rawTask = {
section: currentSection,
type: currentSection,
people: peopleList,
task: descr,
score
};
rawTasks.push(rawTask);
};
export const addTaskOrg = function(descr) {
const newTask = {
section: currentSection,
type: currentSection,
description: descr,
task: descr,
classes: []
};
tasks.push(newTask);
};
const compileTasks = function() {
const compileTask = function(pos) {
return rawTasks[pos].processed;
};
let allProcessed = true;
for (let i = 0; i < rawTasks.length; i++) {
compileTask(i);
allProcessed = allProcessed && rawTasks[i].processed;
}
return allProcessed;
};
const getActors = function() {
return updateActors();
};
export default {
clear,
setTitle,
getTitle,
addSection,
getSections,
getTasks,
addTask,
addTaskOrg,
getActors
};

View File

@ -0,0 +1,90 @@
/* eslint-env jasmine */
import journeyDb from './journeyDb';
describe('when using the journeyDb', function() {
beforeEach(function() {
journeyDb.clear();
});
describe('when calling the clear function', function() {
beforeEach(function() {
journeyDb.addSection('weekends skip test');
journeyDb.addTask('test1', '4: id1, id3');
journeyDb.addTask('test2', '2: id2');
journeyDb.clear();
});
it.each`
fn | expected
${'getTasks'} | ${[]}
${'getTitle'} | ${''}
${'getSections'} | ${[]}
${'getActors'} | ${[]}
`('should clear $fn', ({ fn, expected }) => {
expect(journeyDb[fn]()).toEqual(expected);
});
});
describe('when calling the clear function', function() {
beforeEach(function() {
journeyDb.addSection('weekends skip test');
journeyDb.addTask('test1', '3: id1, id3');
journeyDb.addTask('test2', '1: id2');
journeyDb.clear();
});
it.each`
fn | expected
${'getTasks'} | ${[]}
${'getTitle'} | ${''}
${'getSections'} | ${[]}
`('should clear $fn', ({ fn, expected }) => {
expect(journeyDb[fn]()).toEqual(expected);
});
});
describe('tasks and actors should be added', function() {
journeyDb.setTitle('Shopping');
journeyDb.addSection('Journey to the shops');
journeyDb.addTask('Get car keys', ':5:Dad');
journeyDb.addTask('Go to car', ':3:Dad, Mum, Child#1, Child#2');
journeyDb.addTask('Drive to supermarket', ':4:Dad');
journeyDb.addSection('Do shopping');
journeyDb.addTask('Go shopping', ':5:Mum');
expect(journeyDb.getTitle()).toEqual('Shopping');
expect(journeyDb.getTasks()).toEqual([
{
score: 5,
people: ['Dad'],
section: 'Journey to the shops',
task: 'Get car keys',
type: 'Journey to the shops'
},
{
score: 3,
people: ['Dad', 'Mum', 'Child#1', 'Child#2'],
section: 'Journey to the shops',
task: 'Go to car',
type: 'Journey to the shops'
},
{
score: 4,
people: ['Dad'],
section: 'Journey to the shops',
task: 'Drive to supermarket',
type: 'Journey to the shops'
},
{
score: 5,
people: ['Mum'],
section: 'Do shopping',
task: 'Go shopping',
type: 'Do shopping'
}
]);
expect(journeyDb.getActors()).toEqual(['Child#1', 'Child#2', 'Dad', 'Mum']);
expect(journeyDb.getSections()).toEqual(['Journey to the shops', 'Do shopping']);
});
});

View File

@ -0,0 +1,284 @@
import * as d3 from 'd3';
import { parser } from './parser/journey';
import journeyDb from './journeyDb';
import svgDraw from './svgDraw';
parser.yy = journeyDb;
const conf = {
leftMargin: 150,
diagramMarginX: 50,
diagramMarginY: 20,
// Margin between tasks
taskMargin: 50,
// Width of task boxes
width: 150,
// Height of task boxes
height: 50,
taskFontSize: 14,
taskFontFamily: '"Open-Sans", "sans-serif"',
// Margin around loop boxes
boxMargin: 10,
boxTextMargin: 5,
noteMargin: 10,
// Space between messages
messageMargin: 35,
// Multiline message alignment
messageAlign: 'center',
// Depending on css styling this might need adjustment
// Projects the edge of the diagram downwards
bottomMarginAdj: 1,
// width of activation box
activationWidth: 10,
// text placement as: tspan | fo | old only text as before
textPlacement: 'fo',
actorColours: ['#8FBC8F', '#7CFC00', '#00FFFF', '#20B2AA', '#B0E0E6', '#FFFFE0'],
sectionFills: ['#191970', '#8B008B', '#4B0082', '#2F4F4F', '#800000', '#8B4513', '#00008B'],
sectionColours: ['#fff']
};
export const setConf = function(cnf) {
const keys = Object.keys(cnf);
keys.forEach(function(key) {
conf[key] = cnf[key];
});
};
const actors = {};
function drawActorLegend(diagram) {
// Draw the actors
let yPos = 60;
Object.keys(actors).forEach(person => {
const colour = actors[person];
const circleData = {
cx: 20,
cy: yPos,
r: 7,
fill: colour,
stroke: '#000'
};
svgDraw.drawCircle(diagram, circleData);
const labelData = {
x: 40,
y: yPos + 7,
fill: '#666',
text: person
};
svgDraw.drawText(diagram, labelData);
yPos += 20;
});
}
const LEFT_MARGIN = conf.leftMargin;
export const draw = function(text, id) {
parser.yy.clear();
parser.parse(text + '\n');
bounds.init();
const diagram = d3.select('#' + id);
diagram.attr('xmlns:xlink', 'http://www.w3.org/1999/xlink');
svgDraw.initGraphics(diagram);
const tasks = parser.yy.getTasks();
const title = parser.yy.getTitle();
const actorNames = parser.yy.getActors();
for (let member in actors) delete actors[member];
let actorPos = 0;
actorNames.forEach(actorName => {
actors[actorName] = conf.actorColours[actorPos % conf.actorColours.length];
actorPos++;
});
drawActorLegend(diagram);
bounds.insert(0, 0, LEFT_MARGIN, Object.keys(actors).length * 50);
drawTasks(diagram, tasks, 0);
const box = bounds.getBounds();
if (title) {
diagram
.append('text')
.text(title)
.attr('x', LEFT_MARGIN)
.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;
if (conf.useMaxWidth) {
diagram.attr('height', '100%');
diagram.attr('width', '100%');
diagram.attr('style', 'max-width:' + width + 'px;');
} else {
diagram.attr('height', height);
diagram.attr('width', width);
}
// Draw activity line
diagram
.append('line')
.attr('x1', LEFT_MARGIN)
.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('y2', conf.height * 4)
.attr('stroke-width', 4)
.attr('stroke', 'black')
.attr('marker-end', 'url(#arrowhead)');
const extraVertForTitle = title ? 70 : 0;
diagram.attr('viewBox', `${box.startx} -25 ${width} ${height + extraVertForTitle}`);
diagram.attr('preserveAspectRatio', 'xMinYMin meet');
};
export const bounds = {
data: {
startx: undefined,
stopx: undefined,
starty: undefined,
stopy: undefined
},
verticalPos: 0,
sequenceItems: [],
init: function() {
this.sequenceItems = [];
this.data = {
startx: undefined,
stopx: undefined,
starty: undefined,
stopy: undefined
};
this.verticalPos = 0;
},
updateVal: function(obj, key, val, fun) {
if (typeof obj[key] === 'undefined') {
obj[key] = val;
} else {
obj[key] = fun(val, obj[key]);
}
},
updateBounds: function(startx, starty, stopx, stopy) {
const _self = this;
let cnt = 0;
function updateFn(type) {
return function updateItemBounds(item) {
cnt++;
// The loop sequenceItems is a stack so the biggest margins in the beginning of the sequenceItems
const n = _self.sequenceItems.length - cnt + 1;
_self.updateVal(item, 'starty', starty - n * conf.boxMargin, Math.min);
_self.updateVal(item, 'stopy', stopy + n * conf.boxMargin, Math.max);
_self.updateVal(bounds.data, 'startx', startx - n * conf.boxMargin, Math.min);
_self.updateVal(bounds.data, 'stopx', stopx + n * conf.boxMargin, Math.max);
if (!(type === 'activation')) {
_self.updateVal(item, 'startx', startx - n * conf.boxMargin, Math.min);
_self.updateVal(item, 'stopx', stopx + n * conf.boxMargin, Math.max);
_self.updateVal(bounds.data, 'starty', starty - n * conf.boxMargin, Math.min);
_self.updateVal(bounds.data, 'stopy', stopy + n * conf.boxMargin, Math.max);
}
};
}
this.sequenceItems.forEach(updateFn());
},
insert: function(startx, starty, stopx, stopy) {
const _startx = Math.min(startx, stopx);
const _stopx = Math.max(startx, stopx);
const _starty = Math.min(starty, stopy);
const _stopy = Math.max(starty, stopy);
this.updateVal(bounds.data, 'startx', _startx, Math.min);
this.updateVal(bounds.data, 'starty', _starty, Math.min);
this.updateVal(bounds.data, 'stopx', _stopx, Math.max);
this.updateVal(bounds.data, 'stopy', _stopy, Math.max);
this.updateBounds(_startx, _starty, _stopx, _stopy);
},
bumpVerticalPos: function(bump) {
this.verticalPos = this.verticalPos + bump;
this.data.stopy = this.verticalPos;
},
getVerticalPos: function() {
return this.verticalPos;
},
getBounds: function() {
return this.data;
}
};
const fills = conf.sectionFills;
const textColours = conf.sectionColours;
export const drawTasks = function(diagram, tasks, verticalPos) {
let lastSection = '';
const sectionVHeight = conf.height * 2 + conf.diagramMarginY;
const taskPos = verticalPos + sectionVHeight;
let sectionNumber = 0;
let fill = '#CCC';
let colour = 'black';
// Draw the tasks
for (let i = 0; i < tasks.length; i++) {
let task = tasks[i];
if (lastSection !== task.section) {
fill = fills[sectionNumber % fills.length];
colour = textColours[sectionNumber % textColours.length];
const section = {
x: i * conf.taskMargin + i * conf.width + LEFT_MARGIN,
y: 50,
text: task.section,
fill,
colour
};
svgDraw.drawSection(diagram, section, conf);
lastSection = task.section;
sectionNumber++;
}
// Collect the actors involved in the task
const taskActors = task.people.reduce((acc, actorName) => {
if (actors[actorName]) {
acc[actorName] = actors[actorName];
}
return acc;
}, {});
// Add some rendering data to the object
task.x = i * conf.taskMargin + i * conf.width + LEFT_MARGIN;
task.y = taskPos;
task.width = conf.diagramMarginX;
task.height = conf.diagramMarginY;
task.colour = colour;
task.fill = fill;
task.actors = taskActors;
// Draw the box with the attached line
svgDraw.drawTask(diagram, task, conf);
bounds.insert(task.x, task.y, task.x + task.width + conf.taskMargin, 300 + 5 * 30); // stopy is the length of the descenders.
}
};
export default {
setConf,
draw
};

View File

@ -0,0 +1,52 @@
/** mermaid
* https://mermaidjs.github.io/
* (c) 2015 Knut Sveidqvist
* MIT license.
*/
%lex
%options case-insensitive
%%
[\n]+ return 'NL';
\s+ /* skip whitespace */
\#[^\n]* /* skip comments */
\%%[^\n]* /* skip comments */
"journey" return 'journey';
"title"\s[^#\n;]+ return 'title';
"section"\s[^#:\n;]+ return 'section';
[^#:\n;]+ return 'taskName';
":"[^#\n;]+ return 'taskData';
":" return ':';
<<EOF>> return 'EOF';
. return 'INVALID';
/lex
%left '^'
%start start
%% /* language grammar */
start
: journey document 'EOF' { return $2; }
;
document
: /* empty */ { $$ = [] }
| document line {$1.push($2);$$ = $1}
;
line
: SPACE statement { $$ = $2 }
| statement { $$ = $1 }
| NL { $$=[];}
| EOF { $$=[];}
;
statement
: title {yy.setTitle($1.substr(6));$$=$1.substr(6);}
| section {yy.addSection($1.substr(8));$$=$1.substr(8);}
| taskName taskData {yy.addTask($1, $2);$$='task';}
;

View File

@ -0,0 +1,117 @@
/* eslint-env jasmine */
/* eslint-disable no-eval */
import { parser } from './journey';
import journeyDb from '../journeyDb';
const parserFnConstructor = str => {
return () => {
parser.parse(str);
};
};
describe('when parsing a journey diagram it', function() {
beforeEach(function() {
parser.yy = journeyDb;
parser.yy.clear();
});
it('should handle a title definition', function() {
const str = 'journey\ntitle Adding journey diagram functionality to mermaid';
expect(parserFnConstructor(str)).not.toThrow();
});
it('should handle a section definition', function() {
const str =
'journey\n' +
'title Adding journey diagram functionality to mermaid\n' +
'section Order from website';
expect(parserFnConstructor(str)).not.toThrow();
});
it('should handle multiline section titles with different line breaks', function() {
const str =
'journey\n' +
'title Adding gantt diagram functionality to mermaid\n' +
'section Line1<br>Line2<br/>Line3</br />Line4<br\t/>Line5';
expect(parserFnConstructor(str)).not.toThrow();
});
it('should handle a task definition', function() {
const str =
'journey\n' +
'title Adding journey diagram functionality to mermaid\n' +
'section Documentation\n' +
'A task: 5: Alice, Bob, Charlie\n' +
'B task: 3:Bob, Charlie\n' +
'C task: 5\n' +
'D task: 5: Charlie, Alice\n' +
'E task: 5:\n' +
'section Another section\n' +
'P task: 5:\n' +
'Q task: 5:\n' +
'R task: 5:';
expect(parserFnConstructor(str)).not.toThrow();
const tasks = parser.yy.getTasks();
expect(tasks.length).toEqual(8);
expect(tasks[0]).toEqual({
score: 5,
people: ['Alice', 'Bob', 'Charlie'],
section: 'Documentation',
task: 'A task',
type: 'Documentation'
});
expect(tasks[1]).toEqual({
score: 3,
people: ['Bob', 'Charlie'],
section: 'Documentation',
type: 'Documentation',
task: 'B task'
});
expect(tasks[2]).toEqual({
score: 5,
people: [],
section: 'Documentation',
type: 'Documentation',
task: 'C task'
});
expect(tasks[3]).toEqual({
score: 5,
people: ['Charlie', 'Alice'],
section: 'Documentation',
task: 'D task',
type: 'Documentation'
});
expect(tasks[4]).toEqual({
score: 5,
people: [''],
section: 'Documentation',
type: 'Documentation',
task: 'E task'
});
expect(tasks[5]).toEqual({
score: 5,
people: [''],
section: 'Another section',
type: 'Another section',
task: 'P task'
});
expect(tasks[6]).toEqual({
score: 5,
people: [''],
section: 'Another section',
type: 'Another section',
task: 'Q task'
});
expect(tasks[7]).toEqual({
score: 5,
people: [''],
section: 'Another section',
type: 'Another section',
task: 'R task'
});
});
});

View File

@ -0,0 +1,429 @@
import * as d3 from 'd3';
export const drawRect = function(elem, rectData) {
const rectElem = elem.append('rect');
rectElem.attr('x', rectData.x);
rectElem.attr('y', rectData.y);
rectElem.attr('fill', rectData.fill);
rectElem.attr('stroke', rectData.stroke);
rectElem.attr('width', rectData.width);
rectElem.attr('height', rectData.height);
rectElem.attr('rx', rectData.rx);
rectElem.attr('ry', rectData.ry);
if (typeof rectData.class !== 'undefined') {
rectElem.attr('class', rectData.class);
}
return rectElem;
};
export const drawFace = function(element, faceData) {
const radius = 15;
const circleElement = element
.append('circle')
.attr('cx', faceData.cx)
.attr('cy', faceData.cy)
.attr('fill', '#FFF8DC')
.attr('stroke', '#999')
.attr('r', radius)
.attr('stroke-width', 2)
.attr('overflow', 'visible');
const face = element.append('g');
//left eye
face
.append('circle')
.attr('cx', faceData.cx - radius / 3)
.attr('cy', faceData.cy - radius / 3)
.attr('r', 1.5)
.attr('stroke-width', 2)
.attr('fill', '#666')
.attr('stroke', '#666');
//right eye
face
.append('circle')
.attr('cx', faceData.cx + radius / 3)
.attr('cy', faceData.cy - radius / 3)
.attr('r', 1.5)
.attr('stroke-width', 2)
.attr('fill', '#666')
.attr('stroke', '#666');
function smile(face) {
const arc = d3
.arc()
.startAngle(Math.PI / 2)
.endAngle(3 * (Math.PI / 2))
.innerRadius(radius / 2)
.outerRadius(radius / 2.2);
//mouth
face
.append('path')
.attr('d', arc)
.attr('transform', 'translate(' + faceData.cx + ',' + (faceData.cy + 2) + ')');
}
function sad(face) {
const arc = d3
.arc()
.startAngle((3 * Math.PI) / 2)
.endAngle(5 * (Math.PI / 2))
.innerRadius(radius / 2)
.outerRadius(radius / 2.2);
//mouth
face
.append('path')
.attr('d', arc)
.attr('transform', 'translate(' + faceData.cx + ',' + (faceData.cy + 7) + ')');
}
function ambivalent(face) {
face
.append('line')
.attr('stroke', 2)
.attr('x1', faceData.cx - 5)
.attr('y1', faceData.cy + 7)
.attr('x2', faceData.cx + 5)
.attr('y2', faceData.cy + 7)
.attr('class', 'task-line')
.attr('stroke-width', '1px')
.attr('stroke', '#666');
}
if (faceData.score > 3) {
smile(face);
} else if (faceData.score < 3) {
sad(face);
} else {
ambivalent(face);
}
return circleElement;
};
export const drawCircle = function(element, circleData) {
const circleElement = element.append('circle');
circleElement.attr('cx', circleData.cx);
circleElement.attr('cy', circleData.cy);
circleElement.attr('fill', circleData.fill);
circleElement.attr('stroke', circleData.stroke);
circleElement.attr('r', circleData.r);
if (typeof circleElement.class !== 'undefined') {
circleElement.attr('class', circleElement.class);
}
if (typeof circleData.title !== 'undefined') {
circleElement.append('title').text(circleData.title);
}
return circleElement;
};
export const drawText = function(elem, textData) {
// Remove and ignore br:s
const nText = textData.text.replace(/<br\s*\/?>/gi, ' ');
const textElem = elem.append('text');
textElem.attr('x', textData.x);
textElem.attr('y', textData.y);
textElem.attr('fill', textData.fill);
textElem.style('text-anchor', textData.anchor);
if (typeof textData.class !== 'undefined') {
textElem.attr('class', textData.class);
}
const span = textElem.append('tspan');
span.attr('x', textData.x + textData.textMargin * 2);
span.text(nText);
return textElem;
};
export const drawLabel = function(elem, txtObject) {
function genPoints(x, y, width, height, cut) {
return (
x +
',' +
y +
' ' +
(x + width) +
',' +
y +
' ' +
(x + width) +
',' +
(y + height - cut) +
' ' +
(x + width - cut * 1.2) +
',' +
(y + height) +
' ' +
x +
',' +
(y + height)
);
}
const polygon = elem.append('polygon');
polygon.attr('points', genPoints(txtObject.x, txtObject.y, 50, 20, 7));
polygon.attr('class', 'labelBox');
txtObject.y = txtObject.y + txtObject.labelMargin;
txtObject.x = txtObject.x + 0.5 * txtObject.labelMargin;
drawText(elem, txtObject);
};
export const drawSection = function(elem, section, conf) {
const g = elem.append('g');
const rect = getNoteRect();
rect.x = section.x;
rect.y = section.y;
rect.fill = section.fill;
rect.width = conf.width;
rect.height = conf.height;
rect.class = 'journey-section';
rect.rx = 3;
rect.ry = 3;
drawRect(g, rect);
_drawTextCandidateFunc(conf)(
section.text,
g,
rect.x,
rect.y,
rect.width,
rect.height,
{ class: 'journey-section' },
conf,
section.colour
);
};
let taskCount = -1;
/**
* Draws an actor in the diagram with the attaced line
* @param elem The HTML element
* @param task The task to render
* @param conf The global configuration
*/
export const drawTask = function(elem, task, conf) {
const center = task.x + conf.width / 2;
const g = elem.append('g');
taskCount++;
const maxHeight = 300 + 5 * 30;
g.append('line')
.attr('id', 'task' + taskCount)
.attr('x1', center)
.attr('y1', task.y)
.attr('x2', center)
.attr('y2', maxHeight)
.attr('class', 'task-line')
.attr('stroke-width', '1px')
.attr('stroke-dasharray', '4 2')
.attr('stroke', '#666');
drawFace(g, {
cx: center,
cy: 300 + (5 - task.score) * 30,
score: task.score
});
const rect = getNoteRect();
rect.x = task.x;
rect.y = task.y;
rect.fill = task.fill;
rect.width = conf.width;
rect.height = conf.height;
rect.class = 'task';
rect.rx = 3;
rect.ry = 3;
drawRect(g, rect);
let xPos = task.x + 14;
task.people.forEach(person => {
const colour = task.actors[person];
const circle = {
cx: xPos,
cy: task.y,
r: 7,
fill: colour,
stroke: '#000',
title: person
};
drawCircle(g, circle);
xPos += 10;
});
_drawTextCandidateFunc(conf)(
task.task,
g,
rect.x,
rect.y,
rect.width,
rect.height,
{ class: 'task' },
conf,
task.colour
);
};
/**
* Draws a background rectangle
* @param elem The html element
* @param bounds The bounds of the drawing
*/
export const drawBackgroundRect = function(elem, bounds) {
const rectElem = drawRect(elem, {
x: bounds.startx,
y: bounds.starty,
width: bounds.stopx - bounds.startx,
height: bounds.stopy - bounds.starty,
fill: bounds.fill,
class: 'rect'
});
rectElem.lower();
};
export const getTextObj = function() {
return {
x: 0,
y: 0,
fill: undefined,
'text-anchor': 'start',
width: 100,
height: 100,
textMargin: 0,
rx: 0,
ry: 0
};
};
export const getNoteRect = function() {
return {
x: 0,
y: 0,
width: 100,
anchor: 'start',
height: 100,
rx: 0,
ry: 0
};
};
const _drawTextCandidateFunc = (function() {
function byText(content, g, x, y, width, height, textAttrs, colour) {
const text = g
.append('text')
.attr('x', x + width / 2)
.attr('y', y + height / 2 + 5)
.style('font-color', colour)
.style('text-anchor', 'middle')
.text(content);
_setTextAttrs(text, textAttrs);
}
function byTspan(content, g, x, y, width, height, textAttrs, conf, colour) {
const { taskFontSize, taskFontFamily } = conf;
const lines = content.split(/<br\s*\/?>/gi);
for (let i = 0; i < lines.length; i++) {
const dy = i * taskFontSize - (taskFontSize * (lines.length - 1)) / 2;
const text = g
.append('text')
.attr('x', x + width / 2)
.attr('y', y)
.attr('fill', colour)
.style('text-anchor', 'middle')
.style('font-size', taskFontSize)
.style('font-family', taskFontFamily);
text
.append('tspan')
.attr('x', x + width / 2)
.attr('dy', dy)
.text(lines[i]);
text
.attr('y', y + height / 2.0)
.attr('dominant-baseline', 'central')
.attr('alignment-baseline', 'central');
_setTextAttrs(text, textAttrs);
}
}
function byFo(content, g, x, y, width, height, textAttrs, conf, colour) {
const body = g.append('switch');
const f = body
.append('foreignObject')
.attr('x', x)
.attr('y', y)
.attr('width', width)
.attr('height', height)
.attr('position', 'fixed');
const text = f
.append('div')
.style('display', 'table')
.style('height', '100%')
.style('width', '100%');
text
.append('div')
.style('display', 'table-cell')
.style('text-align', 'center')
.style('vertical-align', 'middle')
.style('color', colour)
.text(content);
byTspan(content, body, x, y, width, height, textAttrs, conf);
_setTextAttrs(text, textAttrs);
}
function _setTextAttrs(toText, fromTextAttrsDict) {
for (const key in fromTextAttrsDict) {
if (key in fromTextAttrsDict) {
// eslint-disable-line
// noinspection JSUnfilteredForInLoop
toText.attr(key, fromTextAttrsDict[key]);
}
}
}
return function(conf) {
return conf.textPlacement === 'fo' ? byFo : conf.textPlacement === 'old' ? byText : byTspan;
};
})();
const initGraphics = function(graphics) {
graphics
.append('defs')
.append('marker')
.attr('id', 'arrowhead')
.attr('refX', 5)
.attr('refY', 2)
.attr('markerWidth', 6)
.attr('markerHeight', 4)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 0,0 V 4 L6,2 Z'); // this is actual shape for arrowhead
};
export default {
drawRect,
drawCircle,
drawSection,
drawText,
drawLabel,
drawTask,
drawBackgroundRect,
getTextObj,
getNoteRect,
initGraphics
};

View File

@ -45,6 +45,9 @@ import pieDb from './diagrams/pie/pieDb';
import erDb from './diagrams/er/erDb';
import erParser from './diagrams/er/parser/erDiagram';
import erRenderer from './diagrams/er/erRenderer';
import journeyParser from './diagrams/user-journey/parser/journey';
import journeyDb from './diagrams/user-journey/journeyDb';
import journeyRenderer from './diagrams/user-journey/journeyRenderer';
const themes = {};
for (const themeName of ['default', 'forest', 'dark', 'neutral']) {
@ -337,6 +340,92 @@ const config = {
*/
axisFormat: '%Y-%m-%d'
},
/**
* The object containing configurations specific for sequence diagrams
*/
journey: {
/**
* margin to the right and left of the sequence diagram.
* **Default value 50**.
*/
diagramMarginX: 50,
/**
* margin to the over and under the sequence diagram.
* **Default value 10**.
*/
diagramMarginY: 10,
/**
* Margin between actors.
* **Default value 50**.
*/
actorMargin: 50,
/**
* Width of actor boxes
* **Default value 150**.
*/
width: 150,
/**
* Height of actor boxes
* **Default value 65**.
*/
height: 65,
/**
* Margin around loop boxes
* **Default value 10**.
*/
boxMargin: 10,
/**
* margin around the text in loop/alt/opt boxes
* **Default value 5**.
*/
boxTextMargin: 5,
/**
* margin around notes.
* **Default value 10**.
*/
noteMargin: 10,
/**
* Space between messages.
* **Default value 35**.
*/
messageMargin: 35,
/**
* Multiline message alignment. Possible values are:
* * left
* * center **default**
* * right
*/
messageAlign: 'center',
/**
* Depending on css styling this might need adjustment.
* Prolongs the edge of the diagram downwards.
* **Default value 1**.
*/
bottomMarginAdj: 1,
/**
* when this flag is set the height and width is set to 100% and is then scaling with the
* available space if not the absolute space required is used.
* **Default value true**.
*/
useMaxWidth: true,
/**
* This will display arrows that start and begin at the same node as right angles, rather than a curve
* **Default value false**.
*/
rightAngles: false
},
class: {},
git: {},
state: {
@ -465,6 +554,11 @@ function parse(text) {
parser = erParser;
parser.parser.yy = erDb;
break;
case 'journey':
logger.debug('Journey');
parser = journeyParser;
parser.parser.yy = journeyDb;
break;
}
parser.parser.yy.parseError = (str, hash) => {
@ -696,6 +790,10 @@ const render = function(id, _txt, cb, container) {
erRenderer.setConf(config.er);
erRenderer.draw(txt, id, pkg.version);
break;
case 'journey':
journeyRenderer.setConf(config.journey);
journeyRenderer.draw(txt, id, pkg.version);
break;
}
d3.select(`[id="${id}"]`)

View File

@ -59,6 +59,10 @@ export const detectType = function(text) {
return 'er';
}
if (text.match(/^\s*journey/)) {
return 'journey';
}
return 'flowchart';
};