mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-09-22 16:59:48 +02:00
User journey handler
This commit is contained in:
123
src/diagrams/user-journey/journeyDb.js
Normal file
123
src/diagrams/user-journey/journeyDb.js
Normal 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
|
||||
};
|
90
src/diagrams/user-journey/journeyDb.spec.js
Normal file
90
src/diagrams/user-journey/journeyDb.spec.js
Normal 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']);
|
||||
});
|
||||
});
|
284
src/diagrams/user-journey/journeyRenderer.js
Normal file
284
src/diagrams/user-journey/journeyRenderer.js
Normal 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
|
||||
};
|
52
src/diagrams/user-journey/parser/journey.jison
Normal file
52
src/diagrams/user-journey/parser/journey.jison
Normal 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';}
|
||||
;
|
117
src/diagrams/user-journey/parser/journey.spec.js
Normal file
117
src/diagrams/user-journey/parser/journey.spec.js
Normal 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'
|
||||
});
|
||||
});
|
||||
});
|
429
src/diagrams/user-journey/svgDraw.js
Normal file
429
src/diagrams/user-journey/svgDraw.js
Normal 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
|
||||
};
|
Reference in New Issue
Block a user