User journey handler

This commit is contained in:
Russell Geraghty
2020-04-04 16:38:30 +01:00
parent 9f8a0234aa
commit f081527731
12 changed files with 1307 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: 1,
journey: { taskMargin: 30 },
});
</script>
</body>
</html>

View File

@@ -263,6 +263,119 @@ The number of alternating section styles.
Datetime format of the axis. This might need adjustment to match your locale and preferences
**Default value '%Y-%m-%d'**.
## journey
The object containing configurations specific for sequence diagrams
### diagramMarginX
margin to the right and left of the sequence diagram.
**Default value 50**.
### diagramMarginY
margin to the over and under the sequence diagram.
**Default value 10**.
### actorMargin
Margin between actors.
**Default value 50**.
### width
Width of actor boxes
**Default value 150**.
### height
Height of actor boxes
**Default value 65**.
### boxMargin
Margin around loop boxes
**Default value 10**.
### boxTextMargin
margin around the text in loop/alt/opt boxes
**Default value 5**.
### noteMargin
margin around notes.
**Default value 10**.
### messageMargin
Space between messages.
**Default value 35**.
### messageAlign
Multiline message alignment. Possible values are:
- left
- center **default**
- right
### bottomMarginAdj
Depending on css styling this might need adjustment.
Prolongs the edge of the diagram downwards.
**Default value 1**.
### useMaxWidth
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**.
### rightAngles
This will display arrows that start and begin at the same node as right angles, rather than a curve
**Default value false**.
## er
The object containing configurations specific for entity relationship diagrams
### layoutDirection
Directional bias for layout of entities. Can be either 'TB', 'BT', 'LR', or 'RL',
where T = top, B = bottom, L = left, and R = right.
### minEntityWidth
The mimimum width of an entity box
### minEntityHeight
The minimum height of an entity box
### entityPadding
The minimum internal padding between the text in an entity box and the enclosing box borders
### stroke
Stroke color of box edges and lines
### fill
Fill color of entity boxes
### fillOpacity
Opacity of entity boxes - if you want to see how the crows feet
retain their elegant joins to the boxes regardless of the angle of incidence
then override this to something less than 100%
### fontSize
Font size
## render
Function that renders an svg with a graph from a chart definition. Usage example below.

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
};